Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d299fc1d93 | |||
| 6447e95d7d | |||
| 7ec5a1eccc | |||
| 4cf70a3431 | |||
| 6ed8b2a8e7 | |||
| bff00cf0a3 | |||
| 3cab735754 | |||
| 79762a0baf | |||
| 24160b6c5d | |||
| 1326045b25 | |||
| e014e997de | |||
| 1bc449687d | |||
| 35ee705510 | |||
| 9f76c200b0 | |||
| ac627a022f | |||
| 9ae24aa6fb | |||
| 91cf2674f7 | |||
| b7a9df6ae0 | |||
| 7bc3c25ba4 | |||
| e2fa036b9c | |||
| 89f0f52841 | |||
| 6f28ea0b16 | |||
| 975c7a2e40 | |||
| f83d67b527 | |||
| 6c48085904 | |||
| 07de51be22 | |||
| d654aad937 | |||
| dd111ce01f | |||
| 978e132c70 | |||
| 1ecebc5dbb | |||
| caf85ad9eb | |||
| d637fbea16 | |||
| 8e03563f65 | |||
| 3ac4201734 | |||
| 85e641ed39 | |||
| 9bf59280b2 | |||
| aee8f4f3db | |||
| 2b029a26f0 | |||
| 2156aa4bbd | |||
| 5eb4543255 | |||
| fb9bb6754c | |||
| 959afd5a63 | |||
| e3ea45f717 | |||
| 8f57b6ff22 | |||
| 60e1b714b7 | |||
| 1e203bfec1 | |||
| 11420685cf | |||
| c674aac344 | |||
| 9c91a0f1fc | |||
| 2bcbbba626 | |||
| b1500f8361 | |||
| bc7512003e | |||
| eaf126b584 | |||
| a9c712be45 | |||
| b0195601de | |||
| c2b58baa6e | |||
| a85d6e42fc | |||
| 53da4a14a0 | |||
| 2453134c51 | |||
| 671cb2dd9a | |||
| 1d511e0f8c | |||
| 18a68367bc |
+8
-2
@@ -1,5 +1,11 @@
|
||||
OpenWeatherMapAPIKey=<owm_api_key>
|
||||
|
||||
# OpenRouter API (AI travel day summaries — server-side proxy)
|
||||
OpenRouterAPIKey=
|
||||
# Optional model override (default: anthropic/claude-3.5-haiku)
|
||||
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
|
||||
# OpenRouterModel=anthropic/claude-3.5-haiku
|
||||
|
||||
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
||||
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
||||
DeepLAPIKey=
|
||||
@@ -13,8 +19,8 @@ RP_ID=localhost
|
||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
ORIGIN=http://localhost:5173
|
||||
|
||||
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
|
||||
# TRUST_PROXY=172.16.10.10
|
||||
# Behind reverse proxy — see docs/deployment/npm-security.md
|
||||
# Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
|
||||
# TRUST_PROXY=1
|
||||
|
||||
# Docker Compose database (required for production deploy)
|
||||
|
||||
+6
-3
@@ -7,7 +7,7 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
|
||||
# Service worker and app shell must revalidate so PWA updates are detected
|
||||
@@ -17,7 +17,7 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
@@ -12,6 +12,7 @@
|
||||
"bip39": "^3.1.0",
|
||||
"dexie": "^4.4.2",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"fflate": "^0.8.3",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jspdf": "^4.2.1",
|
||||
|
||||
+3
-2
@@ -22,15 +22,16 @@
|
||||
"bip39": "^3.1.0",
|
||||
"dexie": "^4.4.2",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"fflate": "^0.8.3",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8",
|
||||
"qrcode": "^1.5.4"
|
||||
"react-i18next": "^17.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
+276
-48
@@ -148,7 +148,8 @@ select.input-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.time-input-24h__select {
|
||||
.time-input-24h__select,
|
||||
.time-input-24h__native {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
@@ -157,6 +158,11 @@ select.input-text {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
input[type='time'].time-input-24h__native {
|
||||
color-scheme: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-input-24h__sep {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
@@ -1174,12 +1180,29 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
color: var(--app-accent-light);
|
||||
}
|
||||
|
||||
.btn-icon.logout:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: #ef4444;
|
||||
.btn-icon.danger {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.btn-icon.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
border-color: #ef4444;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.btn-icon.danger:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-icon.logout:hover {
|
||||
background: var(--app-accent-bg);
|
||||
border-color: var(--app-accent);
|
||||
color: var(--app-accent-light);
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
@@ -2175,12 +2198,12 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #475569;
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
color: #f87171;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -2196,8 +2219,9 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
color: #f43f5e;
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
color: #fca5a5;
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-pdf {
|
||||
@@ -2647,27 +2671,13 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
.events-scroll-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: rgba(11, 12, 16, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for events container */
|
||||
.events-scroll-container::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
.events-scroll-container::-webkit-scrollbar-track {
|
||||
background: rgba(11, 12, 16, 0.2);
|
||||
}
|
||||
.events-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.events-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.events-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -2692,15 +2702,18 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
}
|
||||
|
||||
.events-actions-td {
|
||||
width: 1%;
|
||||
min-width: 88px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.events-actions-td .btn-icon {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.events-actions-td .btn-icon:first-child {
|
||||
margin-left: 0;
|
||||
.events-actions-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.events-table tbody tr:hover {
|
||||
@@ -3180,9 +3193,9 @@ html.theme-cupertino .events-scroll-container {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #f43f5e;
|
||||
background: rgba(239, 68, 68, 0.22);
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
color: #fca5a5;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -3194,9 +3207,10 @@ html.theme-cupertino .events-scroll-container {
|
||||
}
|
||||
|
||||
.photo-btn-delete:hover {
|
||||
background: #f43f5e;
|
||||
background: rgba(239, 68, 68, 0.45);
|
||||
border-color: #ef4444;
|
||||
color: #ffffff;
|
||||
transform: scale(1.1);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.photo-caption-bar {
|
||||
@@ -3478,6 +3492,84 @@ html.theme-cupertino .events-scroll-container {
|
||||
color: var(--app-text, inherit);
|
||||
}
|
||||
|
||||
.live-log-gps-error-modal {
|
||||
color: var(--app-warning-text, #b45309);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.gps-signal-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.gps-signal-hint-label {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gps-signal-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.gps-signal-bars {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gps-signal-bar {
|
||||
width: 4px;
|
||||
border-radius: 1px;
|
||||
background: var(--app-border, rgba(148, 163, 184, 0.45));
|
||||
}
|
||||
|
||||
.gps-signal-bar:nth-child(1) { height: 4px; }
|
||||
.gps-signal-bar:nth-child(2) { height: 7px; }
|
||||
.gps-signal-bar:nth-child(3) { height: 10px; }
|
||||
.gps-signal-bar:nth-child(4) { height: 14px; }
|
||||
|
||||
.gps-signal-bar.is-active {
|
||||
background: var(--app-accent-light, #22c55e);
|
||||
}
|
||||
|
||||
.gps-signal-excellent .gps-signal-bar.is-active {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.gps-signal-good .gps-signal-bar.is-active {
|
||||
background: #84cc16;
|
||||
}
|
||||
|
||||
.gps-signal-fair .gps-signal-bar.is-active,
|
||||
.gps-signal-unknown .gps-signal-bar.is-active {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.gps-signal-poor .gps-signal-bar.is-active {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.gps-signal-poor,
|
||||
.gps-signal-fair {
|
||||
color: var(--app-warning-text, #b45309);
|
||||
}
|
||||
|
||||
.gps-signal-hint-editor {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.gps-signal-hint-modal {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.live-log-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(148px, 200px) 1fr;
|
||||
@@ -3650,6 +3742,113 @@ html.theme-cupertino .events-scroll-container {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.live-log-summary-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-log-summary-block .voice-memo-player-shell {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.live-voice-modal .live-voice-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.live-voice-modal .live-voice-modal-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.live-voice-record-btn,
|
||||
.live-voice-stop-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.live-voice-recording-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
font-weight: 600;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.live-voice-recording-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
animation: live-voice-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes live-voice-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
.live-voice-caption-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.voice-memo-player-shell {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: 2px 4px 2px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.voice-memo-player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
min-width: 260px;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
padding-inline: 2px 12px;
|
||||
}
|
||||
|
||||
.voice-memo-player--compact {
|
||||
max-width: 280px;
|
||||
min-width: 240px;
|
||||
padding-inline-end: 14px;
|
||||
}
|
||||
|
||||
.voice-memo-player-unavailable {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.event-remarks-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.event-remarks-cell--voice {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.events-table .remarks-td {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.events-table .remarks-td + .events-actions-td {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.live-log-layout {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -3744,14 +3943,14 @@ html.theme-cupertino .events-scroll-container {
|
||||
max-width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.live-log-fix-coords {
|
||||
.live-log-position-coords {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-log-fix-label {
|
||||
.live-log-position-label {
|
||||
display: block;
|
||||
margin: 0 0 10px;
|
||||
padding: 0;
|
||||
@@ -3760,35 +3959,35 @@ html.theme-cupertino .events-scroll-container {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-fix-coords-row {
|
||||
.live-log-position-coords-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-log-fix-field {
|
||||
.live-log-position-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-log-fix-field-label {
|
||||
.live-log-position-field-label {
|
||||
font-size: 12px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-fix-field .input-text {
|
||||
.live-log-position-field .input-text {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.live-log-fix-gps-row {
|
||||
.live-log-position-gps-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.live-log-fix-gps-btn {
|
||||
.live-log-position-gps-btn {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
@@ -4013,15 +4212,23 @@ html.theme-cupertino .events-scroll-container {
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
.btn-inline-icon {
|
||||
width: auto !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: rgba(239, 68, 68, 0.15) !important;
|
||||
background: rgba(239, 68, 68, 0.2) !important;
|
||||
color: #fca5a5 !important;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3) !important;
|
||||
border: 1px solid rgba(239, 68, 68, 0.38) !important;
|
||||
}
|
||||
|
||||
.btn.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.25) !important;
|
||||
background: rgba(239, 68, 68, 0.32) !important;
|
||||
border-color: #ef4444 !important;
|
||||
color: #fecaca !important;
|
||||
}
|
||||
|
||||
/* Crew Avatar Card Styling */
|
||||
@@ -5366,7 +5573,7 @@ html.theme-cupertino .events-scroll-container {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0px));
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #64748b;
|
||||
background: rgba(11, 12, 16, 0.72);
|
||||
@@ -5402,6 +5609,27 @@ html.theme-cupertino .events-scroll-container {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.kofi-footer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
background: rgba(255, 94, 91, 0.08);
|
||||
border: 1px solid rgba(255, 94, 91, 0.18);
|
||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.kofi-footer-badge:hover {
|
||||
color: #fecaca;
|
||||
background: rgba(255, 94, 91, 0.14);
|
||||
border-color: rgba(255, 94, 91, 0.32);
|
||||
}
|
||||
|
||||
.demo-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
+3
-2
@@ -103,7 +103,7 @@ function App() {
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLogbookId) {
|
||||
@@ -574,7 +574,8 @@ function App() {
|
||||
const logbookReadOnly =
|
||||
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
||||
const isLogbookOwner =
|
||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
||||
activeAccessRole === 'OWNER' ||
|
||||
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
|
||||
|
||||
if (showUserProfile) {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Coffee } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
||||
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
|
||||
|
||||
export default function AppFooter() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<footer className="app-version-footer">
|
||||
<span className="app-version-footer__version">v{APP_VERSION}</span>
|
||||
@@ -18,6 +23,21 @@ export default function AppFooter() {
|
||||
Markus F.J. Busche
|
||||
</a>
|
||||
</span>
|
||||
<span className="app-version-footer__sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
className="kofi-footer-badge"
|
||||
href={KOFI_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={t('footer.kofi_title')}
|
||||
aria-label={t('footer.kofi_title')}
|
||||
onClick={() => trackPlausibleEvent(PlausibleEvents.KOFI_LINK_CLICKED)}
|
||||
>
|
||||
<Coffee size={14} aria-hidden="true" />
|
||||
<span>{t('footer.kofi_label')}</span>
|
||||
</a>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -818,7 +818,7 @@ export default function CrewForm({
|
||||
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
||||
<button className="btn-icon danger" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -156,6 +156,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
preloadedYacht={yacht}
|
||||
preloadedEntries={entries}
|
||||
preloadedPhotos={photos}
|
||||
preloadedVoiceMemos={[]}
|
||||
preloadedGpsTracks={gpsTracks}
|
||||
controlledSelectedEntryId={tourSelectedEntryId}
|
||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { Compass, Save, Check } from 'lucide-react'
|
||||
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||
|
||||
interface DeviationFormProps {
|
||||
logbookId: string
|
||||
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
|
||||
const sanitizedDeviations: Record<number, number> = {}
|
||||
headings.forEach((h) => {
|
||||
const val = deviations[h] || ''
|
||||
const parsed = parseFloat(val.replace('+', '').trim())
|
||||
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed
|
||||
const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
|
||||
sanitizedDeviations[h] = parsed
|
||||
})
|
||||
|
||||
const dataToSave = {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
||||
|
||||
interface EventRemarksCellProps {
|
||||
event: LogEventPayload
|
||||
logbookId: string
|
||||
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
||||
}
|
||||
|
||||
export default function EventRemarksCell({
|
||||
event,
|
||||
logbookId,
|
||||
voiceMemoLookup
|
||||
}: EventRemarksCellProps) {
|
||||
const { t } = useTranslation()
|
||||
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
||||
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
|
||||
|
||||
let summary = formatEventSummary(event, t)
|
||||
if (voiceId && preloaded?.caption) {
|
||||
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
|
||||
<span>{summary}</span>
|
||||
{voiceId && (
|
||||
<VoiceMemoPlayer
|
||||
audioId={voiceId}
|
||||
logbookId={logbookId}
|
||||
preloaded={preloaded}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useId, useMemo } from 'react'
|
||||
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
|
||||
import { preferNativeCameraPicker } from '../utils/captureVideoFrame.js'
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
|
||||
@@ -18,7 +19,29 @@ export default function EventTimeInput24h({
|
||||
'aria-label': ariaLabel
|
||||
}: EventTimeInput24hProps) {
|
||||
const baseId = useId()
|
||||
const useNativePicker = preferNativeCameraPicker()
|
||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
||||
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
|
||||
|
||||
if (useNativePicker) {
|
||||
return (
|
||||
<div className="time-input-24h">
|
||||
<input
|
||||
id={baseId}
|
||||
type="time"
|
||||
step={60}
|
||||
className="input-text time-input-24h__native"
|
||||
value={timeValue}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
if (next) onChange(next.slice(0, 5))
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="time-input-24h">
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Signal } from 'lucide-react'
|
||||
import {
|
||||
formatGpsAccuracyMeters,
|
||||
gpsQualityI18nKey,
|
||||
type GpsSignalQuality
|
||||
} from '../utils/geolocation.js'
|
||||
|
||||
const SIGNAL_BARS: Record<GpsSignalQuality, number> = {
|
||||
excellent: 4,
|
||||
good: 3,
|
||||
fair: 2,
|
||||
poor: 1,
|
||||
unknown: 0
|
||||
}
|
||||
|
||||
interface GpsSignalHintProps {
|
||||
quality: GpsSignalQuality
|
||||
accuracyM: number | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function GpsSignalHint({ quality, accuracyM, className = '' }: GpsSignalHintProps) {
|
||||
const { t } = useTranslation()
|
||||
const bars = SIGNAL_BARS[quality]
|
||||
const i18nParams = accuracyM != null ? { accuracy: formatGpsAccuracyMeters(accuracyM) } : undefined
|
||||
|
||||
return (
|
||||
<p
|
||||
className={`gps-signal-hint gps-signal-${quality} ${className}`.trim()}
|
||||
role="status"
|
||||
>
|
||||
<span className="gps-signal-hint-label">
|
||||
<Signal size={14} aria-hidden className="gps-signal-icon" />
|
||||
<span className="gps-signal-bars" aria-hidden>
|
||||
{[1, 2, 3, 4].map((level) => (
|
||||
<span
|
||||
key={level}
|
||||
className={`gps-signal-bar ${level <= bars ? 'is-active' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span>{t(gpsQualityI18nKey(quality), i18nParams)}</span>
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Camera, X } from 'lucide-react'
|
||||
import {
|
||||
cameraErrorKeyFromDomException,
|
||||
probeCameraAvailability
|
||||
} from '../utils/cameraAvailability.js'
|
||||
import {
|
||||
captureVideoFrame,
|
||||
preferNativeCameraPicker
|
||||
@@ -15,7 +19,7 @@ interface LiveCameraCaptureProps {
|
||||
onCapture: (blob: Blob) => void
|
||||
}
|
||||
|
||||
type Phase = 'live' | 'preview' | 'native'
|
||||
type Phase = 'checking' | 'live' | 'preview' | 'native'
|
||||
|
||||
export default function LiveCameraCapture({
|
||||
open,
|
||||
@@ -34,7 +38,7 @@ export default function LiveCameraCapture({
|
||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
|
||||
const [phase, setPhase] = useState<Phase>('checking')
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||
const [streamGeneration, setStreamGeneration] = useState(0)
|
||||
@@ -87,12 +91,37 @@ export default function LiveCameraCapture({
|
||||
clearPreview()
|
||||
setCameraError(null)
|
||||
setCapturing(false)
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
setPhase('checking')
|
||||
return
|
||||
}
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
|
||||
let cancelled = false
|
||||
clearPreview()
|
||||
}, [open, stopStream, clearPreview])
|
||||
setCameraError(null)
|
||||
setCapturing(false)
|
||||
setPhase('checking')
|
||||
|
||||
const probe = async () => {
|
||||
const availability = await probeCameraAvailability()
|
||||
if (cancelled) return
|
||||
|
||||
if (availability === 'unsupported') {
|
||||
setCameraError(t('logs.live_photo_camera_unavailable'))
|
||||
return
|
||||
}
|
||||
if (availability === 'none') {
|
||||
setCameraError(t('logs.live_photo_no_camera'))
|
||||
return
|
||||
}
|
||||
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
}
|
||||
|
||||
void probe()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, clearPreview, stopStream, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || phase !== 'live') {
|
||||
@@ -105,11 +134,6 @@ export default function LiveCameraCapture({
|
||||
const start = async () => {
|
||||
setCameraError(null)
|
||||
setReady(false)
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setCameraError(t('logs.live_photo_camera_unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
@@ -141,7 +165,7 @@ export default function LiveCameraCapture({
|
||||
} catch (err) {
|
||||
console.error('Camera access failed:', err)
|
||||
if (!cancelled) {
|
||||
setCameraError(t('logs.live_photo_camera_denied'))
|
||||
setCameraError(t(cameraErrorKeyFromDomException(err)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,7 +240,7 @@ export default function LiveCameraCapture({
|
||||
className="btn secondary live-camera-close"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label={t('logs.confirm_no')}
|
||||
aria-label={t('logs.live_cancel')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -243,6 +267,12 @@ export default function LiveCameraCapture({
|
||||
className="live-camera-preview live-camera-preview-still"
|
||||
/>
|
||||
</div>
|
||||
) : cameraError ? (
|
||||
<div className="live-camera-preview-wrap">
|
||||
<p className="live-camera-loading">{cameraError}</p>
|
||||
</div>
|
||||
) : phase === 'checking' ? (
|
||||
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||
) : phase === 'native' ? (
|
||||
<div className="live-camera-native-prompt">
|
||||
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
|
||||
@@ -256,7 +286,7 @@ export default function LiveCameraCapture({
|
||||
{t('logs.live_photo_open_camera_btn')}
|
||||
</button>
|
||||
</div>
|
||||
) : cameraError && !ready ? null : (
|
||||
) : phase === 'live' ? (
|
||||
<div className="live-camera-preview-wrap">
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -269,7 +299,7 @@ export default function LiveCameraCapture({
|
||||
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{onCaptionChange && (
|
||||
<div className="input-group live-camera-caption">
|
||||
@@ -287,7 +317,7 @@ export default function LiveCameraCapture({
|
||||
|
||||
<div className="live-log-modal-actions live-camera-actions">
|
||||
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
||||
{t('logs.confirm_no')}
|
||||
{t('logs.live_cancel')}
|
||||
</button>
|
||||
|
||||
{showPreview ? (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Camera,
|
||||
Mic,
|
||||
Radio,
|
||||
Sailboat,
|
||||
Undo2,
|
||||
@@ -32,14 +33,16 @@ import {
|
||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||
import {
|
||||
getLastAutoPositionMs,
|
||||
getLastPositionFixWithin,
|
||||
getLatestPositionFix,
|
||||
getLastLoggedPositionWithin,
|
||||
getLatestLoggedPosition,
|
||||
isMotorRunningFromEvents,
|
||||
LIVE_EVENT_CODES,
|
||||
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
|
||||
liveCommentRemark,
|
||||
liveFuelRemark,
|
||||
livePhotoRemark,
|
||||
liveVoiceRemark,
|
||||
parseLiveVoiceRemark,
|
||||
livePrecipRemark,
|
||||
liveSailsRemark,
|
||||
liveSogRemark,
|
||||
@@ -47,12 +50,22 @@ import {
|
||||
liveTempRemark,
|
||||
liveWaterRemark
|
||||
} from '../utils/liveEventCodes.js'
|
||||
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
|
||||
|
||||
const formatSpeedKn = (speedKn: number) =>
|
||||
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
import {
|
||||
geolocationErrorI18nKey,
|
||||
getCurrentPosition,
|
||||
getGeolocationErrorReason,
|
||||
hasSeenGeolocationLiveIntro,
|
||||
markGeolocationLiveIntroSeen,
|
||||
normalizeGpsCoordinates,
|
||||
queryGeolocationPermission
|
||||
queryGeolocationPermission,
|
||||
type GeolocationErrorReason,
|
||||
type GpsSignalQuality
|
||||
} from '../utils/geolocation.js'
|
||||
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
@@ -63,9 +76,15 @@ import {
|
||||
} from '../utils/sailSelection.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import CourseDialInput from './CourseDialInput.tsx'
|
||||
import GpsSignalHint from './GpsSignalHint.tsx'
|
||||
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
||||
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
||||
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
|
||||
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||
import { blobToAudioDataUrl } from '../utils/audioBlob.js'
|
||||
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
||||
|
||||
interface LiveLogViewProps {
|
||||
logbookId: string
|
||||
@@ -88,8 +107,9 @@ type LiveModal =
|
||||
| 'water'
|
||||
| 'sog'
|
||||
| 'stw'
|
||||
| 'fix'
|
||||
| 'position'
|
||||
| 'photo'
|
||||
| 'voice'
|
||||
|
||||
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
|
||||
const AUTO_POSITION_CHECK_MS = 60_000
|
||||
@@ -133,13 +153,21 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function gpsFailureAlertBody(
|
||||
t: (key: string) => string,
|
||||
reason: GeolocationErrorReason
|
||||
): string {
|
||||
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
|
||||
}
|
||||
|
||||
export default function LiveLogView({
|
||||
logbookId,
|
||||
onOpenEditor,
|
||||
onSwitchToList
|
||||
}: LiveLogViewProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
const { showAlert, showConfirm } = useDialog()
|
||||
const [geolocationAccessEpoch, setGeolocationAccessEpoch] = useState(0)
|
||||
|
||||
const [entryId, setEntryId] = useState<string | null>(null)
|
||||
const [dayOfTravel, setDayOfTravel] = useState('')
|
||||
@@ -152,21 +180,30 @@ export default function LiveLogView({
|
||||
const [modal, setModal] = useState<LiveModal>('none')
|
||||
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
||||
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [valueInput, setValueInput] = useState('')
|
||||
const [valueInputSecondary, setValueInputSecondary] = useState('')
|
||||
const [selectedSails, setSelectedSails] = useState<string[]>([])
|
||||
const [undoVisible, setUndoVisible] = useState(false)
|
||||
const [fixLat, setFixLat] = useState('')
|
||||
const [fixLng, setFixLng] = useState('')
|
||||
const [fixGpsLoading, setFixGpsLoading] = useState(false)
|
||||
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false)
|
||||
const [positionLat, setPositionLat] = useState('')
|
||||
const [positionLng, setPositionLng] = useState('')
|
||||
const [positionGpsLoading, setPositionGpsLoading] = useState(false)
|
||||
const [positionGpsUnavailable, setPositionGpsUnavailable] = useState(false)
|
||||
const [positionGpsErrorReason, setPositionGpsErrorReason] = useState<GeolocationErrorReason | null>(null)
|
||||
const [positionGpsSignal, setPositionGpsSignal] = useState<{
|
||||
quality: GpsSignalQuality
|
||||
accuracyM: number | null
|
||||
} | null>(null)
|
||||
const [photoCaption, setPhotoCaption] = useState('')
|
||||
const [photoSaving, setPhotoSaving] = useState(false)
|
||||
const [undoHint, setUndoHint] = useState<'event' | 'photo'>('event')
|
||||
const [voiceCaption, setVoiceCaption] = useState('')
|
||||
const [voiceSaving, setVoiceSaving] = useState(false)
|
||||
const [undoHint, setUndoHint] = useState<'event' | 'photo' | 'voice'>('event')
|
||||
|
||||
const streamEndRef = useRef<HTMLDivElement | null>(null)
|
||||
const undoPhotoIdRef = useRef<string | null>(null)
|
||||
const undoVoiceIdRef = useRef<string | null>(null)
|
||||
const undoTimerRef = useRef<number | null>(null)
|
||||
const autoPositionBusyRef = useRef(false)
|
||||
const busyRef = useRef(busy)
|
||||
@@ -189,10 +226,11 @@ export default function LiveLogView({
|
||||
)
|
||||
const motorRunning = isMotorRunningFromEvents(events)
|
||||
const motorLabel = t('logs.motor_propulsion')
|
||||
const hasPositionFix = useMemo(
|
||||
() => (date ? getLatestPositionFix(events, date) != null : false),
|
||||
const hasLoggedPosition = useMemo(
|
||||
() => (date ? getLatestLoggedPosition(events, date) != null : false),
|
||||
[events, date]
|
||||
)
|
||||
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
|
||||
|
||||
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
@@ -207,7 +245,7 @@ export default function LiveLogView({
|
||||
applyLoadedEntry(loaded)
|
||||
}, [logbookId, applyLoadedEntry])
|
||||
|
||||
const showUndo = useCallback((hint: 'event' | 'photo' = 'event') => {
|
||||
const showUndo = useCallback((hint: 'event' | 'photo' | 'voice' = 'event') => {
|
||||
setUndoHint(hint)
|
||||
setUndoVisible(true)
|
||||
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
|
||||
@@ -215,6 +253,7 @@ export default function LiveLogView({
|
||||
setUndoVisible(false)
|
||||
undoTimerRef.current = null
|
||||
undoPhotoIdRef.current = null
|
||||
undoVoiceIdRef.current = null
|
||||
}, UNDO_TIMEOUT_MS)
|
||||
}, [])
|
||||
|
||||
@@ -269,6 +308,17 @@ export default function LiveLogView({
|
||||
}
|
||||
}, [logbookId, applyLoadedEntry, t])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void runInit()
|
||||
return () => {
|
||||
@@ -284,6 +334,56 @@ export default function LiveLogView({
|
||||
}
|
||||
}, [loading, entryId])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !entryId || !navigator.geolocation) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
const permission = await queryGeolocationPermission()
|
||||
if (cancelled) return
|
||||
|
||||
if (permission === 'granted') {
|
||||
markGeolocationLiveIntroSeen()
|
||||
setGeolocationAccessEpoch((n) => n + 1)
|
||||
return
|
||||
}
|
||||
|
||||
// Only ask when the browser has not granted location yet (state "prompt").
|
||||
if (permission !== 'prompt' || hasSeenGeolocationLiveIntro()) return
|
||||
|
||||
const allow = await showConfirm(
|
||||
t('logs.gps_live_intro_body'),
|
||||
t('logs.gps_live_intro_title'),
|
||||
t('logs.gps_live_intro_allow'),
|
||||
t('logs.gps_live_intro_later')
|
||||
)
|
||||
markGeolocationLiveIntroSeen()
|
||||
if (cancelled || !allow) return
|
||||
|
||||
try {
|
||||
await getCurrentPosition({
|
||||
timeoutMs: 15_000,
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 0
|
||||
})
|
||||
if (!cancelled) setGeolocationAccessEpoch((n) => n + 1)
|
||||
} catch (err) {
|
||||
const reason = getGeolocationErrorReason(err)
|
||||
if (reason === 'permission_denied') {
|
||||
await showAlert(
|
||||
`${t('logs.gps_permission_denied')}\n\n${t('logs.gps_enable_in_settings_hint')}`,
|
||||
t('logs.live_title')
|
||||
)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [loading, entryId, showAlert, showConfirm, t])
|
||||
|
||||
useEffect(() => {
|
||||
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [events.length])
|
||||
@@ -332,7 +432,7 @@ export default function LiveLogView({
|
||||
})
|
||||
await refreshEntry(entryId)
|
||||
} catch {
|
||||
// Best-effort; hint banner shows when no position fix exists yet.
|
||||
// Best-effort; hint banner shows when no position has been logged yet.
|
||||
} finally {
|
||||
autoPositionBusyRef.current = false
|
||||
}
|
||||
@@ -351,7 +451,7 @@ export default function LiveLogView({
|
||||
if (startTimer !== undefined) window.clearTimeout(startTimer)
|
||||
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
||||
}
|
||||
}, [entryId, loading, logbookId, refreshEntry])
|
||||
}, [entryId, loading, logbookId, refreshEntry, geolocationAccessEpoch])
|
||||
|
||||
const runQuickAction = async (
|
||||
action: () => Promise<boolean | void>,
|
||||
@@ -427,16 +527,26 @@ export default function LiveLogView({
|
||||
}, 'moor')
|
||||
}
|
||||
|
||||
const openFixModal = async () => {
|
||||
setFixLat('')
|
||||
setFixLng('')
|
||||
setFixGpsUnavailable(false)
|
||||
setFixGpsLoading(true)
|
||||
setModal('fix')
|
||||
const reportPositionGpsFailure = async (reason: GeolocationErrorReason) => {
|
||||
setPositionGpsUnavailable(true)
|
||||
setPositionGpsErrorReason(reason)
|
||||
setPositionGpsSignal(null)
|
||||
await showAlert(gpsFailureAlertBody(t, reason), t('logs.live_position'))
|
||||
}
|
||||
|
||||
const openPositionModal = async () => {
|
||||
setPositionLat('')
|
||||
setPositionLng('')
|
||||
setPositionGpsUnavailable(false)
|
||||
setPositionGpsErrorReason(null)
|
||||
setPositionGpsSignal(null)
|
||||
setPositionGpsLoading(true)
|
||||
setModal('position')
|
||||
try {
|
||||
const permission = await queryGeolocationPermission()
|
||||
if (permission !== 'granted') {
|
||||
setFixGpsUnavailable(true)
|
||||
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
|
||||
await reportPositionGpsFailure(reason)
|
||||
return
|
||||
}
|
||||
const coords = await getCurrentPosition({
|
||||
@@ -444,26 +554,26 @@ export default function LiveLogView({
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 60_000
|
||||
})
|
||||
setFixLat(coords.lat)
|
||||
setFixLng(coords.lng)
|
||||
} catch {
|
||||
setFixGpsUnavailable(true)
|
||||
setPositionLat(coords.lat)
|
||||
setPositionLng(coords.lng)
|
||||
setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
|
||||
} catch (err) {
|
||||
await reportPositionGpsFailure(getGeolocationErrorReason(err))
|
||||
} finally {
|
||||
setFixGpsLoading(false)
|
||||
setPositionGpsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const retryFixGps = async () => {
|
||||
setFixGpsLoading(true)
|
||||
setFixGpsUnavailable(false)
|
||||
const retryPositionGps = async () => {
|
||||
setPositionGpsLoading(true)
|
||||
setPositionGpsUnavailable(false)
|
||||
setPositionGpsErrorReason(null)
|
||||
setPositionGpsSignal(null)
|
||||
try {
|
||||
const permission = await queryGeolocationPermission()
|
||||
if (permission !== 'granted') {
|
||||
setFixGpsUnavailable(true)
|
||||
await showAlert(
|
||||
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
||||
t('logs.live_fix')
|
||||
)
|
||||
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
|
||||
await reportPositionGpsFailure(reason)
|
||||
return
|
||||
}
|
||||
const coords = await getCurrentPosition({
|
||||
@@ -471,23 +581,21 @@ export default function LiveLogView({
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 60_000
|
||||
})
|
||||
setFixLat(coords.lat)
|
||||
setFixLng(coords.lng)
|
||||
} catch {
|
||||
setFixGpsUnavailable(true)
|
||||
await showAlert(
|
||||
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
||||
t('logs.live_fix')
|
||||
)
|
||||
setPositionLat(coords.lat)
|
||||
setPositionLng(coords.lng)
|
||||
setPositionGpsUnavailable(false)
|
||||
setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
|
||||
} catch (err) {
|
||||
await reportPositionGpsFailure(getGeolocationErrorReason(err))
|
||||
} finally {
|
||||
setFixGpsLoading(false)
|
||||
setPositionGpsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmFix = () => {
|
||||
const coords = normalizeGpsCoordinates(fixLat, fixLng)
|
||||
const confirmPosition = () => {
|
||||
const coords = normalizeGpsCoordinates(positionLat, positionLng)
|
||||
if (!coords) {
|
||||
void showAlert(t('logs.live_fix_invalid'), t('logs.live_fix'))
|
||||
void showAlert(t('logs.live_position_invalid'), t('logs.live_position'))
|
||||
return
|
||||
}
|
||||
setModal('none')
|
||||
@@ -496,25 +604,29 @@ export default function LiveLogView({
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
gpsLat: coords.lat,
|
||||
gpsLng: coords.lng,
|
||||
remarks: LIVE_EVENT_CODES.FIX
|
||||
remarks: LIVE_EVENT_CODES.POSITION
|
||||
})
|
||||
}, 'fix')
|
||||
}, 'position')
|
||||
}
|
||||
|
||||
const handleFetchOwmWeather = () => {
|
||||
if (!entryId || busy || weatherOwmLoading) return
|
||||
if (!isOnline) {
|
||||
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
||||
return
|
||||
}
|
||||
|
||||
const position = getLastPositionFixWithin(
|
||||
const position = getLastLoggedPositionWithin(
|
||||
events,
|
||||
date,
|
||||
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
|
||||
)
|
||||
if (!position) {
|
||||
const latest = getLatestPositionFix(events, date)
|
||||
const latest = getLatestLoggedPosition(events, date)
|
||||
void showAlert(
|
||||
latest
|
||||
? t('logs.live_weather_fix_stale')
|
||||
: t('logs.live_weather_fix_required'),
|
||||
? t('logs.live_weather_position_stale')
|
||||
: t('logs.live_weather_position_required'),
|
||||
t('logs.live_weather_owm_btn')
|
||||
)
|
||||
return
|
||||
@@ -533,6 +645,10 @@ export default function LiveLogView({
|
||||
{ analyticsSource: 'live_log' }
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
|
||||
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
||||
return
|
||||
}
|
||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
||||
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
||||
return
|
||||
@@ -590,8 +706,10 @@ export default function LiveLogView({
|
||||
const handleUndo = () => {
|
||||
if (!entryId || busy) return
|
||||
const photoId = undoPhotoIdRef.current
|
||||
const voiceId = undoVoiceIdRef.current
|
||||
setUndoVisible(false)
|
||||
undoPhotoIdRef.current = null
|
||||
undoVoiceIdRef.current = null
|
||||
if (undoTimerRef.current) {
|
||||
window.clearTimeout(undoTimerRef.current)
|
||||
undoTimerRef.current = null
|
||||
@@ -600,6 +718,9 @@ export default function LiveLogView({
|
||||
if (photoId) {
|
||||
await deleteEntryPhoto(logbookId, photoId)
|
||||
}
|
||||
if (voiceId) {
|
||||
await deleteEntryVoiceMemo(logbookId, voiceId)
|
||||
}
|
||||
await removeLastEvent(logbookId, entryId)
|
||||
}, 'undo', false)
|
||||
}
|
||||
@@ -615,6 +736,56 @@ export default function LiveLogView({
|
||||
setPhotoCaption('')
|
||||
}
|
||||
|
||||
const openVoiceModal = () => {
|
||||
setVoiceCaption('')
|
||||
setModal('voice')
|
||||
}
|
||||
|
||||
const closeVoiceModal = () => {
|
||||
if (voiceSaving) return
|
||||
setModal('none')
|
||||
setVoiceCaption('')
|
||||
}
|
||||
|
||||
const handleVoiceSave = (blob: Blob, mimeType: string, durationSec: number) => {
|
||||
if (!entryId || voiceSaving) return
|
||||
const caption = voiceCaption.trim()
|
||||
setVoiceSaving(true)
|
||||
void (async () => {
|
||||
try {
|
||||
const audioDataUrl = await blobToAudioDataUrl(blob)
|
||||
const voiceId = await saveEntryVoiceMemo({
|
||||
logbookId,
|
||||
entryId,
|
||||
audioDataUrl,
|
||||
mimeType,
|
||||
durationSec,
|
||||
caption,
|
||||
analyticsContext: 'live_log'
|
||||
})
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
remarks: liveVoiceRemark(voiceId)
|
||||
})
|
||||
await refreshEntry(entryId)
|
||||
undoVoiceIdRef.current = voiceId
|
||||
setModal('none')
|
||||
setVoiceCaption('')
|
||||
showUndo('voice')
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
|
||||
} catch (err: unknown) {
|
||||
console.error('Live log voice save failed:', err)
|
||||
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
|
||||
? t('logs.live_voice_too_large')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: t('logs.live_voice_error')
|
||||
void showAlert(msg, t('logs.live_voice_btn'))
|
||||
} finally {
|
||||
setVoiceSaving(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const handlePhotoCapture = (blob: Blob) => {
|
||||
if (!entryId || photoSaving) return
|
||||
const caption = photoCaption.trim()
|
||||
@@ -754,45 +925,45 @@ export default function LiveLogView({
|
||||
break
|
||||
}
|
||||
case 'fuel': {
|
||||
const liters = parseFloat(primary)
|
||||
if (!Number.isFinite(liters) || liters <= 0) return
|
||||
const liters = parseAppDecimal(primary)
|
||||
if (liters == null || liters <= 0) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendTankRefill(logbookId, entryId, 'fuel', liters, {
|
||||
remarks: liveFuelRemark(String(liters))
|
||||
remarks: liveFuelRemark(formatTankLiters(liters))
|
||||
})
|
||||
}, 'fuel')
|
||||
break
|
||||
}
|
||||
case 'water': {
|
||||
const liters = parseFloat(primary)
|
||||
if (!Number.isFinite(liters) || liters <= 0) return
|
||||
const liters = parseAppDecimal(primary)
|
||||
if (liters == null || liters <= 0) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
|
||||
remarks: liveWaterRemark(String(liters))
|
||||
remarks: liveWaterRemark(formatTankLiters(liters))
|
||||
})
|
||||
}, 'water')
|
||||
break
|
||||
}
|
||||
case 'sog': {
|
||||
const speedKn = parseFloat(primary.replace(',', '.'))
|
||||
if (!Number.isFinite(speedKn) || speedKn < 0) return
|
||||
const speedKn = parseAppDecimal(primary)
|
||||
if (speedKn == null || speedKn < 0) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
remarks: liveSogRemark(String(speedKn))
|
||||
remarks: liveSogRemark(formatSpeedKn(speedKn))
|
||||
})
|
||||
}, 'sog')
|
||||
break
|
||||
}
|
||||
case 'stw': {
|
||||
const speedKn = parseFloat(primary.replace(',', '.'))
|
||||
if (!Number.isFinite(speedKn) || speedKn < 0) return
|
||||
const speedKn = parseAppDecimal(primary)
|
||||
if (speedKn == null || speedKn < 0) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
remarks: liveStwRemark(String(speedKn))
|
||||
remarks: liveStwRemark(formatSpeedKn(speedKn))
|
||||
})
|
||||
}, 'stw')
|
||||
break
|
||||
@@ -856,7 +1027,7 @@ export default function LiveLogView({
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{!hasPositionFix && (
|
||||
{!hasLoggedPosition && (
|
||||
<p className="live-log-gps-hint" role="status">
|
||||
<MapPin size={16} aria-hidden />
|
||||
{t('logs.live_gps_start_hint')}
|
||||
@@ -947,9 +1118,9 @@ export default function LiveLogView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className="live-log-action-btn" onClick={() => void openFixModal()} disabled={busy}>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => void openPositionModal()} disabled={busy}>
|
||||
<MapPin size={18} />
|
||||
{t('logs.live_fix')}
|
||||
{t('logs.live_position')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
|
||||
<MessageSquare size={18} />
|
||||
@@ -959,6 +1130,10 @@ export default function LiveLogView({
|
||||
<Camera size={18} />
|
||||
{t('logs.live_photo_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={openVoiceModal} disabled={busy || voiceSaving}>
|
||||
<Mic size={18} />
|
||||
{t('logs.live_voice_btn')}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
|
||||
@@ -967,12 +1142,30 @@ export default function LiveLogView({
|
||||
<p className="live-log-empty">{t('logs.live_no_events')}</p>
|
||||
) : (
|
||||
<ol className="live-log-stream">
|
||||
{events.map((event, index) => (
|
||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||
<time className="live-log-time">{event.time}</time>
|
||||
<span className="live-log-summary">{formatEventSummary(event, t)}</span>
|
||||
</li>
|
||||
))}
|
||||
{events.map((event, index) => {
|
||||
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
||||
const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
|
||||
let summary = formatEventSummary(event, t)
|
||||
if (voiceId && voicePreloaded?.caption) {
|
||||
summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption })
|
||||
}
|
||||
return (
|
||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||
<time className="live-log-time">{event.time}</time>
|
||||
<div className="live-log-summary-block">
|
||||
<span className="live-log-summary">{summary}</span>
|
||||
{voiceId && (
|
||||
<VoiceMemoPlayer
|
||||
audioId={voiceId}
|
||||
logbookId={logbookId}
|
||||
preloaded={voicePreloaded}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<div ref={streamEndRef} />
|
||||
</ol>
|
||||
)}
|
||||
@@ -986,7 +1179,11 @@ export default function LiveLogView({
|
||||
<div className="live-log-undo-bar" role="status">
|
||||
<div className="live-log-undo-bar-inner">
|
||||
<span>
|
||||
{undoHint === 'photo' ? t('logs.live_undo_photo_hint') : t('logs.live_undo_hint')}
|
||||
{undoHint === 'photo'
|
||||
? t('logs.live_undo_photo_hint')
|
||||
: undoHint === 'voice'
|
||||
? t('logs.live_undo_voice_hint')
|
||||
: t('logs.live_undo_hint')}
|
||||
</span>
|
||||
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
|
||||
<Undo2 size={16} />
|
||||
@@ -1030,7 +1227,7 @@ export default function LiveLogView({
|
||||
</p>
|
||||
)}
|
||||
<div className="live-log-modal-actions">
|
||||
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button>
|
||||
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
@@ -1046,68 +1243,79 @@ export default function LiveLogView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal === 'fix' && (
|
||||
{modal === 'position' && (
|
||||
<div
|
||||
className="live-log-modal-backdrop"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
|
||||
>
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t('logs.live_fix')}</h3>
|
||||
{fixGpsUnavailable && (
|
||||
<h3>{t('logs.live_position')}</h3>
|
||||
{positionGpsUnavailable && (
|
||||
<>
|
||||
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p>
|
||||
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
|
||||
{positionGpsErrorReason && (
|
||||
<p className="live-log-modal-hint live-log-gps-error-modal" role="alert">
|
||||
{t(geolocationErrorI18nKey(positionGpsErrorReason))}
|
||||
</p>
|
||||
)}
|
||||
<p className="live-log-modal-hint">{t('logs.live_position_manual_hint')}</p>
|
||||
</>
|
||||
)}
|
||||
<fieldset className="live-log-fix-coords" disabled={busy}>
|
||||
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
||||
<div className="live-log-fix-coords-row">
|
||||
<label className="live-log-fix-field">
|
||||
<span className="live-log-fix-field-label">{t('logs.live_fix_lat_placeholder')}</span>
|
||||
<fieldset className="live-log-position-coords" disabled={busy}>
|
||||
<legend className="live-log-position-label">{t('logs.event_gps')}</legend>
|
||||
<div className="live-log-position-coords-row">
|
||||
<label className="live-log-position-field">
|
||||
<span className="live-log-position-field-label">{t('logs.live_position_lat_placeholder')}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
placeholder="54.123456"
|
||||
value={fixLat}
|
||||
onChange={(e) => setFixLat(e.target.value)}
|
||||
value={positionLat}
|
||||
onChange={(e) => { setPositionGpsSignal(null); setPositionLat(e.target.value) }}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="live-log-fix-field">
|
||||
<span className="live-log-fix-field-label">{t('logs.live_fix_lng_placeholder')}</span>
|
||||
<label className="live-log-position-field">
|
||||
<span className="live-log-position-field-label">{t('logs.live_position_lng_placeholder')}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
placeholder="10.654321"
|
||||
value={fixLng}
|
||||
onChange={(e) => setFixLng(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') confirmFix() }}
|
||||
value={positionLng}
|
||||
onChange={(e) => { setPositionGpsSignal(null); setPositionLng(e.target.value) }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') confirmPosition() }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="live-log-fix-gps-row">
|
||||
{positionGpsSignal && (
|
||||
<GpsSignalHint
|
||||
quality={positionGpsSignal.quality}
|
||||
accuracyM={positionGpsSignal.accuracyM}
|
||||
className="gps-signal-hint-modal"
|
||||
/>
|
||||
)}
|
||||
<div className="live-log-position-gps-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary live-log-fix-gps-btn"
|
||||
onClick={() => void retryFixGps()}
|
||||
className="btn secondary live-log-position-gps-btn"
|
||||
onClick={() => void retryPositionGps()}
|
||||
title={t('logs.gps_btn')}
|
||||
disabled={fixGpsLoading}
|
||||
disabled={positionGpsLoading}
|
||||
aria-label={t('logs.gps_btn')}
|
||||
>
|
||||
<MapPin size={16} />
|
||||
<span>{fixGpsLoading ? t('logs.live_fix_gps_loading') : t('logs.gps_btn')}</span>
|
||||
<span>{positionGpsLoading ? t('logs.live_position_gps_loading') : t('logs.gps_btn')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="live-log-modal-actions">
|
||||
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button>
|
||||
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={confirmFix}
|
||||
disabled={busy || !normalizeGpsCoordinates(fixLat, fixLng)}
|
||||
onClick={confirmPosition}
|
||||
disabled={busy || !normalizeGpsCoordinates(positionLat, positionLng)}
|
||||
>
|
||||
{t('logs.live_sails_confirm')}
|
||||
</button>
|
||||
@@ -1122,7 +1330,7 @@ export default function LiveLogView({
|
||||
<h3>{t('logs.live_comment_btn')}</h3>
|
||||
<input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} />
|
||||
<div className="live-log-modal-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
|
||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
|
||||
<button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1156,7 +1364,7 @@ export default function LiveLogView({
|
||||
/>
|
||||
</div>
|
||||
<div className="live-log-modal-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
|
||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
|
||||
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1178,7 +1386,7 @@ export default function LiveLogView({
|
||||
/>
|
||||
</div>
|
||||
<div className="live-log-modal-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
|
||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
|
||||
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1223,7 +1431,7 @@ export default function LiveLogView({
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }}
|
||||
/>
|
||||
<div className="live-log-modal-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
|
||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
|
||||
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1238,6 +1446,15 @@ export default function LiveLogView({
|
||||
onClose={closePhotoModal}
|
||||
onCapture={handlePhotoCapture}
|
||||
/>
|
||||
|
||||
<LiveVoiceCapture
|
||||
open={modal === 'voice'}
|
||||
busy={voiceSaving}
|
||||
caption={voiceCaption}
|
||||
onCaptionChange={setVoiceCaption}
|
||||
onClose={closeVoiceModal}
|
||||
onSave={handleVoiceSave}
|
||||
/>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Mic, Square, X } from 'lucide-react'
|
||||
import {
|
||||
assertVoiceMemoBlobSize,
|
||||
formatVoiceDuration,
|
||||
pickMediaRecorderMimeType,
|
||||
VOICE_MEMO_MAX_DURATION_SEC
|
||||
} from '../utils/audioBlob.js'
|
||||
|
||||
interface LiveVoiceCaptureProps {
|
||||
open: boolean
|
||||
busy?: boolean
|
||||
caption?: string
|
||||
onCaptionChange?: (value: string) => void
|
||||
onClose: () => void
|
||||
onSave: (blob: Blob, mimeType: string, durationSec: number) => void
|
||||
}
|
||||
|
||||
type Phase = 'idle' | 'recording' | 'preview'
|
||||
|
||||
export default function LiveVoiceCapture({
|
||||
open,
|
||||
busy = false,
|
||||
caption = '',
|
||||
onCaptionChange,
|
||||
onClose,
|
||||
onSave
|
||||
}: LiveVoiceCaptureProps) {
|
||||
const { t } = useTranslation()
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
const previewUrlRef = useRef<string | null>(null)
|
||||
const startedAtRef = useRef<number>(0)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
const [micError, setMicError] = useState<string | null>(null)
|
||||
const [elapsedSec, setElapsedSec] = useState(0)
|
||||
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewMime, setPreviewMime] = useState('audio/webm')
|
||||
const [previewDurationSec, setPreviewDurationSec] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||
track.stop()
|
||||
}
|
||||
streamRef.current = null
|
||||
}, [])
|
||||
|
||||
const clearPreview = useCallback(() => {
|
||||
if (previewUrlRef.current) {
|
||||
URL.revokeObjectURL(previewUrlRef.current)
|
||||
previewUrlRef.current = null
|
||||
}
|
||||
setPreviewUrl(null)
|
||||
setPreviewBlob(null)
|
||||
}, [])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current != null) {
|
||||
window.clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
if (mediaRecorderRef.current?.state === 'recording') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
mediaRecorderRef.current = null
|
||||
chunksRef.current = []
|
||||
clearTimer()
|
||||
stopStream()
|
||||
clearPreview()
|
||||
setPhase('idle')
|
||||
setMicError(null)
|
||||
setElapsedSec(0)
|
||||
setSaving(false)
|
||||
}, [stopStream, clearPreview, clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
resetAll()
|
||||
}
|
||||
}, [open, resetAll])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resetAll()
|
||||
}
|
||||
}, [resetAll])
|
||||
|
||||
const finishRecording = useCallback((blob: Blob, mimeType: string, durationSec: number) => {
|
||||
clearPreview()
|
||||
const url = URL.createObjectURL(blob)
|
||||
previewUrlRef.current = url
|
||||
setPreviewBlob(blob)
|
||||
setPreviewUrl(url)
|
||||
setPreviewMime(mimeType)
|
||||
setPreviewDurationSec(durationSec)
|
||||
setPhase('preview')
|
||||
}, [clearPreview])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
const recorder = mediaRecorderRef.current
|
||||
if (!recorder || recorder.state !== 'recording') return
|
||||
recorder.stop()
|
||||
clearTimer()
|
||||
}, [clearTimer])
|
||||
|
||||
const startRecording = async () => {
|
||||
setMicError(null)
|
||||
chunksRef.current = []
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setMicError(t('logs.live_voice_mic_denied'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
streamRef.current = stream
|
||||
const mimeType = pickMediaRecorderMimeType()
|
||||
const recorder = mimeType
|
||||
? new MediaRecorder(stream, { mimeType })
|
||||
: new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = recorder
|
||||
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
|
||||
|
||||
recorder.ondataavailable = (ev) => {
|
||||
if (ev.data.size > 0) chunksRef.current.push(ev.data)
|
||||
}
|
||||
|
||||
recorder.onstop = () => {
|
||||
const durationSec = Math.min(
|
||||
VOICE_MEMO_MAX_DURATION_SEC,
|
||||
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
|
||||
)
|
||||
const blob = new Blob(chunksRef.current, { type: resolvedMime })
|
||||
chunksRef.current = []
|
||||
stopStream()
|
||||
try {
|
||||
assertVoiceMemoBlobSize(blob)
|
||||
finishRecording(blob, resolvedMime, durationSec)
|
||||
} catch {
|
||||
setMicError(t('logs.live_voice_too_large'))
|
||||
setPhase('idle')
|
||||
}
|
||||
}
|
||||
|
||||
recorder.onerror = () => {
|
||||
setMicError(t('logs.live_voice_record_failed'))
|
||||
resetAll()
|
||||
}
|
||||
|
||||
startedAtRef.current = Date.now()
|
||||
recorder.start(200)
|
||||
setPhase('recording')
|
||||
setElapsedSec(0)
|
||||
timerRef.current = window.setInterval(() => {
|
||||
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
|
||||
setElapsedSec(sec)
|
||||
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
|
||||
stopRecording()
|
||||
}
|
||||
}, 250)
|
||||
} catch {
|
||||
setMicError(t('logs.live_voice_mic_denied'))
|
||||
stopStream()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!previewBlob || saving || busy) return
|
||||
setSaving(true)
|
||||
try {
|
||||
onSave(previewBlob, previewMime, previewDurationSec)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="live-log-modal-backdrop"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !busy && !saving && phase !== 'recording') onClose()
|
||||
}}
|
||||
>
|
||||
<div className="live-log-modal live-voice-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="live-voice-modal-header">
|
||||
<h3>{t('logs.live_voice_btn')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={onClose}
|
||||
disabled={busy || saving || phase === 'recording'}
|
||||
aria-label={t('logs.live_cancel')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{micError && <p className="live-log-modal-hint auth-error">{micError}</p>}
|
||||
|
||||
{phase === 'idle' && (
|
||||
<>
|
||||
<p className="live-log-modal-hint">{t('logs.live_voice_hint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary live-voice-record-btn"
|
||||
onClick={() => void startRecording()}
|
||||
disabled={busy || saving}
|
||||
>
|
||||
<Mic size={18} />
|
||||
{t('logs.live_voice_record')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'recording' && (
|
||||
<>
|
||||
<p className="live-voice-recording-indicator" role="status" aria-live="polite">
|
||||
<span className="live-voice-recording-dot" aria-hidden />
|
||||
{t('logs.live_voice_recording', { time: formatVoiceDuration(elapsedSec) })}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary live-voice-stop-btn"
|
||||
onClick={stopRecording}
|
||||
>
|
||||
<Square size={16} fill="currentColor" />
|
||||
{t('logs.live_voice_stop')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'preview' && previewUrl && (
|
||||
<>
|
||||
<audio className="voice-memo-player" controls src={previewUrl} preload="auto" />
|
||||
{onCaptionChange && (
|
||||
<label className="live-voice-caption-field">
|
||||
<span>{t('logs.live_voice_caption_label')}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={caption}
|
||||
onChange={(e) => onCaptionChange(e.target.value)}
|
||||
placeholder={t('logs.live_voice_caption_placeholder')}
|
||||
disabled={busy || saving}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="live-log-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
clearPreview()
|
||||
setPhase('idle')
|
||||
}}
|
||||
disabled={busy || saving}
|
||||
>
|
||||
{t('logs.live_voice_retake')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={busy || saving}
|
||||
>
|
||||
{saving ? t('logs.live_voice_saving') : t('logs.live_voice_save')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { decryptJson, encryptJson } from '../services/crypto.js'
|
||||
import { encryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { findTodayEntryId } from '../services/quickEventLog.js'
|
||||
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||
import { localDateString } from '../utils/logEntryPayload.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import LiveLogView from './LiveLogView.tsx'
|
||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||
@@ -39,6 +40,7 @@ interface LogEntriesListProps {
|
||||
preloadedYacht?: any
|
||||
preloadedEntries?: any[]
|
||||
preloadedPhotos?: any[]
|
||||
preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[]
|
||||
preloadedGpsTracks?: any[]
|
||||
controlledSelectedEntryId?: string | null
|
||||
onSelectedEntryIdChange?: (id: string | null) => void
|
||||
@@ -63,6 +65,7 @@ export default function LogEntriesList({
|
||||
preloadedYacht,
|
||||
preloadedEntries,
|
||||
preloadedPhotos,
|
||||
preloadedVoiceMemos,
|
||||
preloadedGpsTracks,
|
||||
controlledSelectedEntryId,
|
||||
onSelectedEntryIdChange,
|
||||
@@ -121,6 +124,11 @@ export default function LogEntriesList({
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const todayEntryId = await findTodayEntryId(logbookId)
|
||||
if (todayEntryId) {
|
||||
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
|
||||
}
|
||||
|
||||
const local = await db.entries.where({ logbookId }).toArray()
|
||||
|
||||
const list: DecryptedEntryItem[] = []
|
||||
@@ -136,7 +144,7 @@ export default function LogEntriesList({
|
||||
}
|
||||
|
||||
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (!decrypted) return
|
||||
|
||||
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
|
||||
@@ -266,7 +274,7 @@ export default function LogEntriesList({
|
||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||
|
||||
for (const entry of localEntries) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
||||
}
|
||||
|
||||
@@ -298,7 +306,7 @@ export default function LogEntriesList({
|
||||
|
||||
const localId = window.crypto.randomUUID()
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
const todayStr = localDateString()
|
||||
|
||||
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
|
||||
const entryCrew = await loadDefaultEntryCrewForNewDay(
|
||||
@@ -403,6 +411,7 @@ export default function LogEntriesList({
|
||||
readOnly={readOnly}
|
||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||
preloadedPhotos={preloadedPhotos}
|
||||
preloadedVoiceMemos={preloadedVoiceMemos}
|
||||
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,8 +8,13 @@ import { syncLogbook } from '../services/sync.js'
|
||||
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import EventRemarksCell from './EventRemarksCell.tsx'
|
||||
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||
import type { PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
||||
import SignatureSection from './SignatureSection.tsx'
|
||||
import EntryCrewSection from './EntryCrewSection.tsx'
|
||||
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
|
||||
@@ -37,6 +42,13 @@ import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
import {
|
||||
buildTravelDayContext,
|
||||
fetchTravelDaySummaryUsage,
|
||||
generateTravelDaySummary,
|
||||
TravelDaySummaryApiError
|
||||
} from '../services/aiSummary.js'
|
||||
import { tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||
import {
|
||||
getDecryptedTrack,
|
||||
saveUploadedTrack,
|
||||
@@ -55,6 +67,14 @@ import {
|
||||
} from '../services/nmeaArchive.js'
|
||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import GpsSignalHint from './GpsSignalHint.tsx'
|
||||
import {
|
||||
geolocationErrorI18nKey,
|
||||
getCurrentPosition,
|
||||
getGeolocationErrorReason,
|
||||
queryGeolocationPermission,
|
||||
type GpsSignalQuality
|
||||
} from '../utils/geolocation.js'
|
||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||
import TankLiterInput from './TankLiterInput.tsx'
|
||||
import MetricRangeInput from './MetricRangeInput.tsx'
|
||||
@@ -83,6 +103,17 @@ import {
|
||||
formatTankLitersForInput,
|
||||
type VesselTankCapacities
|
||||
} from '../utils/tankCapacity.js'
|
||||
import {
|
||||
formatAppCoordinate,
|
||||
parseAppDecimal,
|
||||
parseAppDecimalOrZero
|
||||
} from '../utils/numberFormat.js'
|
||||
|
||||
function parseOptionalFormDecimal(input: string): number | undefined {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return undefined
|
||||
return parseAppDecimal(trimmed) ?? undefined
|
||||
}
|
||||
|
||||
function emptyTankLevels() {
|
||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||
@@ -117,19 +148,19 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||
trackDistanceNm:
|
||||
trackDistance != null && trackDistance !== ''
|
||||
? parseFloat(String(trackDistance))
|
||||
? (parseAppDecimal(String(trackDistance)) ?? undefined)
|
||||
: undefined,
|
||||
trackSpeedMaxKn:
|
||||
trackSpeedMax != null && trackSpeedMax !== ''
|
||||
? parseFloat(String(trackSpeedMax))
|
||||
? (parseAppDecimal(String(trackSpeedMax)) ?? undefined)
|
||||
: undefined,
|
||||
trackSpeedAvgKn:
|
||||
trackSpeedAvg != null && trackSpeedAvg !== ''
|
||||
? parseFloat(String(trackSpeedAvg))
|
||||
? (parseAppDecimal(String(trackSpeedAvg)) ?? undefined)
|
||||
: undefined,
|
||||
motorHours:
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? parseFloat(String(motorHoursRaw))
|
||||
? (parseAppDecimal(String(motorHoursRaw)) ?? undefined)
|
||||
: undefined,
|
||||
events: (decrypted.events as LogEventPayload[]) || [],
|
||||
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
||||
@@ -149,6 +180,7 @@ interface LogEntryEditorProps {
|
||||
readOnly?: boolean
|
||||
preloadedEntry?: any
|
||||
preloadedPhotos?: any[]
|
||||
preloadedVoiceMemos?: PreloadedVoiceMemo[]
|
||||
preloadedTrack?: any
|
||||
preloadedYacht?: any
|
||||
}
|
||||
@@ -162,6 +194,7 @@ export default function LogEntryEditor({
|
||||
readOnly = false,
|
||||
preloadedEntry,
|
||||
preloadedPhotos,
|
||||
preloadedVoiceMemos,
|
||||
preloadedTrack,
|
||||
preloadedYacht
|
||||
}: LogEntryEditorProps) {
|
||||
@@ -199,6 +232,13 @@ export default function LogEntryEditor({
|
||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||
const [canSignSkipper, setCanSignSkipper] = useState(false)
|
||||
const [canSignCrew, setCanSignCrew] = useState(false)
|
||||
|
||||
const [aiSummary, setAiSummary] = useState('')
|
||||
const [aiSummaryGeneratedAt, setAiSummaryGeneratedAt] = useState('')
|
||||
const [aiSummaryLoading, setAiSummaryLoading] = useState(false)
|
||||
const [aiSummaryError, setAiSummaryError] = useState<string | null>(null)
|
||||
const [aiSummaryRemaining, setAiSummaryRemaining] = useState<number | null>(null)
|
||||
const [aiSummaryMaxAttempts, setAiSummaryMaxAttempts] = useState(3)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [entryHash, setEntryHash] = useState('')
|
||||
|
||||
@@ -212,6 +252,7 @@ export default function LogEntryEditor({
|
||||
|
||||
// Events list state
|
||||
const [events, setEvents] = useState<LogEvent[]>([])
|
||||
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId, preloadedVoiceMemos)
|
||||
|
||||
// Add Event Form State
|
||||
const [evTime, setEvTime] = useState(() => currentLocalTimeHHMM())
|
||||
@@ -241,6 +282,10 @@ export default function LogEntryEditor({
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
||||
const [gpsSignal, setGpsSignal] = useState<{
|
||||
quality: GpsSignalQuality
|
||||
accuracyM: number | null
|
||||
} | null>(null)
|
||||
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
|
||||
|
||||
// Track file upload
|
||||
@@ -290,22 +335,22 @@ export default function LogEntryEditor({
|
||||
departure,
|
||||
destination,
|
||||
freshwater: {
|
||||
morning: parseFloat(fwMorning) || 0,
|
||||
refilled: parseFloat(fwRefilled) || 0,
|
||||
evening: parseFloat(fwEvening) || 0,
|
||||
consumption: parseFloat(fwConsumption) || 0
|
||||
morning: parseAppDecimalOrZero(fwMorning),
|
||||
refilled: parseAppDecimalOrZero(fwRefilled),
|
||||
evening: parseAppDecimalOrZero(fwEvening),
|
||||
consumption: parseAppDecimalOrZero(fwConsumption)
|
||||
},
|
||||
fuel: {
|
||||
morning: parseFloat(fuelMorning) || 0,
|
||||
refilled: parseFloat(fuelRefilled) || 0,
|
||||
evening: parseFloat(fuelEvening) || 0,
|
||||
consumption: parseFloat(fuelConsumption) || 0
|
||||
morning: parseAppDecimalOrZero(fuelMorning),
|
||||
refilled: parseAppDecimalOrZero(fuelRefilled),
|
||||
evening: parseAppDecimalOrZero(fuelEvening),
|
||||
consumption: parseAppDecimalOrZero(fuelConsumption)
|
||||
},
|
||||
greywater: { level: parseFloat(greywaterLevel) || 0 },
|
||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
||||
greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
|
||||
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
|
||||
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
|
||||
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
|
||||
motorHours: parseOptionalFormDecimal(motorHours),
|
||||
events: eventsOverride ?? events,
|
||||
entryCrew
|
||||
})
|
||||
@@ -328,7 +373,7 @@ export default function LogEntryEditor({
|
||||
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
|
||||
|
||||
const fuelPerMotorHour = useMemo(
|
||||
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
||||
() => computeFuelPerMotorHour(parseAppDecimalOrZero(fuelConsumption), parseAppDecimalOrZero(motorHours)),
|
||||
[fuelConsumption, motorHours]
|
||||
)
|
||||
|
||||
@@ -434,6 +479,8 @@ export default function LogEntryEditor({
|
||||
eventsOverride?: LogEvent[]
|
||||
signSkipper?: SignatureValue | ''
|
||||
signCrew?: SignatureValue | ''
|
||||
aiSummary?: string
|
||||
aiSummaryGeneratedAt?: string
|
||||
}
|
||||
) => {
|
||||
if (readOnly) return
|
||||
@@ -442,15 +489,39 @@ export default function LogEntryEditor({
|
||||
const eventsOverride = normalized.eventsOverride
|
||||
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
|
||||
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
|
||||
let summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
|
||||
let summaryAtToSave =
|
||||
normalized.aiSummaryGeneratedAt !== undefined ? normalized.aiSummaryGeneratedAt : aiSummaryGeneratedAt
|
||||
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const entryData = {
|
||||
// Crew edits must not drop the skipper's AI summary when it is not loaded into editor state.
|
||||
if (!summaryToSave.trim()) {
|
||||
const local = await db.entries.get(entryId)
|
||||
if (local) {
|
||||
const decrypted = await tryDecryptEntryPayload(local, masterKey)
|
||||
if (decrypted) {
|
||||
const existing =
|
||||
typeof decrypted.aiSummary === 'string' ? decrypted.aiSummary.trim() : ''
|
||||
if (existing) {
|
||||
summaryToSave = existing
|
||||
summaryAtToSave =
|
||||
typeof decrypted.aiSummaryGeneratedAt === 'string' ? decrypted.aiSummaryGeneratedAt : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entryData: Record<string, unknown> = {
|
||||
...buildPayloadForSigning(eventsOverride),
|
||||
signSkipper: normalizedSerializedSignature(skipperToSave),
|
||||
signCrew: normalizedSerializedSignature(crewToSave)
|
||||
}
|
||||
if (summaryToSave.trim()) {
|
||||
entryData.aiSummary = summaryToSave.trim()
|
||||
entryData.aiSummaryGeneratedAt = summaryAtToSave || new Date().toISOString()
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
@@ -489,7 +560,8 @@ export default function LogEntryEditor({
|
||||
setEntryHash(hash)
|
||||
lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null
|
||||
}, [
|
||||
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
|
||||
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew,
|
||||
aiSummary, aiSummaryGeneratedAt
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -504,6 +576,8 @@ export default function LogEntryEditor({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setCanSignSkipper(false)
|
||||
setCanSignCrew(false)
|
||||
getLogbookAccess(logbookId).then((access) => {
|
||||
if (!access) return
|
||||
setCanSignSkipper(access.isOwner)
|
||||
@@ -514,6 +588,24 @@ export default function LogEntryEditor({
|
||||
})
|
||||
}, [logbookId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSignSkipper || readOnly || !isOnline) {
|
||||
setAiSummaryRemaining(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
fetchTravelDaySummaryUsage(logbookId, entryId)
|
||||
.then((usage) => {
|
||||
if (cancelled) return
|
||||
setAiSummaryRemaining(usage.remainingAttempts)
|
||||
setAiSummaryMaxAttempts(usage.maxAttempts)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Failed to load AI summary usage:', err)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [canSignSkipper, readOnly, isOnline, logbookId, entryId])
|
||||
|
||||
useEffect(() => {
|
||||
const seq = ++entryHashSeqRef.current
|
||||
let cancelled = false
|
||||
@@ -617,18 +709,18 @@ export default function LogEntryEditor({
|
||||
|
||||
// Auto-calculate Freshwater Consumption
|
||||
useEffect(() => {
|
||||
const morning = parseFloat(fwMorning) || 0
|
||||
const refilled = parseFloat(fwRefilled) || 0
|
||||
const evening = parseFloat(fwEvening) || 0
|
||||
const morning = parseAppDecimalOrZero(fwMorning)
|
||||
const refilled = parseAppDecimalOrZero(fwRefilled)
|
||||
const evening = parseAppDecimalOrZero(fwEvening)
|
||||
const cons = morning + refilled - evening
|
||||
setFwConsumption(cons >= 0 ? String(cons) : '0')
|
||||
}, [fwMorning, fwRefilled, fwEvening])
|
||||
|
||||
// Auto-calculate Fuel Consumption
|
||||
useEffect(() => {
|
||||
const morning = parseFloat(fuelMorning) || 0
|
||||
const refilled = parseFloat(fuelRefilled) || 0
|
||||
const evening = parseFloat(fuelEvening) || 0
|
||||
const morning = parseAppDecimalOrZero(fuelMorning)
|
||||
const refilled = parseAppDecimalOrZero(fuelRefilled)
|
||||
const evening = parseAppDecimalOrZero(fuelEvening)
|
||||
const cons = morning + refilled - evening
|
||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
||||
@@ -639,7 +731,7 @@ export default function LogEntryEditor({
|
||||
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
|
||||
|
||||
useEffect(() => {
|
||||
const refilled = parseFloat(fwRefilled) || 0
|
||||
const refilled = parseAppDecimalOrZero(fwRefilled)
|
||||
if (fwRefilledMax == null) {
|
||||
if (fwRefilledNoCapacity && refilled > 0) {
|
||||
setFwRefilled(formatTankLitersForInput(0))
|
||||
@@ -653,14 +745,14 @@ export default function LogEntryEditor({
|
||||
|
||||
useEffect(() => {
|
||||
if (fwEveningMax == null) return
|
||||
const evening = parseFloat(fwEvening) || 0
|
||||
const evening = parseAppDecimalOrZero(fwEvening)
|
||||
if (evening > fwEveningMax) {
|
||||
setFwEvening(formatTankLitersForInput(fwEveningMax))
|
||||
}
|
||||
}, [fwEveningMax, fwEvening])
|
||||
|
||||
useEffect(() => {
|
||||
const refilled = parseFloat(fuelRefilled) || 0
|
||||
const refilled = parseAppDecimalOrZero(fuelRefilled)
|
||||
if (fuelRefilledMax == null) {
|
||||
if (fuelRefilledNoCapacity && refilled > 0) {
|
||||
setFuelRefilled(formatTankLitersForInput(0))
|
||||
@@ -674,7 +766,7 @@ export default function LogEntryEditor({
|
||||
|
||||
useEffect(() => {
|
||||
if (fuelEveningMax == null) return
|
||||
const evening = parseFloat(fuelEvening) || 0
|
||||
const evening = parseAppDecimalOrZero(fuelEvening)
|
||||
if (evening > fuelEveningMax) {
|
||||
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
|
||||
}
|
||||
@@ -740,6 +832,8 @@ export default function LogEntryEditor({
|
||||
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
||||
loadTrackStatsFromEntry(preloadedEntry)
|
||||
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
||||
setAiSummary(String(preloadedEntry.aiSummary || ''))
|
||||
setAiSummaryGeneratedAt(String(preloadedEntry.aiSummaryGeneratedAt || ''))
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||
return
|
||||
}
|
||||
@@ -779,6 +873,8 @@ export default function LogEntryEditor({
|
||||
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
||||
loadTrackStatsFromEntry(decrypted)
|
||||
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
||||
setAiSummary(String(decrypted.aiSummary || ''))
|
||||
setAiSummaryGeneratedAt(String(decrypted.aiSummaryGeneratedAt || ''))
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||
}
|
||||
}
|
||||
@@ -933,12 +1029,20 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const handleGetGps = () => {
|
||||
const clearGpsSignal = () => setGpsSignal(null)
|
||||
|
||||
const handleGetGps = async () => {
|
||||
if (readOnly) return
|
||||
|
||||
const lookupFallback = async () => {
|
||||
clearGpsSignal()
|
||||
const locationQuery = evLocationName.trim() || departure.trim() || destination.trim()
|
||||
if (!locationQuery) {
|
||||
showAlert('GPS capturing failed, and no location name is entered in "Ort / Hafen" or "Start-Hafen" to look up coordinates.')
|
||||
showAlert(t('logs.gps_fallback_no_location'))
|
||||
return
|
||||
}
|
||||
if (!isOnline) {
|
||||
showAlert(t('logs.weather_offline'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -949,37 +1053,60 @@ export default function LogEntryEditor({
|
||||
)
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
||||
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
|
||||
setEvGpsLat(formatAppCoordinate(Number(coord.lat)))
|
||||
setEvGpsLng(formatAppCoordinate(Number(coord.lon)))
|
||||
showAlert(t('logs.gps_fallback_success', { location: locationQuery }))
|
||||
} else {
|
||||
showAlert(t('logs.gps_fallback_failed'))
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof WeatherApiError && e.code === 'OFFLINE') {
|
||||
showAlert(t('logs.weather_offline'))
|
||||
return
|
||||
}
|
||||
if (e instanceof WeatherApiError && e.code === 'NO_KEY') {
|
||||
showAlert(t('settings.no_key'))
|
||||
return
|
||||
}
|
||||
showAlert('Failed to retrieve GPS location or look up coordinates by location name.')
|
||||
showAlert(t('logs.gps_fallback_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
lookupFallback()
|
||||
return
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setEvGpsLat(pos.coords.latitude.toFixed(6))
|
||||
setEvGpsLng(pos.coords.longitude.toFixed(6))
|
||||
},
|
||||
(err) => {
|
||||
console.warn('GPS capturing failed, trying fallback:', err)
|
||||
lookupFallback()
|
||||
try {
|
||||
const permission = await queryGeolocationPermission()
|
||||
if (permission === 'denied' || permission === 'unsupported') {
|
||||
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
|
||||
showAlert(
|
||||
`${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
|
||||
)
|
||||
await lookupFallback()
|
||||
return
|
||||
}
|
||||
)
|
||||
|
||||
const coords = await getCurrentPosition({
|
||||
timeoutMs: 15_000,
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 60_000
|
||||
})
|
||||
setEvGpsLat(coords.lat)
|
||||
setEvGpsLng(coords.lng)
|
||||
setGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
|
||||
} catch (err) {
|
||||
console.warn('GPS capture failed:', err)
|
||||
const reason = getGeolocationErrorReason(err)
|
||||
showAlert(
|
||||
`${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
|
||||
)
|
||||
await lookupFallback()
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchWeather = async () => {
|
||||
if (!isOnline) {
|
||||
showAlert(t('logs.weather_offline'))
|
||||
return
|
||||
}
|
||||
|
||||
const localToday = new Date()
|
||||
const todayStr = `${localToday.getFullYear()}-${String(localToday.getMonth() + 1).padStart(2, '0')}-${String(localToday.getDate()).padStart(2, '0')}`
|
||||
|
||||
@@ -1008,8 +1135,8 @@ export default function LogEntryEditor({
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
// If fetched by location, automatically pre-fill GPS coordinates
|
||||
if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
||||
setEvGpsLat(formatAppCoordinate(Number(coord.lat)))
|
||||
setEvGpsLng(formatAppCoordinate(Number(coord.lon)))
|
||||
}
|
||||
|
||||
const parsed = parseOwmCurrentWeather(data)
|
||||
@@ -1021,6 +1148,10 @@ export default function LogEntryEditor({
|
||||
|
||||
showAlert(t('settings.weather_success'))
|
||||
} catch (err) {
|
||||
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
|
||||
showAlert(t('logs.weather_offline'))
|
||||
return
|
||||
}
|
||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
||||
showAlert(t('settings.no_key'))
|
||||
return
|
||||
@@ -1032,6 +1163,89 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateAiSummary = async () => {
|
||||
if (!canSignSkipper || readOnly || aiSummaryLoading) return
|
||||
if (!isOnline) {
|
||||
setAiSummaryError(t('logs.ai_summary_offline'))
|
||||
return
|
||||
}
|
||||
if (aiSummaryRemaining === 0) {
|
||||
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
|
||||
return
|
||||
}
|
||||
|
||||
setAiSummaryLoading(true)
|
||||
setAiSummaryError(null)
|
||||
try {
|
||||
const context = buildTravelDayContext(
|
||||
{
|
||||
date,
|
||||
dayOfTravel,
|
||||
departure,
|
||||
destination,
|
||||
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
|
||||
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
|
||||
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
|
||||
motorHours: parseOptionalFormDecimal(motorHours),
|
||||
freshwater: {
|
||||
morning: parseAppDecimalOrZero(fwMorning),
|
||||
refilled: parseAppDecimalOrZero(fwRefilled),
|
||||
evening: parseAppDecimalOrZero(fwEvening),
|
||||
consumption: parseAppDecimalOrZero(fwConsumption)
|
||||
},
|
||||
fuel: {
|
||||
morning: parseAppDecimalOrZero(fuelMorning),
|
||||
refilled: parseAppDecimalOrZero(fuelRefilled),
|
||||
evening: parseAppDecimalOrZero(fuelEvening),
|
||||
consumption: parseAppDecimalOrZero(fuelConsumption)
|
||||
},
|
||||
greywaterLevel: parseAppDecimalOrZero(greywaterLevel),
|
||||
events
|
||||
},
|
||||
t
|
||||
)
|
||||
|
||||
const language = i18n.language.split('-')[0] || 'en'
|
||||
const result = await generateTravelDaySummary({
|
||||
logbookId,
|
||||
entryId,
|
||||
language,
|
||||
context
|
||||
})
|
||||
|
||||
const generatedAt = new Date().toISOString()
|
||||
setAiSummary(result.summary)
|
||||
setAiSummaryGeneratedAt(generatedAt)
|
||||
setAiSummaryRemaining(result.remainingAttempts)
|
||||
setAiSummaryMaxAttempts(result.maxAttempts)
|
||||
|
||||
await persistEntryToDb({
|
||||
aiSummary: result.summary,
|
||||
aiSummaryGeneratedAt: generatedAt
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TravelDaySummaryApiError) {
|
||||
if (err.code === 'OFFLINE') {
|
||||
setAiSummaryError(t('logs.ai_summary_offline'))
|
||||
} else if (err.code === 'NO_KEY') {
|
||||
setAiSummaryError(t('logs.ai_summary_error_no_key'))
|
||||
} else if (err.code === 'RATE_LIMITED') {
|
||||
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
|
||||
setAiSummaryRemaining(0)
|
||||
} else if (err.code === 'FORBIDDEN') {
|
||||
setAiSummaryError(t('logs.ai_summary_error_forbidden'))
|
||||
} else {
|
||||
setAiSummaryError(err.message || t('logs.ai_summary_error'))
|
||||
}
|
||||
} else {
|
||||
console.error('AI summary generation failed:', err)
|
||||
setAiSummaryError(getErrorMessage(err, t('logs.ai_summary_error')))
|
||||
}
|
||||
} finally {
|
||||
setAiSummaryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSails = i18n.language === 'de'
|
||||
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
||||
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
|
||||
@@ -1179,6 +1393,7 @@ export default function LogEntryEditor({
|
||||
|
||||
const handleDeleteEvent = async (index: number) => {
|
||||
if (readOnly) return
|
||||
const voiceId = parseLiveVoiceRemark(events[index]?.remarks?.trim() ?? '')
|
||||
const hadSkipperSignature = !!signSkipper
|
||||
markSkipperSignatureClearedForEventChange()
|
||||
const nextEvents = events.filter((_, idx) => idx !== index)
|
||||
@@ -1196,6 +1411,9 @@ export default function LogEntryEditor({
|
||||
}
|
||||
|
||||
try {
|
||||
if (voiceId && !readOnly) {
|
||||
await deleteEntryVoiceMemo(logbookId, voiceId)
|
||||
}
|
||||
await persistEntryToDb(nextEvents)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to auto-save after event delete:', err)
|
||||
@@ -1398,6 +1616,52 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(aiSummary.trim() || canSignSkipper) && (
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Sparkles size={20} className="form-icon" />
|
||||
<h3>{t('logs.ai_summary_title')}</h3>
|
||||
</div>
|
||||
{aiSummary.trim() && !canSignSkipper && (
|
||||
<p style={{ margin: '0 0 12px', fontSize: '0.9rem', opacity: 0.8 }}>
|
||||
{t('logs.ai_summary_read_only')}
|
||||
</p>
|
||||
)}
|
||||
{aiSummary.trim() ? (
|
||||
<p style={{ whiteSpace: 'pre-wrap', margin: '0 0 16px', lineHeight: 1.5 }}>{aiSummary}</p>
|
||||
) : (
|
||||
<p style={{ margin: '0 0 16px', opacity: 0.75 }}>{t('logs.ai_summary_empty')}</p>
|
||||
)}
|
||||
{canSignSkipper && !readOnly && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleGenerateAiSummary()}
|
||||
disabled={saving || aiSummaryLoading || aiSummaryRemaining === 0}
|
||||
style={{ width: 'auto' }}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{aiSummaryLoading
|
||||
? t('logs.ai_summary_generating')
|
||||
: aiSummary.trim()
|
||||
? t('logs.ai_summary_regenerate')
|
||||
: t('logs.ai_summary_generate')}
|
||||
</button>
|
||||
{aiSummaryRemaining !== null && (
|
||||
<span style={{ fontSize: '0.9rem', opacity: 0.8 }}>
|
||||
{t('logs.ai_summary_attempts_remaining', {
|
||||
remaining: aiSummaryRemaining,
|
||||
max: aiSummaryMaxAttempts
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{aiSummaryError && <div className="auth-error" style={{ marginTop: '12px' }}>{aiSummaryError}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 2: Freshwater and Fuel Consumption */}
|
||||
<div className="form-grid">
|
||||
{/* Freshwater card */}
|
||||
@@ -1588,26 +1852,34 @@ export default function LogEntryEditor({
|
||||
<td className="font-mono text-sm">
|
||||
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
|
||||
</td>
|
||||
<td className="remarks-td">{ev.remarks}</td>
|
||||
<td className="remarks-td">
|
||||
<EventRemarksCell
|
||||
event={ev}
|
||||
logbookId={logbookId}
|
||||
voiceMemoLookup={voiceMemoLookup}
|
||||
/>
|
||||
</td>
|
||||
{!readOnly && (
|
||||
<td className="events-actions-td">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => handleEditEvent(idx)}
|
||||
title={t('logs.edit_event')}
|
||||
disabled={editingEventIndex !== null && editingEventIndex !== idx}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
onClick={() => handleDeleteEvent(idx)}
|
||||
title={t('logs.delete_event')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className="events-actions-cell">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => handleEditEvent(idx)}
|
||||
title={t('logs.edit_event')}
|
||||
disabled={editingEventIndex !== null && editingEventIndex !== idx}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon danger"
|
||||
onClick={() => handleDeleteEvent(idx)}
|
||||
title={t('logs.delete_event')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
@@ -1707,7 +1979,7 @@ export default function LogEntryEditor({
|
||||
placeholder="Lat"
|
||||
className="input-text"
|
||||
value={evGpsLat}
|
||||
onChange={(e) => setEvGpsLat(e.target.value)}
|
||||
onChange={(e) => { clearGpsSignal(); setEvGpsLat(e.target.value) }}
|
||||
disabled={saving}
|
||||
/>
|
||||
<input
|
||||
@@ -1715,13 +1987,13 @@ export default function LogEntryEditor({
|
||||
placeholder="Lng"
|
||||
className="input-text"
|
||||
value={evGpsLng}
|
||||
onChange={(e) => setEvGpsLng(e.target.value)}
|
||||
onChange={(e) => { clearGpsSignal(); setEvGpsLng(e.target.value) }}
|
||||
disabled={saving}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleGetGps}
|
||||
onClick={() => void handleGetGps()}
|
||||
title={t('logs.gps_btn')}
|
||||
style={{ width: 'auto', padding: '12px' }}
|
||||
disabled={saving}
|
||||
@@ -1744,6 +2016,13 @@ export default function LogEntryEditor({
|
||||
<CloudSun size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{gpsSignal && (
|
||||
<GpsSignalHint
|
||||
quality={gpsSignal.quality}
|
||||
accuracyM={gpsSignal.accuracyM}
|
||||
className="gps-signal-hint-editor"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2031,9 +2310,8 @@ export default function LogEntryEditor({
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
className="btn danger btn-sm btn-inline-icon"
|
||||
onClick={handleDeleteTrack}
|
||||
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
|
||||
title={t('logs.gps_track_delete')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -2066,7 +2344,11 @@ export default function LogEntryEditor({
|
||||
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={() => downloadNmeaArchive(nmeaArchive)}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={handleDeleteNmeaArchive}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger btn-sm btn-inline-icon"
|
||||
onClick={handleDeleteNmeaArchive}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,12 @@ import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
downloadBackupBlob,
|
||||
exportLogbookBackup,
|
||||
formatBackupBytes,
|
||||
parseLogbookBackupFile,
|
||||
previewLogbookBackup,
|
||||
restoreLogbookBackup,
|
||||
type LogbookBackupFile,
|
||||
BACKUP_SIZE_CONFIRM_BYTES,
|
||||
type ParsedLogbookBackup,
|
||||
type LogbookBackupPreview
|
||||
} from '../services/logbookBackup.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
@@ -27,6 +29,12 @@ function mapBackupError(code: string, t: (key: string) => string): string {
|
||||
return t('settings.backup_not_owner')
|
||||
case 'BACKUP_INVALID_JSON':
|
||||
return t('settings.backup_invalid_json')
|
||||
case 'BACKUP_INVALID_ARCHIVE':
|
||||
return t('settings.backup_invalid_archive')
|
||||
case 'BACKUP_VERSION_UNSUPPORTED':
|
||||
return t('settings.backup_version_unsupported')
|
||||
case 'BACKUP_WRONG_PASSPHRASE':
|
||||
return t('settings.backup_wrong_passphrase')
|
||||
case 'BACKUP_INVALID_FORMAT':
|
||||
return t('settings.backup_invalid_format')
|
||||
case 'BACKUP_NOT_AUTHENTICATED':
|
||||
@@ -53,12 +61,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
const [importPassphrase, setImportPassphrase] = useState('')
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
||||
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null)
|
||||
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const exportPassphrasesMatch =
|
||||
exportPassphrase.length >= 8 && exportPassphrase === exportConfirm
|
||||
|
||||
const handleExportSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await handleExport()
|
||||
@@ -83,21 +95,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
}
|
||||
|
||||
setExporting(true)
|
||||
setExportProgress(null)
|
||||
try {
|
||||
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase)
|
||||
const { blob, filename, manifest } = await exportLogbookBackup(logbookId, exportPassphrase, {
|
||||
onProgress: (p) => {
|
||||
if (p.phase === 'pack') {
|
||||
setExportProgress(
|
||||
t('settings.backup_export_progress', {
|
||||
current: p.current,
|
||||
total: p.total
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
downloadBackupBlob(blob, filename)
|
||||
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
|
||||
setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries }))
|
||||
setExportPassphrase('')
|
||||
setExportConfirm('')
|
||||
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
|
||||
entries: backup.counts.entries,
|
||||
photos: backup.counts.photos
|
||||
entries: manifest.counts.entries,
|
||||
photos: manifest.counts.photos,
|
||||
voiceMemos: manifest.counts.voiceMemos,
|
||||
bytes: manifest.totalUncompressedBytes
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(mapBackupError(message, t))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
setExportProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +165,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
||||
if (!parsedBackup || !importPassphrase) return
|
||||
|
||||
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
|
||||
const ok = await showConfirm(
|
||||
t('settings.backup_import_size_confirm', {
|
||||
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
|
||||
}),
|
||||
t('settings.backup_restore_title'),
|
||||
t('logs.confirm_yes'),
|
||||
t('logs.confirm_no')
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
setImporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -149,8 +188,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
setParsedBackup(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
||||
entries: parsedBackup.counts.entries,
|
||||
photos: parsedBackup.counts.photos,
|
||||
entries: parsedBackup.manifest.counts.entries,
|
||||
photos: parsedBackup.manifest.counts.photos,
|
||||
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
|
||||
bytes: parsedBackup.manifest.totalUncompressedBytes,
|
||||
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
||||
})
|
||||
onRestored?.(result.logbookId, result.title)
|
||||
@@ -253,11 +294,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={exporting || !exportPassphrase || !exportConfirm}
|
||||
disabled={exporting || !exportPassphrasesMatch}
|
||||
>
|
||||
<Download size={16} />
|
||||
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
||||
</button>
|
||||
{exportProgress && (
|
||||
<p className="text-muted backup-export-progress" role="status">
|
||||
{exportProgress}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -275,7 +321,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
id="backup-import-file"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".daagbok.json,application/json"
|
||||
accept=".daagbok,application/zip"
|
||||
className="input-text"
|
||||
onChange={handleFileChange}
|
||||
disabled={importing}
|
||||
@@ -330,8 +376,14 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
<ul className="backup-preview-stats">
|
||||
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
||||
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
||||
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
|
||||
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
||||
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
||||
<li className="text-muted">
|
||||
{t('settings.backup_stat_size', {
|
||||
size: formatBackupBytes(importPreview.totalUncompressedBytes)
|
||||
})}
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-muted backup-preview-date">
|
||||
{t('settings.backup_exported_at', {
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function PersonPoolForm() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
className="btn-icon danger"
|
||||
onClick={() => void handleDelete(person.payloadId)}
|
||||
title="Delete"
|
||||
>
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
enableCollaboratorChangePush,
|
||||
fetchPushPrefs,
|
||||
getNotificationPermission,
|
||||
isPushSupported
|
||||
isPushSupported,
|
||||
preloadPushService
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
void preloadPushService()
|
||||
try {
|
||||
const prefs = await fetchPushPrefs()
|
||||
setEnabled(prefs.collaboratorChangesEnabled)
|
||||
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('profile.push_error')
|
||||
console.error('Failed to toggle push notifications:', err)
|
||||
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
showAlert(message)
|
||||
void loadPrefs()
|
||||
} finally {
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
||||
const [entries, setEntries] = useState<any[]>([])
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
|
||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -174,6 +175,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
setPhotos(decPhotos)
|
||||
|
||||
const decVoiceMemos = []
|
||||
if (data.voiceMemos) {
|
||||
for (const v of data.voiceMemos) {
|
||||
const dec = await decryptJson(v.encryptedData, v.iv, v.tag, keyBuffer)
|
||||
if (dec) {
|
||||
decVoiceMemos.push({
|
||||
payloadId: v.payloadId,
|
||||
audio: dec.audio,
|
||||
mimeType: dec.mimeType,
|
||||
durationSec: dec.durationSec,
|
||||
caption: dec.caption || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setVoiceMemos(decVoiceMemos)
|
||||
|
||||
// Decrypt GPS Tracks
|
||||
const decGpsTracks = []
|
||||
if (data.gpsTracks) {
|
||||
@@ -282,6 +300,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
preloadedYacht={yacht}
|
||||
preloadedEntries={entries}
|
||||
preloadedPhotos={photos}
|
||||
preloadedVoiceMemos={voiceMemos}
|
||||
preloadedGpsTracks={gpsTracks}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { apiFetch } from '../services/api.js'
|
||||
import {
|
||||
enableCollaboratorChangePush,
|
||||
isCollaboratorPushActive,
|
||||
isPushSupported
|
||||
isPushSupported,
|
||||
preloadPushService
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
|
||||
@@ -55,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
loadCollaborators()
|
||||
loadShareLink()
|
||||
}
|
||||
void preloadPushService()
|
||||
}, [logbookId])
|
||||
|
||||
const loadShareLink = async () => {
|
||||
@@ -191,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to enable push after invite:', err)
|
||||
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
|
||||
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
await showAlert(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +429,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
className="btn-icon danger"
|
||||
onClick={() => handleRevoke(c.id, c.username)}
|
||||
title="Revoke access"
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../services/statsAggregation.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||
import {
|
||||
loadLogbookEventSeries,
|
||||
type EventSeriesPoint,
|
||||
@@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="stats-propulsion-labels">
|
||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
|
||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span>
|
||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
|
||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
|
||||
{totals.unknownPropulsionNm > 0 && (
|
||||
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||
|
||||
interface TankLiterInputProps {
|
||||
id?: string
|
||||
@@ -13,10 +14,8 @@ interface TankLiterInputProps {
|
||||
}
|
||||
|
||||
function parseInputLiters(value: string): number {
|
||||
const trimmed = value.trim().replace(',', '.')
|
||||
if (!trimmed) return 0
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
if (!value.trim()) return 0
|
||||
return parseAppDecimalOrZero(value)
|
||||
}
|
||||
|
||||
export default function TankLiterInput({
|
||||
@@ -34,8 +33,7 @@ export default function TankLiterInput({
|
||||
const emitValue = useCallback(
|
||||
(liters: number) => {
|
||||
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
||||
const str =
|
||||
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
|
||||
const str = formatTankLiters(clamped)
|
||||
onChange(str)
|
||||
},
|
||||
[onChange, maxLiters, useSlider]
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function VesselPoolForm() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
className="btn-icon danger"
|
||||
onClick={() => void handleDelete(v.payloadId)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
|
||||
export interface PreloadedVoiceMemo {
|
||||
payloadId: string
|
||||
audio: string
|
||||
mimeType?: string
|
||||
durationSec?: number
|
||||
caption?: string
|
||||
}
|
||||
|
||||
interface VoiceMemoPlayerProps {
|
||||
audioId: string
|
||||
logbookId: string
|
||||
preloaded?: PreloadedVoiceMemo | null
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function VoiceMemoPlayer({
|
||||
audioId,
|
||||
logbookId,
|
||||
preloaded,
|
||||
compact = false
|
||||
}: VoiceMemoPlayerProps) {
|
||||
const { t } = useTranslation()
|
||||
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (preloaded?.audio) {
|
||||
setSrc(preloaded.audio)
|
||||
setError(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const record = await db.voiceMemos.get(audioId)
|
||||
if (!record || cancelled) return
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey || cancelled) return
|
||||
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
|
||||
if (!decrypted?.audio || cancelled) {
|
||||
setError(true)
|
||||
return
|
||||
}
|
||||
setSrc(String(decrypted.audio))
|
||||
setError(false)
|
||||
} catch {
|
||||
if (!cancelled) setError(true)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [audioId, logbookId, preloaded?.audio])
|
||||
|
||||
if (error || !src) {
|
||||
return (
|
||||
<span className="voice-memo-player-unavailable">
|
||||
{t('logs.live_voice_unavailable')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const playerClass = compact
|
||||
? 'voice-memo-player voice-memo-player--compact'
|
||||
: 'voice-memo-player'
|
||||
|
||||
return (
|
||||
<div className="voice-memo-player-shell">
|
||||
<audio className={playerClass} controls preload="none" src={src} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import type { PreloadedVoiceMemo } from '../components/VoiceMemoPlayer.tsx'
|
||||
|
||||
export function useEntryVoiceMemos(
|
||||
logbookId: string,
|
||||
entryId: string | null,
|
||||
preloaded?: PreloadedVoiceMemo[]
|
||||
): Map<string, PreloadedVoiceMemo> {
|
||||
const localMemos = useLiveQuery(
|
||||
() => (entryId ? db.voiceMemos.where({ entryId }).toArray() : []),
|
||||
[entryId]
|
||||
)
|
||||
|
||||
const [lookup, setLookup] = useState<Map<string, PreloadedVoiceMemo>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (preloaded && preloaded.length > 0) {
|
||||
const map = new Map<string, PreloadedVoiceMemo>()
|
||||
for (const m of preloaded) {
|
||||
map.set(m.payloadId, m)
|
||||
}
|
||||
setLookup(map)
|
||||
return
|
||||
}
|
||||
|
||||
if (!entryId || !localMemos) {
|
||||
setLookup(new Map())
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey || cancelled) return
|
||||
|
||||
const map = new Map<string, PreloadedVoiceMemo>()
|
||||
for (const row of localMemos) {
|
||||
try {
|
||||
const decrypted = await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)
|
||||
if (!decrypted?.audio) continue
|
||||
map.set(row.payloadId, {
|
||||
payloadId: row.payloadId,
|
||||
audio: String(decrypted.audio),
|
||||
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
|
||||
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
|
||||
caption: decrypted.caption ? String(decrypted.caption) : ''
|
||||
})
|
||||
} catch {
|
||||
// skip corrupt memo
|
||||
}
|
||||
}
|
||||
if (!cancelled) setLookup(map)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [localMemos, entryId, logbookId, preloaded])
|
||||
|
||||
return lookup
|
||||
}
|
||||
@@ -42,12 +42,14 @@ function scheduleUpdateChecks(
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdate()
|
||||
// Delay check on wake-up to allow the mobile network stack to stabilize
|
||||
setTimeout(checkForUpdate, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const onOnline = () => {
|
||||
checkForUpdate()
|
||||
// Small delay to ensure connection is fully established
|
||||
setTimeout(checkForUpdate, 500)
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversion - funktioner kan stadig ændres"
|
||||
},
|
||||
"footer": {
|
||||
"kofi_label": "Ko-fi",
|
||||
"kofi_title": "Støt projektet, videreudvikling og driftsomkostninger på Ko-fi"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
@@ -245,13 +249,13 @@
|
||||
"live_sails_confirm": "Indtast",
|
||||
"live_sails_confirm_count": "Indtast ({{count}})",
|
||||
"live_sails": "Sejl: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Henter GPS-position…",
|
||||
"live_fix_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
||||
"live_fix_lng_placeholder": "Længde (Lng)",
|
||||
"live_position": "Position",
|
||||
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||
"live_position_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
|
||||
"live_position_gps_loading": "Henter GPS-position…",
|
||||
"live_position_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
||||
"live_position_lat_placeholder": "Bredde (Lat)",
|
||||
"live_position_lng_placeholder": "Længde (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Tag billede",
|
||||
"live_photo_save_btn": "Gem",
|
||||
@@ -262,10 +266,29 @@
|
||||
"live_photo_camera_starting": "Starter kamera…",
|
||||
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
||||
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
||||
"live_photo_no_camera": "Der er intet kamera tilgængeligt på denne enhed.",
|
||||
"live_photo_error": "Foto kunne ikke gemmes.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto taget",
|
||||
"live_undo_photo_hint": "Foto gemt",
|
||||
"live_voice_btn": "Stemmenotat",
|
||||
"live_voice_hint": "Optag en kort stemmenotat (maks. 60 sekunder).",
|
||||
"live_voice_record": "Start optagelse",
|
||||
"live_voice_stop": "Stop optagelse",
|
||||
"live_voice_recording": "Optager {{time}}",
|
||||
"live_voice_save": "Gem",
|
||||
"live_voice_saving": "Gemmer…",
|
||||
"live_voice_retake": "Optag igen",
|
||||
"live_voice_mic_denied": "Mikrofonadgang nægtet eller utilgængelig.",
|
||||
"live_voice_record_failed": "Optagelse mislykkedes. Prøv igen.",
|
||||
"live_voice_unavailable": "Stemmenotat utilgængelig",
|
||||
"live_voice_too_large": "Optagelsen er for stor. Optag venligst kortere.",
|
||||
"live_voice_error": "Kunne ikke gemme stemmenotat.",
|
||||
"live_voice_entry": "Stemmenotat: {{caption}}",
|
||||
"live_voice_entry_plain": "Stemmenotat",
|
||||
"live_voice_caption_label": "Billedtekst (valgfrit)",
|
||||
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
|
||||
"live_undo_voice_hint": "Stemmenotat gemt",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Indtast tekst…",
|
||||
"live_comment_confirm": "Indtast",
|
||||
@@ -275,8 +298,8 @@
|
||||
"live_weather_btn": "Vejr",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||
"live_weather_owm_loading": "Henter vejr…",
|
||||
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
|
||||
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.",
|
||||
"live_weather_position_required": "Log først en position (Position-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
|
||||
"live_weather_position_stale": "Den seneste position er ældre end 6 timer. Log en ny position, før du henter vejr.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttryk",
|
||||
@@ -284,8 +307,8 @@
|
||||
"live_sea_state_btn": "Søgang",
|
||||
"live_visibility_btn": "Sigtbarhed",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vand",
|
||||
"live_fuel_btn": "+ Diesel",
|
||||
"live_water_btn": "+ Vand",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttryk {{value}} hPa",
|
||||
@@ -298,6 +321,7 @@
|
||||
"live_auto_position": "Auto-position",
|
||||
"live_undo_hint": "Indtastning gemt",
|
||||
"live_undo_btn": "Fortryd",
|
||||
"live_cancel": "Annuller",
|
||||
"live_pressure_placeholder": "f.eks. 1013",
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. let regn",
|
||||
@@ -357,7 +381,26 @@
|
||||
"event_location_placeholder": "z. f.eks. Kiel",
|
||||
"event_remarks": "Bemærkninger / hændelser",
|
||||
"gps_btn": "Hent GPS-koordinater",
|
||||
"gps_permission_denied": "Adgang til placering blev nægtet. Tillad det i browser- eller enhedsindstillinger og prøv igen.",
|
||||
"gps_timeout": "GPS fik timeout. Prøv igen udendørs med frit udsyn til himlen.",
|
||||
"gps_position_unavailable": "Intet GPS-signal tilgængeligt. Vent og prøv igen, eller indtast koordinater manuelt.",
|
||||
"gps_unavailable": "GPS understøttes ikke af denne browser eller enhed.",
|
||||
"gps_failed": "GPS-position kunne ikke bestemmes.",
|
||||
"gps_fallback_no_location": "GPS mislykkedes. Angiv et sted under placering/havn, afgang eller destination, eller indtast koordinater manuelt.",
|
||||
"gps_fallback_success": "Koordinater for \"{{location}}\" fundet via stedsnavn (ikke GPS).",
|
||||
"gps_fallback_failed": "GPS og stedsnavnssøgning mislykkedes. Indtast koordinater manuelt.",
|
||||
"gps_quality_excellent": "Stærk GPS-modtagelse (±{{accuracy}} m)",
|
||||
"gps_quality_good": "God GPS-modtagelse (±{{accuracy}} m)",
|
||||
"gps_quality_fair": "Middel GPS-modtagelse (±{{accuracy}} m) – gå udendørs for bedre signal.",
|
||||
"gps_quality_poor": "Svag GPS-modtagelse (±{{accuracy}} m) – sandsynligvis få satellitter. Prøv udendørs igen eller kontroller positionen.",
|
||||
"gps_quality_unknown": "GPS-position overtaget (nøjagtighed ikke rapporteret af enheden).",
|
||||
"gps_live_intro_title": "Placering til live-log",
|
||||
"gps_live_intro_body": "Appen har brug for din placering til automatiske positionsindlæg og GPS-knappen.\n\nTryk på „Tillad placering“ og bekræft i den næste dialog. Du kan altid indtaste position manuelt via „Position“.",
|
||||
"gps_live_intro_allow": "Tillad placering",
|
||||
"gps_live_intro_later": "Senere",
|
||||
"gps_enable_in_settings_hint": "Adgang til placering er blokeret. Du kan tillade det senere i browser- eller enhedsindstillinger (websted / app → Placering).",
|
||||
"weather_btn": "OpenWeatherMap Kald vejret op",
|
||||
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
|
||||
"event_wind_pressure": "Lufttryk (hPa)",
|
||||
"event_heel": "Krængning (°)",
|
||||
"event_sails": "Sejlhåndtering/motor",
|
||||
@@ -371,6 +414,18 @@
|
||||
"share_csv": "CSV andel",
|
||||
"export_pdf": "Download PDF.",
|
||||
"exporting_pdf": "PDF er genereret...",
|
||||
"ai_summary_title": "AI-resumé",
|
||||
"ai_summary_read_only": "Oprettet af skipperen — kun læsning for besætningen.",
|
||||
"ai_summary_empty": "Intet resumé endnu.",
|
||||
"ai_summary_generate": "Generér resumé",
|
||||
"ai_summary_regenerate": "Generér igen",
|
||||
"ai_summary_generating": "Genererer…",
|
||||
"ai_summary_attempts_remaining": "{{remaining}} af {{max}} forsøg tilbage",
|
||||
"ai_summary_error": "AI-resumé mislykkedes. Prøv igen senere.",
|
||||
"ai_summary_error_no_key": "Ingen OpenRouter API-nøgle konfigureret på serveren.",
|
||||
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
|
||||
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
|
||||
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
|
||||
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
||||
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
||||
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
||||
@@ -449,8 +504,8 @@
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_gps_lost": "GPS-position mistet",
|
||||
"nmea_change_gps_regained": "GPS-position gendannet",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
@@ -464,7 +519,7 @@
|
||||
"new_logbook_placeholder": "Navn på logbog eller yacht",
|
||||
"logout": "Log ud",
|
||||
"logged_in_as": "Logget ind som {{name}}",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok.json) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.",
|
||||
"no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!",
|
||||
"loading": "Logbøgerne er fyldt op...",
|
||||
"status_synced": "Synkroniseret",
|
||||
@@ -743,7 +798,7 @@
|
||||
"delete_account_confirm_yes": "Ja, slet konto og alle data",
|
||||
"delete_account_confirm_no": "Annuller",
|
||||
"delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.",
|
||||
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
|
||||
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok) i indstillingerne for hver logbog, før du sletter dem.",
|
||||
"deleting_account": "Kontoen vil blive slettet...",
|
||||
"invite_push_prompt_title": "Aktivere push-meddelelser?",
|
||||
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
||||
@@ -754,7 +809,7 @@
|
||||
"backup_title": "Sikkerhedskopiering og gendannelse",
|
||||
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
|
||||
"backup_export_title": "Opret backup",
|
||||
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
|
||||
"backup_export_desc": "Downloader alle lokale data som et komprimeret .daagbok-arkiv. Hold filen og adgangssætningen adskilt og sikker.",
|
||||
"backup_restore_title": "Gendan sikkerhedskopi",
|
||||
"backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
|
||||
"backup_passphrase": "Backup-passphrase",
|
||||
@@ -766,7 +821,13 @@
|
||||
"backup_export_btn": "Download backup",
|
||||
"backup_exporting": "Sikkerhedskopien er oprettet...",
|
||||
"backup_export_success": "Backup oprettet ({{count}} rejsedage).",
|
||||
"backup_file_label": "Backup-fil (.daagbok.json)",
|
||||
"backup_file_label": "Backup-fil (.daagbok)",
|
||||
"backup_export_progress": "Pakker filer {{current}} / {{total}}…",
|
||||
"backup_invalid_archive": "Filen er ikke et gyldigt backup-arkiv.",
|
||||
"backup_version_unsupported": "Gammelt backup-format (v1). Brug en aktuel .daagbok-backup.",
|
||||
"backup_import_size_confirm": "Denne backup er ca. {{size}} ukomprimeret. Gendannelse kan tage længere tid. Fortsæt?",
|
||||
"backup_stat_voice": "{{count}} stemmenotater",
|
||||
"backup_stat_size": "Ca. {{size}} ukomprimeret",
|
||||
"backup_preview_btn": "Tjek indhold",
|
||||
"backup_previewing": "Tjek...",
|
||||
"backup_restore_btn": "Gendan",
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||
},
|
||||
"footer": {
|
||||
"kofi_label": "Ko-fi",
|
||||
"kofi_title": "Projekt, Weiterentwicklung und Betriebskosten auf Ko-fi unterstützen"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
@@ -245,13 +249,13 @@
|
||||
"live_sails_confirm": "Eintragen",
|
||||
"live_sails_confirm_count": "Eintragen ({{count}})",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
||||
"live_fix_gps_loading": "GPS-Position wird ermittelt…",
|
||||
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
||||
"live_fix_lat_placeholder": "Breite (Lat)",
|
||||
"live_fix_lng_placeholder": "Länge (Lng)",
|
||||
"live_position": "Position",
|
||||
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||
"live_position_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
||||
"live_position_gps_loading": "GPS-Position wird ermittelt…",
|
||||
"live_position_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
||||
"live_position_lat_placeholder": "Breite (Lat)",
|
||||
"live_position_lng_placeholder": "Länge (Lng)",
|
||||
"live_photo_btn": "Foto (Kamera)",
|
||||
"live_photo_capture_btn": "Aufnehmen",
|
||||
"live_photo_save_btn": "Speichern",
|
||||
@@ -262,21 +266,40 @@
|
||||
"live_photo_camera_starting": "Kamera wird gestartet…",
|
||||
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
||||
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
||||
"live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.",
|
||||
"live_photo_error": "Foto konnte nicht gespeichert werden.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto aufgenommen",
|
||||
"live_undo_photo_hint": "Foto gespeichert",
|
||||
"live_voice_btn": "Sprachnotiz",
|
||||
"live_voice_hint": "Kurze Sprachnotiz aufnehmen (max. 60 Sekunden).",
|
||||
"live_voice_record": "Aufnahme starten",
|
||||
"live_voice_stop": "Aufnahme beenden",
|
||||
"live_voice_recording": "Aufnahme {{time}}",
|
||||
"live_voice_save": "Speichern",
|
||||
"live_voice_saving": "Wird gespeichert…",
|
||||
"live_voice_retake": "Neu aufnehmen",
|
||||
"live_voice_mic_denied": "Mikrofonzugriff verweigert oder nicht verfügbar.",
|
||||
"live_voice_record_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"live_voice_unavailable": "Sprachnotiz nicht verfügbar",
|
||||
"live_voice_too_large": "Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
|
||||
"live_voice_error": "Sprachnotiz konnte nicht gespeichert werden.",
|
||||
"live_voice_entry": "Sprachnotiz: {{caption}}",
|
||||
"live_voice_entry_plain": "Sprachnotiz",
|
||||
"live_voice_caption_label": "Beschriftung (optional)",
|
||||
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
|
||||
"live_undo_voice_hint": "Sprachnotiz gespeichert",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Freitext eingeben…",
|
||||
"live_comment_confirm": "Eintragen",
|
||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
|
||||
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einer Position.",
|
||||
"live_event_generic": "Ereignis",
|
||||
"live_weather_btn": "Wetter",
|
||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||
"live_weather_owm_loading": "Wetter wird geladen…",
|
||||
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.",
|
||||
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.",
|
||||
"live_weather_position_required": "Für Wetter von OpenWeatherMap zuerst eine Position eintragen (Schaltfläche „Position“). Die Position darf höchstens 6 Stunden alt sein.",
|
||||
"live_weather_position_stale": "Die letzte Position ist älter als 6 Stunden. Bitte erneut eine Position loggen, bevor du Wetter abrufst.",
|
||||
"live_wind_btn": "Wind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Luftdruck",
|
||||
@@ -284,8 +307,8 @@
|
||||
"live_sea_state_btn": "Seegang",
|
||||
"live_visibility_btn": "Sichtweite",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Wasser",
|
||||
"live_fuel_btn": "+ Diesel",
|
||||
"live_water_btn": "+ Wasser",
|
||||
"live_wind_entry": "Wind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
||||
@@ -298,6 +321,7 @@
|
||||
"live_auto_position": "Auto-Position",
|
||||
"live_undo_hint": "Eintrag gespeichert",
|
||||
"live_undo_btn": "Rückgängig",
|
||||
"live_cancel": "Abbruch",
|
||||
"live_pressure_placeholder": "z. B. 1013",
|
||||
"live_temp_placeholder": "z. B. 18",
|
||||
"live_precip_placeholder": "z. B. leichter Regen",
|
||||
@@ -357,7 +381,26 @@
|
||||
"event_location_placeholder": "z. B. Kiel",
|
||||
"event_remarks": "Bemerkungen / Vorkommnisse",
|
||||
"gps_btn": "GPS-Koordinaten abrufen",
|
||||
"gps_permission_denied": "Standortzugriff wurde verweigert. Bitte in den Browser- oder Geräteeinstellungen erlauben und erneut versuchen.",
|
||||
"gps_timeout": "GPS-Zeitüberschreitung. Bitte erneut versuchen – am besten im Freien mit gutem Empfang.",
|
||||
"gps_position_unavailable": "Kein GPS-Signal verfügbar. Bitte warten oder Koordinaten manuell eingeben.",
|
||||
"gps_unavailable": "GPS wird von diesem Browser oder Gerät nicht unterstützt.",
|
||||
"gps_failed": "GPS-Position konnte nicht ermittelt werden.",
|
||||
"gps_fallback_no_location": "GPS fehlgeschlagen. Bitte einen Ort unter „Ort / Hafen“, Start- oder Zielhafen eintragen, oder Koordinaten manuell eingeben.",
|
||||
"gps_fallback_success": "Koordinaten für „{{location}}“ über den Ortsnamen ermittelt (nicht per GPS).",
|
||||
"gps_fallback_failed": "GPS und Ortsnamen-Suche sind fehlgeschlagen. Bitte Koordinaten manuell eingeben.",
|
||||
"gps_quality_excellent": "Starker GPS-Empfang (±{{accuracy}} m)",
|
||||
"gps_quality_good": "Guter GPS-Empfang (±{{accuracy}} m)",
|
||||
"gps_quality_fair": "Mäßiger GPS-Empfang (±{{accuracy}} m) – für besseren Empfang ins Freie gehen.",
|
||||
"gps_quality_poor": "Schwacher GPS-Empfang (±{{accuracy}} m) – vermutlich wenig Satelliten. Im Freien erneut versuchen oder Position prüfen.",
|
||||
"gps_quality_unknown": "GPS-Position übernommen (Genauigkeit vom Gerät nicht gemeldet).",
|
||||
"gps_live_intro_title": "Standort für Live-Log",
|
||||
"gps_live_intro_body": "Für automatische Positions-Einträge und den GPS-Knopf braucht die App Zugriff auf deinen Standort.\n\nTippe auf „Standort erlauben“ – im nächsten Dialog die Freigabe bestätigen. Du kannst jederzeit manuell unter „Position“ eintragen.",
|
||||
"gps_live_intro_allow": "Standort erlauben",
|
||||
"gps_live_intro_later": "Später",
|
||||
"gps_enable_in_settings_hint": "Standortzugriff ist blockiert. In den Browser- oder Geräteeinstellungen (Website / App → Standort) kannst du die Freigabe nachträglich erlauben.",
|
||||
"weather_btn": "OpenWeatherMap Wetter abrufen",
|
||||
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||
"event_wind_pressure": "Luftdruck (hPa)",
|
||||
"event_heel": "Krängung (°)",
|
||||
"event_sails": "Segelführung / Motor",
|
||||
@@ -371,6 +414,18 @@
|
||||
"share_csv": "CSV teilen",
|
||||
"export_pdf": "PDF herunterladen",
|
||||
"exporting_pdf": "PDF wird generiert...",
|
||||
"ai_summary_title": "KI-Zusammenfassung",
|
||||
"ai_summary_read_only": "Vom Skipper erstellt — nur lesbar für die Crew.",
|
||||
"ai_summary_empty": "Noch keine Zusammenfassung vorhanden.",
|
||||
"ai_summary_generate": "Zusammenfassung generieren",
|
||||
"ai_summary_regenerate": "Neu generieren",
|
||||
"ai_summary_generating": "Wird generiert…",
|
||||
"ai_summary_attempts_remaining": "Noch {{remaining}} von {{max}} Versuchen",
|
||||
"ai_summary_error": "KI-Zusammenfassung fehlgeschlagen. Bitte später erneut versuchen.",
|
||||
"ai_summary_error_no_key": "Kein OpenRouter API-Schlüssel auf dem Server konfiguriert.",
|
||||
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
||||
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
||||
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||
@@ -439,8 +494,8 @@
|
||||
"nmea_change_engine_stop": "Motor aus",
|
||||
"nmea_change_autopilot_on": "Autopilot ein",
|
||||
"nmea_change_autopilot_off": "Autopilot aus",
|
||||
"nmea_change_gps_lost": "GPS-Fix verloren",
|
||||
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
|
||||
"nmea_change_gps_lost": "GPS-Position verloren",
|
||||
"nmea_change_gps_regained": "GPS-Position wiederhergestellt",
|
||||
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
|
||||
"nmea_change_anchor": "Ankern / Stop",
|
||||
@@ -464,7 +519,7 @@
|
||||
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
||||
"logout": "Abmelden",
|
||||
"logged_in_as": "Angemeldet als {{name}}",
|
||||
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.",
|
||||
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
|
||||
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
||||
"loading": "Logbücher werden geladen...",
|
||||
"status_synced": "Synchronisiert",
|
||||
@@ -743,7 +798,7 @@
|
||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||
"delete_account_confirm_no": "Abbrechen",
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok) in den Einstellungen jedes Logbuchs.",
|
||||
"deleting_account": "Konto wird gelöscht…",
|
||||
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
|
||||
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
|
||||
@@ -752,9 +807,9 @@
|
||||
"invite_push_prompt_later": "Später",
|
||||
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||
"backup_title": "Backup & Wiederherstellung",
|
||||
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
||||
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, Sprachnotizen, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
||||
"backup_export_title": "Backup erstellen",
|
||||
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
|
||||
"backup_export_desc": "Lädt alle lokalen Daten als komprimierte .daagbok-Datei herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
|
||||
"backup_restore_title": "Backup wiederherstellen",
|
||||
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
||||
"backup_passphrase": "Backup-Passphrase",
|
||||
@@ -766,7 +821,13 @@
|
||||
"backup_export_btn": "Backup herunterladen",
|
||||
"backup_exporting": "Backup wird erstellt…",
|
||||
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
|
||||
"backup_file_label": "Backup-Datei (.daagbok.json)",
|
||||
"backup_file_label": "Backup-Datei (.daagbok)",
|
||||
"backup_export_progress": "Packe Dateien {{current}} / {{total}}…",
|
||||
"backup_invalid_archive": "Die Datei ist kein gültiges Backup-Archiv.",
|
||||
"backup_version_unsupported": "Altes Backup-Format (v1). Bitte ein aktuelles .daagbok-Backup verwenden.",
|
||||
"backup_import_size_confirm": "Dieses Backup ist etwa {{size}} groß. Wiederherstellung kann auf dem Gerät länger dauern und viel Speicher belegen. Fortfahren?",
|
||||
"backup_stat_voice": "{{count}} Sprachnotizen",
|
||||
"backup_stat_size": "Unkomprimiert ca. {{size}}",
|
||||
"backup_preview_btn": "Inhalt prüfen",
|
||||
"backup_previewing": "Prüfe…",
|
||||
"backup_restore_btn": "Wiederherstellen",
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta release — features may still change"
|
||||
},
|
||||
"footer": {
|
||||
"kofi_label": "Ko-fi",
|
||||
"kofi_title": "Support the project, development, and running costs on Ko-fi"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
@@ -245,13 +249,13 @@
|
||||
"live_sails_confirm": "Log entry",
|
||||
"live_sails_confirm_count": "Log entry ({{count}})",
|
||||
"live_sails": "Sails: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
||||
"live_fix_gps_loading": "Getting GPS position…",
|
||||
"live_fix_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
||||
"live_fix_lat_placeholder": "Latitude (Lat)",
|
||||
"live_fix_lng_placeholder": "Longitude (Lng)",
|
||||
"live_position": "Position",
|
||||
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||
"live_position_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
||||
"live_position_gps_loading": "Getting GPS position…",
|
||||
"live_position_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
||||
"live_position_lat_placeholder": "Latitude (Lat)",
|
||||
"live_position_lng_placeholder": "Longitude (Lng)",
|
||||
"live_photo_btn": "Photo (camera)",
|
||||
"live_photo_capture_btn": "Capture",
|
||||
"live_photo_save_btn": "Save",
|
||||
@@ -262,21 +266,40 @@
|
||||
"live_photo_camera_starting": "Starting camera…",
|
||||
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
||||
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
||||
"live_photo_no_camera": "No camera is available on this device.",
|
||||
"live_photo_error": "Could not save photo.",
|
||||
"live_photo_entry": "Photo: {{caption}}",
|
||||
"live_photo_entry_plain": "Photo captured",
|
||||
"live_undo_photo_hint": "Photo saved",
|
||||
"live_voice_btn": "Voice memo",
|
||||
"live_voice_hint": "Record a short voice memo (max. 60 seconds).",
|
||||
"live_voice_record": "Start recording",
|
||||
"live_voice_stop": "Stop recording",
|
||||
"live_voice_recording": "Recording {{time}}",
|
||||
"live_voice_save": "Save",
|
||||
"live_voice_saving": "Saving…",
|
||||
"live_voice_retake": "Record again",
|
||||
"live_voice_mic_denied": "Microphone access denied or unavailable.",
|
||||
"live_voice_record_failed": "Recording failed. Please try again.",
|
||||
"live_voice_unavailable": "Voice memo unavailable",
|
||||
"live_voice_too_large": "Recording is too large. Please record a shorter memo.",
|
||||
"live_voice_error": "Could not save voice memo.",
|
||||
"live_voice_entry": "Voice memo: {{caption}}",
|
||||
"live_voice_entry_plain": "Voice memo",
|
||||
"live_voice_caption_label": "Caption (optional)",
|
||||
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
|
||||
"live_undo_voice_hint": "Voice memo saved",
|
||||
"live_comment_btn": "Comment",
|
||||
"live_comment_placeholder": "Enter text…",
|
||||
"live_comment_confirm": "Log entry",
|
||||
"live_gps_error": "Could not determine GPS position.",
|
||||
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
|
||||
"live_gps_start_hint": "Always start your day's voyage with a position.",
|
||||
"live_event_generic": "Event",
|
||||
"live_weather_btn": "Weather",
|
||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||
"live_weather_owm_loading": "Loading weather…",
|
||||
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
|
||||
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.",
|
||||
"live_weather_position_required": "Log a position first (Position button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
|
||||
"live_weather_position_stale": "The last position is older than 6 hours. Log a new position before fetching weather.",
|
||||
"live_wind_btn": "Wind",
|
||||
"live_temp_btn": "Temp °C",
|
||||
"live_pressure_btn": "Pressure",
|
||||
@@ -284,8 +307,8 @@
|
||||
"live_sea_state_btn": "Sea state",
|
||||
"live_visibility_btn": "Visibility",
|
||||
"live_course_btn": "Course",
|
||||
"live_fuel_btn": "Fuel",
|
||||
"live_water_btn": "Water",
|
||||
"live_fuel_btn": "+ Fuel",
|
||||
"live_water_btn": "+ Water",
|
||||
"live_wind_entry": "Wind {{value}}",
|
||||
"live_temp_entry": "Temperature {{temp}} °C",
|
||||
"live_pressure_entry": "Pressure {{value}} hPa",
|
||||
@@ -298,6 +321,7 @@
|
||||
"live_auto_position": "Auto position",
|
||||
"live_undo_hint": "Entry saved",
|
||||
"live_undo_btn": "Undo",
|
||||
"live_cancel": "Cancel",
|
||||
"live_pressure_placeholder": "e.g. 1013",
|
||||
"live_temp_placeholder": "e.g. 18",
|
||||
"live_precip_placeholder": "e.g. light rain",
|
||||
@@ -357,7 +381,26 @@
|
||||
"event_location_placeholder": "e.g. Kiel",
|
||||
"event_remarks": "Remarks / Events",
|
||||
"gps_btn": "Get GPS Location",
|
||||
"gps_permission_denied": "Location access was denied. Allow it in your browser or device settings and try again.",
|
||||
"gps_timeout": "GPS timed out. Try again outdoors with a clear view of the sky.",
|
||||
"gps_position_unavailable": "No GPS signal available. Wait and retry, or enter coordinates manually.",
|
||||
"gps_unavailable": "GPS is not supported by this browser or device.",
|
||||
"gps_failed": "Could not determine GPS position.",
|
||||
"gps_fallback_no_location": "GPS failed. Enter a place under Location / harbour, departure, or destination, or type coordinates manually.",
|
||||
"gps_fallback_success": "Coordinates for \"{{location}}\" resolved from place name (not GPS).",
|
||||
"gps_fallback_failed": "GPS and place-name lookup both failed. Please enter coordinates manually.",
|
||||
"gps_quality_excellent": "Strong GPS reception (±{{accuracy}} m)",
|
||||
"gps_quality_good": "Good GPS reception (±{{accuracy}} m)",
|
||||
"gps_quality_fair": "Fair GPS reception (±{{accuracy}} m) — move outdoors for a better fix.",
|
||||
"gps_quality_poor": "Weak GPS reception (±{{accuracy}} m) — likely few satellites. Retry outdoors or verify the position.",
|
||||
"gps_quality_unknown": "GPS position applied (accuracy not reported by device).",
|
||||
"gps_live_intro_title": "Location for Live Log",
|
||||
"gps_live_intro_body": "The app needs your location for automatic position entries and the GPS button.\n\nTap “Allow location” and confirm in the next dialog. You can always enter a position manually via “Position”.",
|
||||
"gps_live_intro_allow": "Allow location",
|
||||
"gps_live_intro_later": "Later",
|
||||
"gps_enable_in_settings_hint": "Location access is blocked. You can allow it later in your browser or device settings (site / app → Location).",
|
||||
"weather_btn": "Fetch OpenWeatherMap Weather",
|
||||
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
|
||||
"event_wind_pressure": "Barometer (hPa)",
|
||||
"event_heel": "Heel Angle (°)",
|
||||
"event_sails": "Sails / Motor Status",
|
||||
@@ -371,6 +414,18 @@
|
||||
"share_csv": "Share CSV",
|
||||
"export_pdf": "Download PDF",
|
||||
"exporting_pdf": "Generating PDF...",
|
||||
"ai_summary_title": "AI Summary",
|
||||
"ai_summary_read_only": "Created by the skipper — read-only for crew.",
|
||||
"ai_summary_empty": "No summary yet.",
|
||||
"ai_summary_generate": "Generate summary",
|
||||
"ai_summary_regenerate": "Regenerate",
|
||||
"ai_summary_generating": "Generating…",
|
||||
"ai_summary_attempts_remaining": "{{remaining}} of {{max}} attempts remaining",
|
||||
"ai_summary_error": "AI summary failed. Please try again later.",
|
||||
"ai_summary_error_no_key": "No OpenRouter API key configured on the server.",
|
||||
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
||||
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
||||
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
||||
"photos_title": "Photo Attachments (E2E Encrypted)",
|
||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||
@@ -439,8 +494,8 @@
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_gps_lost": "GPS position lost",
|
||||
"nmea_change_gps_regained": "GPS position restored",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
@@ -464,7 +519,7 @@
|
||||
"new_logbook_placeholder": "Logbook or Yacht Name",
|
||||
"logout": "Logout",
|
||||
"logged_in_as": "Signed in as {{name}}",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
|
||||
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
||||
"loading": "Loading logbooks...",
|
||||
"status_synced": "Synced",
|
||||
@@ -743,7 +798,7 @@
|
||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||
"delete_account_confirm_no": "Cancel",
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok) in each logbook's settings.",
|
||||
"deleting_account": "Deleting account…",
|
||||
"invite_push_prompt_title": "Enable push notifications?",
|
||||
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
|
||||
@@ -752,9 +807,9 @@
|
||||
"invite_push_prompt_later": "Later",
|
||||
"invite_push_prompt_success": "Push notifications are active on this device.",
|
||||
"backup_title": "Backup & restore",
|
||||
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
||||
"backup_desc": "Full encrypted backup of this logbook (entries, photos, voice memos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
||||
"backup_export_title": "Create backup",
|
||||
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.",
|
||||
"backup_export_desc": "Downloads all local data as a compressed .daagbok archive. Keep the file and passphrase separate and secure.",
|
||||
"backup_restore_title": "Restore backup",
|
||||
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
|
||||
"backup_passphrase": "Backup passphrase",
|
||||
@@ -766,7 +821,13 @@
|
||||
"backup_export_btn": "Download backup",
|
||||
"backup_exporting": "Creating backup…",
|
||||
"backup_export_success": "Backup created ({{count}} travel days).",
|
||||
"backup_file_label": "Backup file (.daagbok.json)",
|
||||
"backup_file_label": "Backup file (.daagbok)",
|
||||
"backup_export_progress": "Packing files {{current}} / {{total}}…",
|
||||
"backup_invalid_archive": "The file is not a valid backup archive.",
|
||||
"backup_version_unsupported": "Legacy backup format (v1). Please use a current .daagbok backup.",
|
||||
"backup_import_size_confirm": "This backup is about {{size}} uncompressed. Restore may take longer and use significant memory. Continue?",
|
||||
"backup_stat_voice": "{{count}} voice memos",
|
||||
"backup_stat_size": "Approx. {{size}} uncompressed",
|
||||
"backup_preview_btn": "Verify contents",
|
||||
"backup_previewing": "Verifying…",
|
||||
"backup_restore_btn": "Restore",
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
|
||||
},
|
||||
"footer": {
|
||||
"kofi_label": "Ko-fi",
|
||||
"kofi_title": "Støtt prosjektet, videreutvikling og driftskostnader på Ko-fi"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
@@ -245,13 +249,13 @@
|
||||
"live_sails_confirm": "Loggfør",
|
||||
"live_sails_confirm_count": "Loggfør ({{count}})",
|
||||
"live_sails": "Seil: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Henter GPS-posisjon…",
|
||||
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
||||
"live_fix_lng_placeholder": "Lengde (Lng)",
|
||||
"live_position": "Posisjon",
|
||||
"live_position_coords": "Posisjon {{lat}}, {{lng}}",
|
||||
"live_position_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
|
||||
"live_position_gps_loading": "Henter GPS-posisjon…",
|
||||
"live_position_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
||||
"live_position_lat_placeholder": "Bredde (Lat)",
|
||||
"live_position_lng_placeholder": "Lengde (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Ta bilde",
|
||||
"live_photo_save_btn": "Lagre",
|
||||
@@ -262,10 +266,29 @@
|
||||
"live_photo_camera_starting": "Starter kamera…",
|
||||
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
||||
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
||||
"live_photo_no_camera": "Ingen kamera er tilgjengelig på denne enheten.",
|
||||
"live_photo_error": "Kunne ikke lagre foto.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto tatt",
|
||||
"live_undo_photo_hint": "Foto lagret",
|
||||
"live_voice_btn": "Talemelding",
|
||||
"live_voice_hint": "Ta opp en kort talemelding (maks. 60 sekunder).",
|
||||
"live_voice_record": "Start opptak",
|
||||
"live_voice_stop": "Stopp opptak",
|
||||
"live_voice_recording": "Tar opp {{time}}",
|
||||
"live_voice_save": "Lagre",
|
||||
"live_voice_saving": "Lagrer…",
|
||||
"live_voice_retake": "Ta opp på nytt",
|
||||
"live_voice_mic_denied": "Mikrofontilgang nektet eller utilgjengelig.",
|
||||
"live_voice_record_failed": "Opptak mislyktes. Prøv igjen.",
|
||||
"live_voice_unavailable": "Talemelding utilgjengelig",
|
||||
"live_voice_too_large": "Opptaket er for stort. Ta et kortere opptak.",
|
||||
"live_voice_error": "Kunne ikke lagre talemelding.",
|
||||
"live_voice_entry": "Talemelding: {{caption}}",
|
||||
"live_voice_entry_plain": "Talemelding",
|
||||
"live_voice_caption_label": "Bildetekst (valgfritt)",
|
||||
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
|
||||
"live_undo_voice_hint": "Talemelding lagret",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Skriv inn tekst…",
|
||||
"live_comment_confirm": "Loggfør",
|
||||
@@ -275,8 +298,8 @@
|
||||
"live_weather_btn": "Vær",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||
"live_weather_owm_loading": "Henter vær…",
|
||||
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
|
||||
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.",
|
||||
"live_weather_position_required": "Logg først en posisjon (Posisjon-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
|
||||
"live_weather_position_stale": "Siste posisjon er eldre enn 6 timer. Logg en ny posisjon før du henter vær.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttrykk",
|
||||
@@ -284,8 +307,8 @@
|
||||
"live_sea_state_btn": "Sjøgang",
|
||||
"live_visibility_btn": "Sikt",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vann",
|
||||
"live_fuel_btn": "+ Diesel",
|
||||
"live_water_btn": "+ Vann",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttrykk {{value}} hPa",
|
||||
@@ -298,6 +321,7 @@
|
||||
"live_auto_position": "Auto-posisjon",
|
||||
"live_undo_hint": "Oppføring lagret",
|
||||
"live_undo_btn": "Angre",
|
||||
"live_cancel": "Avbryt",
|
||||
"live_pressure_placeholder": "f.eks. 1013",
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. lett regn",
|
||||
@@ -357,7 +381,26 @@
|
||||
"event_location_placeholder": "z. f.eks. Kiel",
|
||||
"event_remarks": "Merknader / hendelser",
|
||||
"gps_btn": "Hent GPS-koordinater",
|
||||
"gps_permission_denied": "Tilgang til posisjon ble nektet. Tillat det i nettleser- eller enhetsinnstillinger og prøv igjen.",
|
||||
"gps_timeout": "GPS fikk tidsavbrudd. Prøv igjen utendørs med fri sikt mot himmelen.",
|
||||
"gps_position_unavailable": "Ingen GPS-signal tilgjengelig. Vent og prøv igjen, eller skriv inn koordinater manuelt.",
|
||||
"gps_unavailable": "GPS støttes ikke av denne nettleseren eller enheten.",
|
||||
"gps_failed": "GPS-posisjon kunne ikke bestemmes.",
|
||||
"gps_fallback_no_location": "GPS mislyktes. Skriv inn et sted under sted/havn, avreise eller destinasjon, eller koordinater manuelt.",
|
||||
"gps_fallback_success": "Koordinater for «{{location}}» funnet via stedsnavn (ikke GPS).",
|
||||
"gps_fallback_failed": "GPS og stedsnavnssøk mislyktes. Skriv inn koordinater manuelt.",
|
||||
"gps_quality_excellent": "Sterk GPS-mottak (±{{accuracy}} m)",
|
||||
"gps_quality_good": "God GPS-mottak (±{{accuracy}} m)",
|
||||
"gps_quality_fair": "Middels GPS-mottak (±{{accuracy}} m) – gå utendørs for bedre signal.",
|
||||
"gps_quality_poor": "Svakt GPS-mottak (±{{accuracy}} m) – sannsynligvis få satellitter. Prøv utendørs igjen eller kontroller posisjonen.",
|
||||
"gps_quality_unknown": "GPS-posisjon tatt i bruk (nøyaktighet ikke rapportert av enheten).",
|
||||
"gps_live_intro_title": "Posisjon for live-logg",
|
||||
"gps_live_intro_body": "Appen trenger posisjonen din for automatiske posisjonsregistreringer og GPS-knappen.\n\nTrykk «Tillat posisjon» og bekreft i neste dialog. Du kan alltid legge inn posisjon manuelt via «Posisjon».",
|
||||
"gps_live_intro_allow": "Tillat posisjon",
|
||||
"gps_live_intro_later": "Senere",
|
||||
"gps_enable_in_settings_hint": "Posisjonstilgang er blokkert. Du kan tillate det senere i nettleser- eller enhetsinnstillinger (nettsted / app → Posisjon).",
|
||||
"weather_btn": "OpenWeatherMap Ring opp været",
|
||||
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
|
||||
"event_wind_pressure": "Lufttrykk (hPa)",
|
||||
"event_heel": "Helning (°)",
|
||||
"event_sails": "Seilhåndtering / motor",
|
||||
@@ -371,6 +414,18 @@
|
||||
"share_csv": "CSV andel",
|
||||
"export_pdf": "Last ned PDF",
|
||||
"exporting_pdf": "PDF genereres...",
|
||||
"ai_summary_title": "AI-sammendrag",
|
||||
"ai_summary_read_only": "Opprettet av skipperen — kun lesbar for mannskapet.",
|
||||
"ai_summary_empty": "Ingen sammendrag ennå.",
|
||||
"ai_summary_generate": "Generer sammendrag",
|
||||
"ai_summary_regenerate": "Generer på nytt",
|
||||
"ai_summary_generating": "Genererer…",
|
||||
"ai_summary_attempts_remaining": "{{remaining}} av {{max}} forsøk igjen",
|
||||
"ai_summary_error": "AI-sammendrag mislyktes. Prøv igjen senere.",
|
||||
"ai_summary_error_no_key": "Ingen OpenRouter API-nøkkel konfigurert på serveren.",
|
||||
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
|
||||
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
|
||||
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
|
||||
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
||||
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
||||
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
||||
@@ -449,8 +504,8 @@
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_gps_lost": "GPS-posisjon tapt",
|
||||
"nmea_change_gps_regained": "GPS-posisjon gjenopprettet",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
@@ -464,7 +519,7 @@
|
||||
"new_logbook_placeholder": "Navn på loggboken eller båten",
|
||||
"logout": "Logg ut",
|
||||
"logged_in_as": "Innlogget som {{name}}",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok.json) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.",
|
||||
"no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!",
|
||||
"loading": "Loggbøker er lastet...",
|
||||
"status_synced": "Synkronisert",
|
||||
@@ -743,7 +798,7 @@
|
||||
"delete_account_confirm_yes": "Ja, slett konto og alle data",
|
||||
"delete_account_confirm_no": "Avbryt",
|
||||
"delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.",
|
||||
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
|
||||
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok) i innstillingene for hver loggbok før du sletter dem.",
|
||||
"deleting_account": "Kontoen vil bli slettet...",
|
||||
"invite_push_prompt_title": "Aktivere push-varsler?",
|
||||
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
||||
@@ -754,7 +809,7 @@
|
||||
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
||||
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
|
||||
"backup_export_title": "Opprett sikkerhetskopi",
|
||||
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
|
||||
"backup_export_desc": "Laster ned alle lokale data som et komprimert .daagbok-arkiv. Hold filen og passordfrasen adskilt og sikker.",
|
||||
"backup_restore_title": "Gjenopprett sikkerhetskopi",
|
||||
"backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.",
|
||||
"backup_passphrase": "Passord for sikkerhetskopiering",
|
||||
@@ -766,7 +821,13 @@
|
||||
"backup_export_btn": "Last ned sikkerhetskopi",
|
||||
"backup_exporting": "Sikkerhetskopien er opprettet...",
|
||||
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
|
||||
"backup_file_label": "Sikkerhetskopifil (.daagbok.json)",
|
||||
"backup_file_label": "Sikkerhetskopifil (.daagbok)",
|
||||
"backup_export_progress": "Pakker filer {{current}} / {{total}}…",
|
||||
"backup_invalid_archive": "Filen er ikke et gyldig backup-arkiv.",
|
||||
"backup_version_unsupported": "Gammelt backup-format (v1). Bruk en aktuell .daagbok-sikkerhetskopi.",
|
||||
"backup_import_size_confirm": "Denne sikkerhetskopien er ca. {{size}} ukomprimert. Gjenoppretting kan ta lengre tid. Fortsette?",
|
||||
"backup_stat_voice": "{{count}} talemeldinger",
|
||||
"backup_stat_size": "Ca. {{size}} ukomprimert",
|
||||
"backup_preview_btn": "Sjekk innhold",
|
||||
"backup_previewing": "Sjekk...",
|
||||
"backup_restore_btn": "Gjenopprett",
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
|
||||
},
|
||||
"footer": {
|
||||
"kofi_label": "Ko-fi",
|
||||
"kofi_title": "Stöd projektet, vidareutveckling och driftskostnader på Ko-fi"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
@@ -245,13 +249,13 @@
|
||||
"live_sails_confirm": "Logga",
|
||||
"live_sails_confirm_count": "Logga ({{count}})",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Hämtar GPS-position…",
|
||||
"live_fix_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
||||
"live_fix_lat_placeholder": "Latitud (Lat)",
|
||||
"live_fix_lng_placeholder": "Longitud (Lng)",
|
||||
"live_position": "Position",
|
||||
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||
"live_position_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
|
||||
"live_position_gps_loading": "Hämtar GPS-position…",
|
||||
"live_position_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
||||
"live_position_lat_placeholder": "Latitud (Lat)",
|
||||
"live_position_lng_placeholder": "Longitud (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Ta foto",
|
||||
"live_photo_save_btn": "Spara",
|
||||
@@ -262,10 +266,29 @@
|
||||
"live_photo_camera_starting": "Startar kamera…",
|
||||
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
||||
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
||||
"live_photo_no_camera": "Ingen kamera finns på den här enheten.",
|
||||
"live_photo_error": "Foto kunde inte sparas.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto taget",
|
||||
"live_undo_photo_hint": "Foto sparat",
|
||||
"live_voice_btn": "Röstanteckning",
|
||||
"live_voice_hint": "Spela in en kort röstanteckning (max 60 sekunder).",
|
||||
"live_voice_record": "Starta inspelning",
|
||||
"live_voice_stop": "Stoppa inspelning",
|
||||
"live_voice_recording": "Spelar in {{time}}",
|
||||
"live_voice_save": "Spara",
|
||||
"live_voice_saving": "Sparar…",
|
||||
"live_voice_retake": "Spela in igen",
|
||||
"live_voice_mic_denied": "Mikrofonåtkomst nekad eller ej tillgänglig.",
|
||||
"live_voice_record_failed": "Inspelning misslyckades. Försök igen.",
|
||||
"live_voice_unavailable": "Röstanteckning ej tillgänglig",
|
||||
"live_voice_too_large": "Inspelningen är för stor. Spela in kortare.",
|
||||
"live_voice_error": "Kunde inte spara röstanteckning.",
|
||||
"live_voice_entry": "Röstanteckning: {{caption}}",
|
||||
"live_voice_entry_plain": "Röstanteckning",
|
||||
"live_voice_caption_label": "Bildtext (valfritt)",
|
||||
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
|
||||
"live_undo_voice_hint": "Röstanteckning sparad",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Ange text…",
|
||||
"live_comment_confirm": "Logga",
|
||||
@@ -275,8 +298,8 @@
|
||||
"live_weather_btn": "Väder",
|
||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||
"live_weather_owm_loading": "Hämtar väder…",
|
||||
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
|
||||
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.",
|
||||
"live_weather_position_required": "Logga först en position (Position-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
|
||||
"live_weather_position_stale": "Senaste positionen är äldre än 6 timmar. Logga en ny position innan du hämtar väder.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttryck",
|
||||
@@ -284,8 +307,8 @@
|
||||
"live_sea_state_btn": "Sjögang",
|
||||
"live_visibility_btn": "Sikt",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vatten",
|
||||
"live_fuel_btn": "+ Diesel",
|
||||
"live_water_btn": "+ Vatten",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttryck {{value}} hPa",
|
||||
@@ -298,6 +321,7 @@
|
||||
"live_auto_position": "Auto-position",
|
||||
"live_undo_hint": "Post sparad",
|
||||
"live_undo_btn": "Ångra",
|
||||
"live_cancel": "Avbryt",
|
||||
"live_pressure_placeholder": "t.ex. 1013",
|
||||
"live_temp_placeholder": "t.ex. 18",
|
||||
"live_precip_placeholder": "t.ex. lätt regn",
|
||||
@@ -357,7 +381,26 @@
|
||||
"event_location_placeholder": "z. t.ex. Kiel",
|
||||
"event_remarks": "Anmärkningar / incidenter",
|
||||
"gps_btn": "Hämta GPS-koordinater",
|
||||
"gps_permission_denied": "Platstillgång nekades. Tillåt det i webbläsar- eller enhetsinställningar och försök igen.",
|
||||
"gps_timeout": "GPS fick tidsgräns. Försök igen utomhus med fri sikt mot himlen.",
|
||||
"gps_position_unavailable": "Ingen GPS-signal tillgänglig. Vänta och försök igen, eller ange koordinater manuellt.",
|
||||
"gps_unavailable": "GPS stöds inte av denna webbläsare eller enhet.",
|
||||
"gps_failed": "GPS-position kunde inte bestämmas.",
|
||||
"gps_fallback_no_location": "GPS misslyckades. Ange en plats under ort/hamn, avresa eller destination, eller skriv koordinater manuellt.",
|
||||
"gps_fallback_success": "Koordinater för \"{{location}}\" hittades via ortsnamn (inte GPS).",
|
||||
"gps_fallback_failed": "GPS och ortnamnssökning misslyckades. Ange koordinater manuellt.",
|
||||
"gps_quality_excellent": "Stark GPS-mottagning (±{{accuracy}} m)",
|
||||
"gps_quality_good": "Bra GPS-mottagning (±{{accuracy}} m)",
|
||||
"gps_quality_fair": "Måttlig GPS-mottagning (±{{accuracy}} m) – gå utomhus för bättre signal.",
|
||||
"gps_quality_poor": "Svag GPS-mottagning (±{{accuracy}} m) – troligen få satelliter. Försök utomhus igen eller kontrollera positionen.",
|
||||
"gps_quality_unknown": "GPS-position övertagen (noggrannhet ej rapporterad av enheten).",
|
||||
"gps_live_intro_title": "Plats för live-logg",
|
||||
"gps_live_intro_body": "Appen behöver din plats för automatiska positionsregistreringar och GPS-knappen.\n\nTryck på „Tillåt plats“ och bekräfta i nästa dialog. Du kan alltid ange position manuellt via „Position“.",
|
||||
"gps_live_intro_allow": "Tillåt plats",
|
||||
"gps_live_intro_later": "Senare",
|
||||
"gps_enable_in_settings_hint": "Platstillgång är blockerad. Du kan tillåta det senare i webbläsar- eller enhetsinställningar (webbplats / app → Plats).",
|
||||
"weather_btn": "OpenWeatherMap Ring upp väder",
|
||||
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
|
||||
"event_wind_pressure": "Lufttryck (hPa)",
|
||||
"event_heel": "Krängning (°)",
|
||||
"event_sails": "Segelhantering / motor",
|
||||
@@ -371,6 +414,18 @@
|
||||
"share_csv": "Aktie",
|
||||
"export_pdf": "Hämta PDF.",
|
||||
"exporting_pdf": "PDF genereras...",
|
||||
"ai_summary_title": "AI-sammanfattning",
|
||||
"ai_summary_read_only": "Skapad av skepparen — endast läsning för besättningen.",
|
||||
"ai_summary_empty": "Ingen sammanfattning ännu.",
|
||||
"ai_summary_generate": "Generera sammanfattning",
|
||||
"ai_summary_regenerate": "Generera igen",
|
||||
"ai_summary_generating": "Genererar…",
|
||||
"ai_summary_attempts_remaining": "{{remaining}} av {{max}} försök kvar",
|
||||
"ai_summary_error": "AI-sammanfattning misslyckades. Försök igen senare.",
|
||||
"ai_summary_error_no_key": "Ingen OpenRouter API-nyckel konfigurerad på servern.",
|
||||
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
|
||||
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
|
||||
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
|
||||
"photos_title": "Fotobilagor (E2E-krypterade)",
|
||||
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
||||
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
||||
@@ -449,8 +504,8 @@
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_gps_lost": "GPS-position förlorad",
|
||||
"nmea_change_gps_regained": "GPS-position återställd",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
@@ -464,7 +519,7 @@
|
||||
"new_logbook_placeholder": "Loggbokens eller båtens namn",
|
||||
"logout": "Logga ut",
|
||||
"logged_in_as": "Inloggad som {{name}}",
|
||||
"delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok.json) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.",
|
||||
"delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.",
|
||||
"no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!",
|
||||
"loading": "Loggböckerna är fulla...",
|
||||
"status_synced": "Synkroniserad",
|
||||
@@ -743,7 +798,7 @@
|
||||
"delete_account_confirm_yes": "Ja, radera konto och all data",
|
||||
"delete_account_confirm_no": "Avbryt",
|
||||
"delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.",
|
||||
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
|
||||
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok) i inställningarna för varje loggbok innan du raderar dem.",
|
||||
"deleting_account": "Kontot kommer att raderas...",
|
||||
"invite_push_prompt_title": "Aktivera push-meddelanden?",
|
||||
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
||||
@@ -754,7 +809,7 @@
|
||||
"backup_title": "Säkerhetskopiering och återställning",
|
||||
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
|
||||
"backup_export_title": "Skapa säkerhetskopia",
|
||||
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.",
|
||||
"backup_export_desc": "Laddar ner alla lokala data som ett komprimerat .daagbok-arkiv. Förvara filen och lösenfrasen separat och säkert.",
|
||||
"backup_restore_title": "Återställ säkerhetskopian",
|
||||
"backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.",
|
||||
"backup_passphrase": "Lösenord för säkerhetskopiering",
|
||||
@@ -766,7 +821,13 @@
|
||||
"backup_export_btn": "Ladda ner backup",
|
||||
"backup_exporting": "Säkerhetskopian skapas...",
|
||||
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
|
||||
"backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)",
|
||||
"backup_file_label": "Säkerhetskopieringsfil (.daagbok)",
|
||||
"backup_export_progress": "Packar filer {{current}} / {{total}}…",
|
||||
"backup_invalid_archive": "Filen är inte ett giltigt backup-arkiv.",
|
||||
"backup_version_unsupported": "Gammalt backup-format (v1). Använd en aktuell .daagbok-säkerhetskopia.",
|
||||
"backup_import_size_confirm": "Denna säkerhetskopia är ca. {{size}} okomprimerad. Återställning kan ta längre tid. Fortsätta?",
|
||||
"backup_stat_voice": "{{count}} röstanteckningar",
|
||||
"backup_stat_size": "Ca. {{size}} okomprimerat",
|
||||
"backup_preview_btn": "Kontrollera innehåll",
|
||||
"backup_previewing": "Check...",
|
||||
"backup_restore_btn": "Återställ",
|
||||
|
||||
@@ -18,3 +18,62 @@ body {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Scrollbars — auf Touch-Geräten breiter und besser sichtbar */
|
||||
:root {
|
||||
--app-scrollbar-size: 10px;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse), (max-width: 768px) {
|
||||
:root {
|
||||
--app-scrollbar-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: var(--app-accent-light) var(--app-surface-inset);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar,
|
||||
*::-webkit-scrollbar {
|
||||
width: var(--app-scrollbar-size);
|
||||
height: var(--app-scrollbar-size);
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-track,
|
||||
body::-webkit-scrollbar-track,
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--app-surface-inset);
|
||||
border-radius: calc(var(--app-scrollbar-size) / 2);
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb,
|
||||
body::-webkit-scrollbar-thumb,
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--app-accent-light) 55%, transparent);
|
||||
border-radius: calc(var(--app-scrollbar-size) / 2);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb:hover,
|
||||
body::-webkit-scrollbar-thumb:hover,
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--app-accent-light) 80%, transparent);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse), (max-width: 768px) {
|
||||
html::-webkit-scrollbar-thumb,
|
||||
body::-webkit-scrollbar-thumb,
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--app-accent-light) 70%, transparent);
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb:active,
|
||||
body::-webkit-scrollbar-thumb:active,
|
||||
*::-webkit-scrollbar-thumb:active {
|
||||
background: var(--app-accent-light);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,17 @@ async function bootstrap(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { scope: '/' })
|
||||
.then((reg) => {
|
||||
console.log('Service Worker registered successfully with scope:', reg.scope)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Service Worker registration failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element')
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { buildTravelDayContext } from './aiSummary.js'
|
||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
|
||||
const t = ((key: string, opts?: Record<string, unknown>) => {
|
||||
if (key === 'logs.live_motor_start') return 'Motor started'
|
||||
if (key === 'logs.live_event_generic') return 'Event'
|
||||
if (opts && 'course' in opts) return `Course ${opts.course}`
|
||||
return key
|
||||
}) as any
|
||||
|
||||
describe('buildTravelDayContext', () => {
|
||||
it('includes route metadata and formatted events', () => {
|
||||
const events: LogEventPayload[] = [
|
||||
{
|
||||
time: '09:00',
|
||||
mgk: '180',
|
||||
rwk: '',
|
||||
windPressure: '',
|
||||
windDirection: '',
|
||||
windStrength: '',
|
||||
seaState: '',
|
||||
visibility: '',
|
||||
weatherIcon: '',
|
||||
current: '',
|
||||
heel: '',
|
||||
sailsOrMotor: 'Genua',
|
||||
logReading: '',
|
||||
distance: '',
|
||||
gpsLat: '',
|
||||
gpsLng: '',
|
||||
remarks: '__live:motor_start'
|
||||
}
|
||||
]
|
||||
|
||||
const context = buildTravelDayContext(
|
||||
{
|
||||
date: '2026-06-03',
|
||||
dayOfTravel: '5',
|
||||
departure: 'Kiel',
|
||||
destination: 'Copenhagen',
|
||||
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
|
||||
fuel: { morning: 50, refilled: 10, evening: 40, consumption: 20 },
|
||||
greywaterLevel: 0,
|
||||
trackDistanceNm: 42.5,
|
||||
motorHours: 3.5,
|
||||
events
|
||||
},
|
||||
t
|
||||
)
|
||||
|
||||
expect(context.departure).toBe('Kiel')
|
||||
expect(context.destination).toBe('Copenhagen')
|
||||
expect(context.trackDistanceNm).toBe(42.5)
|
||||
expect(context.motorHours).toBe(3.5)
|
||||
expect(context.events).toHaveLength(1)
|
||||
expect(context.events[0].summary).toBe('Motor started')
|
||||
expect(context.events[0].sailsOrMotor).toBe('Genua')
|
||||
expect(context.greywater).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import { apiFetch } from './api.js'
|
||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
|
||||
export class TravelDaySummaryApiError extends Error {
|
||||
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED'
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'TravelDaySummaryApiError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export interface TravelDaySummaryContext {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
trackDistanceNm?: number
|
||||
trackSpeedMaxKn?: number
|
||||
trackSpeedAvgKn?: number
|
||||
motorHours?: number
|
||||
freshwater?: {
|
||||
morning: number
|
||||
refilled: number
|
||||
evening: number
|
||||
consumption: number
|
||||
}
|
||||
fuel?: {
|
||||
morning: number
|
||||
refilled: number
|
||||
evening: number
|
||||
consumption: number
|
||||
}
|
||||
greywater?: { level: number }
|
||||
events: Array<{
|
||||
time: string
|
||||
summary: string
|
||||
sailsOrMotor?: string
|
||||
mgk?: string
|
||||
windDirection?: string
|
||||
windStrength?: string
|
||||
windPressure?: string
|
||||
seaState?: string
|
||||
visibility?: string
|
||||
distance?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface TravelDaySummaryInput {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
trackDistanceNm?: number
|
||||
trackSpeedMaxKn?: number
|
||||
trackSpeedAvgKn?: number
|
||||
motorHours?: number
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
greywaterLevel?: number
|
||||
events: LogEventPayload[]
|
||||
}
|
||||
|
||||
const SUMMARY_FETCH_TIMEOUT_MS = 90_000
|
||||
|
||||
export function buildTravelDayContext(
|
||||
input: TravelDaySummaryInput,
|
||||
t: TFunction
|
||||
): TravelDaySummaryContext {
|
||||
const context: TravelDaySummaryContext = {
|
||||
date: input.date,
|
||||
dayOfTravel: input.dayOfTravel,
|
||||
departure: input.departure,
|
||||
destination: input.destination,
|
||||
freshwater: input.freshwater,
|
||||
fuel: input.fuel,
|
||||
events: sortLogEventsByTime(input.events).map((event) => ({
|
||||
time: event.time,
|
||||
summary: formatEventSummary(event, t),
|
||||
...(event.sailsOrMotor ? { sailsOrMotor: event.sailsOrMotor } : {}),
|
||||
...(event.mgk ? { mgk: event.mgk } : {}),
|
||||
...(event.windDirection ? { windDirection: event.windDirection } : {}),
|
||||
...(event.windStrength ? { windStrength: event.windStrength } : {}),
|
||||
...(event.windPressure ? { windPressure: event.windPressure } : {}),
|
||||
...(event.seaState ? { seaState: event.seaState } : {}),
|
||||
...(event.visibility ? { visibility: event.visibility } : {}),
|
||||
...(event.distance ? { distance: event.distance } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
if (input.trackDistanceNm !== undefined) context.trackDistanceNm = input.trackDistanceNm
|
||||
if (input.trackSpeedMaxKn !== undefined) context.trackSpeedMaxKn = input.trackSpeedMaxKn
|
||||
if (input.trackSpeedAvgKn !== undefined) context.trackSpeedAvgKn = input.trackSpeedAvgKn
|
||||
if (input.motorHours !== undefined && input.motorHours > 0) context.motorHours = input.motorHours
|
||||
if (input.greywaterLevel !== undefined && input.greywaterLevel > 0) {
|
||||
context.greywater = { level: input.greywaterLevel }
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function mapApiError(status: number, data: unknown): TravelDaySummaryApiError {
|
||||
const code =
|
||||
typeof data === 'object' && data !== null && 'code' in data
|
||||
? String((data as { code?: string }).code)
|
||||
: ''
|
||||
|
||||
if (status === 503 || code === 'NO_KEY') {
|
||||
return new TravelDaySummaryApiError('No OpenRouter API key configured', 'NO_KEY')
|
||||
}
|
||||
if (status === 403) {
|
||||
return new TravelDaySummaryApiError('Forbidden', 'FORBIDDEN')
|
||||
}
|
||||
if (status === 429 || code === 'RATE_LIMITED') {
|
||||
return new TravelDaySummaryApiError('Rate limit exceeded', 'RATE_LIMITED')
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof data === 'object' && data !== null && 'error' in data && typeof (data as { error: unknown }).error === 'string'
|
||||
? (data as { error: string }).error
|
||||
: 'Request failed'
|
||||
return new TravelDaySummaryApiError(message, 'REQUEST_FAILED')
|
||||
}
|
||||
|
||||
export async function fetchTravelDaySummaryUsage(
|
||||
logbookId: string,
|
||||
entryId: string
|
||||
): Promise<{ remainingAttempts: number; maxAttempts: number }> {
|
||||
const params = new URLSearchParams({ logbookId, entryId })
|
||||
const res = await apiFetch(`/api/ai/usage?${params.toString()}`)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw mapApiError(res.status, data)
|
||||
return data as { remainingAttempts: number; maxAttempts: number }
|
||||
}
|
||||
|
||||
export async function generateTravelDaySummary(params: {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
language: string
|
||||
context: TravelDaySummaryContext
|
||||
}): Promise<{ summary: string; remainingAttempts: number; maxAttempts: number }> {
|
||||
if (!navigator.onLine) {
|
||||
throw new TravelDaySummaryApiError('Offline', 'OFFLINE')
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), SUMMARY_FETCH_TIMEOUT_MS)
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await apiFetch('/api/ai/summary', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new TravelDaySummaryApiError('AI summary request timed out')
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw mapApiError(res.status, data)
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.AI_SUMMARY_GENERATED)
|
||||
|
||||
return data as { summary: string; remainingAttempts: number; maxAttempts: number }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const PlausibleEvents = {
|
||||
PUSH_ENABLED: 'Push Enabled',
|
||||
PUSH_DISABLED: 'Push Disabled',
|
||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
||||
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
|
||||
PROFILE_OPENED: 'Profile Opened',
|
||||
PASSKEY_ADDED: 'Passkey Added',
|
||||
PASSKEY_REMOVED: 'Passkey Removed',
|
||||
@@ -40,8 +41,9 @@ export const PlausibleEvents = {
|
||||
NMEA_UPLOADED: 'NMEA Uploaded',
|
||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
|
||||
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
|
||||
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
|
||||
|
||||
@@ -10,22 +10,43 @@ export class ApiError extends Error {
|
||||
|
||||
export async function apiFetch(
|
||||
input: string,
|
||||
init: RequestInit = {}
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 15000
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body !== undefined && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
if (init.signal) {
|
||||
if (init.signal.aborted) {
|
||||
controller.abort()
|
||||
} else {
|
||||
init.signal.addEventListener('abort', () => controller.abort())
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: controller.signal
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await apiFetch(input, init)
|
||||
export async function apiJson<T>(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 15000
|
||||
): Promise<T> {
|
||||
const res = await apiFetch(input, init, timeoutMs)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
|
||||
@@ -556,6 +556,7 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
db.deviations.clear(),
|
||||
db.entries.clear(),
|
||||
db.photos.clear(),
|
||||
db.voiceMemos.clear(),
|
||||
db.gpsTracks.clear(),
|
||||
db.syncQueue.clear(),
|
||||
db.logbookKeys.clear(),
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
|
||||
// Headers matching the requested event fields & metadata
|
||||
const headers = [
|
||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
||||
'Skipper Signature', 'Crew Signature',
|
||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||
'Event Time', 'MgK Course', 'RwK Course',
|
||||
@@ -120,12 +120,13 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const fuelE = entry.fuel?.evening ?? '';
|
||||
const fuelCons = entry.fuel?.consumption ?? '';
|
||||
const greywaterLevel = entry.greywater?.level ?? '';
|
||||
const aiSummary = entry.aiSummary ?? '';
|
||||
|
||||
const eventsList = entry.events || [];
|
||||
if (eventsList.length === 0) {
|
||||
// Create one row even if there are no events for the day
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
dateVal, travelDay, dep, dest, aiSummary,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
'', '', '',
|
||||
@@ -142,7 +143,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||
for (const ev of sortedEvents) {
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
dateVal, travelDay, dep, dest, aiSummary,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||
|
||||
@@ -65,6 +65,16 @@ export interface LocalPhoto {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalVoiceMemo {
|
||||
payloadId: string
|
||||
entryId: string
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalGpsTrack {
|
||||
entryId: string // one track per daily journal entry
|
||||
logbookId: string
|
||||
@@ -132,6 +142,7 @@ export interface SyncQueueItem {
|
||||
| 'entry'
|
||||
| 'logbook'
|
||||
| 'photo'
|
||||
| 'voiceMemo'
|
||||
| 'gpsTrack'
|
||||
| 'logbookCrew'
|
||||
| 'logbookVessel'
|
||||
@@ -166,6 +177,7 @@ class DaagboxDatabase extends Dexie {
|
||||
deviations!: Table<LocalDeviation>
|
||||
entries!: Table<LocalEntry>
|
||||
photos!: Table<LocalPhoto>
|
||||
voiceMemos!: Table<LocalVoiceMemo>
|
||||
gpsTracks!: Table<LocalGpsTrack>
|
||||
nmeaArchives!: Table<LocalNmeaArchive>
|
||||
logbookKeys!: Table<LocalLogbookKey>
|
||||
@@ -289,6 +301,25 @@ class DaagboxDatabase extends Dexie {
|
||||
userSyncQueue: '++id, action, type, payloadId',
|
||||
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||
})
|
||||
this.version(10).stores({
|
||||
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||
yachts: 'logbookId, updatedAt',
|
||||
crews: 'payloadId, logbookId, updatedAt',
|
||||
deviations: 'logbookId, updatedAt',
|
||||
entries: 'payloadId, logbookId, updatedAt',
|
||||
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||
voiceMemos: 'payloadId, entryId, logbookId, updatedAt',
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId',
|
||||
personPool: 'payloadId, updatedAt',
|
||||
vesselPool: 'payloadId, updatedAt',
|
||||
logbookCrewSelections: 'logbookId, updatedAt',
|
||||
logbookVesselSelections: 'logbookId, updatedAt',
|
||||
userSyncQueue: '++id, action, type, payloadId',
|
||||
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@ export async function deleteLocalLogbookCache(id: string): Promise<void> {
|
||||
await db.deviations.where({ logbookId: id }).delete()
|
||||
await db.entries.where({ logbookId: id }).delete()
|
||||
await db.photos.where({ logbookId: id }).delete()
|
||||
await db.voiceMemos.where({ logbookId: id }).delete()
|
||||
await db.gpsTracks.where({ logbookId: id }).delete()
|
||||
await db.syncQueue.where({ logbookId: id }).delete()
|
||||
await db.logbookKeys.where({ logbookId: id }).delete()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import {
|
||||
@@ -9,89 +10,54 @@ import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
|
||||
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import type { SyncQueueItem } from './db.js'
|
||||
import { getAppVersion } from './pwaVersion.js'
|
||||
import { dexieFieldsFromEncBytes, encBytesFromDexieFields } from './logbookBackup/encBlob.js'
|
||||
import {
|
||||
BACKUP_FORMAT,
|
||||
BACKUP_VERSION,
|
||||
type BackupManifestCounts,
|
||||
type BackupManifestV2,
|
||||
type LogbookMetaJson
|
||||
} from './logbookBackup/manifest.js'
|
||||
import {
|
||||
buildArchiveFromCollected,
|
||||
collectLogbookBackupData,
|
||||
type BackupExportProgress
|
||||
} from './logbookBackup/collector.js'
|
||||
import {
|
||||
isZipArchive,
|
||||
readBinaryFile,
|
||||
readManifestFromArchive,
|
||||
readTextFile,
|
||||
unzipArchive
|
||||
} from './logbookBackup/zipArchive.js'
|
||||
|
||||
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
|
||||
export const BACKUP_VERSION = 1 as const
|
||||
|
||||
export interface LogbookBackupFile {
|
||||
format: typeof BACKUP_FORMAT
|
||||
version: typeof BACKUP_VERSION
|
||||
exportedAt: string
|
||||
logbook: {
|
||||
id: string
|
||||
encryptedTitle: string
|
||||
updatedAt: string
|
||||
isDemo?: boolean
|
||||
}
|
||||
logbookKey: {
|
||||
ciphertext: string
|
||||
iv: string
|
||||
tag: string
|
||||
}
|
||||
payloads: {
|
||||
yacht: {
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
} | null
|
||||
deviation: {
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
} | null
|
||||
crews: Array<{
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
entries: Array<{
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
photos: Array<{
|
||||
payloadId: string
|
||||
entryId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
gpsTracks: Array<{
|
||||
entryId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
}
|
||||
counts: {
|
||||
entries: number
|
||||
photos: number
|
||||
crews: number
|
||||
gpsTracks: number
|
||||
hasYacht: boolean
|
||||
hasDeviation: boolean
|
||||
}
|
||||
}
|
||||
export { BACKUP_FORMAT, BACKUP_VERSION }
|
||||
export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 }
|
||||
|
||||
export interface LogbookBackupPreview {
|
||||
title: string
|
||||
exportedAt: string
|
||||
sourceLogbookId: string
|
||||
counts: LogbookBackupFile['counts']
|
||||
counts: BackupManifestCounts
|
||||
totalUncompressedBytes: number
|
||||
}
|
||||
|
||||
export interface ParsedLogbookBackup {
|
||||
manifest: BackupManifestV2
|
||||
files: Record<string, Uint8Array>
|
||||
}
|
||||
|
||||
export interface ExportLogbookBackupOptions {
|
||||
onProgress?: (progress: BackupExportProgress) => void
|
||||
}
|
||||
|
||||
const BACKUP_PASSPHRASE_SALT = 'KapteinsDaagbokBackupFileSalt_v1'
|
||||
|
||||
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder()
|
||||
const passphraseBytes = encoder.encode(passphrase.trim())
|
||||
const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1')
|
||||
const saltBytes = encoder.encode(BACKUP_PASSPHRASE_SALT)
|
||||
|
||||
const baseKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -120,26 +86,17 @@ async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
|
||||
return encryptBuffer(logbookKey, key)
|
||||
}
|
||||
|
||||
async function unwrapLogbookKey(
|
||||
wrapped: LogbookBackupFile['logbookKey'],
|
||||
async function unwrapLogbookKeyFromEnc(
|
||||
keyEnc: Uint8Array,
|
||||
passphrase: string
|
||||
): Promise<ArrayBuffer> {
|
||||
const key = await deriveBackupPassphraseKey(passphrase)
|
||||
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
|
||||
}
|
||||
|
||||
function isBackupFile(value: unknown): value is LogbookBackupFile {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const obj = value as Partial<LogbookBackupFile>
|
||||
return (
|
||||
obj.format === BACKUP_FORMAT &&
|
||||
obj.version === BACKUP_VERSION &&
|
||||
typeof obj.exportedAt === 'string' &&
|
||||
!!obj.logbook?.id &&
|
||||
!!obj.logbook?.encryptedTitle &&
|
||||
!!obj.logbookKey?.ciphertext &&
|
||||
!!obj.payloads
|
||||
)
|
||||
try {
|
||||
const fields = dexieFieldsFromEncBytes(keyEnc)
|
||||
const cryptoKey = await deriveBackupPassphraseKey(passphrase)
|
||||
return decryptBuffer(fields.encryptedData, fields.iv, fields.tag, cryptoKey)
|
||||
} catch {
|
||||
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
||||
}
|
||||
}
|
||||
|
||||
function encryptedPayloadData(
|
||||
@@ -156,96 +113,12 @@ function encryptedPayloadData(
|
||||
})
|
||||
}
|
||||
|
||||
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
|
||||
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
|
||||
db.yachts.get(logbookId),
|
||||
db.deviations.get(logbookId),
|
||||
db.crews.where({ logbookId }).toArray(),
|
||||
db.entries.where({ logbookId }).toArray(),
|
||||
db.photos.where({ logbookId }).toArray(),
|
||||
db.gpsTracks.where({ logbookId }).toArray()
|
||||
])
|
||||
|
||||
return {
|
||||
yacht: yacht
|
||||
? {
|
||||
encryptedData: yacht.encryptedData,
|
||||
iv: yacht.iv,
|
||||
tag: yacht.tag,
|
||||
updatedAt: yacht.updatedAt
|
||||
}
|
||||
: null,
|
||||
deviation: deviation
|
||||
? {
|
||||
encryptedData: deviation.encryptedData,
|
||||
iv: deviation.iv,
|
||||
tag: deviation.tag,
|
||||
updatedAt: deviation.updatedAt
|
||||
}
|
||||
: null,
|
||||
crews: crews.map((c) => ({
|
||||
payloadId: c.payloadId,
|
||||
encryptedData: c.encryptedData,
|
||||
iv: c.iv,
|
||||
tag: c.tag,
|
||||
updatedAt: c.updatedAt
|
||||
})),
|
||||
entries: entries.map((e) => ({
|
||||
payloadId: e.payloadId,
|
||||
encryptedData: e.encryptedData,
|
||||
iv: e.iv,
|
||||
tag: e.tag,
|
||||
updatedAt: e.updatedAt
|
||||
})),
|
||||
photos: photos.map((p) => ({
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
encryptedData: p.encryptedData,
|
||||
iv: p.iv,
|
||||
tag: p.tag,
|
||||
updatedAt: p.updatedAt
|
||||
})),
|
||||
gpsTracks: gpsTracks.map((t) => ({
|
||||
entryId: t.entryId,
|
||||
encryptedData: t.encryptedData,
|
||||
iv: t.iv,
|
||||
tag: t.tag,
|
||||
updatedAt: t.updatedAt
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function remapBackup(
|
||||
backup: LogbookBackupFile,
|
||||
newLogbookId: string
|
||||
): LogbookBackupFile {
|
||||
return {
|
||||
...backup,
|
||||
logbook: {
|
||||
...backup.logbook,
|
||||
id: newLogbookId
|
||||
},
|
||||
payloads: {
|
||||
...backup.payloads,
|
||||
yacht: backup.payloads.yacht
|
||||
? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
|
||||
: null,
|
||||
deviation: backup.payloads.deviation
|
||||
? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
|
||||
: null,
|
||||
crews: backup.payloads.crews.map((c) => ({ ...c })),
|
||||
entries: backup.payloads.entries.map((e) => ({ ...e })),
|
||||
photos: backup.payloads.photos.map((p) => ({ ...p })),
|
||||
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function queueRestoredLogbookForSync(
|
||||
logbookId: string,
|
||||
encryptedTitle: string,
|
||||
logbookKey: ArrayBuffer,
|
||||
payloads: LogbookBackupFile['payloads']
|
||||
manifest: BackupManifestV2,
|
||||
files: Record<string, Uint8Array>
|
||||
): Promise<void> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found')
|
||||
@@ -276,78 +149,123 @@ async function queueRestoredLogbookForSync(
|
||||
}
|
||||
]
|
||||
|
||||
if (payloads.yacht) {
|
||||
const readFields = (path: string | null) => {
|
||||
if (!path) return null
|
||||
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
|
||||
}
|
||||
|
||||
const yacht = readFields(manifest.files.yacht)
|
||||
if (yacht) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
type: 'yacht',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(
|
||||
payloads.yacht.encryptedData,
|
||||
payloads.yacht.iv,
|
||||
payloads.yacht.tag
|
||||
),
|
||||
updatedAt: payloads.yacht.updatedAt
|
||||
data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.deviation) {
|
||||
const deviation = readFields(manifest.files.deviation)
|
||||
if (deviation) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
type: 'deviation',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(
|
||||
payloads.deviation.encryptedData,
|
||||
payloads.deviation.iv,
|
||||
payloads.deviation.tag
|
||||
),
|
||||
updatedAt: payloads.deviation.updatedAt
|
||||
data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
for (const crew of payloads.crews) {
|
||||
const logbookCrew = readFields(manifest.files.logbookCrewSelection)
|
||||
if (logbookCrew) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
type: 'logbookCrew',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(logbookCrew.encryptedData, logbookCrew.iv, logbookCrew.tag),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
|
||||
if (logbookVessel) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
type: 'logbookVessel',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(
|
||||
logbookVessel.encryptedData,
|
||||
logbookVessel.iv,
|
||||
logbookVessel.tag
|
||||
),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
for (const crew of manifest.files.crews) {
|
||||
const f = readFields(crew.path)
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'crew',
|
||||
payloadId: crew.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
|
||||
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||
updatedAt: crew.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const entry of payloads.entries) {
|
||||
for (const entry of manifest.files.entries) {
|
||||
const f = readFields(entry.path)
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'entry',
|
||||
payloadId: entry.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
|
||||
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||
updatedAt: entry.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const photo of payloads.photos) {
|
||||
for (const photo of manifest.files.photos) {
|
||||
const f = readFields(photo.path)
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'photo',
|
||||
payloadId: photo.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
|
||||
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
|
||||
entryId: photo.entryId
|
||||
}),
|
||||
updatedAt: photo.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const track of payloads.gpsTracks) {
|
||||
for (const voice of manifest.files.voiceMemos) {
|
||||
const f = readFields(voice.path)
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'voiceMemo',
|
||||
payloadId: voice.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
|
||||
entryId: voice.entryId
|
||||
}),
|
||||
updatedAt: voice.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const track of manifest.files.gpsTracks) {
|
||||
const f = readFields(track.path)
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'gpsTrack',
|
||||
payloadId: track.entryId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
|
||||
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||
updatedAt: track.updatedAt
|
||||
})
|
||||
}
|
||||
@@ -357,101 +275,190 @@ async function queueRestoredLogbookForSync(
|
||||
|
||||
async function writeBackupToDexie(
|
||||
logbookId: string,
|
||||
backup: LogbookBackupFile,
|
||||
logbookKey: ArrayBuffer
|
||||
logbookMeta: LogbookMetaJson,
|
||||
logbookKey: ArrayBuffer,
|
||||
manifest: BackupManifestV2,
|
||||
files: Record<string, Uint8Array>
|
||||
): Promise<void> {
|
||||
const { logbook, payloads } = backup
|
||||
|
||||
await db.logbooks.put({
|
||||
id: logbookId,
|
||||
encryptedTitle: logbook.encryptedTitle,
|
||||
updatedAt: logbook.updatedAt,
|
||||
encryptedTitle: logbookMeta.encryptedTitle,
|
||||
updatedAt: logbookMeta.updatedAt,
|
||||
isSynced: 0,
|
||||
isShared: 0,
|
||||
isDemo: logbook.isDemo ? 1 : 0
|
||||
isDemo: logbookMeta.isDemo ? 1 : 0
|
||||
})
|
||||
|
||||
await saveLogbookKey(logbookId, logbookKey)
|
||||
|
||||
if (payloads.yacht) {
|
||||
const readFields = (path: string | null) => {
|
||||
if (!path) return null
|
||||
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
|
||||
}
|
||||
|
||||
const yacht = readFields(manifest.files.yacht)
|
||||
if (yacht) {
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
encryptedData: payloads.yacht.encryptedData,
|
||||
iv: payloads.yacht.iv,
|
||||
tag: payloads.yacht.tag,
|
||||
updatedAt: payloads.yacht.updatedAt
|
||||
encryptedData: yacht.encryptedData,
|
||||
iv: yacht.iv,
|
||||
tag: yacht.tag,
|
||||
updatedAt: logbookMeta.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.deviation) {
|
||||
const deviation = readFields(manifest.files.deviation)
|
||||
if (deviation) {
|
||||
await db.deviations.put({
|
||||
logbookId,
|
||||
encryptedData: payloads.deviation.encryptedData,
|
||||
iv: payloads.deviation.iv,
|
||||
tag: payloads.deviation.tag,
|
||||
updatedAt: payloads.deviation.updatedAt
|
||||
encryptedData: deviation.encryptedData,
|
||||
iv: deviation.iv,
|
||||
tag: deviation.tag,
|
||||
updatedAt: logbookMeta.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.crews.length > 0) {
|
||||
const logbookCrew = readFields(manifest.files.logbookCrewSelection)
|
||||
if (logbookCrew) {
|
||||
await db.logbookCrewSelections.put({
|
||||
logbookId,
|
||||
encryptedData: logbookCrew.encryptedData,
|
||||
iv: logbookCrew.iv,
|
||||
tag: logbookCrew.tag,
|
||||
updatedAt: logbookMeta.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
|
||||
if (logbookVessel) {
|
||||
await db.logbookVesselSelections.put({
|
||||
logbookId,
|
||||
encryptedData: logbookVessel.encryptedData,
|
||||
iv: logbookVessel.iv,
|
||||
tag: logbookVessel.tag,
|
||||
updatedAt: logbookMeta.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (manifest.files.crews.length > 0) {
|
||||
await db.crews.bulkPut(
|
||||
payloads.crews.map((c) => ({
|
||||
payloadId: c.payloadId,
|
||||
logbookId,
|
||||
encryptedData: c.encryptedData,
|
||||
iv: c.iv,
|
||||
tag: c.tag,
|
||||
updatedAt: c.updatedAt
|
||||
}))
|
||||
manifest.files.crews.map((c) => {
|
||||
const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path))
|
||||
return {
|
||||
payloadId: c.payloadId,
|
||||
logbookId,
|
||||
encryptedData: f.encryptedData,
|
||||
iv: f.iv,
|
||||
tag: f.tag,
|
||||
updatedAt: c.updatedAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.entries.length > 0) {
|
||||
if (manifest.files.entries.length > 0) {
|
||||
await db.entries.bulkPut(
|
||||
payloads.entries.map((e) => ({
|
||||
payloadId: e.payloadId,
|
||||
logbookId,
|
||||
encryptedData: e.encryptedData,
|
||||
iv: e.iv,
|
||||
tag: e.tag,
|
||||
updatedAt: e.updatedAt
|
||||
}))
|
||||
manifest.files.entries.map((e) => {
|
||||
const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path))
|
||||
return {
|
||||
payloadId: e.payloadId,
|
||||
logbookId,
|
||||
encryptedData: f.encryptedData,
|
||||
iv: f.iv,
|
||||
tag: f.tag,
|
||||
updatedAt: e.updatedAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.photos.length > 0) {
|
||||
if (manifest.files.photos.length > 0) {
|
||||
await db.photos.bulkPut(
|
||||
payloads.photos.map((p) => ({
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
logbookId,
|
||||
encryptedData: p.encryptedData,
|
||||
iv: p.iv,
|
||||
tag: p.tag,
|
||||
caption: '',
|
||||
updatedAt: p.updatedAt
|
||||
}))
|
||||
manifest.files.photos.map((p) => {
|
||||
const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path))
|
||||
return {
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
logbookId,
|
||||
encryptedData: f.encryptedData,
|
||||
iv: f.iv,
|
||||
tag: f.tag,
|
||||
caption: '',
|
||||
updatedAt: p.updatedAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.gpsTracks.length > 0) {
|
||||
await db.gpsTracks.bulkPut(
|
||||
payloads.gpsTracks.map((t) => ({
|
||||
entryId: t.entryId,
|
||||
logbookId,
|
||||
encryptedData: t.encryptedData,
|
||||
iv: t.iv,
|
||||
tag: t.tag,
|
||||
updatedAt: t.updatedAt
|
||||
}))
|
||||
if (manifest.files.voiceMemos.length > 0) {
|
||||
await db.voiceMemos.bulkPut(
|
||||
manifest.files.voiceMemos.map((v) => {
|
||||
const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path))
|
||||
return {
|
||||
payloadId: v.payloadId,
|
||||
entryId: v.entryId,
|
||||
logbookId,
|
||||
encryptedData: f.encryptedData,
|
||||
iv: f.iv,
|
||||
tag: f.tag,
|
||||
updatedAt: v.updatedAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (manifest.files.gpsTracks.length > 0) {
|
||||
await db.gpsTracks.bulkPut(
|
||||
manifest.files.gpsTracks.map((t) => {
|
||||
const f = dexieFieldsFromEncBytes(readBinaryFile(files, t.path))
|
||||
return {
|
||||
entryId: t.entryId,
|
||||
logbookId,
|
||||
encryptedData: f.encryptedData,
|
||||
iv: f.iv,
|
||||
tag: f.tag,
|
||||
updatedAt: t.updatedAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (manifest.files.nmeaArchives.length > 0) {
|
||||
await db.nmeaArchives.bulkPut(
|
||||
manifest.files.nmeaArchives.map((n) => {
|
||||
const f = dexieFieldsFromEncBytes(readBinaryFile(files, n.path))
|
||||
return {
|
||||
entryId: n.entryId,
|
||||
logbookId,
|
||||
encryptedData: f.encryptedData,
|
||||
iv: f.iv,
|
||||
tag: f.tag,
|
||||
updatedAt: n.updatedAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function remapParsedBackup(
|
||||
parsed: ParsedLogbookBackup,
|
||||
newLogbookId: string
|
||||
): ParsedLogbookBackup {
|
||||
const logbookMeta = JSON.parse(readTextFile(parsed.files, parsed.manifest.files.logbook)) as LogbookMetaJson
|
||||
logbookMeta.id = newLogbookId
|
||||
const newFiles = { ...parsed.files }
|
||||
newFiles[parsed.manifest.files.logbook] = new TextEncoder().encode(JSON.stringify(logbookMeta))
|
||||
return {
|
||||
manifest: { ...parsed.manifest, logbookId: newLogbookId },
|
||||
files: newFiles
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportLogbookBackup(
|
||||
logbookId: string,
|
||||
passphrase: string
|
||||
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
|
||||
passphrase: string,
|
||||
options: ExportLogbookBackupOptions = {}
|
||||
): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> {
|
||||
if (!passphrase.trim() || passphrase.length < 8) {
|
||||
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
|
||||
}
|
||||
@@ -467,70 +474,84 @@ export async function exportLogbookBackup(
|
||||
})
|
||||
}
|
||||
|
||||
options.onProgress?.({ phase: 'collect', current: 0, total: 1, bytesPacked: 0 })
|
||||
const collected = await collectLogbookBackupData(logbookId)
|
||||
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
|
||||
const payloads = await collectLogbookPayloads(logbookId)
|
||||
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
|
||||
const wrapped = await wrapLogbookKey(logbookKey, passphrase)
|
||||
const keyEnc = encBytesFromDexieFields({
|
||||
encryptedData: wrapped.ciphertext,
|
||||
iv: wrapped.iv,
|
||||
tag: wrapped.tag
|
||||
})
|
||||
|
||||
const backup: LogbookBackupFile = {
|
||||
format: BACKUP_FORMAT,
|
||||
version: BACKUP_VERSION,
|
||||
const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, {
|
||||
exportedAt: new Date().toISOString(),
|
||||
logbook: {
|
||||
id: logbook.id,
|
||||
encryptedTitle: logbook.encryptedTitle,
|
||||
updatedAt: logbook.updatedAt,
|
||||
isDemo: logbook.isDemo === 1
|
||||
},
|
||||
logbookKey: wrappedKey,
|
||||
payloads,
|
||||
counts: {
|
||||
entries: payloads.entries.length,
|
||||
photos: payloads.photos.length,
|
||||
crews: payloads.crews.length,
|
||||
gpsTracks: payloads.gpsTracks.length,
|
||||
hasYacht: !!payloads.yacht,
|
||||
hasDeviation: !!payloads.deviation
|
||||
}
|
||||
}
|
||||
appVersion: getAppVersion(),
|
||||
onProgress: options.onProgress
|
||||
})
|
||||
|
||||
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
|
||||
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
||||
const datePart = new Date().toISOString().slice(0, 10)
|
||||
const filename = `${safeTitle}-${datePart}.daagbok.json`
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
|
||||
const filename = `${safeTitle}-${datePart}.daagbok`
|
||||
const blob = new Blob([zipBytes.slice()], { type: 'application/zip' })
|
||||
|
||||
return { blob, filename, backup }
|
||||
return { blob, filename, manifest }
|
||||
}
|
||||
|
||||
export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupFile> {
|
||||
const text = await file.text()
|
||||
let parsed: unknown
|
||||
function detectLegacyJsonV1(text: string): boolean {
|
||||
const trimmed = text.trimStart()
|
||||
if (!trimmed.startsWith('{')) return false
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
const parsed = JSON.parse(trimmed) as { format?: string; version?: number }
|
||||
return parsed.format === BACKUP_FORMAT && parsed.version === 1
|
||||
} catch {
|
||||
throw new Error('BACKUP_INVALID_JSON')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseLogbookBackupFile(file: File): Promise<ParsedLogbookBackup> {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const bytes = new Uint8Array(buffer)
|
||||
|
||||
if (!isZipArchive(bytes)) {
|
||||
const text = new TextDecoder().decode(bytes)
|
||||
if (detectLegacyJsonV1(text)) {
|
||||
throw new Error('BACKUP_VERSION_UNSUPPORTED')
|
||||
}
|
||||
throw new Error('BACKUP_INVALID_ARCHIVE')
|
||||
}
|
||||
|
||||
if (!isBackupFile(parsed)) {
|
||||
throw new Error('BACKUP_INVALID_FORMAT')
|
||||
}
|
||||
|
||||
return parsed
|
||||
const files = unzipArchive(bytes)
|
||||
const manifest = readManifestFromArchive(files)
|
||||
return { manifest, files }
|
||||
}
|
||||
|
||||
export async function previewLogbookBackup(
|
||||
backup: LogbookBackupFile,
|
||||
backup: ParsedLogbookBackup,
|
||||
passphrase: string
|
||||
): Promise<LogbookBackupPreview> {
|
||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
||||
const parsed = JSON.parse(backup.logbook.encryptedTitle)
|
||||
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
|
||||
const logbookKey = await unwrapLogbookKeyFromEnc(
|
||||
readBinaryFile(backup.files, backup.manifest.files.key),
|
||||
passphrase
|
||||
)
|
||||
const logbookMeta = JSON.parse(
|
||||
readTextFile(backup.files, backup.manifest.files.logbook)
|
||||
) as LogbookMetaJson
|
||||
const parsed = JSON.parse(logbookMeta.encryptedTitle)
|
||||
let title: string
|
||||
try {
|
||||
title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
|
||||
} catch {
|
||||
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
exportedAt: backup.exportedAt,
|
||||
sourceLogbookId: backup.logbook.id,
|
||||
counts: backup.counts
|
||||
exportedAt: backup.manifest.exportedAt,
|
||||
sourceLogbookId: backup.manifest.logbookId,
|
||||
counts: backup.manifest.counts,
|
||||
totalUncompressedBytes: backup.manifest.totalUncompressedBytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +561,7 @@ export interface RestoreLogbookOptions {
|
||||
}
|
||||
|
||||
export async function restoreLogbookBackup(
|
||||
backup: LogbookBackupFile,
|
||||
backup: ParsedLogbookBackup,
|
||||
passphrase: string,
|
||||
options: RestoreLogbookOptions = {}
|
||||
): Promise<{ logbookId: string; title: string }> {
|
||||
@@ -548,16 +569,22 @@ export async function restoreLogbookBackup(
|
||||
throw new Error('BACKUP_NOT_AUTHENTICATED')
|
||||
}
|
||||
|
||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
||||
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
|
||||
const title = await decryptJson(
|
||||
parsedTitle.ciphertext,
|
||||
parsedTitle.iv,
|
||||
parsedTitle.tag,
|
||||
logbookKey
|
||||
const logbookKey = await unwrapLogbookKeyFromEnc(
|
||||
readBinaryFile(backup.files, backup.manifest.files.key),
|
||||
passphrase
|
||||
)
|
||||
const logbookMeta = JSON.parse(
|
||||
readTextFile(backup.files, backup.manifest.files.logbook)
|
||||
) as LogbookMetaJson
|
||||
const parsedTitle = JSON.parse(logbookMeta.encryptedTitle)
|
||||
let title: string
|
||||
try {
|
||||
title = await decryptJson(parsedTitle.ciphertext, parsedTitle.iv, parsedTitle.tag, logbookKey)
|
||||
} catch {
|
||||
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
||||
}
|
||||
|
||||
let targetId = backup.logbook.id
|
||||
let targetId = backup.manifest.logbookId
|
||||
const existing = await db.logbooks.get(targetId)
|
||||
|
||||
if (existing && !options.overwrite && !options.assignNewId) {
|
||||
@@ -568,18 +595,29 @@ export async function restoreLogbookBackup(
|
||||
await deleteLocalLogbookCache(targetId)
|
||||
}
|
||||
|
||||
let prepared = backup
|
||||
if (options.assignNewId || (existing && !options.overwrite)) {
|
||||
targetId = crypto.randomUUID()
|
||||
prepared = remapParsedBackup(backup, targetId)
|
||||
}
|
||||
|
||||
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
|
||||
const finalMeta = JSON.parse(
|
||||
readTextFile(prepared.files, prepared.manifest.files.logbook)
|
||||
) as LogbookMetaJson
|
||||
|
||||
await writeBackupToDexie(targetId, prepared, logbookKey)
|
||||
await writeBackupToDexie(
|
||||
targetId,
|
||||
finalMeta,
|
||||
logbookKey,
|
||||
prepared.manifest,
|
||||
prepared.files
|
||||
)
|
||||
await queueRestoredLogbookForSync(
|
||||
targetId,
|
||||
prepared.logbook.encryptedTitle,
|
||||
finalMeta.encryptedTitle,
|
||||
logbookKey,
|
||||
prepared.payloads
|
||||
prepared.manifest,
|
||||
prepared.files
|
||||
)
|
||||
|
||||
if (navigator.onLine) {
|
||||
@@ -599,3 +637,14 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/** Human-readable size for UI warnings. */
|
||||
export function formatBackupBytes(bytes: number): string {
|
||||
const fmt = (n: number) => formatAppDecimal(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB`
|
||||
return `${fmt(bytes / (1024 * 1024))} MB`
|
||||
}
|
||||
|
||||
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
|
||||
export const BACKUP_SIZE_CONFIRM_BYTES = 150_000_000
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { db } from '../db.js'
|
||||
import { encBytesFromDexieFields, type DexieEncFields } from './encBlob.js'
|
||||
import { buildZipArchive, utf8Bytes } from './zipArchive.js'
|
||||
import {
|
||||
BACKUP_FORMAT,
|
||||
BACKUP_VERSION,
|
||||
type BackupIndexedEntryFile,
|
||||
type BackupIndexedPayloadFile,
|
||||
type BackupIndexedTrackFile,
|
||||
type BackupManifestCounts,
|
||||
type BackupManifestFiles,
|
||||
type BackupManifestV2,
|
||||
type LogbookMetaJson
|
||||
} from './manifest.js'
|
||||
|
||||
export interface CollectedBackupData {
|
||||
logbookMeta: LogbookMetaJson
|
||||
yacht: DexieEncFields | null
|
||||
deviation: DexieEncFields | null
|
||||
logbookCrewSelection: DexieEncFields | null
|
||||
logbookVesselSelection: DexieEncFields | null
|
||||
crews: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
|
||||
entries: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
|
||||
photos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
|
||||
voiceMemos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
|
||||
gpsTracks: Array<DexieEncFields & { entryId: string; updatedAt: string }>
|
||||
nmeaArchives: Array<DexieEncFields & { entryId: string; updatedAt: string }>
|
||||
}
|
||||
|
||||
function pickEnc(row: {
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
}): DexieEncFields {
|
||||
return {
|
||||
encryptedData: row.encryptedData,
|
||||
iv: row.iv,
|
||||
tag: row.tag
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectLogbookBackupData(
|
||||
logbookId: string
|
||||
): Promise<CollectedBackupData> {
|
||||
const [
|
||||
logbook,
|
||||
yacht,
|
||||
deviation,
|
||||
logbookCrewSelection,
|
||||
logbookVesselSelection,
|
||||
crews,
|
||||
entries,
|
||||
photos,
|
||||
voiceMemos,
|
||||
gpsTracks,
|
||||
nmeaArchives
|
||||
] = await Promise.all([
|
||||
db.logbooks.get(logbookId),
|
||||
db.yachts.get(logbookId),
|
||||
db.deviations.get(logbookId),
|
||||
db.logbookCrewSelections.get(logbookId),
|
||||
db.logbookVesselSelections.get(logbookId),
|
||||
db.crews.where({ logbookId }).toArray(),
|
||||
db.entries.where({ logbookId }).toArray(),
|
||||
db.photos.where({ logbookId }).toArray(),
|
||||
db.voiceMemos.where({ logbookId }).toArray(),
|
||||
db.gpsTracks.where({ logbookId }).toArray(),
|
||||
db.nmeaArchives.where({ logbookId }).toArray()
|
||||
])
|
||||
|
||||
if (!logbook) throw new Error('BACKUP_LOGBOOK_NOT_FOUND')
|
||||
|
||||
return {
|
||||
logbookMeta: {
|
||||
id: logbook.id,
|
||||
encryptedTitle: logbook.encryptedTitle,
|
||||
updatedAt: logbook.updatedAt,
|
||||
isDemo: logbook.isDemo === 1
|
||||
},
|
||||
yacht: yacht ? pickEnc(yacht) : null,
|
||||
deviation: deviation ? pickEnc(deviation) : null,
|
||||
logbookCrewSelection: logbookCrewSelection ? pickEnc(logbookCrewSelection) : null,
|
||||
logbookVesselSelection: logbookVesselSelection ? pickEnc(logbookVesselSelection) : null,
|
||||
crews: crews.map((c) => ({ ...pickEnc(c), payloadId: c.payloadId, updatedAt: c.updatedAt })),
|
||||
entries: entries.map((e) => ({
|
||||
...pickEnc(e),
|
||||
payloadId: e.payloadId,
|
||||
updatedAt: e.updatedAt
|
||||
})),
|
||||
photos: photos.map((p) => ({
|
||||
...pickEnc(p),
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
updatedAt: p.updatedAt
|
||||
})),
|
||||
voiceMemos: voiceMemos.map((v) => ({
|
||||
...pickEnc(v),
|
||||
payloadId: v.payloadId,
|
||||
entryId: v.entryId,
|
||||
updatedAt: v.updatedAt
|
||||
})),
|
||||
gpsTracks: gpsTracks.map((t) => ({
|
||||
...pickEnc(t),
|
||||
entryId: t.entryId,
|
||||
updatedAt: t.updatedAt
|
||||
})),
|
||||
nmeaArchives: nmeaArchives.map((n) => ({
|
||||
...pickEnc(n),
|
||||
entryId: n.entryId,
|
||||
updatedAt: n.updatedAt
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export type BackupProgressPhase = 'collect' | 'pack' | 'done'
|
||||
|
||||
export interface BackupExportProgress {
|
||||
phase: BackupProgressPhase
|
||||
current: number
|
||||
total: number
|
||||
bytesPacked: number
|
||||
}
|
||||
|
||||
export interface BuiltArchive {
|
||||
zipBytes: Uint8Array
|
||||
manifest: BackupManifestV2
|
||||
counts: BackupManifestCounts
|
||||
totalUncompressedBytes: number
|
||||
}
|
||||
|
||||
function addEncFile(
|
||||
zipFiles: Record<string, Uint8Array>,
|
||||
path: string,
|
||||
fields: DexieEncFields
|
||||
): number {
|
||||
const bytes = encBytesFromDexieFields(fields)
|
||||
zipFiles[path] = bytes
|
||||
return bytes.byteLength
|
||||
}
|
||||
|
||||
export function buildArchiveFromCollected(
|
||||
collected: CollectedBackupData,
|
||||
keyEnc: Uint8Array,
|
||||
options: {
|
||||
exportedAt: string
|
||||
appVersion?: string
|
||||
onProgress?: (progress: BackupExportProgress) => void
|
||||
}
|
||||
): BuiltArchive {
|
||||
const zipFiles: Record<string, Uint8Array> = {}
|
||||
let totalUncompressedBytes = 0
|
||||
|
||||
const logbookPath = 'logbook.meta.json'
|
||||
zipFiles[logbookPath] = utf8Bytes(JSON.stringify(collected.logbookMeta))
|
||||
totalUncompressedBytes += zipFiles[logbookPath].byteLength
|
||||
|
||||
zipFiles['key.enc'] = keyEnc
|
||||
totalUncompressedBytes += keyEnc.byteLength
|
||||
|
||||
const files: BackupManifestFiles = {
|
||||
key: 'key.enc',
|
||||
logbook: logbookPath,
|
||||
yacht: null,
|
||||
deviation: null,
|
||||
logbookCrewSelection: null,
|
||||
logbookVesselSelection: null,
|
||||
crews: [],
|
||||
entries: [],
|
||||
photos: [],
|
||||
voiceMemos: [],
|
||||
gpsTracks: [],
|
||||
nmeaArchives: []
|
||||
}
|
||||
|
||||
const packSteps: Array<() => void> = []
|
||||
|
||||
if (collected.yacht) {
|
||||
packSteps.push(() => {
|
||||
const path = 'payloads/yacht.enc'
|
||||
const size = addEncFile(zipFiles, path, collected.yacht!)
|
||||
files.yacht = path
|
||||
totalUncompressedBytes += size
|
||||
})
|
||||
}
|
||||
|
||||
if (collected.deviation) {
|
||||
packSteps.push(() => {
|
||||
const path = 'payloads/deviation.enc'
|
||||
const size = addEncFile(zipFiles, path, collected.deviation!)
|
||||
files.deviation = path
|
||||
totalUncompressedBytes += size
|
||||
})
|
||||
}
|
||||
|
||||
if (collected.logbookCrewSelection) {
|
||||
packSteps.push(() => {
|
||||
const path = 'payloads/logbook-crew.enc'
|
||||
const size = addEncFile(zipFiles, path, collected.logbookCrewSelection!)
|
||||
files.logbookCrewSelection = path
|
||||
totalUncompressedBytes += size
|
||||
})
|
||||
}
|
||||
|
||||
if (collected.logbookVesselSelection) {
|
||||
packSteps.push(() => {
|
||||
const path = 'payloads/logbook-vessel.enc'
|
||||
const size = addEncFile(zipFiles, path, collected.logbookVesselSelection!)
|
||||
files.logbookVesselSelection = path
|
||||
totalUncompressedBytes += size
|
||||
})
|
||||
}
|
||||
|
||||
for (const c of collected.crews) {
|
||||
packSteps.push(() => {
|
||||
const path = `payloads/crews/${c.payloadId}.enc`
|
||||
const size = addEncFile(zipFiles, path, c)
|
||||
const index: BackupIndexedPayloadFile = {
|
||||
path,
|
||||
payloadId: c.payloadId,
|
||||
updatedAt: c.updatedAt,
|
||||
bytes: size
|
||||
}
|
||||
files.crews.push(index)
|
||||
totalUncompressedBytes += size
|
||||
})
|
||||
}
|
||||
|
||||
for (const e of collected.entries) {
|
||||
packSteps.push(() => {
|
||||
const path = `payloads/entries/${e.payloadId}.enc`
|
||||
const size = addEncFile(zipFiles, path, e)
|
||||
const index: BackupIndexedPayloadFile = {
|
||||
path,
|
||||
payloadId: e.payloadId,
|
||||
updatedAt: e.updatedAt,
|
||||
bytes: size
|
||||
}
|
||||
files.entries.push(index)
|
||||
totalUncompressedBytes += size
|
||||
})
|
||||
}
|
||||
|
||||
for (const p of collected.photos) {
|
||||
packSteps.push(() => {
|
||||
const path = `payloads/photos/${p.payloadId}.enc`
|
||||
const size = addEncFile(zipFiles, path, p)
|
||||
const index: BackupIndexedEntryFile = {
|
||||
path,
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
updatedAt: p.updatedAt,
|
||||
bytes: size
|
||||
}
|
||||
files.photos.push(index)
|
||||
totalUncompressedBytes += size
|
||||
})
|
||||
}
|
||||
|
||||
for (const v of collected.voiceMemos) {
|
||||
packSteps.push(() => {
|
||||
const path = `payloads/voice-memos/${v.payloadId}.enc`
|
||||
const size = addEncFile(zipFiles, path, v)
|
||||
const index: BackupIndexedEntryFile = {
|
||||
path,
|
||||
payloadId: v.payloadId,
|
||||
entryId: v.entryId,
|
||||
updatedAt: v.updatedAt,
|
||||
bytes: size
|
||||
}
|
||||
files.voiceMemos.push(index)
|
||||
totalUncompressedBytes += size
|
||||
})
|
||||
}
|
||||
|
||||
for (const t of collected.gpsTracks) {
|
||||
packSteps.push(() => {
|
||||
const path = `payloads/gps-tracks/${t.entryId}.enc`
|
||||
const size = addEncFile(zipFiles, path, t)
|
||||
const index: BackupIndexedTrackFile = {
|
||||
path,
|
||||
entryId: t.entryId,
|
||||
updatedAt: t.updatedAt,
|
||||
bytes: size
|
||||
}
|
||||
files.gpsTracks.push(index)
|
||||
totalUncompressedBytes += size
|
||||
})
|
||||
}
|
||||
|
||||
for (const n of collected.nmeaArchives) {
|
||||
packSteps.push(() => {
|
||||
const path = `payloads/nmea-archives/${n.entryId}.enc`
|
||||
const size = addEncFile(zipFiles, path, n)
|
||||
const index: BackupIndexedTrackFile = {
|
||||
path,
|
||||
entryId: n.entryId,
|
||||
updatedAt: n.updatedAt,
|
||||
bytes: size
|
||||
}
|
||||
files.nmeaArchives.push(index)
|
||||
totalUncompressedBytes += size
|
||||
})
|
||||
}
|
||||
|
||||
const total = packSteps.length
|
||||
packSteps.forEach((step, i) => {
|
||||
step()
|
||||
options.onProgress?.({
|
||||
phase: 'pack',
|
||||
current: i + 1,
|
||||
total,
|
||||
bytesPacked: totalUncompressedBytes
|
||||
})
|
||||
})
|
||||
|
||||
const counts: BackupManifestCounts = {
|
||||
entries: collected.entries.length,
|
||||
photos: collected.photos.length,
|
||||
voiceMemos: collected.voiceMemos.length,
|
||||
crews: collected.crews.length,
|
||||
gpsTracks: collected.gpsTracks.length,
|
||||
nmeaArchives: collected.nmeaArchives.length,
|
||||
hasYacht: !!collected.yacht,
|
||||
hasDeviation: !!collected.deviation,
|
||||
hasLogbookCrewSelection: !!collected.logbookCrewSelection,
|
||||
hasLogbookVesselSelection: !!collected.logbookVesselSelection
|
||||
}
|
||||
|
||||
const manifest: BackupManifestV2 = {
|
||||
format: BACKUP_FORMAT,
|
||||
version: BACKUP_VERSION,
|
||||
exportedAt: options.exportedAt,
|
||||
appVersion: options.appVersion,
|
||||
compression: 'zip-deflate-6',
|
||||
logbookId: collected.logbookMeta.id,
|
||||
counts,
|
||||
totalUncompressedBytes,
|
||||
files
|
||||
}
|
||||
|
||||
zipFiles['manifest.json'] = utf8Bytes(JSON.stringify(manifest))
|
||||
totalUncompressedBytes += zipFiles['manifest.json'].byteLength
|
||||
|
||||
const zipBytes = buildZipArchive(zipFiles)
|
||||
manifest.totalUncompressedBytes = totalUncompressedBytes
|
||||
|
||||
options.onProgress?.({
|
||||
phase: 'done',
|
||||
current: total,
|
||||
total,
|
||||
bytesPacked: totalUncompressedBytes
|
||||
})
|
||||
|
||||
return { zipBytes, manifest, counts, totalUncompressedBytes }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
dexieFieldsFromEncBytes,
|
||||
encBytesFromDexieFields,
|
||||
ENC_HEADER_SIZE
|
||||
} from './encBlob.js'
|
||||
|
||||
function toB64(bytes: number[]): string {
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
}
|
||||
|
||||
describe('encBlob', () => {
|
||||
it('round-trips dexie AES-GCM fields', () => {
|
||||
const fields = {
|
||||
encryptedData: toB64([9, 8, 7]),
|
||||
iv: toB64(Array.from({ length: 12 }, (_, i) => i)),
|
||||
tag: toB64(Array.from({ length: 16 }, (_, i) => i + 20))
|
||||
}
|
||||
const enc = encBytesFromDexieFields(fields)
|
||||
expect(enc.byteLength).toBe(ENC_HEADER_SIZE + 3)
|
||||
expect(dexieFieldsFromEncBytes(enc)).toEqual(fields)
|
||||
})
|
||||
|
||||
it('rejects invalid magic', () => {
|
||||
expect(() => dexieFieldsFromEncBytes(new Uint8Array(40))).toThrow('BACKUP_INVALID_ENC')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { base64ToBuffer, bufferToBase64 } from '../crypto.js'
|
||||
|
||||
export const ENC_MAGIC = new Uint8Array([0x4b, 0x44, 0x41, 0x42]) // KDAB
|
||||
export const ENC_FORMAT_VERSION = 1
|
||||
export const ENC_HEADER_SIZE = 33 // 4 + 1 + 12 + 16
|
||||
|
||||
export interface DexieEncFields {
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export function encBytesFromDexieFields(fields: DexieEncFields): Uint8Array {
|
||||
const iv = new Uint8Array(base64ToBuffer(fields.iv))
|
||||
const tag = new Uint8Array(base64ToBuffer(fields.tag))
|
||||
const ciphertext = new Uint8Array(base64ToBuffer(fields.encryptedData))
|
||||
if (iv.length !== 12) throw new Error('BACKUP_INVALID_ENC')
|
||||
if (tag.length !== 16) throw new Error('BACKUP_INVALID_ENC')
|
||||
|
||||
const out = new Uint8Array(ENC_HEADER_SIZE + ciphertext.length)
|
||||
out.set(ENC_MAGIC, 0)
|
||||
out[4] = ENC_FORMAT_VERSION
|
||||
out.set(iv, 5)
|
||||
out.set(tag, 17)
|
||||
out.set(ciphertext, 33)
|
||||
return out
|
||||
}
|
||||
|
||||
export function dexieFieldsFromEncBytes(bytes: Uint8Array): DexieEncFields {
|
||||
if (bytes.length < ENC_HEADER_SIZE) throw new Error('BACKUP_INVALID_ENC')
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (bytes[i] !== ENC_MAGIC[i]) throw new Error('BACKUP_INVALID_ENC')
|
||||
}
|
||||
if (bytes[4] !== ENC_FORMAT_VERSION) throw new Error('BACKUP_INVALID_ENC')
|
||||
|
||||
const iv = bufferToBase64(bytes.slice(5, 17).buffer)
|
||||
const tag = bufferToBase64(bytes.slice(17, 33).buffer)
|
||||
const ciphertext = bufferToBase64(bytes.slice(33).buffer)
|
||||
return { encryptedData: ciphertext, iv, tag }
|
||||
}
|
||||
|
||||
export function encByteLength(fields: DexieEncFields): number {
|
||||
const ct = base64ToBuffer(fields.encryptedData).byteLength
|
||||
return ENC_HEADER_SIZE + ct
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
|
||||
export const BACKUP_VERSION = 2 as const
|
||||
|
||||
export interface BackupIndexedFile {
|
||||
path: string
|
||||
updatedAt: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export interface BackupIndexedPayloadFile extends BackupIndexedFile {
|
||||
payloadId: string
|
||||
}
|
||||
|
||||
export interface BackupIndexedEntryFile extends BackupIndexedPayloadFile {
|
||||
entryId: string
|
||||
}
|
||||
|
||||
export interface BackupIndexedTrackFile extends BackupIndexedFile {
|
||||
entryId: string
|
||||
}
|
||||
|
||||
export interface BackupManifestCounts {
|
||||
entries: number
|
||||
photos: number
|
||||
voiceMemos: number
|
||||
crews: number
|
||||
gpsTracks: number
|
||||
nmeaArchives: number
|
||||
hasYacht: boolean
|
||||
hasDeviation: boolean
|
||||
hasLogbookCrewSelection: boolean
|
||||
hasLogbookVesselSelection: boolean
|
||||
}
|
||||
|
||||
export interface BackupManifestFiles {
|
||||
key: string
|
||||
logbook: string
|
||||
yacht: string | null
|
||||
deviation: string | null
|
||||
logbookCrewSelection: string | null
|
||||
logbookVesselSelection: string | null
|
||||
crews: BackupIndexedPayloadFile[]
|
||||
entries: BackupIndexedPayloadFile[]
|
||||
photos: BackupIndexedEntryFile[]
|
||||
voiceMemos: BackupIndexedEntryFile[]
|
||||
gpsTracks: BackupIndexedTrackFile[]
|
||||
nmeaArchives: BackupIndexedTrackFile[]
|
||||
}
|
||||
|
||||
export interface BackupManifestV2 {
|
||||
format: typeof BACKUP_FORMAT
|
||||
version: typeof BACKUP_VERSION
|
||||
exportedAt: string
|
||||
appVersion?: string
|
||||
compression: 'zip-deflate-6'
|
||||
logbookId: string
|
||||
counts: BackupManifestCounts
|
||||
totalUncompressedBytes: number
|
||||
files: BackupManifestFiles
|
||||
}
|
||||
|
||||
export interface LogbookMetaJson {
|
||||
id: string
|
||||
encryptedTitle: string
|
||||
updatedAt: string
|
||||
isDemo?: boolean
|
||||
}
|
||||
|
||||
export function parseManifestJson(text: string): BackupManifestV2 {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('BACKUP_INVALID_FORMAT')
|
||||
}
|
||||
if (!isBackupManifestV2(parsed)) {
|
||||
throw new Error('BACKUP_INVALID_FORMAT')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function isBackupManifestV2(value: unknown): value is BackupManifestV2 {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const obj = value as Partial<BackupManifestV2>
|
||||
return (
|
||||
obj.format === BACKUP_FORMAT &&
|
||||
obj.version === BACKUP_VERSION &&
|
||||
typeof obj.exportedAt === 'string' &&
|
||||
typeof obj.logbookId === 'string' &&
|
||||
!!obj.counts &&
|
||||
!!obj.files
|
||||
)
|
||||
}
|
||||
|
||||
export function serializeManifest(manifest: BackupManifestV2): string {
|
||||
return JSON.stringify(manifest)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { strToU8, unzipSync, zipSync } from 'fflate'
|
||||
import { parseManifestJson, type BackupManifestV2 } from './manifest.js'
|
||||
|
||||
const ZIP_LEVEL = 6
|
||||
|
||||
export function buildZipArchive(files: Record<string, Uint8Array>): Uint8Array {
|
||||
return zipSync(files, { level: ZIP_LEVEL })
|
||||
}
|
||||
|
||||
export function unzipArchive(data: Uint8Array): Record<string, Uint8Array> {
|
||||
try {
|
||||
return unzipSync(data)
|
||||
} catch {
|
||||
throw new Error('BACKUP_INVALID_ARCHIVE')
|
||||
}
|
||||
}
|
||||
|
||||
export function readManifestFromArchive(
|
||||
files: Record<string, Uint8Array>
|
||||
): BackupManifestV2 {
|
||||
const raw = files['manifest.json']
|
||||
if (!raw) throw new Error('BACKUP_INVALID_FORMAT')
|
||||
const text = new TextDecoder().decode(raw)
|
||||
return parseManifestJson(text)
|
||||
}
|
||||
|
||||
export function readTextFile(files: Record<string, Uint8Array>, path: string): string {
|
||||
const raw = files[path]
|
||||
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
|
||||
return new TextDecoder().decode(raw)
|
||||
}
|
||||
|
||||
export function readBinaryFile(files: Record<string, Uint8Array>, path: string): Uint8Array {
|
||||
const raw = files[path]
|
||||
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
|
||||
return raw
|
||||
}
|
||||
|
||||
export function utf8Bytes(text: string): Uint8Array {
|
||||
return strToU8(text)
|
||||
}
|
||||
|
||||
export function isZipArchive(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
|
||||
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
const localLb = await db.logbooks.get(logbookId)
|
||||
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
|
||||
const isShared = localLb?.isShared === 1
|
||||
const masterKey = getActiveMasterKey()
|
||||
|
||||
let key = await getLogbookKey(logbookId)
|
||||
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
|
||||
// Key works, return it
|
||||
return key
|
||||
} catch (err) {
|
||||
if (isShared) {
|
||||
throw new Error(
|
||||
'Shared logbook encryption key is missing or invalid. Please go online and refresh your logbooks.'
|
||||
)
|
||||
}
|
||||
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
|
||||
try {
|
||||
const parsed = JSON.parse(encryptedTitle)
|
||||
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
|
||||
|
||||
// If no logbook key exists yet
|
||||
if (!key) {
|
||||
if (isShared) {
|
||||
throw new Error(
|
||||
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
|
||||
)
|
||||
}
|
||||
|
||||
if (encryptedTitle && masterKey) {
|
||||
try {
|
||||
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { formatAppDecimal } from '../../utils/numberFormat.js'
|
||||
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
||||
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
||||
import { angularDelta } from './nmeaTimeSeries.js'
|
||||
|
||||
function formatNmeaDecimal(value: number): string {
|
||||
return formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
}
|
||||
|
||||
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
||||
const last = events[events.length - 1]
|
||||
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
||||
@@ -64,7 +69,7 @@ export function detectNmeaChanges(
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_wind_speed',
|
||||
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
|
||||
summaryParams: { from: formatNmeaDecimal(lastWindSpeed), to: formatNmeaDecimal(p.windSpeedKnots) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
@@ -79,7 +84,7 @@ export function detectNmeaChanges(
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_pressure',
|
||||
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
|
||||
summaryParams: { from: formatNmeaDecimal(lastPressure), to: formatNmeaDecimal(p.pressureHpa) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
@@ -95,7 +100,7 @@ export function detectNmeaChanges(
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_depth',
|
||||
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
|
||||
summaryParams: { from: formatNmeaDecimal(lastDepth), to: formatNmeaDecimal(p.depthM) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
@@ -156,7 +161,7 @@ export function detectNmeaChanges(
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_water_temp',
|
||||
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
|
||||
summaryParams: { from: formatNmeaDecimal(lastWaterTemp), to: formatNmeaDecimal(p.waterTempC) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
@@ -200,7 +205,7 @@ export function detectNmeaChanges(
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'low',
|
||||
summaryKey: 'logs.nmea_change_speed',
|
||||
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
|
||||
summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
|
||||
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
||||
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
||||
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
||||
import { formatAppDecimal, formatCanonicalCoordinate } from '../../utils/numberFormat.js'
|
||||
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
||||
import type {
|
||||
NmeaChangeEvent,
|
||||
@@ -33,9 +34,12 @@ function pointToLogEvent(
|
||||
windDirection: windDir,
|
||||
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
||||
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
||||
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
|
||||
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
|
||||
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
|
||||
gpsLat: point.lat != null ? formatCanonicalCoordinate(point.lat) : '',
|
||||
gpsLng: point.lng != null ? formatCanonicalCoordinate(point.lng) : '',
|
||||
logReading:
|
||||
point.logDistanceNm != null
|
||||
? formatAppDecimal(point.logDistanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: '',
|
||||
sailsOrMotor,
|
||||
remarks
|
||||
})
|
||||
@@ -51,7 +55,11 @@ function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
|
||||
const parts: string[] = []
|
||||
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
||||
if (change.data?.depthM != null) {
|
||||
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
|
||||
parts.push(
|
||||
t('logs.nmea_remark_depth', {
|
||||
depth: formatAppDecimal(change.data.depthM, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
})
|
||||
)
|
||||
}
|
||||
if (change.confidence === 'low') {
|
||||
parts.push(t('logs.nmea_remark_uncertain'))
|
||||
|
||||
@@ -55,9 +55,6 @@ export async function saveEntryPhoto(options: {
|
||||
})
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
|
||||
if (analyticsContext === 'live_log') {
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
|
||||
}
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
return photoId
|
||||
}
|
||||
|
||||
@@ -27,17 +27,62 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
|
||||
return Notification.permission
|
||||
}
|
||||
|
||||
let cachedVapidKey: string | null = null
|
||||
let cachedRegistration: ServiceWorkerRegistration | null = null
|
||||
|
||||
async function getRegistrationCompat(timeoutMs = 8000): Promise<ServiceWorkerRegistration> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('Service Worker is not supported by your browser')
|
||||
}
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration()
|
||||
if (reg) return reg
|
||||
} catch (e) {
|
||||
console.warn('Failed to get service worker registration directly:', e)
|
||||
}
|
||||
|
||||
// Fallback to waiting for ready state with a timeout
|
||||
const readyPromise = navigator.serviceWorker.ready
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), timeoutMs)
|
||||
)
|
||||
|
||||
return Promise.race([readyPromise, timeoutPromise])
|
||||
}
|
||||
|
||||
export async function preloadPushService(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
try {
|
||||
if (!cachedVapidKey) {
|
||||
await fetchVapidPublicKey()
|
||||
}
|
||||
if (!cachedRegistration) {
|
||||
cachedRegistration = await getRegistrationCompat()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to preload push service:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVapidPublicKey(): Promise<string | null> {
|
||||
if (cachedVapidKey) return cachedVapidKey
|
||||
|
||||
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||
if (typeof envKey === 'string' && envKey.trim()) {
|
||||
return envKey.trim()
|
||||
cachedVapidKey = envKey.trim()
|
||||
return cachedVapidKey
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/vapid-public-key`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return typeof data.publicKey === 'string' ? data.publicKey : null
|
||||
if (typeof data.publicKey === 'string') {
|
||||
cachedVapidKey = data.publicKey.trim()
|
||||
return cachedVapidKey
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -72,11 +117,61 @@ export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promis
|
||||
})
|
||||
}
|
||||
|
||||
async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
if (typeof Notification === 'undefined') return 'denied'
|
||||
|
||||
// Try promise-based signature first
|
||||
try {
|
||||
const result = Notification.requestPermission()
|
||||
if (result !== undefined) {
|
||||
return await result
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back to callback
|
||||
}
|
||||
|
||||
// Callback-based fallback
|
||||
return new Promise<NotificationPermission>((resolve) => {
|
||||
Notification.requestPermission((permission) => {
|
||||
resolve(permission)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
||||
|
||||
const endpoint = subscription.endpoint
|
||||
const json = subscription.toJSON()
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
let p256dh = json.keys?.p256dh
|
||||
let auth = json.keys?.auth
|
||||
|
||||
// Fallback for browsers (like Safari) that might not serialize keys in toJSON()
|
||||
if (!p256dh && typeof subscription.getKey === 'function') {
|
||||
try {
|
||||
const rawKey = subscription.getKey('p256dh')
|
||||
if (rawKey) {
|
||||
p256dh = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract p256dh key manually:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth && typeof subscription.getKey === 'function') {
|
||||
try {
|
||||
const rawAuth = subscription.getKey('auth')
|
||||
if (rawAuth) {
|
||||
auth = btoa(String.fromCharCode(...new Uint8Array(rawAuth)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract auth key manually:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!endpoint || !p256dh || !auth) {
|
||||
throw new Error('Invalid push subscription')
|
||||
}
|
||||
|
||||
@@ -85,8 +180,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
|
||||
await apiJson(`${API_BASE}/subscription`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
endpoint,
|
||||
keys: { p256dh, auth },
|
||||
locale,
|
||||
userAgent: navigator.userAgent
|
||||
})
|
||||
@@ -98,35 +193,48 @@ export async function subscribeToPush(): Promise<void> {
|
||||
throw new Error('Push notifications are not supported on this device')
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission denied')
|
||||
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
|
||||
let registration = cachedRegistration
|
||||
if (!registration) {
|
||||
registration = await getRegistrationCompat()
|
||||
cachedRegistration = registration
|
||||
}
|
||||
|
||||
const publicKey = await fetchVapidPublicKey()
|
||||
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
|
||||
if (!publicKey) {
|
||||
throw new Error('Push notifications are not configured on this server')
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (!subscription) {
|
||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
||||
const applicationServerKey = new Uint8Array(keyBytes)
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
const permission = await requestNotificationPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission denied')
|
||||
}
|
||||
|
||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
||||
const applicationServerKey = new Uint8Array(keyBytes)
|
||||
|
||||
// Always call subscribe with timeout to prevent silent hangs on push network errors
|
||||
const subscribePromise = registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
const subscribeTimeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
|
||||
)
|
||||
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
|
||||
|
||||
await saveSubscriptionToServer(subscription)
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPush(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let registration = cachedRegistration
|
||||
if (!registration) {
|
||||
registration = await getRegistrationCompat()
|
||||
cachedRegistration = registration
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) return
|
||||
|
||||
@@ -164,3 +272,7 @@ export async function disableCollaboratorChangePush(): Promise<void> {
|
||||
await savePushPrefs(false)
|
||||
await unsubscribeFromPush()
|
||||
}
|
||||
|
||||
if (isPushSupported()) {
|
||||
void preloadPushService()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizeLogEvent,
|
||||
sortLogEventsByTime,
|
||||
currentLocalTimeHHMM,
|
||||
localDateString,
|
||||
type LogEventPayload
|
||||
} from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
@@ -124,11 +125,22 @@ function buildEncryptedPayload(
|
||||
})
|
||||
|
||||
const clear = options.clearSignatures
|
||||
return {
|
||||
const entryData: Record<string, unknown> = {
|
||||
...payload,
|
||||
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
||||
signCrew: clear ? '' : (data.signCrew ?? '')
|
||||
}
|
||||
|
||||
const summary = typeof data.aiSummary === 'string' ? data.aiSummary.trim() : ''
|
||||
if (summary) {
|
||||
entryData.aiSummary = summary
|
||||
entryData.aiSummaryGeneratedAt =
|
||||
typeof data.aiSummaryGeneratedAt === 'string' && data.aiSummaryGeneratedAt
|
||||
? data.aiSummaryGeneratedAt
|
||||
: new Date().toISOString()
|
||||
}
|
||||
|
||||
return entryData
|
||||
}
|
||||
|
||||
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
|
||||
@@ -140,18 +152,86 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
|
||||
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
|
||||
}
|
||||
|
||||
function scoreTodayEntry(data: Record<string, unknown>): number {
|
||||
const events = (data.events as unknown[] | undefined)?.length ?? 0
|
||||
const signed = (data.signSkipper || data.signCrew) ? 1 : 0
|
||||
const destination = String(data.destination || '').trim() ? 1 : 0
|
||||
return events * 10 + signed + destination
|
||||
}
|
||||
|
||||
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
|
||||
const todayStr = new Date().toISOString().substring(0, 10)
|
||||
const todayStr = localDateString()
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
|
||||
|
||||
let bestId: string | null = null
|
||||
let bestScore = -1
|
||||
let bestUpdatedAt = ''
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (decrypted && String(decrypted.date) === todayStr) {
|
||||
return entry.payloadId
|
||||
if (!decrypted || String(decrypted.date) !== todayStr) continue
|
||||
|
||||
const score = scoreTodayEntry(decrypted)
|
||||
if (
|
||||
score > bestScore
|
||||
|| (score === bestScore && entry.updatedAt > bestUpdatedAt)
|
||||
) {
|
||||
bestId = entry.payloadId
|
||||
bestScore = score
|
||||
bestUpdatedAt = entry.updatedAt
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
return bestId
|
||||
}
|
||||
|
||||
async function entryHasAttachments(logbookId: string, entryId: string): Promise<boolean> {
|
||||
const [photos, voices, track] = await Promise.all([
|
||||
db.photos.where({ logbookId, entryId }).count(),
|
||||
db.voiceMemos.where({ logbookId, entryId }).count(),
|
||||
db.gpsTracks.get(entryId)
|
||||
])
|
||||
return photos > 0 || voices > 0 || track != null
|
||||
}
|
||||
|
||||
async function isEmptyTodayEntry(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
data: Record<string, unknown>
|
||||
): Promise<boolean> {
|
||||
if (((data.events as unknown[] | undefined)?.length ?? 0) > 0) return false
|
||||
if (data.signSkipper || data.signCrew) return false
|
||||
if (String(data.destination || '').trim()) return false
|
||||
return !(await entryHasAttachments(logbookId, entryId))
|
||||
}
|
||||
|
||||
/** Remove duplicate empty travel days for today (e.g. after parallel Live-log init). */
|
||||
export async function pruneEmptyTodayDuplicates(
|
||||
logbookId: string,
|
||||
keepEntryId: string
|
||||
): Promise<void> {
|
||||
const todayStr = localDateString()
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const local = await db.entries.where({ logbookId }).toArray()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
for (const entry of local) {
|
||||
if (entry.payloadId === keepEntryId) continue
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (!decrypted || String(decrypted.date) !== todayStr) continue
|
||||
if (!(await isEmptyTodayEntry(logbookId, entry.payloadId, decrypted))) continue
|
||||
|
||||
await db.entries.delete(entry.payloadId)
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'entry',
|
||||
payloadId: entry.payloadId,
|
||||
logbookId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
@@ -174,7 +254,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
|
||||
const localId = window.crypto.randomUUID()
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
const todayStr = localDateString()
|
||||
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
@@ -216,20 +296,36 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
return localId
|
||||
}
|
||||
|
||||
const findOrCreateTodayEntryInflight = new Map<string, Promise<string>>()
|
||||
|
||||
async function findOrCreateTodayEntryOnce(logbookId: string): Promise<string> {
|
||||
await ensureLogbookKey(logbookId)
|
||||
|
||||
let entryId = await findTodayEntryId(logbookId)
|
||||
if (!entryId) {
|
||||
entryId = await createTodayEntry(logbookId)
|
||||
}
|
||||
|
||||
await pruneEmptyTodayDuplicates(logbookId, entryId)
|
||||
return entryId
|
||||
}
|
||||
|
||||
/** One travel day per local calendar date; concurrent callers share one in-flight create. */
|
||||
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
|
||||
const id = logbookId.trim()
|
||||
if (!id) throw new Error('Logbook id required')
|
||||
|
||||
await ensureLogbookKey(id)
|
||||
|
||||
const entryCount = await db.entries.where({ logbookId: id }).count()
|
||||
if (entryCount === 0) {
|
||||
return createTodayEntry(id)
|
||||
let inflight = findOrCreateTodayEntryInflight.get(id)
|
||||
if (!inflight) {
|
||||
inflight = findOrCreateTodayEntryOnce(id)
|
||||
findOrCreateTodayEntryInflight.set(id, inflight)
|
||||
void inflight.finally(() => {
|
||||
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
|
||||
findOrCreateTodayEntryInflight.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await findTodayEntryId(id)
|
||||
if (existing) return existing
|
||||
return createTodayEntry(id)
|
||||
return inflight
|
||||
}
|
||||
|
||||
export interface AppendQuickEventResult {
|
||||
|
||||
@@ -258,14 +258,4 @@ export function getTrackColor(index: number): string {
|
||||
return TRACK_COLORS[index % TRACK_COLORS.length]
|
||||
}
|
||||
|
||||
export function formatNm(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
export function formatLiters(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||
}
|
||||
|
||||
export function formatHours(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||
}
|
||||
export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'
|
||||
|
||||
@@ -61,6 +61,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
||||
return !!(await db.entries.get(item.payloadId))
|
||||
case 'photo':
|
||||
return !!(await db.photos.get(item.payloadId))
|
||||
case 'voiceMemo':
|
||||
return !!(await db.voiceMemos.get(item.payloadId))
|
||||
case 'gpsTrack':
|
||||
return !!(await db.gpsTracks.get(item.payloadId))
|
||||
case 'logbookCrew':
|
||||
@@ -131,12 +133,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
|
||||
}
|
||||
|
||||
function scheduleResync(logbookId: string) {
|
||||
if (pendingResync.has(logbookId)) return
|
||||
pendingResync.add(logbookId)
|
||||
queueMicrotask(() => {
|
||||
pendingResync.delete(logbookId)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
})
|
||||
}
|
||||
|
||||
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
||||
@@ -235,6 +232,7 @@ type PulledServerPayload = {
|
||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||
voiceMemos?: Array<{ payloadId: string; updatedAt: string }>
|
||||
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
|
||||
}
|
||||
|
||||
@@ -258,6 +256,7 @@ async function pruneAcknowledgedQueueItems(
|
||||
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
||||
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
||||
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
||||
for (const v of server.voiceMemos ?? []) serverTimes.set('voiceMemo:' + v.payloadId, v.updatedAt)
|
||||
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
|
||||
|
||||
const localLogbook = await db.logbooks.get(logbookId)
|
||||
@@ -304,7 +303,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
|
||||
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, voiceMemos, gpsTracks } =
|
||||
await response.json()
|
||||
|
||||
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
|
||||
@@ -318,6 +317,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
crews,
|
||||
entries,
|
||||
photos,
|
||||
voiceMemos,
|
||||
gpsTracks
|
||||
}
|
||||
|
||||
@@ -476,6 +476,38 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// 5b. Sync Voice Memos
|
||||
const serverVoiceMap = new Map<string, any>()
|
||||
if (voiceMemos && Array.isArray(voiceMemos)) {
|
||||
await forEachInBatches(voiceMemos, 20, async (v) => {
|
||||
serverVoiceMap.set(v.payloadId, v)
|
||||
const local = await db.voiceMemos.get(v.payloadId)
|
||||
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
|
||||
await db.voiceMemos.put({
|
||||
payloadId: v.payloadId,
|
||||
entryId: v.entryId,
|
||||
logbookId,
|
||||
encryptedData: v.encryptedData,
|
||||
iv: v.iv,
|
||||
tag: v.tag,
|
||||
updatedAt: v.updatedAt
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const localVoiceMemos = await db.voiceMemos.where({ logbookId }).toArray()
|
||||
for (const lv of localVoiceMemos) {
|
||||
if (!serverVoiceMap.has(lv.payloadId)) {
|
||||
const pendingCreate = await db.syncQueue
|
||||
.where({ payloadId: lv.payloadId, action: 'create' })
|
||||
.first()
|
||||
if (!pendingCreate) {
|
||||
await db.voiceMemos.delete(lv.payloadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Sync GPS Tracks
|
||||
const serverGpsTrackMap = new Map<string, any>()
|
||||
if (gpsTracks && Array.isArray(gpsTracks)) {
|
||||
@@ -540,6 +572,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
} finally {
|
||||
syncingLogbooks.delete(logbookId)
|
||||
recomputeSyncingState()
|
||||
if (pendingResync.has(logbookId)) {
|
||||
pendingResync.delete(logbookId)
|
||||
setTimeout(() => {
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
|
||||
async function getEncryptionKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
const key = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||
return key
|
||||
}
|
||||
|
||||
export async function saveEntryVoiceMemo(options: {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
audioDataUrl: string
|
||||
mimeType: string
|
||||
durationSec: number
|
||||
caption?: string
|
||||
analyticsContext?: string
|
||||
}): Promise<string> {
|
||||
const {
|
||||
logbookId,
|
||||
entryId,
|
||||
audioDataUrl,
|
||||
mimeType,
|
||||
durationSec,
|
||||
caption = '',
|
||||
analyticsContext = 'logbook'
|
||||
} = options
|
||||
const masterKey = await getEncryptionKey(logbookId)
|
||||
const voiceId = window.crypto.randomUUID()
|
||||
const voicePayload = {
|
||||
audio: audioDataUrl,
|
||||
mimeType,
|
||||
durationSec,
|
||||
caption: caption.trim()
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(voicePayload, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.voiceMemos.put({
|
||||
payloadId: voiceId,
|
||||
entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'voiceMemo',
|
||||
payloadId: voiceId,
|
||||
logbookId,
|
||||
data: JSON.stringify({
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
entryId
|
||||
}),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
return voiceId
|
||||
}
|
||||
|
||||
export async function deleteEntryVoiceMemo(logbookId: string, voiceId: string): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db.voiceMemos.delete(voiceId)
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'voiceMemo',
|
||||
payloadId: voiceId,
|
||||
logbookId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
/** Deletes the newest voice memo for an entry; returns its id or null. */
|
||||
export async function removeLastVoiceMemoForEntry(
|
||||
logbookId: string,
|
||||
entryId: string
|
||||
): Promise<string | null> {
|
||||
const memos = await db.voiceMemos.where({ entryId }).toArray()
|
||||
if (memos.length === 0) return null
|
||||
memos.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)
|
||||
const lastId = memos[0].payloadId
|
||||
await deleteEntryVoiceMemo(logbookId, lastId)
|
||||
return lastId
|
||||
}
|
||||
@@ -44,6 +44,17 @@ describe('fetchOpenWeatherCurrent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('throws OFFLINE when navigator.onLine is false', async () => {
|
||||
vi.stubGlobal('navigator', { ...navigator, onLine: false })
|
||||
|
||||
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(WeatherApiError)
|
||||
expect((err as InstanceType<typeof WeatherApiError>).code).toBe('OFFLINE')
|
||||
|
||||
expect(apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not track when the API request fails', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
} from './analytics.js'
|
||||
|
||||
export class WeatherApiError extends Error {
|
||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
|
||||
|
||||
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
||||
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
||||
super(message)
|
||||
this.name = 'WeatherApiError'
|
||||
this.code = code
|
||||
@@ -26,6 +26,10 @@ export async function fetchOpenWeatherCurrent(
|
||||
},
|
||||
options?: { analyticsSource: OwmAnalyticsSource }
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!navigator.onLine) {
|
||||
throw new WeatherApiError('Offline', 'OFFLINE')
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params.lat && params.lon) {
|
||||
|
||||
+4
-4
@@ -6,6 +6,10 @@ import { NetworkFirst, NetworkOnly } from 'workbox-strategies'
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
cleanupOutdatedCaches()
|
||||
clientsClaim()
|
||||
|
||||
const appShellFallback = createHandlerBoundToURL('/index.html')
|
||||
const navigationStrategy = new NetworkFirst({
|
||||
cacheName: 'app-shell',
|
||||
@@ -20,10 +24,6 @@ registerRoute(({ request }) => request.mode === 'navigate', async (context) => {
|
||||
}
|
||||
})
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
cleanupOutdatedCaches()
|
||||
clientsClaim()
|
||||
|
||||
// Always fetch the live deploy version, even under an older precache.
|
||||
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export const VOICE_MEMO_MAX_DURATION_SEC = 60
|
||||
export const VOICE_MEMO_MAX_BLOB_BYTES = 800_000
|
||||
|
||||
const MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/ogg;codecs=opus'
|
||||
]
|
||||
|
||||
export function pickMediaRecorderMimeType(): string | undefined {
|
||||
if (typeof MediaRecorder === 'undefined') return undefined
|
||||
for (const mime of MIME_CANDIDATES) {
|
||||
if (MediaRecorder.isTypeSupported(mime)) return mime
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function blobToAudioDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(new Error('audio_read_failed'))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export function formatVoiceDuration(seconds: number): string {
|
||||
const s = Math.max(0, Math.floor(seconds))
|
||||
const m = Math.floor(s / 60)
|
||||
const r = s % 60
|
||||
return `${m}:${String(r).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function assertVoiceMemoBlobSize(blob: Blob): void {
|
||||
if (blob.size > VOICE_MEMO_MAX_BLOB_BYTES) {
|
||||
throw new Error('VOICE_MEMO_TOO_LARGE')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
cameraErrorKeyFromDomException,
|
||||
isCameraApiSupported,
|
||||
probeCameraAvailability
|
||||
} from './cameraAvailability.js'
|
||||
|
||||
describe('cameraAvailability', () => {
|
||||
it('detects missing camera API', () => {
|
||||
const nav = { mediaDevices: undefined }
|
||||
vi.stubGlobal('navigator', nav)
|
||||
expect(isCameraApiSupported()).toBe(false)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns none when no videoinput devices', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(),
|
||||
enumerateDevices: vi.fn().mockResolvedValue([
|
||||
{ kind: 'audioinput', deviceId: 'a1', label: '', groupId: '' }
|
||||
])
|
||||
}
|
||||
})
|
||||
await expect(probeCameraAvailability()).resolves.toBe('none')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns available when a videoinput exists', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(),
|
||||
enumerateDevices: vi.fn().mockResolvedValue([
|
||||
{ kind: 'videoinput', deviceId: 'v1', label: '', groupId: '' }
|
||||
])
|
||||
}
|
||||
})
|
||||
await expect(probeCameraAvailability()).resolves.toBe('available')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('maps NotFoundError to no-camera i18n key', () => {
|
||||
expect(cameraErrorKeyFromDomException(new DOMException('', 'NotFoundError'))).toBe(
|
||||
'logs.live_photo_no_camera'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
export type CameraAvailability = 'available' | 'none' | 'unsupported'
|
||||
|
||||
/** Whether the browser exposes camera APIs at all. */
|
||||
export function isCameraApiSupported(): boolean {
|
||||
return typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
|
||||
}
|
||||
|
||||
/** Best-effort probe for at least one video input device (no permission prompt). */
|
||||
export async function probeCameraAvailability(): Promise<CameraAvailability> {
|
||||
if (!isCameraApiSupported()) return 'unsupported'
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
// Cannot list devices; defer to getUserMedia attempt in the capture UI.
|
||||
return 'available'
|
||||
}
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
if (devices.some((d) => d.kind === 'videoinput')) return 'available'
|
||||
return 'none'
|
||||
} catch {
|
||||
return 'none'
|
||||
}
|
||||
}
|
||||
|
||||
export function cameraErrorKeyFromDomException(err: unknown): string {
|
||||
const name = err instanceof DOMException ? err.name : ''
|
||||
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
||||
return 'logs.live_photo_no_camera'
|
||||
}
|
||||
if (name === 'NotAllowedError' || name === 'NotReadableError' || name === 'SecurityError') {
|
||||
return 'logs.live_photo_camera_denied'
|
||||
}
|
||||
return 'logs.live_photo_camera_unavailable'
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { hashEntryForSigning } from './entryCanonicalHash.js'
|
||||
|
||||
describe('hashEntryForSigning', () => {
|
||||
it('excludes aiSummary fields from the signing hash', async () => {
|
||||
const base = {
|
||||
date: '2026-06-03',
|
||||
dayOfTravel: '1',
|
||||
departure: 'A',
|
||||
destination: 'B',
|
||||
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||
events: []
|
||||
}
|
||||
|
||||
const withoutSummary = await hashEntryForSigning(base)
|
||||
const withSummary = await hashEntryForSigning({
|
||||
...base,
|
||||
aiSummary: 'A calm day at sea.',
|
||||
aiSummaryGeneratedAt: '2026-06-03T12:00:00.000Z'
|
||||
})
|
||||
|
||||
expect(withSummary).toBe(withoutSummary)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
|
||||
const AI_SUMMARY_KEYS = new Set(['aiSummary', 'aiSummaryGeneratedAt'])
|
||||
|
||||
function sortEventsByTime(items: unknown[]): unknown[] {
|
||||
return [...items]
|
||||
@@ -25,7 +26,7 @@ function sortValue(value: unknown, parentKey?: string): unknown {
|
||||
const obj = value as Record<string, unknown>
|
||||
const sorted: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(obj).sort()) {
|
||||
if (SIGNATURE_KEYS.has(key)) continue
|
||||
if (SIGNATURE_KEYS.has(key) || AI_SUMMARY_KEYS.has(key)) continue
|
||||
sorted[key] = sortValue(obj[key], key)
|
||||
}
|
||||
return sorted
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
liveSogRemark,
|
||||
parseLiveCommentRemark,
|
||||
livePhotoRemark,
|
||||
liveVoiceRemark,
|
||||
parseLiveVoiceRemark,
|
||||
parseLiveSailsRemark
|
||||
} from './liveEventCodes.js'
|
||||
import { formatEventSummary } from './formatEventSummary.js'
|
||||
@@ -19,8 +21,8 @@ const t = (key: string, opts?: Record<string, unknown>) => {
|
||||
'logs.live_cast_off': 'Cast off',
|
||||
'logs.live_moor': 'Moor',
|
||||
'logs.live_sails': `Sails: ${opts?.sails ?? ''}`,
|
||||
'logs.live_fix': 'Fix',
|
||||
'logs.live_fix_coords': `Fix ${opts?.lat}, ${opts?.lng}`,
|
||||
'logs.live_position': 'Position',
|
||||
'logs.live_position_coords': `Position ${opts?.lat}, ${opts?.lng}`,
|
||||
'logs.live_event_generic': 'Event',
|
||||
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
|
||||
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
|
||||
@@ -28,6 +30,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
|
||||
'logs.live_wind_entry': `Wind ${opts?.value}`,
|
||||
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
|
||||
'logs.live_photo_entry_plain': 'Photo captured',
|
||||
'logs.live_voice_entry_plain': 'Voice memo',
|
||||
'logs.live_course_entry': `Course ${opts?.course}`,
|
||||
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
|
||||
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
|
||||
@@ -59,6 +62,12 @@ describe('liveEventCodes', () => {
|
||||
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
|
||||
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
|
||||
})
|
||||
|
||||
it('parses voice remark with uuid', () => {
|
||||
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
|
||||
expect(parseLiveVoiceRemark(liveVoiceRemark(id))).toBe(id)
|
||||
expect(parseLiveVoiceRemark('__live:voice:not-a-uuid')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatEventSummary', () => {
|
||||
@@ -76,14 +85,14 @@ describe('formatEventSummary', () => {
|
||||
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
|
||||
})
|
||||
|
||||
it('formats fix with coordinates', () => {
|
||||
it('formats position with coordinates', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '09:00',
|
||||
remarks: LIVE_EVENT_CODES.FIX,
|
||||
remarks: LIVE_EVENT_CODES.POSITION,
|
||||
gpsLat: '54.323000',
|
||||
gpsLng: '10.145000'
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000')
|
||||
expect(formatEventSummary(event, t)).toBe('Position 54.323000, 10.145000')
|
||||
})
|
||||
|
||||
it('formats pressure entry', () => {
|
||||
@@ -130,4 +139,10 @@ describe('formatEventSummary', () => {
|
||||
})
|
||||
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
|
||||
})
|
||||
|
||||
it('formats voice memo entry', () => {
|
||||
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
|
||||
const event = normalizeLogEvent({ time: '12:00', remarks: liveVoiceRemark(id) })
|
||||
expect(formatEventSummary(event, t)).toBe('Voice memo')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { LogEventPayload } from './logEntryPayload.js'
|
||||
import {
|
||||
isManualPositionEventCode,
|
||||
LIVE_EVENT_CODES,
|
||||
parseLiveCommentRemark,
|
||||
parseLiveFuelRemark,
|
||||
parseLivePhotoRemark,
|
||||
parseLiveVoiceRemark,
|
||||
parseLivePrecipRemark,
|
||||
parseLiveSailsRemark,
|
||||
parseLiveSogRemark,
|
||||
@@ -34,6 +36,11 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
|
||||
: t('logs.live_photo_entry_plain')
|
||||
}
|
||||
|
||||
const voiceId = parseLiveVoiceRemark(code)
|
||||
if (voiceId) {
|
||||
return t('logs.live_voice_entry_plain')
|
||||
}
|
||||
|
||||
const temp = parseLiveTempRemark(code)
|
||||
if (temp) return t('logs.live_temp_entry', { temp })
|
||||
|
||||
@@ -52,16 +59,16 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
|
||||
const stw = parseLiveStwRemark(code)
|
||||
if (stw) return t('logs.live_stw_entry', { speed: stw })
|
||||
|
||||
if (code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION) {
|
||||
if (isManualPositionEventCode(code) || code === LIVE_EVENT_CODES.AUTO_POSITION) {
|
||||
if (event.gpsLat && event.gpsLng) {
|
||||
const label = code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||
? t('logs.live_auto_position')
|
||||
: t('logs.live_fix')
|
||||
: t('logs.live_position')
|
||||
return `${label} ${event.gpsLat}, ${event.gpsLng}`
|
||||
}
|
||||
return code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||
? t('logs.live_auto_position')
|
||||
: t('logs.live_fix')
|
||||
: t('logs.live_position')
|
||||
}
|
||||
|
||||
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
|
||||
|
||||
@@ -7,7 +7,4 @@ export function computeFuelPerMotorHour(
|
||||
return Number((fuelConsumptionL / motorHours).toFixed(2))
|
||||
}
|
||||
|
||||
export function formatFuelPerMotorHour(value: number | null | undefined): string {
|
||||
if (value == null) return '—'
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||
}
|
||||
export { formatFuelPerMotorHour } from './numberFormat.js'
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
classifyGpsAccuracyMeters,
|
||||
formatGpsAccuracyMeters,
|
||||
geolocationErrorI18nKey,
|
||||
GEOLOCATION_LIVE_INTRO_STORAGE_KEY,
|
||||
getCurrentPosition,
|
||||
getGeolocationErrorReason,
|
||||
hasSeenGeolocationLiveIntro,
|
||||
markGeolocationLiveIntroSeen,
|
||||
normalizeGpsCoordinates,
|
||||
parseGpsCoordinate,
|
||||
queryGeolocationPermission
|
||||
} from './geolocation.js'
|
||||
|
||||
describe('geolocation helpers', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY)
|
||||
})
|
||||
|
||||
it('tracks Live-Log geolocation intro in localStorage', () => {
|
||||
expect(hasSeenGeolocationLiveIntro()).toBe(false)
|
||||
markGeolocationLiveIntroSeen()
|
||||
expect(hasSeenGeolocationLiveIntro()).toBe(true)
|
||||
})
|
||||
|
||||
it('parses coordinates with comma decimals', () => {
|
||||
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
|
||||
})
|
||||
@@ -50,7 +67,7 @@ describe('geolocation helpers', () => {
|
||||
geolocation: {
|
||||
getCurrentPosition: (success: PositionCallback) => {
|
||||
success({
|
||||
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
|
||||
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5, accuracy: 12 }
|
||||
} as GeolocationPosition)
|
||||
}
|
||||
}
|
||||
@@ -59,10 +76,36 @@ describe('geolocation helpers', () => {
|
||||
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
|
||||
lat: '59.910000',
|
||||
lng: '10.750000',
|
||||
speedKn: 4.9
|
||||
speedKn: 4.9,
|
||||
accuracyM: 12,
|
||||
signalQuality: 'excellent'
|
||||
})
|
||||
})
|
||||
|
||||
it('formats GPS accuracy for display', () => {
|
||||
expect(formatGpsAccuracyMeters(12.4)).toBe('12')
|
||||
expect(formatGpsAccuracyMeters(87)).toBe('87')
|
||||
expect(formatGpsAccuracyMeters(105)).toBe('110')
|
||||
expect(formatGpsAccuracyMeters(247)).toBe('250')
|
||||
})
|
||||
|
||||
it('classifies GPS accuracy into signal quality', () => {
|
||||
expect(classifyGpsAccuracyMeters(8)).toBe('excellent')
|
||||
expect(classifyGpsAccuracyMeters(30)).toBe('good')
|
||||
expect(classifyGpsAccuracyMeters(80)).toBe('fair')
|
||||
expect(classifyGpsAccuracyMeters(250)).toBe('poor')
|
||||
expect(classifyGpsAccuracyMeters(null)).toBe('unknown')
|
||||
})
|
||||
|
||||
it('maps GeolocationPositionError codes to reasons', () => {
|
||||
expect(getGeolocationErrorReason({ code: 1 } as GeolocationPositionError)).toBe('permission_denied')
|
||||
expect(getGeolocationErrorReason({ code: 2 } as GeolocationPositionError)).toBe('position_unavailable')
|
||||
expect(getGeolocationErrorReason({ code: 3 } as GeolocationPositionError)).toBe('timeout')
|
||||
expect(getGeolocationErrorReason(new Error('geolocation_timeout'))).toBe('timeout')
|
||||
expect(getGeolocationErrorReason(new Error('geolocation_unavailable'))).toBe('unavailable')
|
||||
expect(geolocationErrorI18nKey('permission_denied')).toBe('logs.gps_permission_denied')
|
||||
})
|
||||
|
||||
it('reads permission state when supported', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
geolocation: {},
|
||||
|
||||
@@ -1,17 +1,80 @@
|
||||
import {
|
||||
formatAppCoordinate,
|
||||
formatCanonicalCoordinate,
|
||||
formatGpsAccuracyMeters,
|
||||
parseAppDecimal
|
||||
} from './numberFormat.js'
|
||||
|
||||
const MPS_TO_KNOTS = 1.9438444924406
|
||||
|
||||
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
||||
const TIMEOUT_GRACE_MS = 750
|
||||
|
||||
/** Estimated fix quality from browser accuracy (metres). Real satellite count is not exposed to web apps. */
|
||||
export type GpsSignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'unknown'
|
||||
|
||||
export interface GeoCoordinates {
|
||||
lat: string
|
||||
lng: string
|
||||
/** SOG from GPS when available (kn), otherwise null. */
|
||||
speedKn: number | null
|
||||
/** Estimated horizontal accuracy in metres, when reported by the browser. */
|
||||
accuracyM: number | null
|
||||
/** Derived signal quality indicator for UI hints. */
|
||||
signalQuality: GpsSignalQuality
|
||||
}
|
||||
|
||||
/** Classifies GPS fix quality from reported accuracy (lower metres = better). */
|
||||
export function classifyGpsAccuracyMeters(accuracyM: number | null | undefined): GpsSignalQuality {
|
||||
if (accuracyM == null || !Number.isFinite(accuracyM) || accuracyM < 0) return 'unknown'
|
||||
if (accuracyM <= 15) return 'excellent'
|
||||
if (accuracyM <= 40) return 'good'
|
||||
if (accuracyM <= 100) return 'fair'
|
||||
return 'poor'
|
||||
}
|
||||
|
||||
export function gpsQualityI18nKey(quality: GpsSignalQuality): string {
|
||||
return `logs.gps_quality_${quality}`
|
||||
}
|
||||
|
||||
export type GeolocationPermissionState = PermissionState | 'unsupported'
|
||||
|
||||
export type GeolocationErrorReason =
|
||||
| 'unavailable'
|
||||
| 'timeout'
|
||||
| 'permission_denied'
|
||||
| 'position_unavailable'
|
||||
| 'unknown'
|
||||
|
||||
/** Maps browser / wrapper errors to a stable reason for i18n. */
|
||||
export function getGeolocationErrorReason(error: unknown): GeolocationErrorReason {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'geolocation_unavailable') return 'unavailable'
|
||||
if (error.message === 'geolocation_timeout') return 'timeout'
|
||||
}
|
||||
const code = (error as GeolocationPositionError | undefined)?.code
|
||||
if (code === 1) return 'permission_denied'
|
||||
if (code === 2) return 'position_unavailable'
|
||||
if (code === 3) return 'timeout'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** i18n key (full path, e.g. logs.gps_timeout) for a geolocation failure reason. */
|
||||
export function geolocationErrorI18nKey(reason: GeolocationErrorReason): string {
|
||||
switch (reason) {
|
||||
case 'unavailable':
|
||||
return 'logs.gps_unavailable'
|
||||
case 'timeout':
|
||||
return 'logs.gps_timeout'
|
||||
case 'permission_denied':
|
||||
return 'logs.gps_permission_denied'
|
||||
case 'position_unavailable':
|
||||
return 'logs.gps_position_unavailable'
|
||||
default:
|
||||
return 'logs.gps_failed'
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetPositionOptions {
|
||||
timeoutMs?: number
|
||||
/** Manual fixes may use high accuracy; background auto-position should not. */
|
||||
@@ -19,11 +82,10 @@ export interface GetPositionOptions {
|
||||
maximumAge?: number
|
||||
}
|
||||
|
||||
export { formatGpsAccuracyMeters }
|
||||
|
||||
export function parseGpsCoordinate(value: string): number | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const n = parseFloat(trimmed.replace(',', '.'))
|
||||
return Number.isFinite(n) ? n : null
|
||||
return parseAppDecimal(value.trim())
|
||||
}
|
||||
|
||||
/** Validates lat/lng and returns normalized strings for storage, or null. */
|
||||
@@ -35,7 +97,26 @@ export function normalizeGpsCoordinates(
|
||||
const lngN = parseGpsCoordinate(lng)
|
||||
if (latN == null || lngN == null) return null
|
||||
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
|
||||
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
|
||||
return { lat: formatCanonicalCoordinate(latN), lng: formatCanonicalCoordinate(lngN) }
|
||||
}
|
||||
|
||||
/** localStorage: user has seen the Live-Log geolocation intro (allow or dismiss). */
|
||||
export const GEOLOCATION_LIVE_INTRO_STORAGE_KEY = 'kdb_geolocation_live_intro_seen'
|
||||
|
||||
export function hasSeenGeolocationLiveIntro(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function markGeolocationLiveIntroSeen(): void {
|
||||
try {
|
||||
localStorage.setItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY, '1')
|
||||
} catch {
|
||||
// Private mode / quota — non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
|
||||
@@ -65,10 +146,15 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
|
||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||
: null
|
||||
const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy)
|
||||
? pos.coords.accuracy
|
||||
: null
|
||||
return {
|
||||
lat: pos.coords.latitude.toFixed(6),
|
||||
lng: pos.coords.longitude.toFixed(6),
|
||||
speedKn
|
||||
lat: formatAppCoordinate(pos.coords.latitude),
|
||||
lng: formatAppCoordinate(pos.coords.longitude),
|
||||
speedKn,
|
||||
accuracyM,
|
||||
signalQuality: classifyGpsAccuracyMeters(accuracyM)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export const LIVE_EVENT_CODES = {
|
||||
MOTOR_STOP: '__live:motor_stop',
|
||||
CAST_OFF: '__live:cast_off',
|
||||
MOOR: '__live:moor',
|
||||
FIX: '__live:fix',
|
||||
POSITION: '__live:position',
|
||||
AUTO_POSITION: '__live:auto_position',
|
||||
COURSE: '__live:course',
|
||||
WIND: '__live:wind',
|
||||
@@ -13,6 +13,9 @@ export const LIVE_EVENT_CODES = {
|
||||
VISIBILITY: '__live:visibility'
|
||||
} as const
|
||||
|
||||
/** @deprecated Stored in older log entries; still recognized when reading events. */
|
||||
export const LEGACY_LIVE_POSITION_REMARK = '__live:fix'
|
||||
|
||||
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
|
||||
|
||||
export function liveSailsRemark(sails: string): string {
|
||||
@@ -50,6 +53,21 @@ export function parseLivePhotoRemark(remarks: string): string | null {
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
const VOICE_UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
export function liveVoiceRemark(audioId: string): string {
|
||||
return `__live:voice:${audioId}`
|
||||
}
|
||||
|
||||
export function parseLiveVoiceRemark(remarks: string): string | null {
|
||||
const trimmed = remarks.trim()
|
||||
const prefix = '__live:voice:'
|
||||
if (!trimmed.startsWith(prefix)) return null
|
||||
const id = trimmed.slice(prefix.length)
|
||||
return VOICE_UUID_RE.test(id) ? id : null
|
||||
}
|
||||
|
||||
export function liveSogRemark(speedKn: string): string {
|
||||
return `__live:sog:${speedKn}`
|
||||
}
|
||||
@@ -133,27 +151,31 @@ export function getLastAutoPositionMs(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Max age of a logged GPS fix for OpenWeatherMap lookups in live log. */
|
||||
/** Max age of a logged position for OpenWeatherMap lookups in live log. */
|
||||
export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
export type LiveLogPositionSource = 'fix' | 'auto_position'
|
||||
export type LiveLogPositionSource = 'position' | 'auto_position'
|
||||
|
||||
export interface LiveLogPositionFix {
|
||||
export interface LiveLogPosition {
|
||||
lat: string
|
||||
lng: string
|
||||
loggedAtMs: number
|
||||
source: LiveLogPositionSource
|
||||
}
|
||||
|
||||
function isPositionEventCode(code: string): boolean {
|
||||
return code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||
export function isManualPositionEventCode(code: string): boolean {
|
||||
return code === LIVE_EVENT_CODES.POSITION || code === LEGACY_LIVE_POSITION_REMARK
|
||||
}
|
||||
|
||||
/** Latest FIX or auto-position event with GPS coordinates (any age). */
|
||||
export function getLatestPositionFix(
|
||||
function isPositionEventCode(code: string): boolean {
|
||||
return isManualPositionEventCode(code) || code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||
}
|
||||
|
||||
/** Latest manual or auto-position event with GPS coordinates (any age). */
|
||||
export function getLatestLoggedPosition(
|
||||
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
|
||||
entryDate: string
|
||||
): LiveLogPositionFix | null {
|
||||
): LiveLogPosition | null {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const event = events[i]
|
||||
const code = event.remarks.trim()
|
||||
@@ -167,20 +189,20 @@ export function getLatestPositionFix(
|
||||
lat,
|
||||
lng,
|
||||
loggedAtMs,
|
||||
source: code === LIVE_EVENT_CODES.FIX ? 'fix' : 'auto_position'
|
||||
source: isManualPositionEventCode(code) ? 'position' : 'auto_position'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** GPS fix for weather if logged within `maxAgeMs` (default 6 h). */
|
||||
export function getLastPositionFixWithin(
|
||||
/** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */
|
||||
export function getLastLoggedPositionWithin(
|
||||
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
|
||||
entryDate: string,
|
||||
maxAgeMs: number = LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
|
||||
nowMs: number = Date.now()
|
||||
): LiveLogPositionFix | null {
|
||||
const latest = getLatestPositionFix(events, entryDate)
|
||||
): LiveLogPosition | null {
|
||||
const latest = getLatestLoggedPosition(events, entryDate)
|
||||
if (!latest) return null
|
||||
if (nowMs - latest.loggedAtMs > maxAgeMs) return null
|
||||
return latest
|
||||
|
||||
@@ -1,54 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getLastPositionFixWithin,
|
||||
getLatestPositionFix,
|
||||
getLastLoggedPositionWithin,
|
||||
getLatestLoggedPosition,
|
||||
LEGACY_LIVE_POSITION_REMARK,
|
||||
LIVE_EVENT_CODES,
|
||||
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
|
||||
} from './liveEventCodes.js'
|
||||
|
||||
const entryDate = '2026-06-01'
|
||||
|
||||
describe('live log position fix', () => {
|
||||
it('returns latest fix with coordinates', () => {
|
||||
describe('live log position', () => {
|
||||
it('returns latest position with coordinates', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
|
||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
|
||||
{ remarks: LIVE_EVENT_CODES.POSITION, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
|
||||
{ remarks: LIVE_EVENT_CODES.POSITION, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
|
||||
]
|
||||
const fix = getLatestPositionFix(events, entryDate)
|
||||
expect(fix?.lat).toBe('54.2')
|
||||
expect(fix?.source).toBe('fix')
|
||||
const position = getLatestLoggedPosition(events, entryDate)
|
||||
expect(position?.lat).toBe('54.2')
|
||||
expect(position?.source).toBe('position')
|
||||
})
|
||||
|
||||
it('accepts auto-position with GPS', () => {
|
||||
it('reads legacy __live:fix remarks', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const events = [
|
||||
{ remarks: LEGACY_LIVE_POSITION_REMARK, time: '09:00', gpsLat: '54.5', gpsLng: '10.5' }
|
||||
]
|
||||
const position = getLatestLoggedPosition(events, entryDate)
|
||||
expect(position?.lat).toBe('54.5')
|
||||
expect(position?.source).toBe('position')
|
||||
})
|
||||
|
||||
it('prefers auto-position source when applicable', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const events = [
|
||||
{
|
||||
remarks: LIVE_EVENT_CODES.AUTO_POSITION,
|
||||
time: '14:00',
|
||||
gpsLat: '55.0',
|
||||
gpsLng: '11.0'
|
||||
gpsLat: '54.3',
|
||||
gpsLng: '10.4'
|
||||
}
|
||||
]
|
||||
expect(getLatestPositionFix(events, entryDate)?.source).toBe('auto_position')
|
||||
expect(getLatestLoggedPosition(events, entryDate)?.source).toBe('auto_position')
|
||||
})
|
||||
|
||||
it('rejects fix older than max age for weather', () => {
|
||||
const noon = new Date(`${entryDate}T12:00:00`).getTime()
|
||||
it('rejects position older than max age for weather', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const noon = new Date('2026-06-01T12:00:00').getTime()
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
|
||||
{ remarks: LIVE_EVENT_CODES.POSITION, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
|
||||
]
|
||||
expect(
|
||||
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
||||
getLastLoggedPositionWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
||||
).toBeNull()
|
||||
expect(getLatestPositionFix(events, entryDate)).not.toBeNull()
|
||||
expect(getLatestLoggedPosition(events, entryDate)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('accepts fix within six hours', () => {
|
||||
const noon = new Date(`${entryDate}T12:00:00`).getTime()
|
||||
it('accepts position within six hours', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const noon = new Date('2026-06-01T12:00:00').getTime()
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
|
||||
{ remarks: LIVE_EVENT_CODES.POSITION, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
|
||||
]
|
||||
expect(
|
||||
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
||||
getLastLoggedPositionWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
||||
).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildLogEntryPayload,
|
||||
hasUnsavedEventDraft,
|
||||
isLogEventDraftEmpty,
|
||||
localDateString,
|
||||
normalizeLogEvent,
|
||||
type LogEventPayload
|
||||
} from './logEntryPayload.js'
|
||||
@@ -13,6 +14,14 @@ const emptyDraft = (): LogEventPayload =>
|
||||
const filledDraft = (): LogEventPayload =>
|
||||
normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' })
|
||||
|
||||
describe('localDateString', () => {
|
||||
it('uses local calendar date, not UTC', () => {
|
||||
const date = new Date(2026, 5, 4, 1, 30, 0)
|
||||
expect(localDateString(date)).toBe('2026-06-04')
|
||||
expect(date.toISOString().substring(0, 10)).toBe('2026-06-03')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logEntryPayload event drafts', () => {
|
||||
it('treats time-only draft as empty', () => {
|
||||
expect(isLogEventDraftEmpty(emptyDraft())).toBe(true)
|
||||
|
||||
@@ -24,6 +24,14 @@ export interface LogEventPayload {
|
||||
remarks: string
|
||||
}
|
||||
|
||||
/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */
|
||||
export function localDateString(date: Date = new Date()): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
/** Local time as HH:MM (24-hour). */
|
||||
export function currentLocalTimeHHMM(date: Date = new Date()): string {
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
|
||||
@@ -56,10 +56,7 @@ export function emptyTankLevels(morning = 0): TankLevels {
|
||||
return { morning, refilled: 0, evening: 0, consumption: 0 }
|
||||
}
|
||||
|
||||
export function formatTankLiters(liters: number): string {
|
||||
if (!Number.isFinite(liters) || liters <= 0) return '0'
|
||||
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
||||
}
|
||||
export { formatTankLiters } from './numberFormat.js'
|
||||
|
||||
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
|
||||
return Number(greywater?.level) || 0
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatAppCoordinate,
|
||||
formatAppDecimal,
|
||||
formatGpsAccuracyMeters,
|
||||
formatTankLiters,
|
||||
getNumberFormatSymbols,
|
||||
parseAppDecimal,
|
||||
resolveDeviceLocale
|
||||
} from './numberFormat.js'
|
||||
|
||||
describe('numberFormat (device locale)', () => {
|
||||
it('resolveDeviceLocale returns a non-empty BCP 47 tag', () => {
|
||||
expect(resolveDeviceLocale().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reads decimal separator from Intl for de-DE and en-US', () => {
|
||||
expect(getNumberFormatSymbols('de-DE').decimal).toBe(',')
|
||||
expect(getNumberFormatSymbols('en-US').decimal).toBe('.')
|
||||
})
|
||||
|
||||
it('formats decimals per locale without grouping', () => {
|
||||
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('12,5')
|
||||
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'en-US' })).toBe('12.5')
|
||||
expect(formatAppDecimal(1234.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('1234,5')
|
||||
})
|
||||
|
||||
it('parses device-locale decimals and tolerates the other separator', () => {
|
||||
expect(parseAppDecimal('12,5', 'de-DE')).toBe(12.5)
|
||||
expect(parseAppDecimal('12.5', 'en-US')).toBe(12.5)
|
||||
expect(parseAppDecimal('12,5', 'en-US')).toBe(12.5)
|
||||
expect(parseAppDecimal('1.234,5', 'de-DE')).toBe(1234.5)
|
||||
expect(parseAppDecimal('', 'de-DE')).toBeNull()
|
||||
})
|
||||
|
||||
it('formats coordinates for form display', () => {
|
||||
expect(formatAppCoordinate(59.912345, 'de-DE')).toBe('59,912345')
|
||||
expect(formatTankLiters(12.5)).toBe(formatAppDecimal(12.5, { minimumFractionDigits: 1, maximumFractionDigits: 1 }))
|
||||
})
|
||||
|
||||
it('formats GPS accuracy with coarse step from 100 m', () => {
|
||||
expect(formatGpsAccuracyMeters(12.4)).toBe(formatAppDecimal(12, { maximumFractionDigits: 0 }))
|
||||
expect(formatGpsAccuracyMeters(105)).toBe(formatAppDecimal(110, { maximumFractionDigits: 0 }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Number formatting and parsing follow the device (browser) locale from Intl,
|
||||
* not the app UI language — e.g. de-DE phone with English UI still uses comma decimals.
|
||||
*/
|
||||
|
||||
export function resolveDeviceLocale(): string {
|
||||
try {
|
||||
const locale = new Intl.NumberFormat().resolvedOptions().locale
|
||||
if (locale) return locale
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||
return navigator.language
|
||||
}
|
||||
return 'en-GB'
|
||||
}
|
||||
|
||||
interface NumberSymbols {
|
||||
decimal: string
|
||||
group: string
|
||||
}
|
||||
|
||||
const symbolCache = new Map<string, NumberSymbols>()
|
||||
|
||||
export function getNumberFormatSymbols(locale = resolveDeviceLocale()): NumberSymbols {
|
||||
const cached = symbolCache.get(locale)
|
||||
if (cached) return cached
|
||||
const parts = new Intl.NumberFormat(locale).formatToParts(1234567.89)
|
||||
const symbols: NumberSymbols = {
|
||||
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
|
||||
group: parts.find((p) => p.type === 'group')?.value ?? ''
|
||||
}
|
||||
symbolCache.set(locale, symbols)
|
||||
return symbols
|
||||
}
|
||||
|
||||
export interface FormatAppDecimalOptions {
|
||||
minimumFractionDigits?: number
|
||||
maximumFractionDigits?: number
|
||||
locale?: string
|
||||
}
|
||||
|
||||
/** User-visible decimal without thousands grouping. */
|
||||
export function formatAppDecimal(value: number, options: FormatAppDecimalOptions = {}): string {
|
||||
if (!Number.isFinite(value)) return ''
|
||||
const locale = options.locale ?? resolveDeviceLocale()
|
||||
const min = options.minimumFractionDigits ?? 0
|
||||
const max = options.maximumFractionDigits ?? min
|
||||
return new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: min,
|
||||
maximumFractionDigits: max,
|
||||
useGrouping: false
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a decimal typed by the user for the device locale.
|
||||
* Also accepts the other common separator for simple values (e.g. 12,5 on en-US).
|
||||
*/
|
||||
export function parseAppDecimal(input: string, locale = resolveDeviceLocale()): number | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const { decimal, group } = getNumberFormatSymbols(locale)
|
||||
const simpleComma = /^-?\d+,\d+$/.test(trimmed)
|
||||
const simpleDot = /^-?\d+\.\d+$/.test(trimmed)
|
||||
|
||||
// Values without grouping: accept locale decimal and the other common separator.
|
||||
if (simpleComma && decimal === ',') {
|
||||
return Number(trimmed.replace(',', '.'))
|
||||
}
|
||||
if (simpleDot && decimal === '.') {
|
||||
return Number(trimmed)
|
||||
}
|
||||
if (simpleComma && decimal === '.') {
|
||||
return Number(trimmed.replace(',', '.'))
|
||||
}
|
||||
if (simpleDot && decimal === ',') {
|
||||
return Number(trimmed)
|
||||
}
|
||||
|
||||
let normalized = trimmed
|
||||
if (group) {
|
||||
normalized = normalized.split(group).join('')
|
||||
}
|
||||
if (decimal !== '.') {
|
||||
normalized = normalized.replace(decimal, '.')
|
||||
}
|
||||
|
||||
const n = Number(normalized)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function parseAppDecimalOrZero(input: string, locale?: string): number {
|
||||
return parseAppDecimal(input, locale) ?? 0
|
||||
}
|
||||
|
||||
/** Canonical storage/API coordinate string (always dot, 6 decimals). */
|
||||
export function formatCanonicalCoordinate(value: number): string {
|
||||
return value.toFixed(6)
|
||||
}
|
||||
|
||||
/** Coordinate string for form fields (device decimal separator). */
|
||||
export function formatAppCoordinate(value: number, locale?: string): string {
|
||||
return formatAppDecimal(value, { minimumFractionDigits: 6, maximumFractionDigits: 6, locale })
|
||||
}
|
||||
|
||||
export function formatNm(value: number): string {
|
||||
return formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
export function formatLiters(value: number): string {
|
||||
return Number.isInteger(value)
|
||||
? formatAppDecimal(value, { maximumFractionDigits: 0 })
|
||||
: formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
}
|
||||
|
||||
export function formatHours(value: number): string {
|
||||
return formatLiters(value)
|
||||
}
|
||||
|
||||
export function formatTankLiters(liters: number): string {
|
||||
if (!Number.isFinite(liters) || liters <= 0) return formatAppDecimal(0, { maximumFractionDigits: 0 })
|
||||
return formatLiters(liters)
|
||||
}
|
||||
|
||||
export function formatFuelPerMotorHour(value: number | null | undefined): string {
|
||||
if (value == null) return '—'
|
||||
return Number.isInteger(value)
|
||||
? formatAppDecimal(value, { maximumFractionDigits: 0 })
|
||||
: formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
/** GPS accuracy for i18n (±{{accuracy}} m): 1 m below 100 m, 10 m from 100 m upward. */
|
||||
export function formatGpsAccuracyMeters(accuracyM: number): string {
|
||||
const rounded = accuracyM < 100 ? Math.round(accuracyM) : Math.round(accuracyM / 10) * 10
|
||||
return formatAppDecimal(rounded, { maximumFractionDigits: 0 })
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { degreesToCardinal } from './courseAngle.js'
|
||||
import { formatAppDecimal } from './numberFormat.js'
|
||||
import { formatVisibilityMeters } from './weatherMetrics.js'
|
||||
|
||||
/** @deprecated Use formatVisibilityMeters */
|
||||
@@ -33,7 +34,7 @@ export function mpsToBeaufort(mps: number): number {
|
||||
|
||||
export function formatWindStrengthBeaufort(mps: number): string {
|
||||
const bft = mpsToBeaufort(mps)
|
||||
return `${bft} Bft (${mps.toFixed(1)} m/s)`
|
||||
return `${bft} Bft (${formatAppDecimal(mps, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} m/s)`
|
||||
}
|
||||
|
||||
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
|
||||
@@ -49,7 +50,7 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
|
||||
|
||||
let tempC: string | null = null
|
||||
if (main?.temp != null && Number.isFinite(main.temp)) {
|
||||
tempC = Number(main.temp).toFixed(1)
|
||||
tempC = formatAppDecimal(main.temp, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
}
|
||||
|
||||
let precipText: string | null = null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatTankLiters } from './logEntryTankLevels.js'
|
||||
import { formatTankLiters, parseAppDecimal } from './numberFormat.js'
|
||||
|
||||
export interface VesselTankCapacities {
|
||||
freshwaterCapacityL?: number
|
||||
@@ -7,10 +7,10 @@ export interface VesselTankCapacities {
|
||||
}
|
||||
|
||||
export function parseOptionalTankLiters(input: string): number | undefined {
|
||||
const trimmed = input.trim().replace(',', '.')
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
const parsed = parseAppDecimal(trimmed)
|
||||
if (parsed == null || parsed < 0) {
|
||||
throw new Error('invalid_tank_liters')
|
||||
}
|
||||
return parsed
|
||||
@@ -24,10 +24,10 @@ function capacityFromStored(value: unknown): number | undefined {
|
||||
if (value == null || value === '') return undefined
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim().replace(',', '.')
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
||||
const parsed = parseAppDecimal(trimmed)
|
||||
if (parsed != null && parsed >= 0) return parsed
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
import { formatAppDecimal } from './numberFormat.js'
|
||||
|
||||
const NM_IN_METERS = 1852
|
||||
const MAX_PLAUSIBLE_KNOTS = 50
|
||||
@@ -100,8 +101,14 @@ export function formatTrackStats(stats: TrackStats): {
|
||||
speedAvgKn: string
|
||||
} {
|
||||
return {
|
||||
distanceNm: stats.distanceNm.toFixed(2),
|
||||
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '',
|
||||
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : ''
|
||||
distanceNm: formatAppDecimal(stats.distanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
||||
speedMaxKn:
|
||||
stats.speedMaxKn > 0
|
||||
? formatAppDecimal(stats.speedMaxKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
: '',
|
||||
speedAvgKn:
|
||||
stats.speedAvgKn > 0
|
||||
? formatAppDecimal(stats.speedAvgKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
|
||||
import { formatAppDecimal, parseAppDecimal } from './numberFormat.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
|
||||
export function metricInputFromStored(value: unknown): string {
|
||||
if (value == null || value === '') return ''
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return formatAppDecimal(value, { maximumFractionDigits: 6 })
|
||||
}
|
||||
if (typeof value === 'string') return value.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
export function parseOptionalMetricMeters(input: string): number | undefined {
|
||||
const trimmed = input.trim().replace(',', '.')
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
const parsed = parseAppDecimal(trimmed)
|
||||
if (parsed == null || parsed < 0) {
|
||||
throw new Error('invalid_metric')
|
||||
}
|
||||
return parsed
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatAppDecimal } from './numberFormat.js'
|
||||
|
||||
/** Barometric pressure (hPa), typical marine range. */
|
||||
export const PRESSURE_MIN_HPA = 960
|
||||
export const PRESSURE_MAX_HPA = 1050
|
||||
@@ -90,7 +92,9 @@ export function formatVisibilityMeters(meters: number): string {
|
||||
if (meters >= 1000) {
|
||||
const km = meters / 1000
|
||||
const rounded = Math.round(km * 10) / 10
|
||||
return Number.isInteger(rounded) ? `${rounded} km` : `${rounded.toFixed(1)} km`
|
||||
return Number.isInteger(rounded)
|
||||
? `${formatAppDecimal(rounded, { maximumFractionDigits: 0 })} km`
|
||||
: `${formatAppDecimal(rounded, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`
|
||||
}
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ services:
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
||||
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
||||
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# Backup-Format v2 — Design
|
||||
|
||||
**Status:** Implementiert in `feature/backup-format-v2` (`BACKUP_VERSION = 2`, Datei `*.daagbok`).
|
||||
|
||||
**Ziel:** Logbuch-Backups skalieren für viele Reisetage, Fotos, Voice-Memos und GPS-Tracks — ohne den gesamten Inhalt als eine große JSON-Datei im Browser-RAM zu halten.
|
||||
|
||||
**Ausgangslage:** v1 (`BACKUP_VERSION = 1`, Datei `*.daagbok.json`) serialisiert alle Payloads in ein einziges JSON-Objekt mit Pretty-Print. Binärdaten stecken doppelt als Base64-Strings in `encryptedData`. Import nutzt `file.text()` + `JSON.parse()` auf der vollen Datei.
|
||||
|
||||
**Entscheidung:** Keine Abwärtskompatibilität zu v1 — es gibt noch keine produktiven User-Backups. v1-Code und `-json`-Dateiendung wurden durch v2 ersetzt.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anforderungen
|
||||
|
||||
### Funktional
|
||||
|
||||
| ID | Anforderung |
|
||||
|----|-------------|
|
||||
| B-01 | Export enthält **alle** lokalen Logbuch-Payloads: yacht, deviation, crews, entries, photos, voiceMemos, gpsTracks, **logbookCrewSelection**, **logbookVesselSelection**, **nmeaArchives**. |
|
||||
| B-02 | E2E-Verschlüsselung bleibt: Logbuch-Key mit Backup-Passphrase (PBKDF2) gewrappt; Payload-Blobs unverändert wie in Dexie (AES-GCM über `encryptJson`). |
|
||||
| B-03 | Medien (Fotos, Voice-Memos) als **binäre Blob-Dateien** im Archiv, nicht als Base64 in JSON. |
|
||||
| B-04 | Strukturdaten (Manifest, kleine Metadaten) als **kompaktes JSON** (ein Zeile, kein Pretty-Print). |
|
||||
| B-05 | Gesamtarchiv **DEFLATE-komprimiert** (ZIP). |
|
||||
| B-06 | Preview (Titel, Counts, Export-Datum) ohne vollständiges Entpacken aller Medien — nur Manifest + Key-Entschlüsselung. |
|
||||
| B-07 | Restore-Optionen wie heute: `overwrite`, `assignNewId`, Konflikt bei gleicher ID. |
|
||||
| B-08 | Nach Restore: Sync-Queue wie heute befüllen, optional `syncLogbook` wenn online. |
|
||||
| B-09 | Export vor Download optional mit **Fortschrittsanzeige** (Anzahl Blobs / Bytes). |
|
||||
|
||||
### Nicht-Ziele (v2)
|
||||
|
||||
- Inkrementelles / dedupliziertes Backup über mehrere Dateien.
|
||||
- Backup auf dem Server (nur lokaler Download wie heute).
|
||||
- Klartext-Manifest oder unverschlüsselte Medien.
|
||||
- Account-weites Multi-Logbuch-Archiv (weiterhin **ein Logbuch pro Datei**).
|
||||
|
||||
### Akzeptanzkriterien (UAT)
|
||||
|
||||
1. Logbuch mit 50 Fotos + 20 Voice-Memos exportieren → Datei `.daagbok` deutlich kleiner als vergleichbares v1-JSON (Kompression + kein Pretty-Print + binäre Blobs).
|
||||
2. Restore auf frischem Gerät (eingeloggt) → alle Einträge, Medien abspielbar, Crew/Vessel-Selection vorhanden.
|
||||
3. Falsches Passphrase → `BACKUP_WRONG_PASSPHRASE` (wie heute).
|
||||
4. Beschädigtes ZIP / fehlende Manifest → `BACKUP_INVALID_FORMAT`.
|
||||
5. v1-Datei (`version: 1`) → `BACKUP_VERSION_UNSUPPORTED` mit Hinweis.
|
||||
|
||||
---
|
||||
|
||||
## 2. Container: ZIP-Archiv
|
||||
|
||||
| Eigenschaft | Wert |
|
||||
|-------------|------|
|
||||
| MIME-Typ (Download) | `application/vnd.kapteins-daagbok+zip` (Fallback: `application/zip`) |
|
||||
| Dateiendung | `.daagbok` (kein `.json`) |
|
||||
| Kompression | ZIP mit DEFLATE (Level 6 — Balance Größe/Geschwindigkeit) |
|
||||
| Magic / Erkennung | ZIP-Signatur `PK\x03\x04` + `manifest.json` mit `format` + `version: 2` |
|
||||
|
||||
**Bibliothek (Client):** [`fflate`](https://github.com/101arrowz/fflate) (klein, ESM, ZIP sync/async). Als direkte `dependencies`-Eintrag in `client/package.json`, nicht nur transitiv.
|
||||
|
||||
**Warum ZIP und nicht nur gzip auf einer JSON-Datei?**
|
||||
|
||||
- Viele unabhängige Blobs → paralleles Entpacken, Preview ohne alle Medien zu lesen.
|
||||
- Manifest bleibt klein (< 100 KB typisch).
|
||||
- Standard-Tooling (optional manuelle Inspektion mit `unzip -l`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Archiv-Layout
|
||||
|
||||
```
|
||||
backup.daagbok (ZIP)
|
||||
├── manifest.json # Klartext-Metadaten + Index (kompakt)
|
||||
├── key.enc # Mit Backup-Passphrase gewrapptes Logbuch-Key (binär)
|
||||
├── logbook.meta.json # encryptedTitle, id, updatedAt, isDemo (klein, JSON)
|
||||
└── payloads/
|
||||
├── yacht.enc
|
||||
├── deviation.enc
|
||||
├── logbook-crew.enc
|
||||
├── logbook-vessel.enc
|
||||
├── crews/
|
||||
│ └── {payloadId}.enc
|
||||
├── entries/
|
||||
│ └── {payloadId}.enc
|
||||
├── photos/
|
||||
│ └── {payloadId}.enc # + sidecar optional: {payloadId}.meta.json
|
||||
├── voice-memos/
|
||||
│ └── {payloadId}.enc
|
||||
├── gps-tracks/
|
||||
│ └── {entryId}.enc
|
||||
└── nmea-archives/
|
||||
└── {entryId}.enc
|
||||
```
|
||||
|
||||
### 3.1 `manifest.json` (Schema)
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "kapteins-daagbok-backup",
|
||||
"version": 2,
|
||||
"exportedAt": "2026-06-03T12:00:00.000Z",
|
||||
"appVersion": "0.1.0.109",
|
||||
"compression": "zip-deflate-6",
|
||||
"logbookId": "uuid",
|
||||
"counts": {
|
||||
"entries": 42,
|
||||
"photos": 50,
|
||||
"voiceMemos": 12,
|
||||
"crews": 3,
|
||||
"gpsTracks": 40,
|
||||
"nmeaArchives": 2,
|
||||
"hasYacht": true,
|
||||
"hasDeviation": true,
|
||||
"hasLogbookCrewSelection": true,
|
||||
"hasLogbookVesselSelection": true
|
||||
},
|
||||
"totalUncompressedBytes": 125000000,
|
||||
"files": {
|
||||
"key": "key.enc",
|
||||
"logbook": "logbook.meta.json",
|
||||
"yacht": "payloads/yacht.enc",
|
||||
"deviation": null,
|
||||
"logbookCrewSelection": "payloads/logbook-crew.enc",
|
||||
"logbookVesselSelection": "payloads/logbook-vessel.enc",
|
||||
"crews": [
|
||||
{ "payloadId": "…", "path": "payloads/crews/….enc", "updatedAt": "…" }
|
||||
],
|
||||
"entries": [ … ],
|
||||
"photos": [
|
||||
{ "payloadId": "…", "entryId": "…", "path": "…", "updatedAt": "…", "bytes": 183422 }
|
||||
],
|
||||
"voiceMemos": [ … ],
|
||||
"gpsTracks": [ … ],
|
||||
"nmeaArchives": [ … ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`appVersion`:** optional, aus Client-Build (PWA-Version) — nur für Support/Debug.
|
||||
- **`totalUncompressedBytes`:** Summe der Blob-Größen vor ZIP — für UI („~120 MB“) und Speicher-Warnung vor Import.
|
||||
- **Keine** `encryptedData` / IV / Tag im Manifest für große Blobs — nur im Binärformat (siehe 3.2).
|
||||
|
||||
### 3.2 Binärformat `.enc` (einheitlich für alle Payloads)
|
||||
|
||||
Jede `.enc`-Datei ist **roh**, kein JSON:
|
||||
|
||||
```
|
||||
Offset Size Inhalt
|
||||
0 4 Magic ASCII "KDAB" (Kaptein's Daagbok)
|
||||
4 1 Format version = 1
|
||||
5 12 IV (AES-GCM, wie heute)
|
||||
17 16 Auth tag (AES-GCM)
|
||||
33 N Ciphertext (identisch mit heutigem decryptJson-Eingang:
|
||||
Base64-decode von encryptedData + concat mit tag in decryptBuffer)
|
||||
```
|
||||
|
||||
**Migration von Dexie:** Beim Export aus `encryptedData` (Base64-String) + `iv` + `tag` (Base64) → einmal decodieren → `.enc`-Datei schreiben. Beim Import umgekehrt → Dexie-Felder wie heute.
|
||||
|
||||
Vorteil: ~33 % weniger Speicher als Base64-in-JSON; Parser liest nur Header + Länge.
|
||||
|
||||
**`key.enc`:** Gleiches Binärformat, Inhalt = `encryptBuffer(logbookKey, passphraseDerivedKey)` — ersetzt das JSON-Objekt `logbookKey: { ciphertext, iv, tag }` aus v1.
|
||||
|
||||
**`logbook.meta.json`:** Unverändert kleines JSON (nur `encryptedTitle`, `updatedAt`, `isDemo`) — kein Binärbedarf.
|
||||
|
||||
### 3.3 Optionale Sidecars (Phase 2, nicht blocking v2)
|
||||
|
||||
Für Photos/Voice-Memos könnte `{id}.meta.json` nur `{ entryId, updatedAt }` enthalten, falls das Manifest zu groß wird (>10k Medien). v2 startet mit **allen Metadaten im Manifest** — ausreichend bis ~einige tausend Dateien.
|
||||
|
||||
---
|
||||
|
||||
## 4. Kryptographie (unverändert in der Semantik)
|
||||
|
||||
| Element | v1 | v2 |
|
||||
|---------|----|----|
|
||||
| Logbuch-Key im Backup | PBKDF2 + AES-GCM (`KapteinsDaagbokBackupFileSalt_v1`) | **Gleich** (Salt-String beibehalten für gleiche Passphrase → gleicher Key) |
|
||||
| Payload-Verschlüsselung | `encryptJson` mit Logbuch-Key | **Byte-für-byte gleicher Ciphertext** in `.enc` |
|
||||
| Passphrase-Mindestlänge | 8 Zeichen | 8 Zeichen |
|
||||
|
||||
Optional in v2.1 (nicht v2): PBKDF2-Iterationen erhöhen (z. B. 310_000) mit neuem Salt `…_v2` und Feld `keyWrap: "pbkdf2-v2"` in Manifest — nur wenn gewünscht.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ablauf Export
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as LogbookBackupPanel
|
||||
participant Svc as logbookBackupV2
|
||||
participant DB as Dexie
|
||||
participant ZIP as fflate ZIP
|
||||
|
||||
UI->>Svc: exportLogbookBackup(logbookId, passphrase)
|
||||
Svc->>DB: collect all tables (batched)
|
||||
loop each payload
|
||||
Svc->>Svc: base64 → KDAB .enc bytes
|
||||
Svc->>ZIP: add file (deflate)
|
||||
end
|
||||
Svc->>ZIP: manifest.json + key.enc + logbook.meta.json
|
||||
Svc->>UI: Blob + filename.daagbok
|
||||
```
|
||||
|
||||
### Implementierungsdetails
|
||||
|
||||
1. **Pre-sync:** wie heute `syncLogbook(logbookId)` wenn online.
|
||||
2. **Sammlung:** `collectLogbookPayloadsV2()` — alle Tabellen aus Abschnitt B-01; Batches à 20 für Medien.
|
||||
3. **ZIP-Erzeugung:** `fflate` `zipSync` oder `zip` mit Streaming-Callback; **nicht** das gesamte Archiv als ein Array im RAM, wenn `totalUncompressedBytes > 80_000_000` → Warnung in UI + ggf. `requestIdleCallback` zwischen Blobs.
|
||||
4. **Fortschritt:** `onProgress({ phase, current, total, bytes })`.
|
||||
5. **Download:** `downloadBackupBlob(blob, `${safeTitle}-${date}.daagbok`)`.
|
||||
|
||||
### Speicher-Richtwerte (UI-Warnung)
|
||||
|
||||
| Uncompressed | Empfehlung |
|
||||
|--------------|------------|
|
||||
| < 50 MB | Normal exportieren |
|
||||
| 50–150 MB | Hinweis: Import kann auf schwachen Geräten dauern |
|
||||
| > 150 MB | Bestätigungsdialog; Export trotzdem erlauben |
|
||||
|
||||
ZIP reduziert typisch Medien-Anteil um **20–40 %** (JPEG/WebM komprimieren schlecht in ZIP, aber Base64-Overhead entfällt).
|
||||
|
||||
---
|
||||
|
||||
## 6. Ablauf Import / Preview
|
||||
|
||||
### Preview (Passphrase-Check)
|
||||
|
||||
1. ZIP öffnen (nur zentrales Directory lesen — `fflate` `unzip`).
|
||||
2. `manifest.json` parsen → `version === 2` prüfen.
|
||||
3. `key.enc` laden → Passphrase → Logbuch-Key.
|
||||
4. `logbook.meta.json` → Titel entschlüsseln.
|
||||
5. Counts aus Manifest anzeigen — **keine** Medien-Blobs dekodieren.
|
||||
|
||||
### Restore
|
||||
|
||||
1. Vollständig entpacken in **temporäre Struktur** (Object-URLs / `Map<path, Uint8Array>`) — bei >150 MB Warnung.
|
||||
2. `writeBackupToDexieV2()` — analog v1, aber aus `.enc` Bytes.
|
||||
3. `queueRestoredLogbookForSync()` — unveränderte Sync-Queue-Semantik.
|
||||
4. `listCache` auf Entries: nach Restore optional aus Entry-Payload neu ableiten (wie bei normalem Decrypt) oder beim Export mit speichern — **Design:** listCache beim Export **nicht** sichern (bleibt abgeleitetes Feld); nach Restore beim ersten Öffnen neu berechnen.
|
||||
|
||||
### Fehlercodes (neu/angepasst)
|
||||
|
||||
| Code | Bedeutung |
|
||||
|------|-----------|
|
||||
| `BACKUP_VERSION_UNSUPPORTED` | `version !== 2` (inkl. v1) |
|
||||
| `BACKUP_INVALID_ARCHIVE` | Kein ZIP / kein manifest |
|
||||
| `BACKUP_MISSING_BLOB` | Index verweist auf fehlende Datei |
|
||||
| `BACKUP_INVALID_ENC` | Magic/ Länge ungültig |
|
||||
| *(bestehend)* | `BACKUP_WRONG_PASSPHRASE`, `BACKUP_ID_CONFLICT`, … |
|
||||
|
||||
---
|
||||
|
||||
## 7. Code-Struktur (Implementierung)
|
||||
|
||||
| Datei | Aufgabe |
|
||||
|-------|---------|
|
||||
| [`client/src/services/logbookBackup/encBlob.ts`](client/src/services/logbookBackup/encBlob.ts) | `dexieRecordToEncBytes`, `encBytesToDexieFields` |
|
||||
| [`client/src/services/logbookBackup/manifest.ts`](client/src/services/logbookBackup/manifest.ts) | Typen `BackupManifestV2`, Validierung |
|
||||
| [`client/src/services/logbookBackup/zipArchive.ts`](client/src/services/logbookBackup/zipArchive.ts) | ZIP pack/unpack mit fflate |
|
||||
| [`client/src/services/logbookBackup.ts`](client/src/services/logbookBackup.ts) | Öffentliche API: `exportLogbookBackup`, `parseLogbookBackupFile`, `preview…`, `restore…` — ruft v2 intern auf |
|
||||
| [`client/src/components/LogbookBackupPanel.tsx`](client/src/components/LogbookBackupPanel.tsx) | `.daagbok`-Accept, Fortschrittsbalken, Größen-Warnung |
|
||||
| i18n `settings.backup_*` | Texte für v2, `BACKUP_VERSION_UNSUPPORTED` |
|
||||
| [`docs/plausible-events.md`](docs/plausible-events.md) | Properties `bytes`, `counts` bei Export/Restore |
|
||||
|
||||
**v1 entfernen:** `BACKUP_VERSION = 1`, `LogbookBackupFile`-Monolith, `JSON.stringify(…, null, 2)`, `normalizeBackupPayloads` für voiceMemos — löschen, nicht parallel halten.
|
||||
|
||||
---
|
||||
|
||||
## 8. Vergleich v1 → v2
|
||||
|
||||
| Aspekt | v1 | v2 |
|
||||
|--------|----|----|
|
||||
| Container | Eine JSON-Datei | ZIP `.daagbok` |
|
||||
| Medien im Export | Base64 in JSON-Strings | Binäre `.enc`-Dateien |
|
||||
| Manifest | Alles in einem Objekt | Schlankes `manifest.json` + Blobs |
|
||||
| Kompression | Keine (+ Pretty-Print) | DEFLATE |
|
||||
| RAM Import | `file.text()` + full parse | ZIP directory + gezieltes Entpacken; Preview nur Manifest |
|
||||
| Fehlende Payloads | Kein crew/vessel selection, kein NMEA | Vollständig |
|
||||
| Abwärtskompatibel | — | Nein (bewusst) |
|
||||
|
||||
### Größenbeispiel (Schätzung)
|
||||
|
||||
100 Fotos à ~250 KB verschlüsselt (≈190 KB Ciphertext):
|
||||
|
||||
- **v1 JSON:** ~100 × (190 KB × 4/3 Base64) ≈ **25 MB** nur Fotos-Ciphertext-Strings + JSON-Escaping + Pretty-Print → oft **35+ MB**
|
||||
- **v2 ZIP:** ~100 × 190 KB + Kompression ≈ **19–22 MB** Archiv
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementierungsplan (Phasen)
|
||||
|
||||
### Phase 1 — Kern (MVP v2)
|
||||
|
||||
- [x] `encBlob` + `manifest` + `zipArchive` Module
|
||||
- [x] Export/Import/Preview/Restore auf v2
|
||||
- [x] Alle Dexie-Tabellen inkl. crew/vessel selection + nmeaArchives
|
||||
- [x] UI: `.daagbok`, Fehler `BACKUP_VERSION_UNSUPPORTED` für v1-JSON
|
||||
- [x] Tests: `encBlob` unit tests
|
||||
|
||||
### Phase 2 — UX & Robustheit
|
||||
|
||||
- [x] Export-Fortschritt
|
||||
- [x] Größen-Warnung vor Import (>150 MB)
|
||||
- [ ] `onProgress` während Restore
|
||||
|
||||
### Phase 3 — Optional
|
||||
|
||||
- [ ] Streaming-Export für sehr große Archive (fflate async + Chunk-Schreiben)
|
||||
- [ ] PBKDF2 v2 mit höheren Iterationen
|
||||
- [ ] Sidecar-Metadaten wenn Manifest > 2 MB
|
||||
|
||||
---
|
||||
|
||||
## 10. Testplan
|
||||
|
||||
| Test | Typ |
|
||||
|------|-----|
|
||||
| `encBlob`: round-trip Dexie-Felder ↔ Bytes | Unit |
|
||||
| Manifest-Validator: version 2, fehlende paths | Unit |
|
||||
| Export → unzip → Manifest-Counts = DB-Counts | Integration (Vitest + IndexedDB fake) |
|
||||
| Restore → decrypt photo/voice → gültige Data-URL | Integration |
|
||||
| v1-JSON-Datei → `BACKUP_VERSION_UNSUPPORTED` | Unit |
|
||||
| Korruptes ZIP → `BACKUP_INVALID_ARCHIVE` | Unit |
|
||||
|
||||
---
|
||||
|
||||
## 11. Dokumentation & Analytics
|
||||
|
||||
- Nutzer-Texte: „Sicherungsdatei `.daagbok`“ statt `.daagbok.json`.
|
||||
- Plausible **Backup Exported / Restored:** Properties `bytes`, `photos`, `voiceMemos` (Anzahlen, keine Inhalte).
|
||||
- Deployment: **kein Server-Change** — Backup ist rein clientseitig.
|
||||
|
||||
---
|
||||
|
||||
## 12. Offene Punkte (für Review)
|
||||
|
||||
1. **Schwellwert Speicher-Warnung:** 150 MB uncompressed — anpassen nach ersten Real-Logbüchern?
|
||||
2. **NMEA-Archive:** oft groß — eigenes Subdirectory ausreichend; später ggf. „NMEA nicht ins Backup“ als Opt-out?
|
||||
3. **Geteilte Logbücher (`isShared === 1`):** Export weiterhin nur Owner — unverändert.
|
||||
|
||||
---
|
||||
|
||||
*Implementierung: [`client/src/services/logbookBackup/`](../client/src/services/logbookBackup/), API in [`client/src/services/logbookBackup.ts`](../client/src/services/logbookBackup.ts).*
|
||||
@@ -30,8 +30,9 @@ proxy_set_header X-Real-IP $remote_addr;
|
||||
ORIGIN=https://kapteins-daagbok.eu
|
||||
RP_ID=kapteins-daagbok.eu
|
||||
SESSION_SECRET=<min. 32 Zeichen, openssl rand -base64 48>
|
||||
TRUST_PROXY=172.16.10.10
|
||||
# oder TRUST_PROXY=1 für genau einen Proxy-Hop
|
||||
# Docker Compose: Frontend-Nginx ist der direkte Proxy zum Backend → 1 Hop
|
||||
TRUST_PROXY=1
|
||||
# Nur bei direktem Backend-Zugriff ohne Frontend-Nginx: NPM-IP, z. B. TRUST_PROXY=172.16.10.10
|
||||
```
|
||||
|
||||
`ORIGIN` muss **exakt** der Browser-URL entsprechen (ohne trailing slash).
|
||||
|
||||
@@ -37,13 +37,15 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
||||
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
||||
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
||||
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
|
||||
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
|
||||
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
|
||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
|
||||
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
||||
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
|
||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
|
||||
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
|
||||
| Ko-fi Link Clicked | Klick auf Ko-fi-Unterstützen-Badge im App-Footer (`AppFooter.tsx`) | — |
|
||||
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
|
||||
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
|
||||
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
|
||||
@@ -82,8 +84,9 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
|
||||
| `temp` | Temperatur |
|
||||
| `precip` | Niederschlag |
|
||||
| `sea_state` | Seegang |
|
||||
| `fix` | GPS-Fix (manuell) |
|
||||
| `position` | GPS-Position (manuell) |
|
||||
| `comment` | Kommentar |
|
||||
| `voice` | Sprachnotiz (Modal gespeichert) |
|
||||
| `undo` | Letztes Ereignis rückgängig |
|
||||
|
||||
### OWM-Quellen
|
||||
@@ -134,7 +137,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
|
||||
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
||||
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
|
||||
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
|
||||
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
|
||||
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `position`, `course`, `motor_start`) → Photo Uploaded / Voice Memo Uploaded (Filter `context`: `live_log`)
|
||||
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
|
||||
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
|
||||
|
||||
@@ -147,7 +150,8 @@ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
|
||||
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
||||
|
||||
@@ -34,7 +34,7 @@ if ! grep -q "^POSTGRES_PASSWORD=" "$ENV_FILE" || grep -q "^POSTGRES_PASSWORD=$"
|
||||
else
|
||||
echo " keep POSTGRES_PASSWORD (already set)"
|
||||
fi
|
||||
# NPM on 172.16.10.10 → app on this host
|
||||
ensure_var TRUST_PROXY "172.16.10.10"
|
||||
# Frontend-Nginx → Backend (one hop); NPM is in front of Nginx, not Backend directly
|
||||
ensure_var TRUST_PROXY "1"
|
||||
|
||||
echo "Done. Verify with: docker exec daagbox-prod-db psql -U postgres -d daagbox -c 'SELECT 1'"
|
||||
|
||||
@@ -93,6 +93,7 @@ model Logbook {
|
||||
deviations DeviationPayload[]
|
||||
entries EntryPayload[]
|
||||
photos PhotoPayload[]
|
||||
voiceMemos VoiceMemoPayload[]
|
||||
gpsTracks GpsTrackPayload[]
|
||||
collaborators Collaboration[]
|
||||
invitations Invitation[]
|
||||
@@ -240,6 +241,22 @@ model PhotoPayload {
|
||||
@@index([entryId])
|
||||
}
|
||||
|
||||
model VoiceMemoPayload {
|
||||
id String @id @default(uuid())
|
||||
logbookId String
|
||||
payloadId String
|
||||
entryId String
|
||||
encryptedData String
|
||||
iv String
|
||||
tag String
|
||||
updatedAt DateTime @updatedAt
|
||||
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([logbookId, payloadId])
|
||||
@@index([logbookId])
|
||||
@@index([entryId])
|
||||
}
|
||||
|
||||
model GpsTrackPayload {
|
||||
id String @id @default(uuid())
|
||||
logbookId String
|
||||
@@ -252,3 +269,14 @@ model GpsTrackPayload {
|
||||
|
||||
@@index([logbookId])
|
||||
}
|
||||
|
||||
model AiSummaryUsage {
|
||||
id String @id @default(uuid())
|
||||
logbookId String
|
||||
entryId String
|
||||
count Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([logbookId, entryId])
|
||||
@@index([logbookId])
|
||||
}
|
||||
|
||||
@@ -45,4 +45,18 @@ describe('API smoke', () => {
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toMatch(/Token/i)
|
||||
})
|
||||
|
||||
it('POST /api/ai/summary requires session', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/ai/summary')
|
||||
.send({ logbookId: 'x', entryId: 'y', context: {} })
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||
})
|
||||
|
||||
it('GET /api/ai/usage requires session', async () => {
|
||||
const res = await request(app).get('/api/ai/usage')
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||
})
|
||||
})
|
||||
|
||||
+44
-2
@@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js'
|
||||
import signRouter from './routes/sign.js'
|
||||
import pushRouter from './routes/push.js'
|
||||
import weatherRouter from './routes/weather.js'
|
||||
import aiRouter from './routes/ai.js'
|
||||
import feedbackRouter from './routes/feedback.js'
|
||||
import { prisma } from './db.js'
|
||||
import { buildCorsOptions } from './cors.js'
|
||||
@@ -45,13 +46,45 @@ export function createApp(): express.Express {
|
||||
app.use(cookieParser())
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
/** WebAuthn login/register/session — strict per IP; excludes high-volume sync routes. */
|
||||
const authFlowPaths = new Set([
|
||||
'/register-options',
|
||||
'/register-verify',
|
||||
'/login-options',
|
||||
'/login-verify',
|
||||
'/reauth-options',
|
||||
'/reauth-verify',
|
||||
'/logout',
|
||||
'/session'
|
||||
])
|
||||
|
||||
/** Account/key/credential mutations — also strict; separate bucket from login flow. */
|
||||
const sensitiveAuthExactPaths = new Set([
|
||||
'/delete-account',
|
||||
'/enroll-prf',
|
||||
'/rotate-recovery',
|
||||
'/add-credential-options',
|
||||
'/add-credential-verify'
|
||||
])
|
||||
|
||||
function isSensitiveAuthPath(path: string): boolean {
|
||||
return sensitiveAuthExactPaths.has(path) || path.startsWith('/credentials/')
|
||||
}
|
||||
|
||||
const authFlowLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
const sensitiveAuthLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000,
|
||||
max: 300,
|
||||
@@ -66,7 +99,15 @@ export function createApp(): express.Express {
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
app.use('/api/auth', authLimiter)
|
||||
app.use('/api/auth', (req, res, next) => {
|
||||
if (authFlowPaths.has(req.path)) {
|
||||
return authFlowLimiter(req, res, next)
|
||||
}
|
||||
if (isSensitiveAuthPath(req.path)) {
|
||||
return sensitiveAuthLimiter(req, res, next)
|
||||
}
|
||||
return next()
|
||||
})
|
||||
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
|
||||
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
|
||||
app.use('/api', apiLimiter)
|
||||
@@ -78,6 +119,7 @@ export function createApp(): express.Express {
|
||||
app.use('/api/sign', signRouter)
|
||||
app.use('/api/push', pushRouter)
|
||||
app.use('/api/weather', weatherRouter)
|
||||
app.use('/api/ai', aiRouter)
|
||||
app.use('/api/feedback', feedbackRouter)
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const MAX_ATTEMPTS_PER_ENTRY = 3
|
||||
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||
const FETCH_TIMEOUT_MS = 60_000
|
||||
|
||||
/** Common misconfiguration aliases → valid OpenRouter model IDs */
|
||||
const MODEL_ALIASES: Record<string, string> = {
|
||||
'anthropic/claude-haiku-latest': 'anthropic/claude-3.5-haiku',
|
||||
'claude-haiku-latest': 'anthropic/claude-3.5-haiku'
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
de: 'German',
|
||||
en: 'English',
|
||||
da: 'Danish',
|
||||
nb: 'Norwegian Bokmål',
|
||||
sv: 'Swedish'
|
||||
}
|
||||
|
||||
function resolveOpenRouterApiKey(): string | null {
|
||||
const fromEnv =
|
||||
process.env.OpenRouterAPIKey?.trim() ||
|
||||
process.env.OPENROUTER_API_KEY?.trim()
|
||||
return fromEnv || null
|
||||
}
|
||||
|
||||
function resolveOpenRouterModel(): string {
|
||||
const configured =
|
||||
process.env.OpenRouterModel?.trim() ||
|
||||
process.env.OPENROUTER_MODEL?.trim() ||
|
||||
DEFAULT_MODEL
|
||||
return MODEL_ALIASES[configured] ?? configured
|
||||
}
|
||||
|
||||
function extractOpenRouterError(data: unknown): string | null {
|
||||
if (typeof data !== 'object' || data === null) return null
|
||||
const nested = (data as { error?: { message?: string } }).error
|
||||
if (nested && typeof nested.message === 'string' && nested.message.trim()) {
|
||||
return nested.message.trim()
|
||||
}
|
||||
const topLevel = (data as { error?: string }).error
|
||||
if (typeof topLevel === 'string' && topLevel.trim()) return topLevel.trim()
|
||||
return null
|
||||
}
|
||||
|
||||
async function getLogbookOwner(logbookId: string) {
|
||||
return prisma.logbook.findUnique({
|
||||
where: { id: logbookId },
|
||||
select: { userId: true }
|
||||
})
|
||||
}
|
||||
|
||||
async function getUsageCount(logbookId: string, entryId: string): Promise<number> {
|
||||
const row = await prisma.aiSummaryUsage.findUnique({
|
||||
where: { logbookId_entryId: { logbookId, entryId } },
|
||||
select: { count: true }
|
||||
})
|
||||
return row?.count ?? 0
|
||||
}
|
||||
|
||||
function remainingAttempts(used: number): number {
|
||||
return Math.max(0, MAX_ATTEMPTS_PER_ENTRY - used)
|
||||
}
|
||||
|
||||
function resolveLanguageLabel(language: unknown): string {
|
||||
if (typeof language === 'string' && LANGUAGE_LABELS[language]) {
|
||||
return LANGUAGE_LABELS[language]
|
||||
}
|
||||
return LANGUAGE_LABELS.en
|
||||
}
|
||||
|
||||
function buildSystemPrompt(languageLabel: string): string {
|
||||
return [
|
||||
'You are a maritime logbook assistant for sailing yachts.',
|
||||
`Write a concise narrative summary of one travel day in ${languageLabel}.`,
|
||||
'Use 2–4 short paragraphs in plain prose.',
|
||||
'Cover route, sailing conditions, notable events, and tank/fuel highlights when data is present.',
|
||||
'Do not invent facts not supported by the input.',
|
||||
'Do not include coordinates, personal names, or signature metadata.',
|
||||
'Respond with the summary text only — no title, markdown, or JSON.'
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
router.get('/usage', async (req: any, res) => {
|
||||
try {
|
||||
const logbookId = String(req.query.logbookId || '')
|
||||
const entryId = String(req.query.entryId || '')
|
||||
if (!logbookId || !entryId) {
|
||||
return res.status(400).json({ error: 'logbookId and entryId are required' })
|
||||
}
|
||||
|
||||
const logbook = await getLogbookOwner(logbookId)
|
||||
if (!logbook) return res.status(404).json({ error: 'Logbook not found' })
|
||||
if (logbook.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Forbidden: Skipper only' })
|
||||
}
|
||||
|
||||
const used = await getUsageCount(logbookId, entryId)
|
||||
return res.json({ remainingAttempts: remainingAttempts(used), maxAttempts: MAX_ATTEMPTS_PER_ENTRY })
|
||||
} catch (error: unknown) {
|
||||
console.error('AI summary usage lookup failed:', error)
|
||||
return res.status(500).json({ error: 'Failed to load AI summary usage' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/summary', async (req: any, res) => {
|
||||
try {
|
||||
const { logbookId, entryId, language, context } = req.body ?? {}
|
||||
if (!logbookId || !entryId || !context || typeof context !== 'object') {
|
||||
return res.status(400).json({ error: 'logbookId, entryId, and context are required' })
|
||||
}
|
||||
|
||||
const logbook = await getLogbookOwner(String(logbookId))
|
||||
if (!logbook) return res.status(404).json({ error: 'Logbook not found' })
|
||||
if (logbook.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Forbidden: Skipper only' })
|
||||
}
|
||||
|
||||
const used = await getUsageCount(String(logbookId), String(entryId))
|
||||
if (used >= MAX_ATTEMPTS_PER_ENTRY) {
|
||||
return res.status(429).json({
|
||||
error: 'Rate limit exceeded for this travel day',
|
||||
code: 'RATE_LIMITED',
|
||||
remainingAttempts: 0,
|
||||
maxAttempts: MAX_ATTEMPTS_PER_ENTRY
|
||||
})
|
||||
}
|
||||
|
||||
const apiKey = resolveOpenRouterApiKey()
|
||||
if (!apiKey) {
|
||||
return res.status(503).json({
|
||||
error: 'No OpenRouter API key configured',
|
||||
code: 'NO_KEY'
|
||||
})
|
||||
}
|
||||
|
||||
const languageLabel = resolveLanguageLabel(language)
|
||||
const model = resolveOpenRouterModel()
|
||||
const contextJson = JSON.stringify(context)
|
||||
if (contextJson.length > 100_000) {
|
||||
return res.status(400).json({ error: 'Travel day context is too large' })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
|
||||
let openRouterRes: Response
|
||||
try {
|
||||
openRouterRes = await fetch(OPENROUTER_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': process.env.ORIGIN || 'https://kapteins-daagbok.eu',
|
||||
'X-Title': 'Kapteins Daagbok'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: buildSystemPrompt(languageLabel) },
|
||||
{
|
||||
role: 'user',
|
||||
content: `Summarize this travel day from the structured log data:\n\n${contextJson}`
|
||||
}
|
||||
],
|
||||
max_tokens: 800,
|
||||
temperature: 0.4
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return res.status(504).json({ error: 'OpenRouter request timed out' })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
const data = await openRouterRes.json().catch(() => ({}))
|
||||
if (!openRouterRes.ok) {
|
||||
const detail = extractOpenRouterError(data)
|
||||
console.error('OpenRouter error:', openRouterRes.status, data)
|
||||
return res.status(502).json({
|
||||
error: detail || 'OpenRouter request failed',
|
||||
code: 'OPENROUTER_ERROR'
|
||||
})
|
||||
}
|
||||
|
||||
const summary =
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
Array.isArray((data as { choices?: unknown[] }).choices) &&
|
||||
(data as { choices: Array<{ message?: { content?: string } }> }).choices[0]?.message?.content
|
||||
? String((data as { choices: Array<{ message?: { content?: string } }> }).choices[0].message?.content).trim()
|
||||
: ''
|
||||
|
||||
if (!summary) {
|
||||
return res.status(502).json({ error: 'OpenRouter returned an empty summary' })
|
||||
}
|
||||
|
||||
const updated = await prisma.aiSummaryUsage.upsert({
|
||||
where: {
|
||||
logbookId_entryId: { logbookId: String(logbookId), entryId: String(entryId) }
|
||||
},
|
||||
create: {
|
||||
logbookId: String(logbookId),
|
||||
entryId: String(entryId),
|
||||
count: 1
|
||||
},
|
||||
update: { count: { increment: 1 } }
|
||||
})
|
||||
|
||||
return res.json({
|
||||
summary,
|
||||
remainingAttempts: remainingAttempts(updated.count),
|
||||
maxAttempts: MAX_ATTEMPTS_PER_ENTRY
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
console.error('AI summary generation failed:', error)
|
||||
return res.status(500).json({ error: 'Failed to generate AI summary' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -83,6 +83,7 @@ router.get('/share-pull', async (req: any, res) => {
|
||||
const logbookVesselSelection = await findLogbookVesselSelectionSafe(logbookId)
|
||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||
const voiceMemos = await prisma.voiceMemoPayload.findMany({ where: { logbookId } })
|
||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||
|
||||
return res.json({
|
||||
@@ -94,6 +95,7 @@ router.get('/share-pull', async (req: any, res) => {
|
||||
logbookVesselSelection,
|
||||
entries,
|
||||
photos,
|
||||
voiceMemos,
|
||||
gpsTracks
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -143,6 +143,8 @@ router.post('/push', async (req: any, res) => {
|
||||
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else if (type === 'photo') {
|
||||
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else if (type === 'voiceMemo') {
|
||||
await prisma.voiceMemoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else if (type === 'gpsTrack') {
|
||||
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
||||
} else if (type === 'logbookCrew') {
|
||||
@@ -234,6 +236,22 @@ router.post('/push', async (req: any, res) => {
|
||||
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
||||
})
|
||||
}
|
||||
} else if (type === 'voiceMemo') {
|
||||
{
|
||||
const existing = await prisma.voiceMemoPayload.findUnique({
|
||||
where: { logbookId_payloadId: { logbookId, payloadId } }
|
||||
})
|
||||
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
||||
continue
|
||||
}
|
||||
const entryId = parsed.entryId || ''
|
||||
await prisma.voiceMemoPayload.upsert({
|
||||
where: { logbookId_payloadId: { logbookId, payloadId } },
|
||||
create: { logbookId, payloadId, entryId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
|
||||
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
||||
})
|
||||
}
|
||||
} else if (type === 'gpsTrack') {
|
||||
{
|
||||
const existing = await prisma.gpsTrackPayload.findUnique({
|
||||
@@ -365,6 +383,7 @@ router.get('/pull', async (req: any, res) => {
|
||||
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
|
||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||
const voiceMemos = await prisma.voiceMemoPayload.findMany({ where: { logbookId } })
|
||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||
const { findLogbookCrewSelectionSafe, findLogbookVesselSelectionSafe } =
|
||||
await import('../utils/crewPoolSchema.js')
|
||||
@@ -379,6 +398,7 @@ router.get('/pull', async (req: any, res) => {
|
||||
logbookVesselSelection,
|
||||
entries,
|
||||
photos,
|
||||
voiceMemos,
|
||||
gpsTracks
|
||||
})
|
||||
} catch (error: any) {
|
||||
|
||||
Reference in New Issue
Block a user