Compare commits

...

31 Commits

Author SHA1 Message Date
elpatron 2a8ec2fccf chore: release v0.1.0.76 2026-06-01 11:43:33 +02:00
elpatron 60a8533a44 feat: add Plausible events for live log photos and OWM usage
Track Live Log Photo Uploaded and centralize OWM Weather Fetched with
source props for live log and entry editor call sites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:34:21 +02:00
elpatron c86ac4273c chore: release v0.1.0.75 2026-06-01 10:59:31 +02:00
elpatron 73467f2263 fix: live journal camera save on Android
Use native camera picker on mobile, add preview-and-save step, and
harden canvas capture with toDataURL fallback when toBlob fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:45:54 +02:00
elpatron e068f083c1 style: add blank line before LiveLogViewProps interface
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:01:34 +02:00
elpatron f083294db5 chore: release v0.1.0.74 2026-06-01 09:58:44 +02:00
elpatron 8fc15081e2 Show QR codes for invite and public share links.
Generate scannable QR codes in settings next to collaboration links so crew can open invites on mobile without copying long URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:58:16 +02:00
elpatron efa0fcf934 Add live journal camera photos and harden OWM button.
Capture photos via getUserMedia in live log, share encrypted save logic with the editor, and disable weather fetch while other quick actions run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:47:56 +02:00
elpatron c1ecdcad9c Fix live journal hang on empty new logbooks.
Fast-path today's entry creation, add init timeout, defer auto-position GPS, and migrate logbook keys when the server returns a different id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:39:51 +02:00
elpatron d6c7952af8 Fix live journal freeze during OpenWeatherMap fetch.
Batch weather events in one persist cycle, avoid global busy state while loading, and add a 20s API timeout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:19:03 +02:00
elpatron 3d02f841a0 Add live journal OWM weather and manual GPS fix dialog.
Fetch OpenWeatherMap in live log when a GPS fix is under six hours old, open a fix modal with manual coordinates when geolocation fails, and only show the undo bar after a successful save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:03:37 +02:00
elpatron 0caaf681d8 Fix live journal freeze and passkey login on localhost.
Harden live log init with safe per-entry decrypt, stable loading state, and no parallel list scan in live mode. Improve multi-sail picker UX, stop WebAuthn retry after user cancel, redirect 127.0.0.1 to localhost, and tolerate missing appearance prefs table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 08:49:45 +02:00
elpatron 43dc994c4f Update beta flyer with NMEA, live log, and route stats features.
Add three feature bullets in all locales, reduce feature list font to 8.5pt for single-page layout, and regenerate PDF/PNG exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:52:11 +02:00
elpatron d94502097e chore: release v0.1.0.73 2026-05-31 21:46:44 +02:00
elpatron a36ca2facb Add Plausible analytics for live journal and NMEA upload.
Track Live Log Opened/Event Logged with action types, NMEA Uploaded on parse success, and align NMEA Imported properties with docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:43:30 +02:00
elpatron b7a1085d52 chore: release v0.1.0.72 2026-05-31 21:39:22 +02:00
elpatron 3925c6f822 Add cloc code statistics report for the project.
Documents line counts by language, area, and largest source files for onboarding and size tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:39:00 +02:00
elpatron 0b2c1c22c6 Guard optional event fields before calling trim in live-log paths.
Legacy decrypted events may omit mgk or wind fields; optional chaining prevents runtime crashes in course prefill and stats aggregation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:29:42 +02:00
elpatron aa03573e1f Fix live-log dial modals overlapping journal text.
Portal overlays to document.body and use opaque modal panels so fixed positioning works outside form-card and journal entries stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:28:02 +02:00
elpatron a0b8664e23 Use course dials for live-log wind direction and course entry.
Reuses CourseDialInput from the classic journal editor in the live modals, prefilled from the most recent wind or course values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:20:19 +02:00
elpatron 74282f50d0 Add SOG and STW live-log actions and capitalize motor labels.
SOG prefills from GPS speed when available; STW is entered manually. Motor journal entries now read “Motor Start” / “Motor Stop”.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:17:51 +02:00
elpatron 5b47415d55 Extend live journal with weather, tanks, undo, and event series stats.
Adds weather and course quick actions, diesel/water refills, five-second undo, foreground auto-position every three hours, and chronological pressure/wind/motor series in the stats tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:11:52 +02:00
elpatron 039e4e2736 Add live journal mode for one-tap event logging during travel.
Introduces a parallel Live view alongside the existing travel-day list so skippers can log motor, sail, and position events instantly without navigating the full editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:09:02 +02:00
elpatron 35bfbc1043 Update .gitignore to exclude userfeedback directory. 2026-05-31 21:00:43 +02:00
elpatron 6c866dbad5 Add NMEA journal import with wizard and CRC-based duplicate detection.
Enables importing .nmea logs into travel-day events with interval/change modes, optional GPS track, local encrypted archive, and a test fixture for the Kieler Förde route.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 20:41:42 +02:00
elpatron bb667afec8 Document NMEA import research for future backlog evaluation.
Captures PWA constraints, file-import scope, and GPX comparison for a later feature decision.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 17:20:39 +02:00
elpatron beee33f842 Update plausible-events.md to specify that recommended goal chains are for business use only. 2026-05-31 16:41:16 +02:00
elpatron 77a7072b77 chore: release v0.1.0.71 2026-05-31 16:38:32 +02:00
elpatron bd1edd89f3 Track language selection with Plausible Language Changed event.
Centralize UI language switches in cycleAppLanguage and document the event in plausible-events.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:38:10 +02:00
elpatron ffe6b19818 chore: release v0.1.0.70 2026-05-31 16:33:35 +02:00
elpatron eb1f87f57e Add translation error category to feedback form.
Lets users report i18n issues via the feedback dropdown in all supported locales.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:33:28 +02:00
87 changed files with 7539 additions and 283 deletions
+4 -4
View File
@@ -5,13 +5,13 @@ OpenWeatherMapAPIKey=<owm_api_key>
DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin)
# For local dev: localhost and http://localhost
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
RP_ID=localhost
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173
# API session signing (min. 32 chars; required in production)
# Generate: openssl rand -base64 48
+2
View File
@@ -11,3 +11,5 @@ server/dist/
.env.local
.env.*.local
*.log
userfeedback/
+1 -1
View File
@@ -1 +1 @@
0.1.0.70
0.1.0.77
+12 -30
View File
@@ -17,6 +17,7 @@
"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"
@@ -25,6 +26,7 @@
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
@@ -34,7 +36,6 @@
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^6.3.5",
@@ -2970,6 +2971,16 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -3461,7 +3472,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3471,7 +3481,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -3777,7 +3786,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3855,7 +3863,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
@@ -3867,7 +3874,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3880,7 +3886,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
@@ -4051,7 +4056,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4140,7 +4144,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
@@ -4195,7 +4198,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
@@ -4948,7 +4950,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -5498,7 +5499,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6208,7 +6208,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6231,7 +6230,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6376,7 +6374,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
@@ -6458,7 +6455,6 @@
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
@@ -6653,7 +6649,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6673,7 +6668,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/resolve": {
@@ -6845,7 +6839,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/set-function-length": {
@@ -7113,7 +7106,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -7230,7 +7222,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -8067,7 +8058,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/which-typed-array": {
@@ -8343,7 +8333,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -8380,7 +8369,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yallist": {
@@ -8394,7 +8382,6 @@
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
@@ -8417,7 +8404,6 @@
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
@@ -8431,7 +8417,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
@@ -8445,7 +8430,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
@@ -8458,7 +8442,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
@@ -8474,7 +8457,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
+3 -2
View File
@@ -29,12 +29,14 @@
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8"
"react-i18next": "^17.0.8",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
@@ -44,7 +46,6 @@
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^6.3.5",
+685
View File
@@ -611,6 +611,7 @@ html.scheme-dark .themed-select-option.is-selected {
width: 100%;
max-width: 560px;
max-height: min(90vh, 820px);
overflow-y: auto;
}
.feedback-modal {
@@ -662,6 +663,182 @@ html.scheme-dark .themed-select-option.is-selected {
margin-top: 0;
}
.registration-disclaimer.feedback-modal {
align-items: stretch;
width: 100%;
}
.registration-disclaimer.feedback-modal .auth-header,
.registration-disclaimer.feedback-modal > p,
.registration-disclaimer.feedback-modal .nmea-import-summary,
.registration-disclaimer.feedback-modal .nmea-import-warning,
.registration-disclaimer.feedback-modal .nmea-import-mode,
.registration-disclaimer.feedback-modal .feedback-form__field,
.registration-disclaimer.feedback-modal .nmea-import-checkbox,
.registration-disclaimer.feedback-modal .nmea-preview-actions,
.registration-disclaimer.feedback-modal .nmea-preview-list,
.registration-disclaimer.feedback-modal .auth-actions {
width: 100%;
box-sizing: border-box;
}
.nmea-import-warning {
width: 100%;
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
text-align: left;
color: var(--app-warning-text, #fcd34d);
background: var(--app-warning-bg, rgba(251, 191, 36, 0.1));
border: 1px solid var(--app-warning-border, rgba(251, 191, 36, 0.35));
box-sizing: border-box;
}
.nmea-import-summary {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
background: var(--app-surface-inset);
border: 1px solid var(--app-border-muted);
text-align: left;
font-size: 13px;
line-height: 1.5;
}
.nmea-import-summary p {
margin: 0;
}
.nmea-import-summary p + p {
margin-top: 6px;
}
.nmea-import-mode {
border: 1px solid var(--app-border-muted);
border-radius: 8px;
padding: 12px 16px;
margin: 0 0 16px;
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
}
.nmea-import-mode legend {
padding: 0 4px;
font-size: 13px;
font-weight: 600;
color: var(--app-text-heading, #f1f5f9);
}
.nmea-import-mode label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 14px;
line-height: 1.4;
}
.nmea-import-checkbox {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
cursor: pointer;
text-align: left;
font-size: 14px;
}
.nmea-preview-actions {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.nmea-preview-actions .btn {
flex: 1;
margin: 0;
}
.nmea-preview-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(45vh, 360px);
overflow-y: auto;
margin-bottom: 16px;
padding: 2px;
}
.nmea-preview-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--app-border-muted);
border-radius: 8px;
background: var(--app-surface-inset);
cursor: pointer;
text-align: left;
transition: border-color 0.15s ease;
}
.nmea-preview-row:hover {
border-color: var(--app-accent-border, rgba(212, 175, 55, 0.35));
}
.nmea-preview-row__check {
flex-shrink: 0;
margin: 2px 0 0;
width: 18px;
height: 18px;
accent-color: var(--app-accent-light, #d4af37);
}
.nmea-preview-row__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.nmea-preview-row__meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.nmea-preview-time {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 14px;
color: var(--app-accent-light, #d4af37);
min-width: 3.25rem;
}
.nmea-preview-source {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.15);
color: var(--app-text-muted, #94a3b8);
}
.nmea-preview-remarks {
font-size: 13px;
line-height: 1.45;
color: var(--app-text, #e2e8f0);
word-break: break-word;
}
.feedback-form {
display: flex;
flex-direction: column;
@@ -1539,6 +1716,38 @@ html.scheme-dark .themed-select-option.is-selected {
min-width: 0;
}
.link-with-qr {
display: flex;
flex-direction: column;
gap: 16px;
}
.link-qr-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
border-radius: var(--app-radius-card, 12px);
background: var(--app-surface-inset, rgba(0, 0, 0, 0.2));
border: 1px solid var(--app-border-subtle);
}
.link-qr-label {
margin: 0;
font-size: 13px;
color: var(--app-text-muted);
text-align: center;
}
.link-qr-image {
display: block;
border-radius: 8px;
background: #fff;
padding: 8px;
box-sizing: content-box;
}
.form-actions--start {
justify-content: flex-start;
}
@@ -2990,6 +3199,482 @@ html.theme-cupertino .events-scroll-container {
color: #38bdf8;
}
/* Live log journal mode */
.logs-view-toggle {
display: inline-flex;
gap: 4px;
margin-right: 4px;
}
.logs-view-toggle-btn.is-active {
background: rgba(59, 130, 246, 0.2);
border-color: rgba(59, 130, 246, 0.45);
color: var(--app-accent-light, #93c5fd);
}
.live-log-card {
min-height: 420px;
}
.live-log-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: var(--app-text-muted);
}
.live-log-layout {
display: grid;
grid-template-columns: minmax(148px, 200px) 1fr;
gap: 20px;
align-items: start;
}
.live-log-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.live-log-action-btn {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 14px;
border-radius: var(--app-radius-btn, 10px);
border: 1px solid var(--app-border-muted);
background: var(--app-surface);
color: var(--app-text);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.live-log-action-btn:hover:not(:disabled) {
background: rgba(59, 130, 246, 0.08);
border-color: rgba(59, 130, 246, 0.3);
}
.live-log-action-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.live-log-action-btn.is-active {
background: rgba(251, 191, 36, 0.15);
border-color: rgba(251, 191, 36, 0.45);
color: #fbbf24;
}
.live-log-stream-panel {
min-height: 280px;
border: 1px solid var(--app-border-muted);
border-radius: var(--app-radius-card, 12px);
background: rgba(0, 0, 0, 0.12);
padding: 16px 18px;
}
.live-log-stream-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
color: var(--app-accent-light);
}
.live-log-empty {
margin: 0;
color: var(--app-text-muted);
font-size: 14px;
}
.live-log-stream {
list-style: none;
margin: 0;
padding: 0;
max-height: min(60vh, 520px);
overflow-y: auto;
}
.live-log-entry {
display: flex;
gap: 14px;
padding: 10px 0;
border-bottom: 1px solid var(--app-border-muted);
font-size: 14px;
line-height: 1.4;
}
.live-log-entry:last-child {
border-bottom: none;
}
.live-log-time {
flex-shrink: 0;
min-width: 3.25rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--app-text-muted);
}
.live-log-summary {
flex: 1;
}
.live-log-modal-backdrop {
position: fixed;
inset: 0;
z-index: 10050;
background: rgba(2, 6, 23, 0.78);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.live-log-modal {
width: min(420px, 100%);
padding: 20px;
border-radius: var(--app-radius-card, 12px);
background: var(--app-surface-alt);
border: 1px solid var(--app-border-muted);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.55);
}
.live-log-modal--dial {
width: min(320px, 100%);
}
.live-log-dial-field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
border-radius: var(--app-radius-input, 8px);
background: var(--app-surface-inset);
border: 1px solid var(--app-border-subtle);
}
.live-log-dial-field label {
font-size: 13px;
font-weight: 600;
color: var(--app-text-muted);
}
.live-log-modal h3 {
margin: 0 0 16px;
font-size: 17px;
}
.live-log-modal-hint {
margin: -8px 0 12px;
font-size: 13px;
color: var(--app-text-muted);
line-height: 1.4;
}
.live-log-sail-pills {
margin-bottom: 12px;
}
.live-log-sails-selection {
margin: 0 0 12px;
font-size: 13px;
color: var(--app-accent-light, #93c5fd);
line-height: 1.4;
}
.live-log-modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
@media (max-width: 720px) {
.live-log-layout {
grid-template-columns: 1fr;
}
.live-log-actions {
flex-direction: row;
flex-wrap: wrap;
}
.live-log-action-btn {
width: auto;
flex: 1 1 calc(50% - 4px);
min-width: 140px;
justify-content: center;
font-size: 13px;
padding: 10px 12px;
}
.live-log-weather-group {
flex: 1 1 100%;
}
}
.live-log-weather-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.live-log-weather-toggle {
justify-content: space-between;
}
.live-log-weather-toggle.is-expanded {
border-color: rgba(59, 130, 246, 0.35);
}
.live-log-weather-submenu {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 8px;
}
.live-log-subaction-btn {
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--app-border-muted);
background: rgba(0, 0, 0, 0.15);
color: var(--app-text-muted);
font-size: 13px;
cursor: pointer;
}
.live-log-subaction-btn-owm {
border-color: rgba(59, 130, 246, 0.35);
color: var(--app-text);
font-weight: 500;
}
.live-log-subaction-btn:hover:not(:disabled) {
color: var(--app-text);
border-color: rgba(59, 130, 246, 0.3);
}
.live-log-undo-bar {
position: fixed;
inset-inline: 0;
bottom: 24px;
z-index: 10060;
display: flex;
justify-content: center;
padding-inline: 16px;
pointer-events: none;
}
.live-log-undo-bar-inner {
pointer-events: auto;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 12px;
background: var(--app-surface-alt);
border: 1px solid var(--app-border-muted);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
font-size: 14px;
max-width: min(100%, 420px);
}
.live-log-fix-coords {
margin: 0;
padding: 0;
border: none;
min-width: 0;
}
.live-log-fix-label {
display: block;
margin: 0 0 10px;
padding: 0;
font-size: 13px;
font-weight: 600;
color: var(--app-text-muted);
}
.live-log-fix-coords-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
min-width: 0;
}
.live-log-fix-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.live-log-fix-field-label {
font-size: 12px;
color: var(--app-text-muted);
}
.live-log-fix-field .input-text {
width: 100%;
box-sizing: border-box;
}
.live-log-fix-gps-row {
margin-top: 10px;
}
.live-log-fix-gps-btn {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 14px;
}
.live-camera-modal {
width: min(480px, 100%);
}
.live-camera-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.live-camera-header h3 {
margin: 0;
}
.live-camera-close {
width: auto;
padding: 8px 10px;
}
.live-camera-preview-wrap {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--app-radius-input, 8px);
overflow: hidden;
background: #000;
margin-bottom: 12px;
}
.live-camera-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.live-camera-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 12px;
text-align: center;
font-size: 14px;
color: var(--app-text-muted);
background: rgba(0, 0, 0, 0.45);
}
.live-camera-caption {
margin-bottom: 12px;
}
.live-camera-actions {
margin-top: 0;
}
.live-camera-shutter {
display: inline-flex;
align-items: center;
gap: 8px;
}
.live-camera-file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.live-camera-preview-still {
display: block;
object-fit: contain;
background: #000;
}
.live-camera-native-prompt {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
margin-bottom: 12px;
}
.live-camera-open-native {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
}
.live-camera-actions {
flex-wrap: wrap;
}
.stats-event-series-block + .stats-event-series-block {
margin-top: 16px;
}
.stats-event-series-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
max-height: 180px;
overflow-y: auto;
}
.stats-event-series-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid var(--app-border-muted);
font-size: 13px;
}
.stats-event-series-when {
color: var(--app-text-muted);
white-space: nowrap;
}
.stats-event-series-value {
text-align: right;
}
.grid-span-2 {
grid-column: span 2;
}
+2 -2
View File
@@ -45,7 +45,7 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from './utils/i18nLanguages.js'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import {
resolveTourLogbookContext,
seedDemoLogbookIfNeeded
@@ -497,7 +497,7 @@ function App() {
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const handleExitDemo = () => {
+78 -20
View File
@@ -1,21 +1,28 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
getActiveMasterKey,
getKnownUsernames,
forgetUsername
forgetUsername,
hasUnlockedLocalSession,
logoutUser
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx'
import {
isPasskeyCompatibleLocation,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from '../utils/passkeyHost.ts'
interface AuthOnboardingProps {
onAuthenticated: () => void
@@ -54,6 +61,16 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
const formatAuthError = (message: string) =>
localizeWebAuthnError(message, {
invalidHost: t('auth.error_invalid_host'),
cancelled: t('auth.error_passkey_cancelled'),
invalidRpId: t('auth.error_invalid_rp_id')
})
const finishAuth = () => {
if (isNewRegistration) {
setShowDisclaimer(true)
@@ -81,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setError(err.message || 'Registration failed')
setError(formatAuthError(err.message || 'Registration failed'))
} finally {
setLoading(false)
}
@@ -121,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
}
} catch (err: any) {
setError(err.message || 'Login failed')
setError(formatAuthError(err.message || 'Login failed'))
} finally {
setLoading(false)
}
@@ -185,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const handlePinLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!pinLoginInput.trim()) return
if (!pinLoginInput.trim() || loading) return
const resolvedUser =
username.trim() ||
encryptedPayloads?.username ||
localStorage.getItem('active_username') ||
''
if (!resolvedUser) {
setError(t('auth.error_session_incomplete'))
return
}
setLoading(true)
setError(null)
try {
const resolvedUser = username.trim() || encryptedPayloads?.username
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
if (key) {
onAuthenticated()
} else {
if (!key) {
setError(t('auth.error_incorrect_pin'))
return
}
} catch (err: any) {
if (!hasUnlockedLocalSession()) {
setError(t('auth.error_session_incomplete'))
return
}
setShowPinLogin(false)
onAuthenticated()
} catch {
setError(t('auth.error_incorrect_pin'))
} finally {
setLoading(false)
@@ -210,7 +241,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const copyToClipboard = () => {
@@ -361,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
>
{t('auth.use_recovery_instead')}
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
void (async () => {
setShowPinLogin(false)
setPinLoginInput('')
setEncryptedPayloads(null)
setError(null)
await logoutUser()
})()
}}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.back')}
</button>
</div>
</form>
</div>
@@ -445,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div>
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!passkeyHostOk && passkeyCompatibleUrl && (
<div className="auth-error" role="alert">
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
{t('auth.use_localhost_link')}
</a>
</div>
)}
{/* Prominent Login button */}
<button
type="button"
className="btn primary"
onClick={() => handleLogin()}
disabled={loading}
disabled={loading || !passkeyHostOk}
style={{ width: '100%', padding: '16px' }}
>
{loading
@@ -583,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<button
type="submit"
className="btn secondary"
disabled={loading || !username.trim()}
disabled={loading || !username.trim() || !passkeyHostOk}
style={{ width: '100%' }}
>
{t('auth.register')}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogEntriesList from './LogEntriesList.tsx'
@@ -49,7 +49,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
+1
View File
@@ -172,6 +172,7 @@ export default function FeedbackModal({
<option value="general">{t('feedback.category_general')}</option>
<option value="bug">{t('feedback.category_bug')}</option>
<option value="feature">{t('feedback.category_feature')}</option>
<option value="translation">{t('feedback.category_translation')}</option>
</select>
</label>
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import {
getActiveMasterKey,
@@ -309,7 +309,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
if (recoveryPhrase) {
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import QRCode from 'qrcode'
interface LinkQrCodeProps {
value: string
size?: number
}
export default function LinkQrCode({ value, size = 200 }: LinkQrCodeProps) {
const { t } = useTranslation()
const [dataUrl, setDataUrl] = useState<string | null>(null)
useEffect(() => {
if (!value.trim()) {
setDataUrl(null)
return
}
let cancelled = false
void QRCode.toDataURL(value, {
width: size,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#0f172a', light: '#ffffff' }
})
.then((url) => {
if (!cancelled) setDataUrl(url)
})
.catch((err) => {
console.error('QR code generation failed:', err)
if (!cancelled) setDataUrl(null)
})
return () => {
cancelled = true
}
}, [value, size])
if (!value.trim() || !dataUrl) return null
return (
<div className="link-qr-block">
<p className="link-qr-label">{t('settings.link_qr_hint')}</p>
<img
src={dataUrl}
width={size}
height={size}
className="link-qr-image"
alt={t('settings.link_qr_alt')}
/>
</div>
)
}
+323
View File
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react'
import {
captureVideoFrame,
preferNativeCameraPicker
} from '../utils/captureVideoFrame.js'
interface LiveCameraCaptureProps {
open: boolean
busy?: boolean
caption?: string
onCaptionChange?: (value: string) => void
onClose: () => void
onCapture: (blob: Blob) => void
}
type Phase = 'live' | 'preview' | 'native'
export default function LiveCameraCapture({
open,
busy = false,
caption = '',
onCaptionChange,
onClose,
onCapture
}: LiveCameraCaptureProps) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const previewUrlRef = useRef<string | null>(null)
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 [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0)
const clearPreview = useCallback(() => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = null
}
setPreviewUrl(null)
setPreviewBlob(null)
}, [])
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop()
}
streamRef.current = null
if (videoRef.current) {
videoRef.current.srcObject = null
}
setReady(false)
}, [])
const enterPreview = useCallback((blob: Blob) => {
stopStream()
clearPreview()
const url = URL.createObjectURL(blob)
previewUrlRef.current = url
setPreviewBlob(blob)
setPreviewUrl(url)
setPhase('preview')
}, [stopStream, clearPreview])
const resetToLive = useCallback(() => {
clearPreview()
setCameraError(null)
setCapturing(false)
if (preferNativeCameraPicker()) {
setPhase('native')
} else {
setPhase('live')
setStreamGeneration((n) => n + 1)
}
}, [clearPreview])
useEffect(() => {
if (!open) {
stopStream()
clearPreview()
setCameraError(null)
setCapturing(false)
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
return
}
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
clearPreview()
}, [open, stopStream, clearPreview])
useEffect(() => {
if (!open || phase !== 'live') {
stopStream()
return
}
let cancelled = false
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: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: false
})
if (cancelled) {
for (const track of stream.getTracks()) track.stop()
return
}
streamRef.current = stream
const video = videoRef.current
if (!video) return
const markReady = () => {
if (cancelled) return
if (video.videoWidth > 0 && video.videoHeight > 0) {
setReady(true)
}
}
video.onloadedmetadata = markReady
video.srcObject = stream
await video.play()
markReady()
} catch (err) {
console.error('Camera access failed:', err)
if (!cancelled) {
setCameraError(t('logs.live_photo_camera_denied'))
}
}
}
void start()
return () => {
cancelled = true
stopStream()
}
}, [open, phase, streamGeneration, stopStream, t])
const handleCapture = async () => {
const video = videoRef.current
if (!video || !ready || busy || capturing) return
setCapturing(true)
setCameraError(null)
try {
const blob = await captureVideoFrame(video)
enterPreview(blob)
} catch (err) {
console.error('Live camera capture failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
} finally {
setCapturing(false)
}
}
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || busy) return
setCameraError(null)
try {
enterPreview(file)
} catch (err) {
console.error('Live camera file pick failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
}
}
const handleSave = () => {
if (!previewBlob || busy) return
onCapture(previewBlob)
}
const handleRetake = () => {
if (busy) return
resetToLive()
}
const openNativePicker = () => {
if (busy) return
fileInputRef.current?.click()
}
if (!open) return null
const showPreview = phase === 'preview' && previewUrl
return (
<div
className="live-log-modal-backdrop live-camera-backdrop"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="live-log-modal live-camera-modal" onClick={(e) => e.stopPropagation()}>
<div className="live-camera-header">
<h3>{t('logs.live_photo_btn')}</h3>
<button
type="button"
className="btn secondary live-camera-close"
onClick={onClose}
disabled={busy}
aria-label={t('logs.confirm_no')}
>
<X size={18} />
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
className="live-camera-file-input"
onChange={(e) => void handleNativeFile(e)}
/>
{cameraError && (
<p className="live-log-modal-hint auth-error">{cameraError}</p>
)}
{showPreview ? (
<div className="live-camera-preview-wrap">
<img
src={previewUrl}
alt=""
className="live-camera-preview live-camera-preview-still"
/>
</div>
) : phase === 'native' ? (
<div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
<button
type="button"
className="btn primary live-camera-open-native"
onClick={openNativePicker}
disabled={busy}
>
<Camera size={18} />
{t('logs.live_photo_open_camera_btn')}
</button>
</div>
) : cameraError && !ready ? null : (
<div className="live-camera-preview-wrap">
<video
ref={videoRef}
className="live-camera-preview"
playsInline
muted
autoPlay
/>
{!ready && (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)}
</div>
)}
{onCaptionChange && (
<div className="input-group live-camera-caption">
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
className="input-text"
placeholder={t('logs.photo_caption_placeholder')}
value={caption}
onChange={(e) => onCaptionChange(e.target.value)}
disabled={busy}
/>
</div>
)}
<div className="live-log-modal-actions live-camera-actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.confirm_no')}
</button>
{showPreview ? (
<>
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
{t('logs.live_photo_retake_btn')}
</button>
<button
type="button"
className="btn primary live-camera-shutter"
onClick={handleSave}
disabled={busy || !previewBlob}
>
<Camera size={18} />
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
</button>
</>
) : phase === 'native' ? null : (
<button
type="button"
className="btn primary live-camera-shutter"
onClick={() => void handleCapture()}
disabled={busy || capturing || !ready || !!cameraError}
>
<Camera size={18} />
{capturing ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
</button>
)}
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+53 -4
View File
@@ -9,10 +9,11 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
import {
carryOverFromPreviousDay,
compareTravelDaysChronological,
@@ -36,6 +37,8 @@ interface LogEntriesListProps {
highlightEntryId?: string | null
}
type LogsViewMode = 'list' | 'live'
interface DecryptedEntryItem {
id: string
date: string
@@ -75,6 +78,8 @@ export default function LogEntriesList({
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
const loadEntries = useCallback(async () => {
@@ -144,17 +149,19 @@ export default function LogEntriesList({
}, [logbookId, readOnly, preloadedEntries])
useEffect(() => {
if (viewMode === 'live') return
loadEntries()
}, [loadEntries])
}, [loadEntries, viewMode])
useEffect(() => {
if (viewMode === 'live') return
const prevSelectedEntryId = prevSelectedEntryIdRef.current
prevSelectedEntryIdRef.current = selectedEntryId
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
loadEntries()
}
}, [selectedEntryId, loadEntries])
}, [selectedEntryId, loadEntries, viewMode])
const handleDownloadCsv = async () => {
setExporting(true)
@@ -350,7 +357,13 @@ export default function LogEntriesList({
<LogEntryEditor
entryId={selectedEntryId}
logbookId={logbookId}
onBack={() => setSelectedEntryId(null)}
onBack={() => {
setSelectedEntryId(null)
if (returnToLiveAfterEditor) {
setViewMode('live')
setReturnToLiveAfterEditor(false)
}
}}
readOnly={readOnly}
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
preloadedPhotos={preloadedPhotos}
@@ -359,6 +372,19 @@ export default function LogEntriesList({
)
}
if (viewMode === 'live' && !readOnly) {
return (
<LiveLogView
logbookId={logbookId}
onOpenEditor={(entryId) => {
setReturnToLiveAfterEditor(true)
setSelectedEntryId(entryId)
}}
onSwitchToList={() => setViewMode('list')}
/>
)
}
if (loading) {
return (
<div className="tab-placeholder">
@@ -381,6 +407,29 @@ export default function LogEntriesList({
<h2>{t('logs.title')}</h2>
</div>
<div className="section-toolbar">
{!readOnly && (
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
<button
type="button"
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
onClick={() => setViewMode('list')}
title={t('logs.view_list')}
>
<List size={16} />
<span className="hide-mobile">{t('logs.view_list')}</span>
</button>
<button
type="button"
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
onClick={() => setViewMode('live')}
title={t('logs.live_mode')}
>
<Radio size={16} />
<span className="hide-mobile">{t('logs.live_mode')}</span>
</button>
</div>
)}
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
<Download size={16} />
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
+100 -36
View File
@@ -25,7 +25,7 @@ import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
import EventTimeInput24h from './EventTimeInput24h.tsx'
import CourseDialInput from './CourseDialInput.tsx'
import { degreesToCardinal } from '../utils/courseAngle.js'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -37,8 +37,16 @@ import {
deleteTrack,
downloadTrackFile,
parseTrackFile,
type SavedTrack
type SavedTrack,
type TrackWaypoint
} from '../services/trackUpload.js'
import NmeaImportWizard from './NmeaImportWizard.tsx'
import {
deleteNmeaArchive,
downloadNmeaArchive,
getNmeaArchive,
type NmeaArchiveRecord
} from '../services/nmeaArchive.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
@@ -210,6 +218,8 @@ export default function LogEntryEditor({
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
const [dragOver, setDragOver] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [nmeaWizardOpen, setNmeaWizardOpen] = useState(false)
const [nmeaArchive, setNmeaArchive] = useState<NmeaArchiveRecord | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
@@ -762,6 +772,45 @@ export default function LogEntryEditor({
loadTrack()
}, [entryId, preloadedTrack])
const loadNmeaArchive = async () => {
if (readOnly) return
try {
const archive = await getNmeaArchive(entryId)
setNmeaArchive(archive)
} catch {
setNmeaArchive(null)
}
}
useEffect(() => {
loadNmeaArchive()
}, [entryId, readOnly])
const handleNmeaImport = async (importedEvents: LogEventPayload[], waypoints?: TrackWaypoint[]) => {
setEvents((prev) => sortLogEventsByTime([...prev, ...importedEvents]))
if (waypoints && waypoints.length > 0) {
try {
const gpxLike = waypoints
.map((wp) => ` <trkpt lat="${wp.lat}" lon="${wp.lng}"><time>${new Date(wp.timestamp).toISOString()}</time></trkpt>`)
.join('\n')
const content = `<?xml version="1.0"?><gpx><trk><trkseg>\n${gpxLike}\n</trkseg></trk></gpx>`
await saveUploadedTrack(logbookId, entryId, content, waypoints, 'imported-from-nmea.nmea', 'nmea')
applyTrackStats(waypoints)
await loadTrack()
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
} catch (err: unknown) {
console.warn('Failed to save NMEA track:', err)
}
}
await loadNmeaArchive()
}
const handleDeleteNmeaArchive = async () => {
if (!window.confirm(t('logs.nmea_archive_delete_confirm'))) return
await deleteNmeaArchive(entryId)
setNmeaArchive(null)
}
useEffect(() => {
if (!savedTrack || savedTrack.waypoints.length < 2) return
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
@@ -851,7 +900,10 @@ export default function LogEntryEditor({
}
try {
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
const data = await fetchOpenWeatherCurrent(
{ q: locationQuery },
{ analyticsSource: 'entry_editor_gps_lookup' }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
if (coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
@@ -906,7 +958,8 @@ export default function LogEntryEditor({
const data = await fetchOpenWeatherCurrent(
hasGps
? { lat: evGpsLat, lon: evGpsLng }
: { q: fallbackLocation }
: { q: fallbackLocation },
{ analyticsSource: 'entry_editor' }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
@@ -916,38 +969,11 @@ export default function LogEntryEditor({
setEvGpsLng(Number(coord.lon).toFixed(6))
}
const wind = data.wind as { speed?: number; deg?: number } | undefined
const main = data.main as { pressure?: number } | undefined
// Convert wind speed m/s to Beaufort scale
const mps = wind?.speed || 0
let bft = 0
if (mps < 0.3) bft = 0
else if (mps < 1.6) bft = 1
else if (mps < 3.4) bft = 2
else if (mps < 5.5) bft = 3
else if (mps < 8.0) bft = 4
else if (mps < 10.8) bft = 5
else if (mps < 13.9) bft = 6
else if (mps < 17.2) bft = 7
else if (mps < 20.8) bft = 8
else if (mps < 24.5) bft = 9
else if (mps < 28.5) bft = 10
else if (mps < 32.7) bft = 11
else bft = 12
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
setEvWindPressure(String(main?.pressure || ''))
// Calculate wind compass direction sector
if (wind?.deg !== undefined) {
setEvWindDirection(degreesToCardinal(wind.deg))
}
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
const first = data.weather[0] as { icon?: string }
if (first.icon) setEvWeatherIcon(first.icon)
}
const parsed = parseOwmCurrentWeather(data)
setEvWindStrength(parsed.windStrength)
setEvWindPressure(parsed.windPressure)
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
showAlert(t('settings.weather_success'))
} catch (err) {
@@ -1925,6 +1951,31 @@ export default function LogEntryEditor({
</>
)}
{!readOnly && (
<div className="nmea-import-section" style={{ marginTop: '12px' }}>
<button
type="button"
className="btn secondary"
onClick={() => setNmeaWizardOpen(true)}
style={{ width: 'auto', padding: '8px 14px', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<FileText size={16} />
{t('logs.nmea_import_btn')}
</button>
{nmeaArchive && (
<div className="nmea-archive-info" style={{ marginTop: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<span>{t('logs.nmea_archive_stored', { name: nmeaArchive.filename })}</span>
<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}>
<Trash2 size={14} />
</button>
</div>
)}
</div>
)}
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
<div className="form-grid track-stats-grid">
<div className="input-group">
@@ -2030,6 +2081,19 @@ export default function LogEntryEditor({
</div>
)}
</form>
<NmeaImportWizard
open={nmeaWizardOpen}
onClose={() => {
setNmeaWizardOpen(false)
void loadNmeaArchive()
}}
logbookId={logbookId}
entryId={entryId}
entryDate={date}
nmeaArchive={nmeaArchive}
onImport={handleNmeaImport}
/>
</div>
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
@@ -194,7 +194,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
+333
View File
@@ -0,0 +1,333 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { FileText, X } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js'
import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js'
import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js'
import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js'
import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import type { TrackWaypoint } from '../services/trackUpload.js'
interface NmeaImportWizardProps {
open: boolean
onClose: () => void
logbookId: string
entryId: string
entryDate: string
nmeaArchive: NmeaArchiveRecord | null
onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void
}
type WizardStep = 'config' | 'preview' | 'archive'
export default function NmeaImportWizard({
open,
onClose,
logbookId,
entryId,
entryDate,
nmeaArchive,
onImport
}: NmeaImportWizardProps) {
const { t } = useTranslation()
const [step, setStep] = useState<WizardStep>('config')
const [parseResult, setParseResult] = useState<NmeaParseResult | null>(null)
const [mode, setMode] = useState<NmeaImportMode>('both')
const [intervalMinutes, setIntervalMinutes] = useState(60)
const [importTrack, setImportTrack] = useState(true)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null)
const [duplicateFile, setDuplicateFile] = useState(false)
const filteredPoints = useMemo(() => {
if (!parseResult) return []
return filterPointsForDate(parseResult.points, entryDate)
}, [parseResult, entryDate])
const candidates = useMemo(() => {
if (!parseResult || filteredPoints.length === 0) return []
return generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
}, [parseResult, filteredPoints, mode, intervalMinutes, t])
const reset = () => {
setStep('config')
setParseResult(null)
setMode('both')
setIntervalMinutes(60)
setImportTrack(true)
setSelectedIds(new Set())
setError(null)
setDuplicateFile(false)
setPendingRaw(null)
}
const handleClose = () => {
reset()
onClose()
}
const handleFile = (file: File) => {
setError(null)
setDuplicateFile(false)
const reader = new FileReader()
reader.onload = () => {
try {
const text = String(reader.result ?? '')
const crc32 = nmeaFileCrc32(text)
const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false
setDuplicateFile(alreadyImported)
const result = parseNmeaFile(text, file.name)
if (result.points.length === 0) {
setError(t('logs.nmea_error_no_samples'))
return
}
setParseResult(result)
setPendingRaw({ filename: file.name, text })
const generated = generateNmeaJournalCandidates({
points: filterPointsForDate(result.points, entryDate),
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, {
duplicate: alreadyImported,
lines: result.stats.parsedLines,
candidates: generated.length,
has_position: !result.warnings.includes('no_position')
})
} catch (err) {
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
}
}
reader.onerror = () => setError(t('logs.nmea_error_read'))
reader.readAsText(file)
}
const toggleAll = (checked: boolean) => {
setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set())
}
const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const goPreview = () => {
if (!parseResult) {
setError(t('logs.nmea_error_no_file'))
return
}
const generated = generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
setStep('preview')
}
const applyImport = async () => {
const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event)
if (picked.length === 0) {
setError(t('logs.nmea_error_no_selection'))
return
}
const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined
onImport(sortLogEventsByTime(picked), waypoints)
if (pendingRaw) {
try {
await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
} catch (err) {
console.warn('NMEA import CRC record failed:', err)
}
}
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
mode,
events: picked.length,
track: importTrack && (waypoints?.length ?? 0) > 0
})
setStep('archive')
}
const finishArchive = async (archive: boolean) => {
try {
if (archive && pendingRaw) {
await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
}
} catch (err) {
console.warn('NMEA archive save failed:', err)
}
handleClose()
}
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') handleClose()
}
window.addEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
}
}, [open])
if (!open) return null
return createPortal(
<div className="disclaimer-modal-overlay" onClick={handleClose}>
<div className="disclaimer-modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
<button
type="button"
className="registration-disclaimer__close feedback-modal__close"
onClick={handleClose}
aria-label={t('logs.nmea_cancel')}
>
<X size={18} />
</button>
<div className="auth-header">
<FileText className="auth-icon accent" size={40} />
<h2>{t('logs.nmea_import_title')}</h2>
</div>
{error && <div className="track-error-msg">{error}</div>}
{duplicateFile && (
<div className="nmea-import-warning" role="status">
{t('logs.nmea_warn_duplicate_file')}
</div>
)}
{step === 'config' && (
<>
<p className="registration-disclaimer__intro">{t('logs.nmea_import_intro')}</p>
<label className="feedback-form__field">
<span>{t('logs.nmea_file_label')}</span>
<input
type="file"
accept=".nmea,.log,.txt"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
</label>
{parseResult && (
<div className="nmea-import-summary">
<p>{t('logs.nmea_stats', {
lines: parseResult.stats.parsedLines,
types: parseResult.stats.sentenceTypes.join(', ')
})}</p>
{parseResult.warnings.includes('no_position') && (
<p>{t('logs.nmea_warn_no_position')}</p>
)}
</div>
)}
<fieldset className="nmea-import-mode">
<legend>{t('logs.nmea_mode_label')}</legend>
<label><input type="radio" name="nmea-mode" checked={mode === 'interval'} onChange={() => setMode('interval')} /> {t('logs.nmea_mode_interval')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'change'} onChange={() => setMode('change')} /> {t('logs.nmea_mode_change')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'both'} onChange={() => setMode('both')} /> {t('logs.nmea_mode_both')}</label>
</fieldset>
{(mode === 'interval' || mode === 'both') && (
<label className="feedback-form__field">
<span>{t('logs.nmea_interval_label')}</span>
<select value={intervalMinutes} onChange={(e) => setIntervalMinutes(Number(e.target.value))}>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
<option value={90}>90 min</option>
<option value={120}>120 min</option>
</select>
</label>
)}
<label className="nmea-import-checkbox">
<input type="checkbox" checked={importTrack} onChange={(e) => setImportTrack(e.target.checked)} />
{t('logs.nmea_import_track')}
</label>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={handleClose}>{t('logs.nmea_cancel')}</button>
<button type="button" className="btn primary" onClick={goPreview} disabled={!parseResult}>
{t('logs.nmea_preview')}
</button>
</div>
</>
)}
{step === 'preview' && (
<>
<p>{t('logs.nmea_preview_hint', { count: candidates.length })}</p>
<div className="nmea-preview-actions">
<button type="button" className="btn secondary" onClick={() => toggleAll(true)}>{t('logs.nmea_select_all')}</button>
<button type="button" className="btn secondary" onClick={() => toggleAll(false)}>{t('logs.nmea_select_none')}</button>
</div>
<div className="nmea-preview-list">
{candidates.map((c) => (
<label key={c.id} className="nmea-preview-row">
<input
type="checkbox"
className="nmea-preview-row__check"
checked={selectedIds.has(c.id)}
onChange={() => toggleOne(c.id)}
/>
<div className="nmea-preview-row__body">
<div className="nmea-preview-row__meta">
<span className="nmea-preview-time">{c.event.time}</span>
<span className="nmea-preview-source">{t(`logs.nmea_source_${c.source}`)}</span>
</div>
<span className="nmea-preview-remarks">{c.event.remarks || c.event.mgk || '—'}</span>
</div>
</label>
))}
</div>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => setStep('config')}>{t('logs.nmea_back')}</button>
<button type="button" className="btn primary" onClick={applyImport}>{t('logs.nmea_apply')}</button>
</div>
</>
)}
{step === 'archive' && (
<>
<p>{t('logs.nmea_archive_question')}</p>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => finishArchive(false)}>
{t('logs.nmea_archive_discard')}
</button>
<button type="button" className="btn primary" onClick={() => finishArchive(true)}>
{t('logs.nmea_archive_keep')}
</button>
</div>
</>
)}
</div>
</div>
</div>,
document.body
)
}
+21 -100
View File
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { decryptJson } from '../services/crypto.js'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react'
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
setUploading(true)
setError(null)
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = async () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
const MAX_WIDTH = 1280
const MAX_HEIGHT = 720
// Calculate resizing conserving aspect ratio
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
// Compress to JPEG, 70% quality
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
// Encrypt
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const photoId = window.crypto.randomUUID()
const photoPayload = {
image: compressedBase64,
caption: caption.trim()
}
const encrypted = await encryptJson(photoPayload, masterKey)
const now = new Date().toISOString()
// Store locally
await db.photos.put({
payloadId: photoId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
caption: '', // stored encrypted inside payload
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'create',
type: 'photo',
payloadId: photoId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to process image:', err)
setError(err.message || 'Failed to process image')
} finally {
setUploading(false)
}
}
img.src = event.target?.result as string
try {
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
await saveEntryPhoto({
logbookId,
entryId,
imageDataUrl: compressedBase64,
caption: caption.trim(),
analyticsContext: 'logbook'
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
} catch (err: unknown) {
console.error('Failed to process image:', err)
setError(err instanceof Error ? err.message : 'Failed to process image')
} finally {
setUploading(false)
}
reader.readAsDataURL(file)
}
const handleDelete = async (photoId: string) => {
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const now = new Date().toISOString()
await db.photos.delete(photoId)
await db.syncQueue.put({
action: 'delete',
type: 'photo',
payloadId: photoId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
await deleteEntryPhoto(logbookId, photoId)
} catch (err: unknown) {
console.error('Failed to delete photo:', err)
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
@@ -137,7 +137,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
if (loading) {
+43 -34
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import LinkQrCode from './LinkQrCode.tsx'
import { useDialog } from './ModalDialog.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js'
@@ -314,23 +315,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{shareEnabled && shareLink && (
<div className="input-group mb-4 copy-link-row">
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
<div className="link-with-qr mb-4">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={shareLink} />
</div>
)}
</div>
@@ -367,23 +372,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{inviteLink && (
<div className="input-group mb-6 copy-link-row">
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
<div className="link-with-qr mb-6">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={inviteLink} />
</div>
)}
+69 -4
View File
@@ -14,6 +14,11 @@ import {
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import {
loadLogbookEventSeries,
type EventSeriesPoint,
type EventSeriesSummary
} from '../services/eventSeriesAggregation.js'
interface StatsDashboardProps {
logbookId: string
@@ -217,7 +222,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)
}
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
if (points.length === 0) {
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<p className="stats-section-sub">{emptyLabel}</p>
</div>
)
}
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<ul className="stats-event-series-list">
{points.map((point, idx) => (
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
<span className="stats-event-series-when">
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
{' · '}
{point.time}
</span>
<span className="stats-event-series-value">{point.summary}</span>
</li>
))}
</ul>
</div>
)
}
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
const { t } = useTranslation()
const motorPoints = series.motor.map((point) => ({
...point,
summary: point.summary === 'start'
? t('logs.live_motor_start')
: t('logs.live_motor_stop')
}))
return (
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
</div>
)
}
function LogbookScopeView({
summary,
eventSeries
}: {
summary: LogbookStatsSummary
eventSeries: EventSeriesSummary | null
}) {
const { t } = useTranslation()
const { travelDays, routePorts, trackSegments, totals } = summary
@@ -313,6 +373,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
<PropulsionBreakdown totals={totals} />
</div>
{eventSeries && <EventSeriesPanel series={eventSeries} />}
</>
)
}
@@ -323,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [lb, acc] = await Promise.all([
const [lb, acc, series] = await Promise.all([
loadLogbookStats(logbookId, logbookTitle, true),
loadAccountStats(false)
loadAccountStats(false),
loadLogbookEventSeries(logbookId)
])
setLogbookStats(lb)
setAccountStats(acc)
setEventSeries(series)
} catch (err: unknown) {
console.error('Failed to load statistics:', err)
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
@@ -397,7 +462,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<p>{t('stats.loading')}</p>
</div>
) : scope === 'logbook' && logbookStats ? (
<LogbookScopeView summary={logbookStats} />
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
) : scope === 'account' && accountStats ? (
<>
<TotalsGrid totals={accountStats.totals} />
+155 -3
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Indtast din pinkode...",
"decrypt_with_pin": "Afkodning",
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet",
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes."
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes.",
"error_invalid_host": "Passkeys virker ikke via 127.0.0.1. Åbn appen via localhost.",
"use_localhost_link": "Skift til localhost",
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
},
"pwa": {
"title": "Installer app",
@@ -197,6 +202,94 @@
"saving": "Vil blive reddet...",
"saved": "Logbogsside gemt med succes!",
"loading": "Dagbogen er ved at blive indlæst.",
"view_mode_label": "Visning",
"view_list": "Liste",
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal indlæses...",
"live_retry": "Prøv igen",
"live_load_error": "Live-journal kunne ikke indlæses.",
"live_action_error": "Indtastning kunne ikke gemmes.",
"live_open_editor": "Fuld editor",
"live_actions_label": "Hurtighandlinger",
"live_stream_label": "Hændelseslog",
"live_stream_title": "Journal",
"live_no_events": "Ingen indtastninger endnu — tryk på en handling.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stop",
"live_cast_off": "Afsejling",
"live_moor": "Anløb",
"live_sails_btn": "Sejl",
"live_sails_pick": "Vælg sejl",
"live_sails_pick_hint": "Tryk på flere sejl (tryk igen for at fravælge), og indtast derefter.",
"live_sails_selected": "Valgt: {{sails}}",
"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_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Tag billede",
"live_photo_save_btn": "Gem",
"live_photo_retake_btn": "Tag igen",
"live_photo_capture_failed": "Optagelse mislykkedes. Prøv igen.",
"live_photo_open_camera_btn": "Åbn kamera",
"live_photo_native_hint": "Tag et foto med enhedens kamera og gem det her bagefter.",
"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_error": "Foto kunne ikke gemmes.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto gemt",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast",
"live_gps_error": "GPS-position kunne ikke bestemmes.",
"live_event_generic": "Hændelse",
"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_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Søgang",
"live_course_btn": "Kurs",
"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",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Søgang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vand +{{liters}} L",
"live_auto_position": "Auto-position",
"live_undo_hint": "Indtastning gemt",
"live_undo_btn": "Fortryd",
"live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. let regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Optankede liter",
"live_water_placeholder": "Optankede liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "f.eks. 5,2",
"live_stw_placeholder": "f.eks. 4,8",
"live_sog_hint": "Fart over grund (kn) — GPS-værdi forudfyldes, hvis tilgængelig.",
"delete_entry": "Slet tag",
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag permanent?",
"carry_over_tanks_title": "Overføre data fra den foregående dag?",
@@ -283,7 +376,57 @@
"revoke": "Fjerne",
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
"invite_role": "Rolle",
"invite_expires": "Linket er gyldigt i 48 timer"
"invite_expires": "Linket er gyldigt i 48 timer",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"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_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
},
"dashboard": {
"title": "Dine logbøger",
@@ -495,6 +638,8 @@
"share_enable": "Aktivér offentligt link",
"share_copied": "Link kopieret!",
"share_copy_btn": "Kopier link",
"link_qr_hint": "Scan QR-koden med din telefon",
"link_qr_alt": "QR-kode til linket",
"danger_zone_title": "Farezone",
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
"delete_account_btn": "Slet konto uigenkaldeligt",
@@ -574,6 +719,7 @@
"category_general": "Generelt",
"category_bug": "Rapporter fejl",
"category_feature": "Anmodning om funktion",
"category_translation": "Oversættelsesfejl",
"contact_label": "E-mail (valgfrit)",
"contact_placeholder": "deine@email.beispiel",
"message_label": "Besked",
@@ -662,7 +808,13 @@
"unit_l": "L",
"day_label": "Dag {{day}}",
"account_logbooks": "Et overblik over logbøger",
"col_logbook": "Logbog"
"col_logbook": "Logbog",
"event_series_title": "Hændelsesforløb",
"event_series_hint": "Kronologiske værdier fra hændelsesloggen.",
"event_series_pressure": "Lufttryk",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Ingen indtastninger endnu."
},
"tour": {
"skip": "Spring turen over",
+154 -2
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Gib deine PIN ein...",
"decrypt_with_pin": "Entschlüsseln",
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
"use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
},
"pwa": {
"title": "App installieren",
@@ -197,6 +202,94 @@
"saving": "Wird gespeichert...",
"saved": "Logbuchseite erfolgreich gespeichert!",
"loading": "Journal wird geladen...",
"view_mode_label": "Ansicht",
"view_list": "Liste",
"live_mode": "Live",
"live_title": "Live-Journal",
"live_loading": "Live-Journal wird geladen...",
"live_retry": "Erneut versuchen",
"live_load_error": "Live-Journal konnte nicht geladen werden.",
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
"live_open_editor": "Vollständiger Editor",
"live_actions_label": "Schnellaktionen",
"live_stream_label": "Ereignisprotokoll",
"live_stream_title": "Journal",
"live_no_events": "Noch keine Einträge — tippe auf eine Aktion.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stop",
"live_cast_off": "Ablegen",
"live_moor": "Anlegen",
"live_sails_btn": "Segel",
"live_sails_pick": "Segel auswählen",
"live_sails_pick_hint": "Mehrere Segel antippen (erneut antippen zum Abwählen), dann Eintragen.",
"live_sails_selected": "Auswahl: {{sails}}",
"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_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen",
"live_photo_save_btn": "Speichern",
"live_photo_retake_btn": "Neu aufnehmen",
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
"live_photo_open_camera_btn": "Kamera öffnen",
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
"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_error": "Foto konnte nicht gespeichert werden.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen",
"live_undo_photo_hint": "Foto gespeichert",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"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_wind_btn": "Wind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck",
"live_precip_btn": "Niederschlag",
"live_sea_state_btn": "Seegang",
"live_course_btn": "Kurs",
"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",
"live_precip_entry": "Niederschlag {{value}}",
"live_sea_state_entry": "Seegang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Wasser +{{liters}} L",
"live_auto_position": "Auto-Position",
"live_undo_hint": "Eintrag gespeichert",
"live_undo_btn": "Rückgängig",
"live_pressure_placeholder": "z. B. 1013",
"live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen",
"live_sea_state_placeholder": "z. B. 3",
"live_course_placeholder": "z. B. 245",
"live_fuel_placeholder": "Nachgefüllte Liter",
"live_water_placeholder": "Nachgefüllte Liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "z. B. 5,2",
"live_stw_placeholder": "z. B. 4,8",
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
"delete_entry": "Tag löschen",
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
@@ -273,6 +366,56 @@
"track_map_end": "Ziel",
"track_map_speed_slow": "langsam",
"track_map_speed_fast": "schnell",
"nmea_import_title": "NMEA-Protokoll importieren",
"nmea_import_intro": "Lade eine .nmea-Datei vom Bord-Logger. Die App schlägt Journal-Einträge vor — du entscheidest, was übernommen wird.",
"nmea_import_btn": "NMEA importieren",
"nmea_file_label": "NMEA-Datei",
"nmea_stats": "{{lines}} Sätze erkannt · Typen: {{types}}",
"nmea_warn_no_position": "Keine Positions-Sätze gefunden — Track und GPS-Felder können leer bleiben.",
"nmea_warn_duplicate_file": "Diese NMEA-Datei wurde bereits importiert. Ein erneuter Import derselben Datei fügt doppelte Journal-Einträge hinzu.",
"nmea_mode_label": "Journal-Einträge erzeugen",
"nmea_mode_interval": "Nach Zeitintervall",
"nmea_mode_change": "Bei signifikanter Änderung",
"nmea_mode_both": "Beides (zusammenführen)",
"nmea_interval_label": "Intervall (Minuten)",
"nmea_import_track": "GPS-Track aus NMEA übernehmen",
"nmea_preview": "Vorschau",
"nmea_preview_hint": "{{count}} vorgeschlagene Journal-Einträge",
"nmea_select_all": "Alle auswählen",
"nmea_select_none": "Keine auswählen",
"nmea_source_interval": "Intervall",
"nmea_source_change": "Ereignis",
"nmea_apply": "In Journal übernehmen",
"nmea_back": "Zurück",
"nmea_cancel": "Abbrechen",
"nmea_archive_question": "Rohprotokoll lokal archivieren? (Nur auf diesem Gerät, nicht synchronisiert.)",
"nmea_archive_keep": "Archivieren",
"nmea_archive_discard": "Verwerfen",
"nmea_archive_stored": "NMEA archiviert: {{name}}",
"nmea_archive_delete_confirm": "Archiviertes NMEA-Protokoll von diesem Gerät löschen?",
"nmea_error_no_samples": "Keine verwertbaren NMEA-Sätze in der Datei.",
"nmea_error_parse": "NMEA-Datei konnte nicht gelesen werden.",
"nmea_error_read": "Datei konnte nicht gelesen werden.",
"nmea_error_no_file": "Bitte zuerst eine NMEA-Datei wählen.",
"nmea_error_no_selection": "Bitte mindestens einen Journal-Eintrag auswählen.",
"nmea_remark_interval": "NMEA Intervall",
"nmea_remark_uncertain": "unsicher",
"nmea_remark_depth": "Tiefe {{depth}} m",
"nmea_change_course": "Kursänderung {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Luftdruck {{from}} → {{to}} hPa",
"nmea_change_depth": "Tiefe {{from}} → {{to}} m",
"nmea_change_engine_start": "Motor an ({{rpm}} U/min)",
"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_water_temp": "Wassertemp. {{from}} → {{to}} °C",
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
"nmea_change_anchor": "Ankern / Stop",
"nmea_change_speed": "Geschw. {{from}} → {{to}} kn",
"track_map_error": "Karte konnte nicht geladen werden.",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
@@ -495,6 +638,8 @@
"share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren",
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
"link_qr_alt": "QR-Code für den Link",
"danger_zone_title": "Gefahrenzone",
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_account_btn": "Konto unwiderruflich löschen",
@@ -574,6 +719,7 @@
"category_general": "Allgemein",
"category_bug": "Fehler melden",
"category_feature": "Feature-Wunsch",
"category_translation": "Übersetzungsfehler",
"contact_label": "E-Mail (optional)",
"contact_placeholder": "deine@email.beispiel",
"message_label": "Nachricht",
@@ -662,7 +808,13 @@
"unit_l": "L",
"day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick",
"col_logbook": "Logbuch"
"col_logbook": "Logbuch",
"event_series_title": "Ereignis-Verläufe",
"event_series_hint": "Chronologische Werte aus dem Ereignisprotokoll.",
"event_series_pressure": "Luftdruck",
"event_series_wind": "Wind",
"event_series_motor": "Motor",
"event_series_empty": "Keine Einträge vorhanden."
},
"tour": {
"skip": "Tour überspringen",
+154 -2
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Enter your PIN...",
"decrypt_with_pin": "Decrypt",
"use_recovery_instead": "Use recovery phrase instead",
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
"use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
},
"pwa": {
"title": "Install app",
@@ -197,6 +202,94 @@
"saving": "Saving...",
"saved": "Logbook page saved successfully!",
"loading": "Loading journal...",
"view_mode_label": "View",
"view_list": "List",
"live_mode": "Live",
"live_title": "Live Journal",
"live_loading": "Loading live journal...",
"live_retry": "Try again",
"live_load_error": "Could not load live journal.",
"live_action_error": "Could not save entry.",
"live_open_editor": "Full editor",
"live_actions_label": "Quick actions",
"live_stream_label": "Event log",
"live_stream_title": "Journal",
"live_no_events": "No entries yet — tap an action.",
"live_motor_start": "Engine Start",
"live_motor_stop": "Engine Stop",
"live_cast_off": "Cast off",
"live_moor": "Moor",
"live_sails_btn": "Sails",
"live_sails_pick": "Select sails",
"live_sails_pick_hint": "Tap multiple sails (tap again to deselect), then log.",
"live_sails_selected": "Selected: {{sails}}",
"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_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture",
"live_photo_save_btn": "Save",
"live_photo_retake_btn": "Retake",
"live_photo_capture_failed": "Capture failed. Please try again.",
"live_photo_open_camera_btn": "Open camera",
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
"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_error": "Could not save photo.",
"live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured",
"live_undo_photo_hint": "Photo saved",
"live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS 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_wind_btn": "Wind",
"live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure",
"live_precip_btn": "Precipitation",
"live_sea_state_btn": "Sea state",
"live_course_btn": "Course",
"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",
"live_precip_entry": "Precipitation {{value}}",
"live_sea_state_entry": "Sea state {{value}}",
"live_course_entry": "Course {{course}}",
"live_fuel_entry": "Fuel +{{liters}} L",
"live_water_entry": "Water +{{liters}} L",
"live_auto_position": "Auto position",
"live_undo_hint": "Entry saved",
"live_undo_btn": "Undo",
"live_pressure_placeholder": "e.g. 1013",
"live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain",
"live_sea_state_placeholder": "e.g. 3",
"live_course_placeholder": "e.g. 245",
"live_fuel_placeholder": "Liters refilled",
"live_water_placeholder": "Liters refilled",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "e.g. 5.2",
"live_stw_placeholder": "e.g. 4.8",
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
"delete_entry": "Delete Day",
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
"carry_over_tanks_title": "Carry over from previous day?",
@@ -273,6 +366,56 @@
"track_map_end": "End",
"track_map_speed_slow": "slow",
"track_map_speed_fast": "fast",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"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_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"track_map_error": "Could not load map.",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
@@ -495,6 +638,8 @@
"share_enable": "Enable Public Link",
"share_copied": "Link copied!",
"share_copy_btn": "Copy Link",
"link_qr_hint": "Scan this QR code with your phone",
"link_qr_alt": "QR code for the link",
"danger_zone_title": "Danger Zone",
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
"delete_account_btn": "Permanently Delete Account",
@@ -574,6 +719,7 @@
"category_general": "General",
"category_bug": "Bug report",
"category_feature": "Feature request",
"category_translation": "Translation error",
"contact_label": "Email (optional)",
"contact_placeholder": "your@email.example",
"message_label": "Message",
@@ -662,7 +808,13 @@
"unit_l": "L",
"day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview",
"col_logbook": "Logbook"
"col_logbook": "Logbook",
"event_series_title": "Event series",
"event_series_hint": "Chronological values from the event log.",
"event_series_pressure": "Barometric pressure",
"event_series_wind": "Wind",
"event_series_motor": "Engine",
"event_series_empty": "No entries yet."
},
"tour": {
"skip": "Skip tour",
+155 -3
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Tast inn PIN-koden din...",
"decrypt_with_pin": "Dekryptere",
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet",
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes."
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes.",
"error_invalid_host": "Passkeys fungerer ikke via 127.0.0.1. Åpne appen via localhost.",
"use_localhost_link": "Bytt til localhost",
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
},
"pwa": {
"title": "Installer app",
@@ -197,6 +202,94 @@
"saving": "...vil bli reddet...",
"saved": "Loggboksiden er vellykket lagret!",
"loading": "Tidsskriftet lastes inn...",
"view_mode_label": "Visning",
"view_list": "Liste",
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal lastes inn...",
"live_retry": "Prøv igjen",
"live_load_error": "Live-journal kunne ikke lastes inn.",
"live_action_error": "Oppføringen kunne ikke lagres.",
"live_open_editor": "Full editor",
"live_actions_label": "Hurtighandlinger",
"live_stream_label": "Hendelseslogg",
"live_stream_title": "Journal",
"live_no_events": "Ingen oppføringer ennå — trykk på en handling.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stopp",
"live_cast_off": "Avreise",
"live_moor": "Anløp",
"live_sails_btn": "Seil",
"live_sails_pick": "Velg seil",
"live_sails_pick_hint": "Trykk flere seil (trykk igjen for å fjerne), deretter loggfør.",
"live_sails_selected": "Valgt: {{sails}}",
"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_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta bilde",
"live_photo_save_btn": "Lagre",
"live_photo_retake_btn": "Ta på nytt",
"live_photo_capture_failed": "Opptak mislyktes. Prøv igjen.",
"live_photo_open_camera_btn": "Åpne kamera",
"live_photo_native_hint": "Ta et bilde med enhetskameraet og lagre det her etterpå.",
"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_error": "Kunne ikke lagre foto.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt",
"live_undo_photo_hint": "Foto lagret",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør",
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
"live_event_generic": "Hendelse",
"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_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttrykk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Sjøgang",
"live_course_btn": "Kurs",
"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",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Sjøgang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vann +{{liters}} L",
"live_auto_position": "Auto-posisjon",
"live_undo_hint": "Oppføring lagret",
"live_undo_btn": "Angre",
"live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. lett regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Påfylte liter",
"live_water_placeholder": "Påfylte liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "f.eks. 5,2",
"live_stw_placeholder": "f.eks. 4,8",
"live_sog_hint": "Fart over grunn (kn) — GPS-verdi fylles inn hvis tilgjengelig.",
"delete_entry": "Slett tagg",
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?",
"carry_over_tanks_title": "Overføre data fra dagen før?",
@@ -283,7 +376,57 @@
"revoke": "Fjern",
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
"invite_role": "Rolle",
"invite_expires": "Lenken er gyldig i 48 timer"
"invite_expires": "Lenken er gyldig i 48 timer",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"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_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
},
"dashboard": {
"title": "Loggbøkene dine",
@@ -495,6 +638,8 @@
"share_enable": "Aktiver offentlig lenke",
"share_copied": "Linken er kopiert!",
"share_copy_btn": "Kopier lenke",
"link_qr_hint": "Skann QR-koden med telefonen",
"link_qr_alt": "QR-kode for lenken",
"danger_zone_title": "Faresone",
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
"delete_account_btn": "Slett konto ugjenkallelig",
@@ -574,6 +719,7 @@
"category_general": "Generelt",
"category_bug": "Rapporter feil",
"category_feature": "Forespørsel om funksjonalitet",
"category_translation": "Oversettelsesfeil",
"contact_label": "E-post (valgfritt)",
"contact_placeholder": "deine@email.beispiel",
"message_label": "Melding",
@@ -662,7 +808,13 @@
"unit_l": "L",
"day_label": "Dag {{day}}",
"account_logbooks": "Oversikt over loggbøker",
"col_logbook": "Loggbok"
"col_logbook": "Loggbok",
"event_series_title": "Hendelsesforløp",
"event_series_hint": "Kronologiske verdier fra hendelsesloggen.",
"event_series_pressure": "Lufttrykk",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Ingen oppføringer ennå."
},
"tour": {
"skip": "Hopp over turen",
+155 -3
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Ange din PIN-kod...",
"decrypt_with_pin": "Dekryptera",
"use_recovery_instead": "Använd återställningsnycklar istället",
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades."
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades.",
"error_invalid_host": "Passkeys fungerar inte via 127.0.0.1. Öppna appen via localhost.",
"use_localhost_link": "Byt till localhost",
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
},
"pwa": {
"title": "Installera app",
@@ -197,6 +202,94 @@
"saving": "Kommer att sparas...",
"saved": "Loggbokssidan har sparats framgångsrikt!",
"loading": "Journalen laddas...",
"view_mode_label": "Vy",
"view_list": "Lista",
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal laddas...",
"live_retry": "Försök igen",
"live_load_error": "Live-journal kunde inte laddas.",
"live_action_error": "Posten kunde inte sparas.",
"live_open_editor": "Fullständig editor",
"live_actions_label": "Snabbåtgärder",
"live_stream_label": "Händelselogg",
"live_stream_title": "Journal",
"live_no_events": "Inga poster ännu — tryck på en åtgärd.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stopp",
"live_cast_off": "Avgång",
"live_moor": "Anlöp",
"live_sails_btn": "Segel",
"live_sails_pick": "Välj segel",
"live_sails_pick_hint": "Tryck på flera segel (tryck igen för att avmarkera), logga sedan.",
"live_sails_selected": "Valt: {{sails}}",
"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_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta foto",
"live_photo_save_btn": "Spara",
"live_photo_retake_btn": "Ta om",
"live_photo_capture_failed": "Bildtagning misslyckades. Försök igen.",
"live_photo_open_camera_btn": "Öppna kamera",
"live_photo_native_hint": "Ta ett foto med enhetens kamera och spara det här efteråt.",
"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_error": "Foto kunde inte sparas.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto sparat",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga",
"live_gps_error": "GPS-position kunde inte bestämmas.",
"live_event_generic": "Händelse",
"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_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryck",
"live_precip_btn": "Nederbörd",
"live_sea_state_btn": "Sjögang",
"live_course_btn": "Kurs",
"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",
"live_precip_entry": "Nederbörd {{value}}",
"live_sea_state_entry": "Sjögang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vatten +{{liters}} L",
"live_auto_position": "Auto-position",
"live_undo_hint": "Post sparad",
"live_undo_btn": "Ångra",
"live_pressure_placeholder": "t.ex. 1013",
"live_temp_placeholder": "t.ex. 18",
"live_precip_placeholder": "t.ex. lätt regn",
"live_sea_state_placeholder": "t.ex. 3",
"live_course_placeholder": "t.ex. 245",
"live_fuel_placeholder": "Påfyllda liter",
"live_water_placeholder": "Påfyllda liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "t.ex. 5,2",
"live_stw_placeholder": "t.ex. 4,8",
"live_sog_hint": "Fart över grund (kn) — GPS-värde fylls i om tillgängligt.",
"delete_entry": "Ta bort tagg",
"delete_confirm": "Är du säker på att du vill radera den här resedagen permanent?",
"carry_over_tanks_title": "Överföra data från föregående dag?",
@@ -283,7 +376,57 @@
"revoke": "Ta bort",
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
"invite_role": "Roll",
"invite_expires": "Länken är giltig i 48 timmar"
"invite_expires": "Länken är giltig i 48 timmar",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"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_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
},
"dashboard": {
"title": "Dina loggböcker",
@@ -495,6 +638,8 @@
"share_enable": "Aktivera offentlig länk",
"share_copied": "Länk kopierad!",
"share_copy_btn": "Kopiera länk",
"link_qr_hint": "Skanna QR-koden med mobilen",
"link_qr_alt": "QR-kod för länken",
"danger_zone_title": "Farlig zon",
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
"delete_account_btn": "Ta bort konto oåterkalleligt",
@@ -574,6 +719,7 @@
"category_general": "Allmänt",
"category_bug": "Rapportera fel",
"category_feature": "Begäran om funktion",
"category_translation": "Översättningsfel",
"contact_label": "E-post (valfritt)",
"contact_placeholder": "deine@email.beispiel",
"message_label": "Meddelande",
@@ -662,7 +808,13 @@
"unit_l": "L",
"day_label": "Dag {{day}}__.",
"account_logbooks": "Loggböcker i en överblick",
"col_logbook": "Loggbok"
"col_logbook": "Loggbok",
"event_series_title": "Händelseförlopp",
"event_series_hint": "Kronologiska värden från händelseloggen.",
"event_series_pressure": "Lufttryck",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Inga poster ännu."
},
"tour": {
"skip": "Hoppa över turen",
+5
View File
@@ -12,6 +12,7 @@ import {
markReloadAttempt,
reconcileVersionOnStartup
} from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
/** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> {
@@ -40,6 +41,10 @@ function renderBootstrapError(message: string): void {
}
async function bootstrap(): Promise<void> {
if (redirectToPasskeyCompatibleHostIfNeeded()) {
return
}
applyAppearanceToDocument()
installStaleAssetRecovery()
await clearDevServiceWorkerCaches()
+11 -1
View File
@@ -34,9 +34,19 @@ export const PlausibleEvents = {
LOCAL_PIN_SET: 'Local PIN Set',
LOCAL_PIN_REMOVED: 'Local PIN Removed',
DEVICE_FORGOTTEN: 'Device Forgotten',
RECOVERY_ROTATED: 'Recovery Rotated'
RECOVERY_ROTATED: 'Recovery Rotated',
LANGUAGE_CHANGED: 'Language Changed',
NMEA_IMPORTED: 'NMEA Imported',
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',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched'
} as const
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
+6 -1
View File
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
import { apiFetch, apiJson } from './api.js'
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
const API_BASE = '/api/auth'
@@ -361,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
const prfRequested = !!options.extensions?.prf
try {
credentialResponse = await startAuthentication({ optionsJSON: options })
} catch (err: any) {
} catch (err: unknown) {
// User cancelled or timed out — never open a second platform prompt.
if (isWebAuthnUserAbortError(err)) {
throw err
}
if (prfRequested) {
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
if (options.extensions) {
+22
View File
@@ -64,6 +64,15 @@ export interface LocalGpsTrack {
updatedAt: string
}
export interface LocalNmeaArchive {
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookKey {
logbookId: string
encryptedKey: string
@@ -89,6 +98,7 @@ class DaagboxDatabase extends Dexie {
entries!: Table<LocalEntry>
photos!: Table<LocalPhoto>
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
syncQueue!: Table<SyncQueueItem>
@@ -145,6 +155,18 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(6).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',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
}
}
@@ -0,0 +1,106 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { LIVE_EVENT_CODES } from '../utils/liveEventCodes.js'
export interface EventSeriesPoint {
entryId: string
date: string
dayOfTravel: string
time: string
summary: string
}
export interface EventSeriesSummary {
pressure: EventSeriesPoint[]
wind: EventSeriesPoint[]
motor: EventSeriesPoint[]
}
function sortPoints(points: EventSeriesPoint[]): EventSeriesPoint[] {
return [...points].sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.time.localeCompare(b.time)
})
}
export async function loadLogbookEventSeries(logbookId: string): Promise<EventSeriesSummary> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<{
entryId: string
date: string
dayOfTravel: string
events: LogEventPayload[]
}> = []
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) continue
decryptedEntries.push({
entryId: entry.payloadId,
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
events: (decrypted.events as LogEventPayload[]) || []
})
}
decryptedEntries.sort((a, b) =>
compareTravelDaysChronological(
{ date: a.date, dayOfTravel: a.dayOfTravel },
{ date: b.date, dayOfTravel: b.dayOfTravel }
)
)
const pressure: EventSeriesPoint[] = []
const wind: EventSeriesPoint[] = []
const motor: EventSeriesPoint[] = []
for (const entry of decryptedEntries) {
for (const event of entry.events) {
const base = {
entryId: entry.entryId,
date: entry.date,
dayOfTravel: entry.dayOfTravel,
time: event.time
}
if (event.windPressure?.trim()) {
pressure.push({
...base,
summary: `${event.windPressure} hPa`
})
}
if (event.windDirection?.trim() || event.windStrength?.trim()) {
wind.push({
...base,
summary: [event.windDirection, event.windStrength].filter(Boolean).join(' ')
})
}
const code = event.remarks?.trim() ?? ''
if (
code === LIVE_EVENT_CODES.MOTOR_START ||
code === LIVE_EVENT_CODES.MOTOR_STOP
) {
motor.push({
...base,
summary: code === LIVE_EVENT_CODES.MOTOR_START ? 'start' : 'stop'
})
}
}
}
return {
pressure: sortPoints(pressure),
wind: sortPoints(wind),
motor: sortPoints(motor)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { apiFetch } from './api.js'
export type FeedbackCategory = 'bug' | 'feature' | 'general'
export type FeedbackCategory = 'bug' | 'feature' | 'general' | 'translation'
export class FeedbackApiError extends Error {
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
+4
View File
@@ -214,6 +214,10 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
if (response.ok) {
const serverLb = await response.json()
if (serverLb.id !== localId) {
await saveLogbookKey(serverLb.id, logbookKey)
await db.logbookKeys.delete(localId)
}
await db.logbooks.put({
id: serverLb.id,
encryptedTitle: serverLb.encryptedTitle,
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseNmeaFile } from './nmeaParse.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
import { generateNmeaJournalCandidates } from './nmeaJournalGenerator.js'
const nmeaPath = resolve(import.meta.dirname, '../../../../testdata/tracks/kieler-foerde-5sm.nmea')
describe('kieler-foerde testdata', () => {
it('parses the sample NMEA log and yields journal candidates', () => {
const text = readFileSync(nmeaPath, 'utf8')
const result = parseNmeaFile(text, 'kieler-foerde-5sm.nmea')
expect(result.stats.checksumErrors).toBe(0)
expect(result.points.length).toBeGreaterThan(30)
expect(result.stats.sentenceTypes).toEqual(expect.arrayContaining(['RMC', 'GGA', 'MWV', 'DPT', 'MDA']))
const changes = detectNmeaChanges(result.points)
expect(changes.length).toBeGreaterThan(0)
expect(changes.some((c) => ['wind', 'engine_start', 'departure', 'speed', 'depth'].includes(c.type))).toBe(true)
const journal = generateNmeaJournalCandidates({
points: result.points,
mode: 'both',
intervalMinutes: 60,
t: (key) => key
})
expect(journal.candidates.length).toBeGreaterThanOrEqual(3)
})
})
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { NmeaTimePoint } from './nmeaTypes.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
function point(
timestamp: number,
overrides: Partial<NmeaTimePoint> = {}
): NmeaTimePoint {
return { timestamp, ...overrides }
}
describe('detectNmeaChanges', () => {
it('detects significant course changes while underway', () => {
const points = [
point(0, { cog: 0, sog: 5 }),
point(60_000, { cog: 45, sog: 5 })
]
const events = detectNmeaChanges(points, {
courseDeltaDeg: 30,
windDirDeltaDeg: 30,
windSpeedDeltaKnots: 5,
pressureDeltaHpa: 2,
depthDeltaM: 1,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 2,
dedupeWindowMs: 60_000
})
expect(events.some((e) => e.type === 'course')).toBe(true)
const course = events.find((e) => e.type === 'course')
expect(course?.summaryParams).toMatchObject({ from: 0, to: 45 })
})
it('detects engine start when RPM rises above threshold', () => {
const points = [
point(0, { sog: 0, rpm: 0 }),
point(30_000, { sog: 3, rpm: 1200 })
]
const events = detectNmeaChanges(points)
expect(events.some((e) => e.type === 'engine_start')).toBe(true)
})
it('dedupes repeated events within the configured window', () => {
const points = [
point(0, { cog: 0, sog: 5 }),
point(10_000, { cog: 50, sog: 5 }),
point(20_000, { cog: 100, sog: 5 })
]
const events = detectNmeaChanges(points, {
courseDeltaDeg: 30,
windDirDeltaDeg: 30,
windSpeedDeltaKnots: 5,
pressureDeltaHpa: 2,
depthDeltaM: 1,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 2,
dedupeWindowMs: 120_000
})
const courseEvents = events.filter((e) => e.type === 'course')
expect(courseEvents.length).toBe(1)
})
})
@@ -0,0 +1,211 @@
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
import { angularDelta } from './nmeaTimeSeries.js'
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
events.push(event)
}
export function detectNmeaChanges(
points: NmeaTimePoint[],
config: NmeaDetectionConfig = DEFAULT_NMEA_DETECTION_CONFIG
): NmeaChangeEvent[] {
const events: NmeaChangeEvent[] = []
if (points.length < 2) return events
let lastCourse: number | undefined
let lastWindDir: number | undefined
let lastWindSpeed: number | undefined
let lastPressure: number | undefined
let lastDepth: number | undefined
let lastWaterTemp: number | undefined
let lastFix: boolean | undefined
let engineRunning = false
let autopilot: boolean | undefined
let underWay = false
let stoppedSince: number | null = null
let lastSog: number | undefined
for (const p of points) {
const course = p.cog ?? p.hdt ?? p.hdm
if (course != null && lastCourse != null && (p.sog ?? 0) > 1) {
if (angularDelta(course, lastCourse) >= config.courseDeltaDeg) {
pushUnique(events, {
type: 'course',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_course',
summaryParams: { from: Math.round(lastCourse), to: Math.round(course) },
data: p
}, config.dedupeWindowMs)
}
}
if (course != null) lastCourse = course
if (p.windDir != null && lastWindDir != null) {
if (angularDelta(p.windDir, lastWindDir) >= config.windDirDeltaDeg) {
pushUnique(events, {
type: 'wind',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_wind',
summaryParams: { from: Math.round(lastWindDir), to: Math.round(p.windDir) },
data: p
}, config.dedupeWindowMs)
} else if (
p.windSpeedKnots != null &&
lastWindSpeed != null &&
Math.abs(p.windSpeedKnots - lastWindSpeed) >= config.windSpeedDeltaKnots
) {
pushUnique(events, {
type: 'wind',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_wind_speed',
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.windDir != null) lastWindDir = p.windDir
if (p.windSpeedKnots != null) lastWindSpeed = p.windSpeedKnots
if (p.pressureHpa != null && lastPressure != null) {
if (Math.abs(p.pressureHpa - lastPressure) >= config.pressureDeltaHpa) {
pushUnique(events, {
type: 'pressure',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_pressure',
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.pressureHpa != null) lastPressure = p.pressureHpa
if (p.depthM != null && lastDepth != null) {
const delta = Math.abs(p.depthM - lastDepth)
const rel = lastDepth > 0 ? (delta / lastDepth) * 100 : 100
if (delta >= config.depthDeltaM || rel >= config.depthDeltaPercent) {
pushUnique(events, {
type: 'depth',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_depth',
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.depthM != null) lastDepth = p.depthM
if (p.rpm != null) {
const running = p.rpm >= config.rpmRunning
const idle = p.rpm <= config.rpmIdle
if (running && !engineRunning) {
pushUnique(events, {
type: 'engine_start',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_engine_start',
summaryParams: { rpm: Math.round(p.rpm) },
data: p
}, config.dedupeWindowMs)
engineRunning = true
} else if (idle && engineRunning) {
pushUnique(events, {
type: 'engine_stop',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_engine_stop',
data: p
}, config.dedupeWindowMs)
engineRunning = false
}
}
if (p.autopilotEngaged != null && autopilot != null && p.autopilotEngaged !== autopilot) {
pushUnique(events, {
type: p.autopilotEngaged ? 'autopilot_on' : 'autopilot_off',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: p.autopilotEngaged ? 'logs.nmea_change_autopilot_on' : 'logs.nmea_change_autopilot_off',
data: p
}, config.dedupeWindowMs)
}
if (p.autopilotEngaged != null) autopilot = p.autopilotEngaged
if (p.fixValid != null && lastFix != null && p.fixValid !== lastFix) {
pushUnique(events, {
type: p.fixValid ? 'gps_fix_regained' : 'gps_fix_lost',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: p.fixValid ? 'logs.nmea_change_gps_regained' : 'logs.nmea_change_gps_lost',
data: p
}, config.dedupeWindowMs)
}
if (p.fixValid != null) lastFix = p.fixValid
if (p.waterTempC != null && lastWaterTemp != null) {
if (Math.abs(p.waterTempC - lastWaterTemp) >= 2) {
pushUnique(events, {
type: 'water_temp',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_water_temp',
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.waterTempC != null) lastWaterTemp = p.waterTempC
const sog = p.sog ?? 0
if (sog >= config.sogUnderWayKn && !underWay) {
if (stoppedSince != null && p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
pushUnique(events, {
type: 'departure',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_departure',
data: p
}, config.dedupeWindowMs)
}
underWay = true
stoppedSince = null
}
if (sog <= config.sogStoppedKn && underWay) {
underWay = false
stoppedSince = p.timestamp
}
if (sog <= config.sogStoppedKn && stoppedSince != null && !underWay) {
if (p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
pushUnique(events, {
type: 'anchor',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_anchor',
data: p
}, config.dedupeWindowMs)
stoppedSince = null
}
}
if (lastSog != null && Math.abs(sog - lastSog) >= config.speedDeltaKn) {
pushUnique(events, {
type: 'speed',
timestamp: p.timestamp,
confidence: 'low',
summaryKey: 'logs.nmea_change_speed',
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
lastSog = sog
}
return events.sort((a, b) => a.timestamp - b.timestamp)
}
@@ -0,0 +1,139 @@
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 { degreesToCardinal } from '../../utils/courseAngle.js'
import type {
NmeaChangeEvent,
NmeaImportMode,
NmeaJournalCandidate,
NmeaTimePoint
} from './nmeaTypes.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
import { intervalTimestamps, sampleAt, timestampToHHMM } from './nmeaTimeSeries.js'
export interface GeneratedNmeaJournal {
candidates: Array<NmeaJournalCandidate & { event: LogEventPayload }>
}
function pointToLogEvent(
point: NmeaTimePoint,
remarks: string,
sailsOrMotor: string
): LogEventPayload {
const course = point.cog ?? point.hdt ?? point.hdm
const mgk = course != null ? formatCourseAngle(course) : ''
const windDir =
point.windDir != null ? degreesToCardinal(point.windDir) : ''
return normalizeLogEvent({
time: timestampToHHMM(point.timestamp),
mgk,
rwk: '',
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) : '',
sailsOrMotor,
remarks
})
}
function changeToSailsOrMotor(type: NmeaChangeEvent['type']): string {
if (type === 'engine_start') return 'Motor'
if (type === 'engine_stop') return 'Segel'
return ''
}
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) }))
}
if (change.confidence === 'low') {
parts.push(t('logs.nmea_remark_uncertain'))
}
return parts.join(' · ')
}
function dedupeCandidates(
items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }>,
windowMs: number
): Array<NmeaJournalCandidate & { event: LogEventPayload }> {
const sorted = [...items].sort((a, b) => a.timestamp - b.timestamp)
const kept: typeof sorted = []
for (const item of sorted) {
const near = kept.find((k) => Math.abs(k.timestamp - item.timestamp) <= windowMs)
if (!near) {
kept.push(item)
continue
}
if (item.source === 'change' && near.source === 'interval') {
const idx = kept.indexOf(near)
kept[idx] = {
...item,
event: {
...near.event,
remarks: [item.event.remarks, near.event.remarks].filter(Boolean).join(' · ')
}
}
}
}
return kept
}
export function generateNmeaJournalCandidates(options: {
points: NmeaTimePoint[]
mode: NmeaImportMode
intervalMinutes: number
t: TFunction
}): GeneratedNmeaJournal {
const { points, mode, intervalMinutes, t } = options
const items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }> = []
if (mode === 'interval' || mode === 'both') {
for (const ts of intervalTimestamps(points, intervalMinutes)) {
const sample = sampleAt(points, ts)
if (!sample) continue
items.push({
id: `interval-${ts}`,
timestamp: ts,
source: 'interval',
selected: true,
event: pointToLogEvent(sample, t('logs.nmea_remark_interval'), '')
})
}
}
if (mode === 'change' || mode === 'both') {
const changes = detectNmeaChanges(points)
for (const change of changes) {
const sample = change.data ?? sampleAt(points, change.timestamp)
if (!sample) continue
items.push({
id: `change-${change.type}-${change.timestamp}`,
timestamp: change.timestamp,
source: 'change',
changeType: change.type,
confidence: change.confidence,
selected: true,
event: pointToLogEvent(
{ ...sample, timestamp: change.timestamp },
buildRemarks(change, t),
changeToSailsOrMotor(change.type)
)
})
}
}
const deduped = mode === 'both'
? dedupeCandidates(items, 15 * 60 * 1000)
: items
return { candidates: deduped }
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { nmeaPointsToWaypoints, parseNmeaFile } from './nmeaParse.js'
describe('parseNmeaFile', () => {
it('parses RMC position, course and speed', () => {
const text = [
'$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W',
'$GPRMC,133519,A,4808.038,N,01132.000,E,025.0,090.0,230394,003.1,W'
].join('\n')
const result = parseNmeaFile(text, 'test.nmea')
expect(result.stats.parsedLines).toBe(2)
expect(result.stats.sentenceTypes).toContain('RMC')
expect(result.points.length).toBeGreaterThanOrEqual(2)
const first = result.points[0]
expect(first.lat).toBeCloseTo(48.1173, 3)
expect(first.lng).toBeCloseTo(11.516667, 3)
expect(first.sog).toBe(22.4)
expect(first.cog).toBe(84.4)
expect(first.fixValid).toBe(true)
})
it('merges wind and depth sentences onto the same timestamp', () => {
const text = [
'$GPRMC,100000,A,5400.000,N,01000.000,E,5.0,180.0,010124,003.0,E',
'$IIMWV,270.0,R,12.5,N,A',
'$SDDPT,4.5,0.0'
].join('\n')
const result = parseNmeaFile(text, 'merged.nmea')
const last = result.points[result.points.length - 1]
expect(last.windDir).toBe(270)
expect(last.windSpeedKnots).toBe(12.5)
expect(last.depthM).toBe(4.5)
})
it('skips lines with invalid checksum', () => {
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*FF'
const result = parseNmeaFile(text, 'bad.nmea')
expect(result.stats.checksumErrors).toBe(1)
expect(result.points).toHaveLength(0)
expect(result.warnings).toContain('no_samples')
})
it('warns when no position sentences are present', () => {
const text = '$IIMWV,090.0,R,8.0,N,A'
const result = parseNmeaFile(text, 'wind-only.nmea')
expect(result.warnings).toContain('no_position')
})
})
describe('nmeaPointsToWaypoints', () => {
it('maps points with coordinates to track waypoints', () => {
const waypoints = nmeaPointsToWaypoints([
{ timestamp: 1, lat: 54.0, lng: 10.0, sog: 6, cog: 90 },
{ timestamp: 2, windDir: 180 },
{ timestamp: 3, lat: 54.01, lng: 10.01, hdt: 95 }
])
expect(waypoints).toHaveLength(2)
expect(waypoints[0]).toMatchObject({ lat: 54, lng: 10, speedKnots: 6, heading: 90 })
expect(waypoints[1].heading).toBe(95)
})
})
+283
View File
@@ -0,0 +1,283 @@
import type { NmeaParseResult, NmeaParseStats, NmeaTimePoint } from './nmeaTypes.js'
function parseChecksum(line: string): boolean {
const star = line.lastIndexOf('*')
if (star < 0) return true
const expected = line.slice(star + 1, star + 3)
if (!/^[0-9A-Fa-f]{2}$/.test(expected)) return false
let sum = 0
for (let i = 1; i < star; i++) sum ^= line.charCodeAt(i)
return sum.toString(16).toUpperCase().padStart(2, '0') === expected.toUpperCase()
}
function sentenceType(field0: string): string {
return field0.length >= 3 ? field0.slice(-3) : field0
}
function parseLatLon(latStr: string, latHem: string, lonStr: string, lonHem: string): { lat?: number; lng?: number } {
const latVal = parseFloat(latStr)
const lonVal = parseFloat(lonStr)
if (Number.isNaN(latVal) || Number.isNaN(lonVal)) return {}
const latDeg = Math.floor(latVal / 100)
const latMin = latVal - latDeg * 100
let lat = latDeg + latMin / 60
if (latHem === 'S') lat = -lat
const lonDeg = Math.floor(lonVal / 100)
const lonMin = lonVal - lonDeg * 100
let lng = lonDeg + lonMin / 60
if (lonHem === 'W') lng = -lng
return { lat: Number(lat.toFixed(6)), lng: Number(lng.toFixed(6)) }
}
function parseRmcDateTime(timeStr: string, dateStr: string, baseYear = new Date().getFullYear()): number | null {
if (!timeStr || timeStr.length < 6) return null
const hh = parseInt(timeStr.slice(0, 2), 10)
const mm = parseInt(timeStr.slice(2, 4), 10)
const ss = parseInt(timeStr.slice(4, 6), 10)
if ([hh, mm, ss].some((n) => Number.isNaN(n))) return null
let year = baseYear
let month = 0
let day = 1
if (dateStr && dateStr.length >= 6) {
day = parseInt(dateStr.slice(0, 2), 10)
month = parseInt(dateStr.slice(2, 4), 10) - 1
const yy = parseInt(dateStr.slice(4, 6), 10)
year = yy >= 70 ? 1900 + yy : 2000 + yy
}
return Date.UTC(year, month, day, hh, mm, ss)
}
function parseWindSpeed(value: string, unit: string): number | undefined {
const speed = parseFloat(value)
if (Number.isNaN(speed)) return undefined
if (unit === 'N') return speed
if (unit === 'M') return speed * 1.94384
if (unit === 'K') return speed * 0.539957
return speed
}
interface MutableState extends NmeaTimePoint {
lastTimestamp: number | null
}
function snapshot(state: MutableState): NmeaTimePoint | null {
if (state.lastTimestamp == null) return null
const { lastTimestamp, ...rest } = state
void lastTimestamp
if (
rest.lat == null &&
rest.lng == null &&
rest.cog == null &&
rest.sog == null &&
rest.hdt == null &&
rest.windDir == null &&
rest.windSpeedKnots == null &&
rest.depthM == null &&
rest.rpm == null
) {
return null
}
return rest as NmeaTimePoint
}
function pushPoint(points: NmeaTimePoint[], state: MutableState) {
const snap = snapshot(state)
if (!snap) return
const last = points[points.length - 1]
if (last && last.timestamp === snap.timestamp) {
points[points.length - 1] = { ...last, ...snap }
return
}
points.push(snap)
}
function applySentence(state: MutableState, type: string, fields: string[], points: NmeaTimePoint[]) {
switch (type) {
case 'RMC': {
const status = fields[2]
const ts = parseRmcDateTime(fields[1], fields[9])
if (ts != null) {
state.timestamp = ts
state.lastTimestamp = ts
}
if (status === 'A') {
Object.assign(state, parseLatLon(fields[3], fields[4], fields[5], fields[6]))
state.fixValid = true
const sog = parseFloat(fields[7])
const cog = parseFloat(fields[8])
if (!Number.isNaN(sog)) state.sog = sog
if (!Number.isNaN(cog)) state.cog = cog
} else {
state.fixValid = false
}
pushPoint(points, state)
break
}
case 'GGA': {
const ts = parseRmcDateTime(fields[1], '')
if (ts != null) {
state.timestamp = ts
state.lastTimestamp = ts
}
Object.assign(state, parseLatLon(fields[2], fields[3], fields[4], fields[5]))
const quality = parseInt(fields[6], 10)
state.fixValid = !Number.isNaN(quality) && quality > 0
pushPoint(points, state)
break
}
case 'GLL': {
const ts = parseRmcDateTime(fields[5], fields[6] ?? '')
if (ts != null) {
state.timestamp = ts
state.lastTimestamp = ts
}
Object.assign(state, parseLatLon(fields[1], fields[2], fields[3], fields[4]))
state.fixValid = fields[7] === 'A'
pushPoint(points, state)
break
}
case 'VTG': {
const cog = parseFloat(fields[1])
const sog = parseFloat(fields[5] || fields[7])
if (!Number.isNaN(cog)) state.cog = cog
if (!Number.isNaN(sog)) state.sog = sog
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'HDT':
state.hdt = parseFloat(fields[1])
if (state.lastTimestamp != null) pushPoint(points, state)
break
case 'HDM':
state.hdm = parseFloat(fields[1])
if (state.lastTimestamp != null) pushPoint(points, state)
break
case 'HDG': {
const hdg = parseFloat(fields[1])
if (!Number.isNaN(hdg)) state.hdm = hdg
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MWV': {
if (fields[5] !== 'A') break
const dir = parseFloat(fields[1])
const speed = parseWindSpeed(fields[3], fields[4])
if (!Number.isNaN(dir)) state.windDir = dir
if (speed != null) state.windSpeedKnots = Number(speed.toFixed(1))
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MWD': {
const dir = parseFloat(fields[1])
const speed = parseFloat(fields[5])
if (!Number.isNaN(dir)) state.windDir = dir
if (!Number.isNaN(speed)) state.windSpeedKnots = Number(speed.toFixed(1))
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'DPT':
case 'DBT': {
const depth = parseFloat(fields[1])
if (!Number.isNaN(depth)) state.depthM = depth
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'RPM': {
const rpm = parseFloat(fields[3] ?? fields[2])
if (!Number.isNaN(rpm)) state.rpm = rpm
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MDA': {
const inchHg = parseFloat(fields[3])
const hpaField = parseFloat(fields[15] ?? fields[4])
if (!Number.isNaN(hpaField) && hpaField > 800) state.pressureHpa = hpaField
else if (!Number.isNaN(inchHg)) state.pressureHpa = inchHg * 33.8639
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MTW': {
const temp = parseFloat(fields[1])
if (!Number.isNaN(temp)) state.waterTempC = temp
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'VLW': {
const nm = parseFloat(fields[1] ?? fields[2])
if (!Number.isNaN(nm)) state.logDistanceNm = nm
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'APA': {
const mode = fields[1]
state.autopilotEngaged = mode === '1' || mode?.toUpperCase() === 'A'
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
default:
break
}
}
export function parseNmeaFile(text: string, filename: string): NmeaParseResult {
const warnings: string[] = []
const points: NmeaTimePoint[] = []
const typesSeen = new Set<string>()
let totalLines = 0
let parsedLines = 0
let checksumErrors = 0
const state: MutableState = { timestamp: 0, lastTimestamp: null }
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || (!line.startsWith('$') && !line.startsWith('!'))) continue
totalLines++
if (!parseChecksum(line)) {
checksumErrors++
continue
}
const star = line.indexOf('*')
const body = star >= 0 ? line.slice(0, star) : line
const fields = body.slice(1).split(',')
if (fields.length < 2) continue
const type = sentenceType(fields[0])
typesSeen.add(type)
applySentence(state, type, fields, points)
parsedLines++
}
if (points.length === 0) {
warnings.push('no_samples')
}
if (!typesSeen.has('RMC') && !typesSeen.has('GGA') && !typesSeen.has('GLL')) {
warnings.push('no_position')
}
const stats: NmeaParseStats = {
totalLines,
parsedLines,
checksumErrors,
sentenceTypes: [...typesSeen].sort()
}
return { points, stats, warnings, rawText: text, filename }
}
export function nmeaPointsToWaypoints(points: NmeaTimePoint[]): import('../trackUpload.js').TrackWaypoint[] {
return points
.filter((p) => p.lat != null && p.lng != null)
.map((p) => ({
timestamp: p.timestamp,
lat: p.lat!,
lng: p.lng!,
speedKnots: p.sog,
heading: p.cog ?? p.hdt ?? p.hdm
}))
}
@@ -0,0 +1,58 @@
import type { NmeaTimePoint } from './nmeaTypes.js'
/** Nearest sample at or before timestamp (carry-forward). */
export function sampleAt(points: NmeaTimePoint[], timestamp: number): NmeaTimePoint | null {
if (points.length === 0) return null
let best: NmeaTimePoint | null = null
for (const p of points) {
if (p.timestamp <= timestamp) best = p
else break
}
return best ?? points[0]
}
export function filterPointsForDate(points: NmeaTimePoint[], dateYmd: string): NmeaTimePoint[] {
if (!dateYmd || points.length === 0) return points
const [y, m, d] = dateYmd.split('-').map((v) => parseInt(v, 10))
if ([y, m, d].some((n) => Number.isNaN(n))) return points
const start = Date.UTC(y, m - 1, d, 0, 0, 0)
const end = Date.UTC(y, m - 1, d, 23, 59, 59)
const filtered = points.filter((p) => p.timestamp >= start && p.timestamp <= end)
return filtered.length > 0 ? filtered : points
}
export function timestampToHHMM(timestamp: number, timeZone?: string): string {
const opts: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timeZone ?? undefined
}
const parts = new Intl.DateTimeFormat('en-GB', opts).formatToParts(new Date(timestamp))
const hh = parts.find((p) => p.type === 'hour')?.value ?? '00'
const mm = parts.find((p) => p.type === 'minute')?.value ?? '00'
return `${hh}:${mm}`
}
export function angularDelta(a: number, b: number): number {
const diff = Math.abs(a - b) % 360
return diff > 180 ? 360 - diff : diff
}
export function intervalTimestamps(
points: NmeaTimePoint[],
intervalMinutes: number
): number[] {
if (points.length === 0) return []
const start = points[0].timestamp
const end = points[points.length - 1].timestamp
const stepMs = intervalMinutes * 60 * 1000
const stamps: number[] = []
for (let t = start; t <= end; t += stepMs) {
stamps.push(t)
}
if (stamps[stamps.length - 1] !== end) stamps.push(end)
return stamps
}
+102
View File
@@ -0,0 +1,102 @@
export type NmeaChangeType =
| 'course'
| 'wind'
| 'pressure'
| 'engine_start'
| 'engine_stop'
| 'autopilot_on'
| 'autopilot_off'
| 'depth'
| 'anchor'
| 'departure'
| 'speed'
| 'gps_fix_lost'
| 'gps_fix_regained'
| 'water_temp'
| 'wind_shift'
export interface NmeaParseStats {
totalLines: number
parsedLines: number
checksumErrors: number
sentenceTypes: string[]
}
export interface NmeaTimePoint {
timestamp: number
lat?: number
lng?: number
cog?: number
sog?: number
hdt?: number
hdm?: number
windDir?: number
windSpeedKnots?: number
depthM?: number
rpm?: number
pressureHpa?: number
waterTempC?: number
logDistanceNm?: number
fixValid?: boolean
autopilotEngaged?: boolean
}
export interface NmeaChangeEvent {
type: NmeaChangeType
timestamp: number
confidence: 'high' | 'medium' | 'low'
summaryKey: string
summaryParams?: Record<string, string | number>
data?: Partial<NmeaTimePoint>
}
export interface NmeaParseResult {
points: NmeaTimePoint[]
stats: NmeaParseStats
warnings: string[]
rawText: string
filename: string
}
export type NmeaImportMode = 'interval' | 'change' | 'both'
export interface NmeaJournalCandidate {
id: string
timestamp: number
source: 'interval' | 'change'
changeType?: NmeaChangeType
confidence?: 'high' | 'medium' | 'low'
selected: boolean
}
export interface NmeaDetectionConfig {
courseDeltaDeg: number
windDirDeltaDeg: number
windSpeedDeltaKnots: number
pressureDeltaHpa: number
depthDeltaM: number
depthDeltaPercent: number
rpmIdle: number
rpmRunning: number
sogUnderWayKn: number
sogStoppedKn: number
anchorMinutes: number
speedDeltaKn: number
dedupeWindowMs: number
}
export const DEFAULT_NMEA_DETECTION_CONFIG: NmeaDetectionConfig = {
courseDeltaDeg: 28,
windDirDeltaDeg: 35,
windSpeedDeltaKnots: 4,
pressureDeltaHpa: 2,
depthDeltaM: 2,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 3,
dedupeWindowMs: 5 * 60 * 1000
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { isNmeaCrcAlreadyImported, type NmeaArchiveRecord } from './nmeaArchive.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
describe('nmeaArchive CRC tracking', () => {
it('detects duplicate file content by CRC32', () => {
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W\n'
const record: NmeaArchiveRecord = {
filename: 'a.nmea',
rawText: '',
importedAt: '2026-05-29T10:00:00.000Z',
importedFiles: [{
crc32: nmeaFileCrc32(text),
filename: 'a.nmea',
importedAt: '2026-05-29T10:00:00.000Z'
}]
}
expect(isNmeaCrcAlreadyImported(record, text)).toBe(true)
expect(isNmeaCrcAlreadyImported(record, text.replace('\n', '\r\n'))).toBe(true)
expect(isNmeaCrcAlreadyImported(record, '$GPRMC,999999,A\n')).toBe(false)
expect(isNmeaCrcAlreadyImported(null, text)).toBe(false)
})
})
+146
View File
@@ -0,0 +1,146 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson, decryptJson } from './crypto.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
export interface NmeaImportedFile {
crc32: string
filename: string
importedAt: string
}
export interface NmeaArchiveRecord {
filename: string
rawText: string
importedAt: string
importedFiles: NmeaImportedFile[]
}
function normalizeArchiveRecord(raw: Partial<NmeaArchiveRecord>): NmeaArchiveRecord {
const importedFiles = [...(raw.importedFiles ?? [])]
if (importedFiles.length === 0 && raw.rawText) {
importedFiles.push({
crc32: nmeaFileCrc32(raw.rawText),
filename: raw.filename ?? '',
importedAt: raw.importedAt ?? ''
})
}
return {
filename: raw.filename ?? '',
rawText: raw.rawText ?? '',
importedAt: raw.importedAt ?? '',
importedFiles
}
}
async function putNmeaArchiveRecord(
logbookId: string,
entryId: string,
payload: NmeaArchiveRecord
): Promise<void> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const encrypted = await encryptJson(payload, masterKey)
await db.nmeaArchives.put({
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: payload.importedAt || new Date().toISOString()
})
}
export async function getNmeaArchive(entryId: string): Promise<NmeaArchiveRecord | null> {
const record = await db.nmeaArchives.get(entryId)
if (!record) return null
const masterKey = await getLogbookKey(record.logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
try {
return normalizeArchiveRecord(
await decryptJson(record.encryptedData, record.iv, record.tag, masterKey) as Partial<NmeaArchiveRecord>
)
} catch {
return null
}
}
export function isNmeaCrcAlreadyImported(record: NmeaArchiveRecord | null, rawText: string): boolean {
if (!record) return false
const crc32 = nmeaFileCrc32(rawText)
return record.importedFiles.some((file) => file.crc32 === crc32)
}
/** Remember imported file by CRC (even when raw log is discarded). */
export async function recordNmeaFileImport(
logbookId: string,
entryId: string,
filename: string,
rawText: string
): Promise<string> {
const crc32 = nmeaFileCrc32(rawText)
const existing = await getNmeaArchive(entryId)
const importedFiles = [...(existing?.importedFiles ?? [])]
if (!importedFiles.some((file) => file.crc32 === crc32)) {
importedFiles.push({
crc32,
filename,
importedAt: new Date().toISOString()
})
}
const payload: NmeaArchiveRecord = {
filename: existing?.filename ?? '',
rawText: existing?.rawText ?? '',
importedAt: new Date().toISOString(),
importedFiles
}
await putNmeaArchiveRecord(logbookId, entryId, payload)
return crc32
}
export async function saveNmeaArchive(
logbookId: string,
entryId: string,
filename: string,
rawText: string
): Promise<void> {
const crc32 = nmeaFileCrc32(rawText)
const existing = await getNmeaArchive(entryId)
const importedFiles = [...(existing?.importedFiles ?? [])]
if (!importedFiles.some((file) => file.crc32 === crc32)) {
importedFiles.push({
crc32,
filename,
importedAt: new Date().toISOString()
})
}
const payload: NmeaArchiveRecord = {
filename,
rawText,
importedAt: new Date().toISOString(),
importedFiles
}
await putNmeaArchiveRecord(logbookId, entryId, payload)
}
export async function deleteNmeaArchive(entryId: string): Promise<void> {
await db.nmeaArchives.delete(entryId)
}
export function downloadNmeaArchive(record: NmeaArchiveRecord): void {
const blob = new Blob([record.rawText], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = record.filename || 'track.nmea'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
+92
View File
@@ -0,0 +1,92 @@
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 saveEntryPhoto(options: {
logbookId: string
entryId: string
imageDataUrl: string
caption?: string
analyticsContext?: string
}): Promise<string> {
const { logbookId, entryId, imageDataUrl, caption = '', analyticsContext = 'logbook' } = options
const masterKey = await getEncryptionKey(logbookId)
const photoId = window.crypto.randomUUID()
const photoPayload = {
image: imageDataUrl,
caption: caption.trim()
}
const encrypted = await encryptJson(photoPayload, masterKey)
const now = new Date().toISOString()
await db.photos.put({
payloadId: photoId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
caption: '',
updatedAt: now
})
await db.syncQueue.put({
action: 'create',
type: 'photo',
payloadId: photoId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
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
}
export async function deleteEntryPhoto(logbookId: string, photoId: string): Promise<void> {
const now = new Date().toISOString()
await db.photos.delete(photoId)
await db.syncQueue.put({
action: 'delete',
type: 'photo',
payloadId: photoId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
/** Deletes the newest photo for an entry; returns its id or null. */
export async function removeLastPhotoForEntry(
logbookId: string,
entryId: string
): Promise<string | null> {
const photos = await db.photos.where({ entryId }).toArray()
if (photos.length === 0) return null
photos.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
const lastId = photos[0].payloadId
await deleteEntryPhoto(logbookId, lastId)
return lastId
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from 'vitest'
import { tryDecryptEntryPayload } from './quickEventLog.js'
vi.mock('./crypto.js', () => ({
decryptJson: vi.fn(async (_c: string, _i: string, _t: string) => {
throw new Error('decrypt failed')
}),
encryptJson: vi.fn()
}))
describe('tryDecryptEntryPayload', () => {
it('returns null when decryption fails', async () => {
const result = await tryDecryptEntryPayload(
{ encryptedData: 'x', iv: 'y', tag: 'z' },
new ArrayBuffer(32)
)
expect(result).toBeNull()
})
})
+388
View File
@@ -0,0 +1,388 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import {
buildLogEntryPayload,
normalizeLogEvent,
sortLogEventsByTime,
currentLocalTimeHHMM,
type LogEventPayload
} from '../utils/logEntryPayload.js'
import {
carryOverFromPreviousDay,
compareTravelDaysChronological,
getNextTravelDayNumber,
type LogEntryTankSource,
type TravelDaySortable
} from '../utils/logEntryTankLevels.js'
export interface LoadedEntry {
payloadId: string
updatedAt: string
data: Record<string, unknown>
}
type EncryptedRecord = {
encryptedData: string
iv: string
tag: string
}
async function getMasterKey(logbookId: string): Promise<ArrayBuffer> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
return masterKey
}
/** Decrypt one record; skip corrupt or legacy entries instead of aborting the whole scan. */
export async function tryDecryptEntryPayload(
record: EncryptedRecord,
key: ArrayBuffer
): Promise<Record<string, unknown> | null> {
try {
return await decryptJson(record.encryptedData, record.iv, record.tag, key)
} catch {
return null
}
}
function sortEntriesNewestFirst<T extends { updatedAt: string }>(entries: T[]): T[] {
return [...entries].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
}
function tankLevelsFromData(data: Record<string, unknown>) {
const fw = (data.freshwater as Record<string, number> | undefined) ?? {
morning: 0, refilled: 0, evening: 0, consumption: 0
}
const fuel = (data.fuel as Record<string, number> | undefined) ?? {
morning: 0, refilled: 0, evening: 0, consumption: 0
}
const gw = data.greywater as { level?: number } | undefined
return { fw, fuel, gw }
}
function buildEncryptedPayload(
data: Record<string, unknown>,
options: {
events: LogEventPayload[]
departure?: string
destination?: string
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
clearSignatures?: boolean
}
): Record<string, unknown> {
const { fw, fuel, gw } = tankLevelsFromData(data)
const trackDistance = data.trackDistanceNm
const trackSpeedMax = data.trackSpeedMaxKn
const trackSpeedAvg = data.trackSpeedAvgKn
const motorHoursRaw = data.motorHours
const freshwater = options.freshwater ?? {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
}
const fuelLevels = options.fuel ?? {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
}
const payload = buildLogEntryPayload({
date: String(data.date || ''),
dayOfTravel: String(data.dayOfTravel || ''),
departure: options.departure ?? String(data.departure || ''),
destination: options.destination ?? String(data.destination || ''),
freshwater,
fuel: fuelLevels,
greywater: gw ? { level: gw.level || 0 } : undefined,
trackDistanceNm:
trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance))
: undefined,
trackSpeedMaxKn:
trackSpeedMax != null && trackSpeedMax !== ''
? parseFloat(String(trackSpeedMax))
: undefined,
trackSpeedAvgKn:
trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg))
: undefined,
motorHours:
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
: undefined,
events: options.events
})
const clear = options.clearSignatures
return {
...payload,
signSkipper: clear ? '' : (data.signSkipper ?? ''),
signCrew: clear ? '' : (data.signCrew ?? '')
}
}
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
const masterKey = await getMasterKey(logbookId)
const record = await db.entries.get(entryId)
if (!record) return null
const data = await tryDecryptEntryPayload(record, masterKey)
if (!data) return null
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
}
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10)
const masterKey = await getMasterKey(logbookId)
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
for (const entry of local) {
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted && String(decrypted.date) === todayStr) {
return entry.payloadId
}
}
return null
}
export async function createTodayEntry(logbookId: string): Promise<string> {
const masterKey = await getMasterKey(logbookId)
const localEntries = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
if (localEntries.length > 0) {
for (const entry of localEntries) {
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted) {
decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
}
}
}
decryptedEntries.sort(compareTravelDaysChronological)
const previousEntry = decryptedEntries.at(-1) ?? null
const { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
const initialPayload = {
date: todayStr,
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
departure,
destination: '',
freshwater,
fuel,
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
signSkipper: '',
signCrew: '',
events: []
}
const encrypted = await encryptJson(initialPayload, masterKey)
await db.entries.put({
payloadId: localId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: nowStr
})
await db.syncQueue.put({
action: 'create',
type: 'entry',
payloadId: localId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: nowStr
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return localId
}
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)
}
const existing = await findTodayEntryId(id)
if (existing) return existing
return createTodayEntry(id)
}
export interface AppendQuickEventResult {
events: LogEventPayload[]
hadSignature: boolean
}
export async function appendQuickEvent(
logbookId: string,
entryId: string,
partialEvent: Partial<LogEventPayload>,
headerPatch?: { departure?: string; destination?: string }
): Promise<AppendQuickEventResult> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
const newEvent = normalizeLogEvent({
time: currentLocalTimeHHMM(),
...partialEvent
})
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
await persistEntry(logbookId, entryId, loaded.data, {
events: nextEvents,
departure: headerPatch?.departure,
destination: headerPatch?.destination,
clearSignatures: hadSignature
})
return { events: nextEvents, hadSignature }
}
/** Append multiple events in one load/encrypt/persist cycle (avoids UI freezes). */
export async function appendQuickEvents(
logbookId: string,
entryId: string,
partialEvents: Partial<LogEventPayload>[]
): Promise<AppendQuickEventResult> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
if (partialEvents.length === 0) {
return { events: currentEvents, hadSignature }
}
const time = currentLocalTimeHHMM()
const newEvents = partialEvents.map((partial) =>
normalizeLogEvent({ time, ...partial })
)
const nextEvents = sortLogEventsByTime([...currentEvents, ...newEvents])
await persistEntry(logbookId, entryId, loaded.data, {
events: nextEvents,
clearSignatures: hadSignature
})
return { events: nextEvents, hadSignature }
}
async function persistEntry(
logbookId: string,
entryId: string,
data: Record<string, unknown>,
options: Parameters<typeof buildEncryptedPayload>[1]
): Promise<void> {
const hadSignature = !!(data.signSkipper || data.signCrew)
const entryData = buildEncryptedPayload(data, {
...options,
clearSignatures: options.clearSignatures ?? hadSignature
})
const masterKey = await getMasterKey(logbookId)
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function removeLastEvent(
logbookId: string,
entryId: string
): Promise<LogEventPayload[]> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
if (currentEvents.length === 0) return []
const nextEvents = sortLogEventsByTime(currentEvents.slice(0, -1))
await persistEntry(logbookId, entryId, loaded.data, { events: nextEvents })
return nextEvents
}
export async function appendTankRefill(
logbookId: string,
entryId: string,
tank: 'fuel' | 'freshwater',
addLiters: number,
event: Partial<LogEventPayload>
): Promise<AppendQuickEventResult> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const { fw, fuel } = tankLevelsFromData(loaded.data)
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
const newEvent = normalizeLogEvent({
time: currentLocalTimeHHMM(),
...event
})
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
const tankPatch = tank === 'fuel'
? {
fuel: {
morning: fuel.morning || 0,
refilled: (fuel.refilled || 0) + addLiters,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
}
}
: {
freshwater: {
morning: fw.morning || 0,
refilled: (fw.refilled || 0) + addLiters,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
}
}
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
await persistEntry(logbookId, entryId, loaded.data, {
events: nextEvents,
...tankPatch,
clearSignatures: hadSignature
})
return { events: nextEvents, hadSignature }
}
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PlausibleEvents } from './analytics.js'
const apiFetch = vi.fn()
const trackPlausibleEvent = vi.fn()
vi.mock('./api.js', () => ({ apiFetch }))
vi.mock('./analytics.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./analytics.js')>()
return {
...actual,
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
}
})
vi.mock('./userPreferences.js', () => ({
getOwmApiKeyForActiveUser: () => ''
}))
describe('fetchOpenWeatherCurrent', () => {
beforeEach(() => {
apiFetch.mockReset()
trackPlausibleEvent.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('tracks OWM Weather Fetched on success when analyticsSource is set', async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ coord: { lat: 54, lon: 10 }, main: { temp: 20 } })
})
const { fetchOpenWeatherCurrent } = await import('./weather.js')
await fetchOpenWeatherCurrent(
{ lat: '54.0', lon: '10.0' },
{ analyticsSource: 'live_log' }
)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.OWM_WEATHER_FETCHED, {
source: 'live_log'
})
})
it('does not track when the API request fails', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: 'fail' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
await expect(
fetchOpenWeatherCurrent({ lat: '54', lon: '10' }, { analyticsSource: 'entry_editor' })
).rejects.toBeInstanceOf(WeatherApiError)
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
})
+37 -6
View File
@@ -1,5 +1,10 @@
import { apiFetch } from './api.js'
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
import {
type OwmAnalyticsSource,
PlausibleEvents,
trackPlausibleEvent
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
@@ -11,11 +16,16 @@ export class WeatherApiError extends Error {
}
}
export async function fetchOpenWeatherCurrent(params: {
lat?: string
lon?: string
q?: string
}): Promise<Record<string, unknown>> {
const OWM_FETCH_TIMEOUT_MS = 20_000
export async function fetchOpenWeatherCurrent(
params: {
lat?: string
lon?: string
q?: string
},
options?: { analyticsSource: OwmAnalyticsSource }
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {
@@ -31,7 +41,22 @@ export async function fetchOpenWeatherCurrent(params: {
const headers: Record<string, string> = {}
if (userKey) headers['X-OWM-Api-Key'] = userKey
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), OWM_FETCH_TIMEOUT_MS)
let res: Response
try {
res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, {
headers,
signal: controller.signal
})
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new WeatherApiError('Weather request timed out')
}
throw err
} finally {
window.clearTimeout(timeoutId)
}
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
@@ -42,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
throw new WeatherApiError('Weather API rejected the request')
}
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
source: options.analyticsSource
})
}
return data
}
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
import { captureVideoFrame, preferNativeCameraPicker } from './captureVideoFrame.js'
describe('preferNativeCameraPicker', () => {
it('returns true on Android user agents', () => {
vi.stubGlobal('navigator', { ...navigator, userAgent: 'Mozilla/5.0 (Linux; Android 14)' })
expect(preferNativeCameraPicker()).toBe(true)
vi.unstubAllGlobals()
})
it('returns false on desktop without touch', () => {
vi.stubGlobal('navigator', {
...navigator,
userAgent: 'Mozilla/5.0 (Windows NT 10.0)',
maxTouchPoints: 0
})
vi.stubGlobal('matchMedia', () => ({
matches: false,
addEventListener: () => {},
removeEventListener: () => {}
}))
Object.defineProperty(window, 'ontouchstart', { value: undefined, configurable: true })
expect(preferNativeCameraPicker()).toBe(false)
vi.unstubAllGlobals()
})
})
describe('captureVideoFrame', () => {
it('throws when video dimensions are zero', async () => {
const video = { videoWidth: 0, videoHeight: 0 } as HTMLVideoElement
await expect(captureVideoFrame(video)).rejects.toThrow('video_frame_not_ready')
})
})
+59
View File
@@ -0,0 +1,59 @@
/** Capture current video frame as JPEG blob (with Android-safe fallbacks). */
export async function captureVideoFrame(video: HTMLVideoElement, quality = 0.92): Promise<Blob> {
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) {
throw new Error('video_frame_not_ready')
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('canvas_context_unavailable')
}
ctx.drawImage(video, 0, 0, width, height)
const blob = await canvasToJpegBlob(canvas, quality)
if (blob) return blob
const dataUrl = canvas.toDataURL('image/jpeg', quality)
const response = await fetch(dataUrl)
const fallback = await response.blob()
if (!fallback.size) {
throw new Error('capture_encode_failed')
}
return fallback
}
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
return new Promise((resolve) => {
let settled = false
const finish = (blob: Blob | null) => {
if (settled) return
settled = true
window.clearTimeout(timer)
resolve(blob)
}
const timer = window.setTimeout(() => finish(null), 3000)
try {
canvas.toBlob((blob) => finish(blob), 'image/jpeg', quality)
} catch {
finish(null)
}
})
}
/** Mobile: native camera via file input is more reliable than getUserMedia + canvas. */
export function preferNativeCameraPicker(): boolean {
if (typeof window === 'undefined') return false
const ua = navigator.userAgent
if (/Android|iPhone|iPad|iPod/i.test(ua)) return true
const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const coarse = window.matchMedia('(pointer: coarse)').matches
const narrow = window.matchMedia('(max-width: 768px)').matches
return touch && (coarse || narrow)
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { crc32Hex, nmeaFileCrc32, normalizeNmeaTextForCrc } from './crc32.js'
describe('crc32', () => {
it('hashes known test vectors', () => {
expect(crc32Hex('')).toBe('00000000')
expect(crc32Hex('123456789')).toBe('CBF43926')
})
it('normalizes line endings before hashing NMEA content', () => {
const a = nmeaFileCrc32('$GPRMC,123519,A\r\n$GPGGA,123519\r\n')
const b = nmeaFileCrc32('$GPRMC,123519,A\n$GPGGA,123519\n')
expect(a).toBe(b)
expect(normalizeNmeaTextForCrc('a\r\nb\r')).toBe('a\nb')
})
})
+30
View File
@@ -0,0 +1,30 @@
/** Normalize NMEA text so identical content hashes the same across platforms. */
export function normalizeNmeaTextForCrc(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd()
}
const CRC32_TABLE = (() => {
const table = new Uint32Array(256)
for (let i = 0; i < 256; i++) {
let c = i
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
}
table[i] = c >>> 0
}
return table
})()
/** CRC-32 (IEEE / Ethernet polynomial), uppercase 8-char hex. */
export function crc32Hex(text: string): string {
const bytes = new TextEncoder().encode(text)
let crc = 0xffffffff
for (const byte of bytes) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
}
return ((crc ^ 0xffffffff) >>> 0).toString(16).toUpperCase().padStart(8, '0')
}
export function nmeaFileCrc32(text: string): string {
return crc32Hex(normalizeNmeaTextForCrc(text))
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import {
isMotorRunningFromEvents,
LIVE_EVENT_CODES,
liveCommentRemark,
liveSailsRemark,
liveSogRemark,
parseLiveCommentRemark,
livePhotoRemark,
parseLiveSailsRemark
} from './liveEventCodes.js'
import { formatEventSummary } from './formatEventSummary.js'
import { normalizeLogEvent } from './logEntryPayload.js'
const t = (key: string, opts?: Record<string, unknown>) => {
const map: Record<string, string> = {
'logs.live_motor_start': 'Motor Start',
'logs.live_motor_stop': 'Motor Stop',
'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_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured',
'logs.live_course_entry': `Course ${opts?.course}`,
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
'logs.event_mgk': 'Course',
'logs.event_wind_pressure': 'Pressure'
}
return map[key] ?? key
}
describe('liveEventCodes', () => {
it('derives motor running from last motor event', () => {
const events = [
{ remarks: LIVE_EVENT_CODES.MOTOR_START },
{ remarks: LIVE_EVENT_CODES.MOTOR_STOP },
{ remarks: LIVE_EVENT_CODES.MOTOR_START }
]
expect(isMotorRunningFromEvents(events)).toBe(true)
})
it('returns false when last motor event is stop', () => {
const events = [
{ remarks: LIVE_EVENT_CODES.MOTOR_START },
{ remarks: LIVE_EVENT_CODES.MOTOR_STOP }
]
expect(isMotorRunningFromEvents(events)).toBe(false)
})
it('parses sail and comment remarks', () => {
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
})
})
describe('formatEventSummary', () => {
it('formats live motor start', () => {
const event = normalizeLogEvent({ time: '08:10', remarks: LIVE_EVENT_CODES.MOTOR_START })
expect(formatEventSummary(event, t)).toBe('Motor Start')
})
it('formats sails remark', () => {
const event = normalizeLogEvent({
time: '08:20',
remarks: liveSailsRemark('Main + Genoa'),
sailsOrMotor: 'Main + Genoa'
})
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
})
it('formats fix with coordinates', () => {
const event = normalizeLogEvent({
time: '09:00',
remarks: LIVE_EVENT_CODES.FIX,
gpsLat: '54.323000',
gpsLng: '10.145000'
})
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000')
})
it('formats pressure entry', () => {
const event = normalizeLogEvent({
time: '09:00',
remarks: LIVE_EVENT_CODES.PRESSURE,
windPressure: '1013'
})
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa')
})
it('formats SOG entry', () => {
const event = normalizeLogEvent({
time: '10:15',
remarks: liveSogRemark('5.2')
})
expect(formatEventSummary(event, t)).toBe('SOG 5.2 kn')
})
it('formats STW entry', () => {
const event = normalizeLogEvent({
time: '10:20',
remarks: '__live:stw:4.8'
})
expect(formatEventSummary(event, t)).toBe('STW 4.8 kn')
})
it('formats photo entry', () => {
const plain = normalizeLogEvent({ time: '11:00', remarks: livePhotoRemark() })
expect(formatEventSummary(plain, t)).toBe('Photo captured')
const captioned = normalizeLogEvent({
time: '11:05',
remarks: livePhotoRemark('Mastbruch')
})
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
})
})
+100
View File
@@ -0,0 +1,100 @@
import type { TFunction } from 'i18next'
import type { LogEventPayload } from './logEntryPayload.js'
import {
LIVE_EVENT_CODES,
parseLiveCommentRemark,
parseLiveFuelRemark,
parseLivePhotoRemark,
parseLivePrecipRemark,
parseLiveSailsRemark,
parseLiveSogRemark,
parseLiveStwRemark,
parseLiveTempRemark,
parseLiveWaterRemark
} from './liveEventCodes.js'
export function formatEventSummary(event: LogEventPayload, t: TFunction): string {
const code = event.remarks.trim()
if (code === LIVE_EVENT_CODES.MOTOR_START) return t('logs.live_motor_start')
if (code === LIVE_EVENT_CODES.MOTOR_STOP) return t('logs.live_motor_stop')
if (code === LIVE_EVENT_CODES.CAST_OFF) return t('logs.live_cast_off')
if (code === LIVE_EVENT_CODES.MOOR) return t('logs.live_moor')
const sails = parseLiveSailsRemark(code)
if (sails) return t('logs.live_sails', { sails })
const comment = parseLiveCommentRemark(code)
if (comment) return comment
const photo = parseLivePhotoRemark(code)
if (photo !== null) {
return photo
? t('logs.live_photo_entry', { caption: photo })
: t('logs.live_photo_entry_plain')
}
const temp = parseLiveTempRemark(code)
if (temp) return t('logs.live_temp_entry', { temp })
const precip = parseLivePrecipRemark(code)
if (precip) return t('logs.live_precip_entry', { value: precip })
const fuel = parseLiveFuelRemark(code)
if (fuel) return t('logs.live_fuel_entry', { liters: fuel })
const water = parseLiveWaterRemark(code)
if (water) return t('logs.live_water_entry', { liters: water })
const sog = parseLiveSogRemark(code)
if (sog) return t('logs.live_sog_entry', { speed: sog })
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 (event.gpsLat && event.gpsLng) {
const label = code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position')
: t('logs.live_fix')
return `${label} ${event.gpsLat}, ${event.gpsLng}`
}
return code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position')
: t('logs.live_fix')
}
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
return t('logs.live_course_entry', { course: event.mgk })
}
if (code === LIVE_EVENT_CODES.WIND) {
const wind = [event.windDirection, event.windStrength].filter(Boolean).join(' ')
return wind ? t('logs.live_wind_entry', { value: wind }) : t('logs.live_wind_btn')
}
if (code === LIVE_EVENT_CODES.PRESSURE && event.windPressure) {
return t('logs.live_pressure_entry', { value: event.windPressure })
}
if (code === LIVE_EVENT_CODES.SEA_STATE && event.seaState) {
return t('logs.live_sea_state_entry', { value: event.seaState })
}
if (code && !code.startsWith('__live:')) {
return code
}
const parts: string[] = []
if (event.sailsOrMotor) parts.push(event.sailsOrMotor)
if (event.mgk) parts.push(`${t('logs.event_mgk')} ${event.mgk}`)
if (event.windDirection || event.windStrength) {
parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' '))
}
if (event.windPressure) parts.push(`${t('logs.event_wind_pressure')}: ${event.windPressure}`)
if (event.gpsLat && event.gpsLng) {
parts.push(`${event.gpsLat}, ${event.gpsLng}`)
}
return parts.join(' · ') || t('logs.live_event_generic')
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js'
describe('geolocation helpers', () => {
it('parses coordinates with comma decimals', () => {
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
})
it('normalizes valid lat/lng', () => {
expect(normalizeGpsCoordinates('54.1', '10.2')).toEqual({
lat: '54.100000',
lng: '10.200000'
})
})
it('rejects out-of-range values', () => {
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
})
})
+51
View File
@@ -0,0 +1,51 @@
const MPS_TO_KNOTS = 1.9438444924406
export interface GeoCoordinates {
lat: string
lng: string
/** SOG from GPS when available (kn), otherwise null. */
speedKn: number | null
}
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
}
/** Validates lat/lng and returns normalized strings for storage, or null. */
export function normalizeGpsCoordinates(
lat: string,
lng: string
): { lat: string; lng: string } | null {
const latN = parseGpsCoordinate(lat)
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) }
}
export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('geolocation_unavailable'))
return
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
resolve({
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
})
},
(err) => reject(err),
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
)
})
}
+64 -2
View File
@@ -1,7 +1,40 @@
import { describe, expect, it } from 'vitest'
import { getNextLanguage, normalizeAppLanguage, SUPPORTED_LANGUAGES } from './i18nLanguages.js'
import type { i18n as I18nInstance } from 'i18next'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PlausibleEvents } from '../services/analytics.js'
import {
changeAppLanguage,
cycleAppLanguage,
getNextLanguage,
normalizeAppLanguage,
SUPPORTED_LANGUAGES
} from './i18nLanguages.js'
const trackPlausibleEvent = vi.fn()
vi.mock('../services/analytics.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../services/analytics.js')>()
return {
...actual,
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
}
})
function createMockI18n(language: string): I18nInstance {
let current = language
return {
language: current,
changeLanguage: vi.fn(async (lng: string) => {
current = lng
;(this as { language: string }).language = lng
})
} as unknown as I18nInstance
}
describe('i18nLanguages', () => {
beforeEach(() => {
trackPlausibleEvent.mockReset()
})
it('normalizes regional tags to supported base codes', () => {
expect(normalizeAppLanguage('de-DE')).toBe('de')
expect(normalizeAppLanguage('nb-NO')).toBe('nb')
@@ -18,4 +51,33 @@ describe('i18nLanguages', () => {
expect(seen.size).toBe(SUPPORTED_LANGUAGES.length)
expect(current).toBe('de')
})
it('tracks explicit language changes', () => {
const i18n = createMockI18n('de')
changeAppLanguage(i18n, 'sv')
expect(i18n.changeLanguage).toHaveBeenCalledWith('sv')
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
from: 'de',
to: 'sv'
})
})
it('does not track when language stays the same', () => {
const i18n = createMockI18n('en')
changeAppLanguage(i18n, 'en')
expect(i18n.changeLanguage).not.toHaveBeenCalled()
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
it('cycleAppLanguage tracks the next language', () => {
const i18n = createMockI18n('nb')
cycleAppLanguage(i18n)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
from: 'nb',
to: 'de'
})
})
})
+17
View File
@@ -1,3 +1,6 @@
import type { i18n as I18nInstance } from 'i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
/** Supported UI languages (ISO 639-1, language-only). */
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const
@@ -20,3 +23,17 @@ export function getNextLanguage(current?: string): AppLanguage {
export function isGermanLocale(language?: string): boolean {
return normalizeAppLanguage(language) === 'de'
}
/** Switch UI language and track explicit user choice (not auto-detection). */
export function changeAppLanguage(i18n: I18nInstance, language: AppLanguage): void {
const from = normalizeAppLanguage(i18n.language)
const to = normalizeAppLanguage(language)
if (from === to) return
void i18n.changeLanguage(to)
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from, to })
}
export function cycleAppLanguage(i18n: I18nInstance): void {
changeAppLanguage(i18n, getNextLanguage(i18n.language))
}
+46
View File
@@ -0,0 +1,46 @@
export const PHOTO_MAX_WIDTH = 1280
export const PHOTO_MAX_HEIGHT = 720
export const PHOTO_JPEG_QUALITY = 0.7
function loadImageFromDataUrl(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('image_load_failed'))
img.src = dataUrl
})
}
export function compressImageElement(img: HTMLImageElement): string {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
if (width > PHOTO_MAX_WIDTH || height > PHOTO_MAX_HEIGHT) {
const ratio = Math.min(PHOTO_MAX_WIDTH / width, PHOTO_MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
return canvas.toDataURL('image/jpeg', PHOTO_JPEG_QUALITY)
}
export async function blobToCompressedJpegDataUrl(blob: Blob): Promise<string> {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('image_read_failed'))
reader.readAsDataURL(blob)
})
const img = await loadImageFromDataUrl(dataUrl)
return compressImageElement(img)
}
export async function fileToCompressedJpegDataUrl(file: Blob): Promise<string> {
return blobToCompressedJpegDataUrl(file)
}
+186
View File
@@ -0,0 +1,186 @@
/** Machine-readable live-log markers stored in event.remarks (locale-independent). */
export const LIVE_EVENT_CODES = {
MOTOR_START: '__live:motor_start',
MOTOR_STOP: '__live:motor_stop',
CAST_OFF: '__live:cast_off',
MOOR: '__live:moor',
FIX: '__live:fix',
AUTO_POSITION: '__live:auto_position',
COURSE: '__live:course',
WIND: '__live:wind',
PRESSURE: '__live:pressure',
SEA_STATE: '__live:sea_state'
} as const
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
export function liveSailsRemark(sails: string): string {
return `__live:sails:${sails}`
}
export function liveCommentRemark(text: string): string {
return `__live:comment:${text}`
}
export function liveTempRemark(tempC: string): string {
return `__live:temp:${tempC}`
}
export function livePrecipRemark(text: string): string {
return `__live:precip:${text}`
}
export function liveFuelRemark(liters: string): string {
return `__live:fuel:${liters}`
}
export function liveWaterRemark(liters: string): string {
return `__live:water:${liters}`
}
export function livePhotoRemark(caption?: string): string {
const text = caption?.trim()
return text ? `__live:photo:${text}` : '__live:photo'
}
export function parseLivePhotoRemark(remarks: string): string | null {
if (remarks === '__live:photo') return ''
const prefix = '__live:photo:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function liveSogRemark(speedKn: string): string {
return `__live:sog:${speedKn}`
}
export function liveStwRemark(speedKn: string): string {
return `__live:stw:${speedKn}`
}
export function parseLiveSailsRemark(remarks: string): string | null {
const prefix = '__live:sails:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveCommentRemark(remarks: string): string | null {
const prefix = '__live:comment:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveTempRemark(remarks: string): string | null {
const prefix = '__live:temp:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLivePrecipRemark(remarks: string): string | null {
const prefix = '__live:precip:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveFuelRemark(remarks: string): string | null {
const prefix = '__live:fuel:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveWaterRemark(remarks: string): string | null {
const prefix = '__live:water:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveSogRemark(remarks: string): string | null {
const prefix = '__live:sog:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveStwRemark(remarks: string): string | null {
const prefix = '__live:stw:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
/** Derive motor running state from event history (survives reload). */
export function isMotorRunningFromEvents(
events: Array<{ remarks: string }>,
motorStartCode: string = LIVE_EVENT_CODES.MOTOR_START,
motorStopCode: string = LIVE_EVENT_CODES.MOTOR_STOP
): boolean {
for (let i = events.length - 1; i >= 0; i--) {
const code = events[i].remarks.trim()
if (code === motorStartCode) return true
if (code === motorStopCode) return false
}
return false
}
export function eventTimestampMs(date: string, time: string): number | null {
const normalized = time.trim().match(/^(\d{1,2}):(\d{2})/)
if (!normalized || !date) return null
const hours = parseInt(normalized[1], 10)
const minutes = parseInt(normalized[2], 10)
if (hours > 23 || minutes > 59) return null
const parsed = new Date(`${date}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`)
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime()
}
export function getLastAutoPositionMs(
events: Array<{ remarks: string; time: string }>,
entryDate: string
): number | null {
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].remarks.trim() !== LIVE_EVENT_CODES.AUTO_POSITION) continue
return eventTimestampMs(entryDate, events[i].time)
}
return null
}
/** Max age of a logged GPS fix 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 interface LiveLogPositionFix {
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
}
/** Latest FIX or auto-position event with GPS coordinates (any age). */
export function getLatestPositionFix(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string
): LiveLogPositionFix | null {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i]
const code = event.remarks.trim()
if (!isPositionEventCode(code)) continue
const lat = event.gpsLat?.trim()
const lng = event.gpsLng?.trim()
if (!lat || !lng) continue
const loggedAtMs = eventTimestampMs(entryDate, event.time)
if (loggedAtMs == null) continue
return {
lat,
lng,
loggedAtMs,
source: code === LIVE_EVENT_CODES.FIX ? 'fix' : 'auto_position'
}
}
return null
}
/** GPS fix for weather if logged within `maxAgeMs` (default 6 h). */
export function getLastPositionFixWithin(
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)
if (!latest) return null
if (nowMs - latest.loggedAtMs > maxAgeMs) return null
return latest
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import {
getLastPositionFixWithin,
getLatestPositionFix,
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', () => {
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' }
]
const fix = getLatestPositionFix(events, entryDate)
expect(fix?.lat).toBe('54.2')
expect(fix?.source).toBe('fix')
})
it('accepts auto-position with GPS', () => {
const events = [
{
remarks: LIVE_EVENT_CODES.AUTO_POSITION,
time: '14:00',
gpsLat: '55.0',
gpsLng: '11.0'
}
]
expect(getLatestPositionFix(events, entryDate)?.source).toBe('auto_position')
})
it('rejects fix older than max age for weather', () => {
const noon = new Date(`${entryDate}T12:00:00`).getTime()
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
]
expect(
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
).toBeNull()
expect(getLatestPositionFix(events, entryDate)).not.toBeNull()
})
it('accepts fix within six hours', () => {
const noon = new Date(`${entryDate}T12:00:00`).getTime()
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
]
expect(
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
).not.toBeNull()
})
})
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
formatWindStrengthBeaufort,
mpsToBeaufort,
parseOwmCurrentWeather
} from './openWeatherMap.js'
describe('openWeatherMap', () => {
it('maps m/s to Beaufort', () => {
expect(mpsToBeaufort(0)).toBe(0)
expect(mpsToBeaufort(5)).toBe(3)
expect(mpsToBeaufort(15)).toBe(7)
expect(formatWindStrengthBeaufort(5)).toBe('3 Bft (5.0 m/s)')
})
it('parses OWM current weather payload', () => {
const parsed = parseOwmCurrentWeather({
wind: { speed: 8.5, deg: 225 },
main: { pressure: 1018, temp: 17.4 },
weather: [{ icon: '04d', description: 'Bedeckt' }]
})
expect(parsed.windDirection).toBe('SW')
expect(parsed.windStrength).toBe('5 Bft (8.5 m/s)')
expect(parsed.windPressure).toBe('1018')
expect(parsed.tempC).toBe('17.4')
expect(parsed.precipText).toBe('Bedeckt')
expect(parsed.weatherIcon).toBe('04d')
})
})
+68
View File
@@ -0,0 +1,68 @@
import { degreesToCardinal } from './courseAngle.js'
export interface ParsedOwmCurrent {
windDirection: string
windStrength: string
windPressure: string
tempC: string | null
precipText: string | null
weatherIcon: string | null
}
/** Beaufort scale from wind speed in m/s (OWM `wind.speed`). */
export function mpsToBeaufort(mps: number): number {
if (mps < 0.3) return 0
if (mps < 1.6) return 1
if (mps < 3.4) return 2
if (mps < 5.5) return 3
if (mps < 8.0) return 4
if (mps < 10.8) return 5
if (mps < 13.9) return 6
if (mps < 17.2) return 7
if (mps < 20.8) return 8
if (mps < 24.5) return 9
if (mps < 28.5) return 10
if (mps < 32.7) return 11
return 12
}
export function formatWindStrengthBeaufort(mps: number): string {
const bft = mpsToBeaufort(mps)
return `${bft} Bft (${mps.toFixed(1)} m/s)`
}
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
const wind = data.wind as { speed?: number; deg?: number } | undefined
const main = data.main as { pressure?: number; temp?: number } | undefined
const rain = data.rain as { '1h'?: number } | undefined
const weatherArr = data.weather as Array<{ icon?: string; description?: string }> | undefined
const mps = wind?.speed ?? 0
const windStrength = formatWindStrengthBeaufort(mps)
const windDirection = wind?.deg !== undefined ? degreesToCardinal(wind.deg) : ''
const windPressure = main?.pressure != null ? String(main.pressure) : ''
let tempC: string | null = null
if (main?.temp != null && Number.isFinite(main.temp)) {
tempC = Number(main.temp).toFixed(1)
}
let precipText: string | null = null
const firstWeather = weatherArr?.[0]
if (firstWeather?.description?.trim()) {
precipText = firstWeather.description.trim()
} else if (rain?.['1h'] != null && Number.isFinite(rain['1h'])) {
precipText = `${rain['1h']} mm/h`
}
const weatherIcon = firstWeather?.icon?.trim() ? firstWeather.icon.trim() : null
return {
windDirection,
windStrength,
windPressure,
tempC,
precipText,
weatherIcon
}
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
isPasskeyCompatibleHostname,
isPasskeyInvalidDomainError,
isWebAuthnUserAbortError,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from './passkeyHost.js'
describe('isPasskeyCompatibleHostname', () => {
it('accepts localhost and real domains', () => {
expect(isPasskeyCompatibleHostname('localhost')).toBe(true)
expect(isPasskeyCompatibleHostname('kapteins-daagbok.eu')).toBe(true)
})
it('rejects IP addresses', () => {
expect(isPasskeyCompatibleHostname('127.0.0.1')).toBe(false)
})
})
describe('toPasskeyCompatibleUrl', () => {
it('rewrites 127.0.0.1 to localhost', () => {
expect(toPasskeyCompatibleUrl('http://127.0.0.1:5173/demo?lng=de')).toBe(
'http://localhost:5173/demo?lng=de'
)
})
})
describe('isPasskeyInvalidDomainError', () => {
it('detects simplewebauthn browser message', () => {
expect(isPasskeyInvalidDomainError('127.0.0.1 is an invalid domain')).toBe(true)
expect(isPasskeyInvalidDomainError('User cancelled')).toBe(false)
})
})
describe('isWebAuthnUserAbortError', () => {
it('detects NotAllowedError and timeout messages', () => {
expect(isWebAuthnUserAbortError({ name: 'NotAllowedError', message: 'timed out' })).toBe(true)
expect(
isWebAuthnUserAbortError(
new Error('The operation either timed out or was not allowed.')
)
).toBe(true)
expect(isWebAuthnUserAbortError({ name: 'SecurityError', message: 'bad rp' })).toBe(false)
})
})
describe('localizeWebAuthnError', () => {
it('maps cancellation to a friendly message', () => {
expect(
localizeWebAuthnError('The operation either timed out or was not allowed.', {
invalidHost: 'host',
cancelled: 'cancelled'
})
).toBe('cancelled')
})
})
+69
View File
@@ -0,0 +1,69 @@
/**
* WebAuthn / Passkeys require a valid domain (see WHATWG valid domain).
* IP addresses such as 127.0.0.1 are rejected by browsers and @simplewebauthn/browser.
*/
export function isPasskeyCompatibleHostname(hostname: string): boolean {
return (
hostname === 'localhost' ||
/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(hostname)
)
}
export function isPasskeyCompatibleLocation(loc: Location = window.location): boolean {
return isPasskeyCompatibleHostname(loc.hostname)
}
/** Same page on localhost — for dev links when opened via 127.0.0.1. */
export function toPasskeyCompatibleUrl(href: string): string {
const url = new URL(href)
if (url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname === '::1') {
url.hostname = 'localhost'
}
return url.toString()
}
/**
* Redirect 127.0.0.1 / ::1 to localhost (dev). Returns true if navigation was started.
*/
export function redirectToPasskeyCompatibleHostIfNeeded(loc: Location = window.location): boolean {
if (isPasskeyCompatibleHostname(loc.hostname)) return false
const target = toPasskeyCompatibleUrl(loc.href)
if (target === loc.href) return false
window.location.replace(target)
return true
}
export function isPasskeyInvalidDomainError(message: string): boolean {
return /is an invalid domain$/i.test(message)
}
export function localizePasskeyHostError(message: string, invalidHostMessage: string): string {
return isPasskeyInvalidDomainError(message) ? invalidHostMessage : message
}
/** User dismissed or denied the platform passkey prompt (do not auto-retry WebAuthn). */
export function isWebAuthnUserAbortError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false
const name = 'name' in err ? String((err as { name: string }).name) : ''
if (name === 'NotAllowedError' || name === 'AbortError') return true
const message = 'message' in err ? String((err as { message: string }).message) : String(err)
return /timed out|not allowed|cancel/i.test(message)
}
export function localizeWebAuthnError(
message: string,
messages: {
invalidHost: string
cancelled: string
invalidRpId?: string
}
): string {
if (isPasskeyInvalidDomainError(message)) return messages.invalidHost
if (/timed out|not allowed|cancel/i.test(message)) return messages.cancelled
if (/invalid for this domain/i.test(message) && messages.invalidRpId) {
return messages.invalidRpId
}
return message
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
dedupeSailNames,
isSailInSelection,
joinSailSelection,
splitSailSelection,
toggleSailInSelection
} from './sailSelection.js'
describe('toggleSailInSelection', () => {
it('adds a second sail without removing the first', () => {
const first = toggleSailInSelection([], 'Mainsail')
expect(first).toEqual(['Mainsail'])
const second = toggleSailInSelection(first, 'Genoa')
expect(second).toEqual(['Mainsail', 'Genoa'])
})
it('removes sail when toggled again', () => {
const selected = toggleSailInSelection(
toggleSailInSelection([], 'Mainsail'),
'Genoa'
)
expect(toggleSailInSelection(selected, 'Mainsail')).toEqual(['Genoa'])
})
it('matches case-insensitively', () => {
expect(toggleSailInSelection(['genua'], 'Genua')).toEqual([])
expect(isSailInSelection(['Großsegel'], 'großsegel')).toBe(true)
})
})
describe('joinSailSelection / splitSailSelection', () => {
it('round-trips multiple sails', () => {
const joined = joinSailSelection(['Großsegel', 'Genua'])
expect(joined).toBe('Großsegel + Genua')
expect(splitSailSelection(joined)).toEqual(['Großsegel', 'Genua'])
})
})
describe('dedupeSailNames', () => {
it('removes duplicate names', () => {
expect(dedupeSailNames(['Genua', 'genua', 'Fock'])).toEqual(['Genua', 'Fock'])
})
})
+42
View File
@@ -0,0 +1,42 @@
/** Toggle one sail label in a multi-select list (case-insensitive). */
export function toggleSailInSelection(selected: readonly string[], sail: string): string[] {
const normalized = sail.trim()
if (!normalized) return [...selected]
return selected.some((s) => s.toLowerCase() === normalized.toLowerCase())
? selected.filter((s) => s.toLowerCase() !== normalized.toLowerCase())
: [...selected, normalized]
}
export function isSailInSelection(selected: readonly string[], sail: string): boolean {
const normalized = sail.trim().toLowerCase()
if (!normalized) return false
return selected.some((s) => s.toLowerCase() === normalized)
}
/** Join selected sails for logbook `sailsOrMotor` (matches LogEntryEditor). */
export function joinSailSelection(selected: readonly string[]): string {
return selected.map((s) => s.trim()).filter(Boolean).join(' + ')
}
export function splitSailSelection(value: string): string[] {
return value
.split(/\s*(?:\+|\bplus\b|,)\s*/i)
.map((s) => s.trim())
.filter(Boolean)
}
/** Deduplicate sail names for picker UI (case-insensitive, keeps first spelling). */
export function dedupeSailNames(sails: readonly string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const sail of sails) {
const trimmed = sail.trim()
if (!trimmed) continue
const key = trimmed.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
result.push(trimmed)
}
return result
}
+2
View File
@@ -46,6 +46,8 @@ export default defineConfig({
include: ['leaflet']
},
server: {
// Passkeys require localhost or a real domain — not 127.0.0.1
host: 'localhost',
port: 5173,
proxy: {
'/api': {
+121
View File
@@ -0,0 +1,121 @@
# Code-Statistik — Kapteins Daagbok
Erstellt am **31. Mai 2026** mit [cloc](https://github.com/AlDanial/cloc) v1.98.
## Methode
```bash
cloc . \
--exclude-dir=node_modules,dist,.git,userfeedback,.cursor,.planning \
--md
```
Ausgeschlossen: Build-Artefakte (`dist/`), Abhängigkeiten (`node_modules/`), lokales Feedback, Cursor-/Planungs-Artefakte.
## Gesamtübersicht
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 145 | 3012 | 540 | 23599 |
| JSON | 14 | 4 | 0 | 15005 |
| CSS | 3 | 743 | 45 | 4837 |
| XML | 3 | 0 | 0 | 4302 |
| HTML | 5 | 160 | 0 | 1411 |
| Markdown | 8 | 390 | 12 | 1077 |
| JavaScript | 8 | 117 | 43 | 709 |
| Bourne Shell | 3 | 81 | 21 | 412 |
| YAML | 1 | 3 | 0 | 55 |
| Dockerfile | 2 | 20 | 21 | 39 |
| SVG | 4 | 0 | 0 | 27 |
| **SUM** | **196** | **4530** | **682** | **51473** |
### Anwendungscode (TypeScript, JavaScript, CSS)
Ohne JSON, GPX/XML, HTML, Docs und Assets — näher an der eigentlichen Implementierung:
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 145 | 3012 | 540 | 23599 |
| CSS | 3 | 743 | 45 | 4837 |
| JavaScript | 8 | 117 | 43 | 709 |
| **SUM** | **156** | **3872** | **628** | **29145** |
> **Hinweis:** Der hohe JSON-Anteil (~15k Zeilen) stammt überwiegend aus i18n-Locale-Dateien (`client/src/i18n/locales/*.json`). XML (~4,3k Zeilen) sind Demo-GPX-Tracks unter `client/src/assets/demo/`.
## Aufteilung nach Bereich
| Bereich | Dateien | Leer | Kommentar | Code |
| :--- | ---: | ---: | ---: | ---: |
| `client/` | 154 | 3398 | 557 | 43534 |
| `server/` | 20 | 399 | 54 | 4426 |
| `scripts/` | 9 | 193 | 59 | 1065 |
| `docs/` | 8 | 418 | 0 | 2079 |
### `client/`
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 129 | 2625 | 499 | 21291 |
| JSON | 10 | 4 | 0 | 12898 |
| CSS | 3 | 743 | 45 | 4837 |
| XML | 3 | 0 | 0 | 4302 |
| Markdown | 1 | 13 | 0 | 60 |
| JavaScript | 2 | 5 | 5 | 56 |
| HTML | 1 | 0 | 0 | 47 |
| SVG | 4 | 0 | 0 | 27 |
| Dockerfile | 1 | 8 | 8 | 16 |
| **SUM** | **154** | **3398** | **557** | **43534** |
### `server/`
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 16 | 387 | 41 | 2308 |
| JSON | 3 | 0 | 0 | 2095 |
| Dockerfile | 1 | 12 | 13 | 23 |
| **SUM** | **20** | **399** | **54** | **4426** |
### `scripts/`
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| JavaScript | 6 | 112 | 38 | 653 |
| Bourne Shell | 3 | 81 | 21 | 412 |
| **SUM** | **9** | **193** | **59** | **1065** |
## Größte Quelldateien (TypeScript & CSS)
| Datei | blank | comment | code |
| :--- | ---: | ---: | ---: |
| `client/src/App.css` | 730 | 31 | 4430 |
| `client/src/components/LogEntryEditor.tsx` | 176 | 17 | 1929 |
| `client/src/components/UserProfilePage.tsx` | 52 | 0 | 746 |
| `client/src/components/LiveLogView.tsx` | 50 | 2 | 711 |
| `client/src/App.tsx` | 85 | 21 | 656 |
| `client/src/components/CrewForm.tsx` | 82 | 117 | 644 |
| `client/src/components/VesselForm.tsx` | 55 | 8 | 558 |
| `client/src/services/auth.ts` | 80 | 66 | 556 |
| `client/src/services/logbookBackup.ts` | 56 | 0 | 545 |
| `client/src/components/AuthOnboarding.tsx` | 49 | 25 | 542 |
| `client/src/components/StatsDashboard.tsx` | 43 | 0 | 521 |
| `client/src/components/LogbookDashboard.tsx` | 46 | 2 | 508 |
| `client/src/components/InvitationAcceptance.tsx` | 59 | 0 | 461 |
| `client/src/components/LogEntriesList.tsx` | 50 | 4 | 447 |
| `client/src/services/sync.ts` | 70 | 29 | 428 |
## Kurzfassung
- **~51k** physische Codezeilen gesamt (inkl. Locales, Demo-GPX, Docs).
- **~29k** Zeilen reiner Anwendungscode (TS/JS/CSS).
- **~21k** TypeScript im Client, **~2,3k** im Server.
- Größte Einzeldatei: `App.css` (~4,4k Zeilen), größte Komponente: `LogEntryEditor.tsx` (~1,9k Zeilen).
## Report aktualisieren
```bash
cloc . \
--exclude-dir=node_modules,dist,.git,userfeedback,.cursor,.planning \
--md > docs/cloc-report-raw.md
```
Für eine reine Markdown-Tabelle reicht `--md`; dieser Report fasst mehrere cloc-Läufe manuell zusammen.
+3 -1
View File
@@ -157,7 +157,7 @@
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 10.5pt;
font-size: 8.5pt;
line-height: 1.28;
color: #e2e8f0;
}
@@ -320,6 +320,8 @@
<div class="feature"><span class="feature-icon"></span><span>Simpelt login uden adgangskode Passkey.</span></div>
<div class="feature"><span class="feature-icon"></span><span>Ende-til-ende-kryptering</span></div>
<div class="feature"><span class="feature-icon"></span><span>Upload af GPS-spor (GPX/KML) med kortvisning</span></div>
<div class="feature"><span class="feature-icon"></span><span>Automatisk loggenerering fra NMEA-data</span></div>
<div class="feature"><span class="feature-icon"></span><span>Live-log (klik-til-log)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Rute-statistik</span></div>
<div class="feature"><span class="feature-icon"></span><span>Vedhæftede billeder pr. rejsedag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotoavatarbilleder til skipper og besætning</span></div>
+3 -1
View File
@@ -157,7 +157,7 @@
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 10.5pt;
font-size: 8.5pt;
line-height: 1.28;
color: #e2e8f0;
}
@@ -320,6 +320,8 @@
<div class="feature"><span class="feature-icon"></span><span>Einfache passwortlose Passkey-Anmeldung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Ende-zu-Ende Verschlüsselung</span></div>
<div class="feature"><span class="feature-icon"></span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Automatische Log-Erstellung aus NMEA-Daten</span></div>
<div class="feature"><span class="feature-icon"></span><span>Live-Log (Click-to-Log)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Streckenstatistik</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge pro Reisetag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Avatarbilder für Skipper und Crew</span></div>
+6 -4
View File
@@ -157,7 +157,7 @@
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 10.5pt;
font-size: 8.5pt;
line-height: 1.28;
color: #e2e8f0;
}
@@ -309,7 +309,7 @@
</header>
<p class="intro">
Oppbevar loggboken om bord digitalt: reisedager, GPS-spor, mannskaps- og skipsdata
r loggboken om bord digitalt: reisedager, GPS-spor, mannskaps- og skipsdata
<strong>Ende-til-ende-kryptert</strong>kan installeres som en app og
<strong>også offline</strong> kan brukes til sjøs.
</p>
@@ -320,6 +320,8 @@
<div class="feature"><span class="feature-icon"></span><span>Enkel passordfri Passkey-pålogging</span></div>
<div class="feature"><span class="feature-icon"></span><span>Ende-til-ende-kryptering</span></div>
<div class="feature"><span class="feature-icon"></span><span>Opplasting av GPS-spor (GPX/KML) med kartvisning</span></div>
<div class="feature"><span class="feature-icon"></span><span>Automatisk logggenerering fra NMEA-data</span></div>
<div class="feature"><span class="feature-icon"></span><span>Live-logg (klikk-til-logg)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Rutestatistikk</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotobilag per reisedag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Avatarbilder for skipper og mannskap</span></div>
@@ -349,7 +351,7 @@
</section>
<section class="beta-box">
<h2>Betafasen - dine tilbakemeldinger teller</h2>
<h2>Betafase - dine tilbakemeldinger teller</h2>
<p>
Kapteins Daagbok er en<strong>Privat hobbyprosjekt uten profitthensikt</strong>.
Som betatester bidrar du til å forbedre appen for skippere og mannskap i hverdagen - tilbakemeldinger er hjertelig velkomne.
@@ -366,7 +368,7 @@
<p>Åpne i nettleseren eller legg til som en app på startskjermen. Registrer deg med Passkey - ingen appbutikk er nødvendig.</p>
<div class="tags">
<span class="tag">Kostnadsfritt</span>
<span class="tag">Gratis annonsering</span>
<span class="tag">Reklame gratis</span>
<span class="tag">E2E-kryptert</span>
</div>
</div>
+3 -1
View File
@@ -157,7 +157,7 @@
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 10.5pt;
font-size: 8.5pt;
line-height: 1.28;
color: #e2e8f0;
}
@@ -320,6 +320,8 @@
<div class="feature"><span class="feature-icon"></span><span>Enkel lösenordsfri Passkey-inloggning</span></div>
<div class="feature"><span class="feature-icon"></span><span>End-to-end-kryptering</span></div>
<div class="feature"><span class="feature-icon"></span><span>Uppladdning av GPS-spår (GPX/KML) med kartvisning</span></div>
<div class="feature"><span class="feature-icon"></span><span>Automatisk logggenerering från NMEA-data</span></div>
<div class="feature"><span class="feature-icon"></span><span>Live-logg (klicka för att logga)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Statistik över rutter</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotobilagor per resdag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotoavatarbilder för skeppare och besättning</span></div>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+165
View File
@@ -0,0 +1,165 @@
# NMEA-Import — Recherche & Entscheidungsnotizen
Stand: 2026-05-31 · Status: **In Umsetzung** (`feature/nmea-journal-import`)
Anlass: Nutzeranfrage, ob Kapteins Daagbok um NMEA-Empfang erweiterbar sei.
## Kurzfassung
| Ansatz | Machbarkeit (PWA) | Empfehlung |
|--------|-------------------|------------|
| **Live-NMEA** (Serial/TCP/Bluetooth vom Plotter) | Praktisch nein (Browser-Sandbox, iOS) | Nicht als reine PWA versprechen |
| **NMEA-Dateiimport** | Ja (Parsing im Client) | Sinnvoller nächster Schritt, wenn überhaupt |
| **GPX-Import** (bereits vorhanden) | Ja | Für die meisten Freizeit-Skipper der praktischere Weg |
---
## Aktueller Stand in Kapteins Daagbok
- **PWA** (installierbar, offline-fähig), kein nativer App-Store-Wrapper
- **Position:** `navigator.geolocation` (Geräte-GPS) in `LogEntryEditor.tsx`
- **Tracks:** Upload von **GPX/KML/GeoJSON** → Karte, Streckenstatistik (`trackUpload.ts`, `LogEntryEditor.tsx`)
- **Log-Ereignisse** u. a.: Zeit, MgK/rwk, Wind (Richtung/Stärke/Druck), Seegang, Wetter, Strom, Krängung, Segel/Motor, Log, Distanz, GPS-Koordinaten, Bemerkungen (`logEntryPayload.ts`)
Es gibt **keinen** NMEA-Parser und **keinen** Live-Datenstrom.
---
## Warum Live-NMEA in einer PWA schwierig ist
Typische NMEA-Quellen an Bord und Browser-Fähigkeiten:
| Quelle | PWA-tauglich? |
|--------|----------------|
| USB/Serial (RS422/232) | Kaum — Web Serial API nur Chrome/Edge, **nicht iOS/Safari**, am Tablet/Phone selten praktikabel |
| TCP/UDP (z. B. Port 10110) | **Nein** — Browser haben keine Raw-Sockets |
| Bluetooth-NMEA | Sehr eingeschränkt (Web Bluetooth), iOS praktisch unbrauchbar |
| Handy-GPS | **Ja** — Geolocation API (bereits implementiert), aber **kein NMEA vom Plotter** |
Weitere PWA-Limits:
- Kein zuverlässiger **Hintergrundbetrieb** für kontinuierlichen Empfang
- **HTTPS-App → lokales Boot-Netz** (`192.168.x.x`): Mixed Content, CORS, ggf. Local-Network-Permissions
- iPad/iPhone als installierte PWA besonders restriktiv
**Umweg (später optional):** Gateway im Boot (z. B. SignalK) mit WebSocket/HTTP — PWA verbindet sich dann zu einem **Server**, nicht direkt zu NMEA. Setup-Aufwand, eher für Technikaffine.
**Native Hülle** (Capacitor, Electron, …) würde Serial/TCP/Bluetooth erweitern — wäre keine reine PWA mehr.
---
## Was ist eine NMEA-Datei?
**NMEA 0183** = textbasiertes Protokoll aus **Einzelzeilen-Sätzen**, z. B.:
```
$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47
$HDT,274.3,T*2F
$MWV,274.5,R,15.2,N,A*2B
$DPT,12.4,0.5*42
```
Eine `.nmea`- oder `.log`-Datei ist ein **Zeitstempel-Stream** — alles, was der Logger in diesem Zeitraum mitgeschrieben hat.
**Nicht alle Telemetriedaten sind garantiert enthalten.** Es hängt ab von:
1. Sensoren an Bord (GPS ja, Wind nur mit Windgeber, …)
2. Logger-/Multiplexer-Konfiguration
3. Empfang während der Aufzeichnung
Ein reiner GPS-Logger liefert praktisch nur Position/Kurs/Fahrt.
---
## Was könnte ein NMEA-Dateiimport in der App bewirken?
Mapping zu bestehenden Logbuch-Feldern (Auszug):
| NMEA-Satz (Beispiel) | Inhalt | Nutzen |
|----------------------|--------|--------|
| RMC / GGA / GLL | Position, Zeit, oft SOG/COG | GPS-Koordinaten, **Track** (analog GPX), Kurs |
| VTG / VHW | Fahrt über Grund/Wasser, Kurs | Streckenstatistik, Kursfelder |
| HDT / HDG / HDM | Peilung/Kompass | MgK/rwk-Vorschläge |
| MWV / MWD | Wind | Windfelder im Reisetag |
| DPT / DBT | Tiefe | aktuell kein eigenes Feld |
| MTW | Wassertemperatur | ggf. Bemerkungen |
| XDR | diverse Transducer | abhängig vom Gerät |
**Mehrwert gegenüber GPX:**
- Track **plus** zeitlich zugeordnete Wind-/Kursdaten (wenn in der Datei vorhanden)
- Automatisches Vorschlagen von Log-Ereignissen aus Bord-Sensoren
- Eine Quelle (Bordanlage) statt nur Handy-GPS
**Was NMEA typischerweise nicht liefert** (bleibt manuell / Wetter-API):
- Seegang, Wetter-Symbolik, Strom, Krängung
- Crew, Hafen, Bemerkungen, Tankstände
- Segel/Motor-Konfiguration im nautischen Sinne
NMEA = **Sensor-Telemetrie**, kein **Skipper-Logbuch**.
---
## Wird NMEA an Bord üblicherweise aufgezeichnet & exportiert?
**Teilweise — selten so einfach wie GPX für Endnutzer.**
| Quelle | Typischer Export | Einfach für Freizeit-Skipper? |
|--------|------------------|-------------------------------|
| Chartplotter (Garmin, Raymarine, B&G, …) | **GPX** (Track/Route) | ✅ oft (SD/USB/App) |
| Chartplotter | Roh-NMEA | ⚠️ selten direkt |
| WiFi-Multiplexer, SignalK, Raspi | NMEA-Datei oder Stream | ⚠️ Technikaffine |
| PC-Software (OpenCPN, …) | NMEA-Log | ⚠️ |
**GPX ist der de-facto-Standard** für „Track mit nach Hause nehmen“. NMEA-Rohlogs sind Nischen- oder Profi-/Tüftler-Setup.
---
## Mögliche Roadmap (wenn wir es angehen)
### Phase 1 — NMEA-Dateiimport (PWA-kompatibel)
- Parser für gängige Sätze: RMC, GGA, GLL, VTG, optional MWV/MWD, HDT
- Track aus Positions-Sätzen (wie GPX-Pipeline)
- UI: Upload neben GPX/KML in `LogEntryEditor`
- Checksummen-Validierung (`*XX`), Encoding, gemischte Talker-IDs (GP, GN, …)
### Phase 2 — Anreicherung Log-Ereignisse
- Aus NMEA-Stream pro Zeitpunkt Wind/Kurs/Position in Log-Events vorschlagen
- Nutzer bestätigt/korrigiert (kein blindes Überschreiben)
### Phase 3 — optional, nicht PWA-pur
- SignalK-WebSocket (Nutzer konfiguriert Boot-URL)
- Oder native Wrapper / Companion-Bridge
**Nicht empfohlen als Phase 1:** Live-NMEA direkt aus der PWA.
---
## Antwortvorlage für Nutzer
> Als reine Browser-App können wir keinen direkten NMEA-Anschluss (Serial/TCP vom Plotter) zuverlässig anbieten — mobile Browser erlauben das nicht, besonders auf iPhone/iPad.
> Position über Handy-GPS und GPX-Tracks (Export vom Plotter oder Nav-App) funktionieren bereits.
> Ein **Import von NMEA-Dateien** (vom Gateway oder Logger) ist grundsätzlich denkbar und könnte Track plus ggf. Wind/Kurs ins Logbuch übernehmen — das prüfen wir für eine spätere Version.
> Für die meisten Skipper ist **GPX vom Plotter** der einfachere Weg.
---
## Offene Fragen für spätere Planung
- Welche NMEA-Varianten melden Nutzer realistisch (0183 vs. NMEA 2000 nur über Gateway)?
- Reicht Parser-Abdeckung für 95 % der Dateien mit RMC+GGA+MWV?
- Sollen importierte Rohdaten gespeichert werden oder nur abgeleitete GPX/Events?
- Datenschutz: NMEA-Datei lokal parsen, nichts an Server senden (passt zu E2E-Modell)
- Plausible-Event analog `GPS Track Uploaded` → z. B. `NMEA File Imported`?
## Referenzen
- [NMEA 0183](https://www.nmea.org/) — Protokollstandard
- [SignalK](https://signalk.org/) — moderne Boot-API, WebSocket
- [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) — Browser, eingeschränkt
- Bestehender Code: `client/src/services/trackUpload.ts`, `client/src/components/LogEntryEditor.tsx`
+61 -2
View File
@@ -21,6 +21,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
| NMEA Uploaded | NMEA-Datei erfolgreich gelesen und geparst (`NmeaImportWizard.tsx`) | `lines` (Anzahl Sätze), `candidates` (Vorschläge für Reisetag), `duplicate` (Datei schon importiert), `has_position` |
| NMEA Imported | NMEA-Vorschläge in Journal übernommen (`NmeaImportWizard.tsx`) | `mode`: `interval` \| `change` \| `both`, `events` (übernommene Einträge), `track` (GPS-Track mit importiert) |
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
@@ -34,7 +36,9 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| 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`) | — |
| 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` |
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
@@ -49,6 +53,46 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — |
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
| Language Changed | Sprache über UI-Wechsler gewählt (`i18nLanguages.ts` via Sprach-Button in App, Dashboard, Auth, Demo, Einladung, Share-Viewer) | `from`, `to`: ISO 639-1 (`de`, `en`, `da`, `sv`, `nb`) |
| Live Log Opened | Live-Journal-Ansicht geladen (`LiveLogView.tsx`, einmal pro Mount nach erfolgreichem Init) | — |
| Live Log Event Logged | Quick-Action erfolgreich ins heutige Journal geschrieben (`LiveLogView.tsx`) | `action`: siehe [Live-Log-Aktionen](#live-log-aktionen) |
### Live-Log-Aktionen
Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel, keine Inhalte (kein Kurs, kein Kommentartext, keine Koordinaten):
| `action` | Button / Auslöser |
|----------|-------------------|
| `motor_start` | Motor Start |
| `motor_stop` | Motor Stop |
| `cast_off` | Ablegen |
| `moor` | Anlegen |
| `sails` | Segel (Modal bestätigt) |
| `course` | Kurs (Dial/Modal bestätigt) |
| `sog` | SOG |
| `stw` | STW |
| `fuel` | Diesel-Tank |
| `water` | Wasser-Tank |
| `wind` | Wind (Richtung/Stärke) |
| `pressure` | Luftdruck |
| `temp` | Temperatur |
| `precip` | Niederschlag |
| `sea_state` | Seegang |
| `fix` | GPS-Fix (manuell) |
| `comment` | Kommentar |
| `undo` | Letztes Ereignis rückgängig |
### OWM-Quellen
Property `source` bei **OWM Weather Fetched** — ein Event pro erfolgreichem API-Call (keine Koordinaten, kein Ortsname):
| `source` | Auslöser |
|----------|----------|
| `live_log` | OpenWeatherMap-Wetter im Live-Journal (`LiveLogView.tsx`) |
| `entry_editor` | Wetter-Button im Reisetag-Editor (`LogEntryEditor.tsx`, `handleFetchWeather`) |
| `entry_editor_gps_lookup` | GPS-Fallback per Ortsname im Reisetag-Editor (`LogEntryEditor.tsx`, `handleGetGps`) |
Fehlgeschlagene Abrufe (kein API-Key, Timeout, leere Antwort) lösen **kein** Event aus.
## Bewusst nicht getrackt
@@ -56,11 +100,16 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
- **Sprache bei Erstbesuch:** Automatische Browser-/URL-Erkennung (`i18next-browser-languagedetector`, `?lng=`) löst kein `Language Changed` aus — nur explizite Klicks auf den Sprach-Button.
- **Live-Log Auto-Position:** Hintergrund-GPS alle 3 h (`LIVE_EVENT_CODES.AUTO_POSITION`) — automatisch, best-effort, kein Nutzer-Tap.
- **Live-Log Modals:** Öffnen/Abbrechen von Dialogen ohne Speichern; Wechsel Liste ↔ Live (nur `Live Log Opened` beim erneuten Mount).
- **Live-Log Editor-Link:** Öffnen des vollständigen Editors aus der Live-Ansicht.
- **NMEA-Import:** Abbrechen, Vorschau ohne Übernahme, Archiv-Entscheid (Archivieren/Verwerfen); fehlgeschlagene Datei-Lesevorgänge.
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
## Typische Funnels (Plausible Goals)
Empfohlene Goal-Ketten für Auswertung:
Empfohlene Goal-Ketten für Auswertung (nur Business!):
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
@@ -69,6 +118,10 @@ Empfohlene Goal-Ketten für Auswertung:
5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
6. **Datensicherung:** Backup Exported → Backup Restored
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
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
## Entwicklung
@@ -77,6 +130,12 @@ import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
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.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 })
```
Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.
+31 -4
View File
@@ -49,6 +49,21 @@ function parseColorSchemePreference(value: unknown): string | null {
return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null
}
function isMissingAppearancePrefsTable(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === 'P2021'
)
}
const DEFAULT_APPEARANCE_PREFS = {
theme: 'auto',
colorScheme: 'auto',
persisted: false
} as const
router.post('/register-options', async (req, res) => {
try {
const { username } = req.body
@@ -448,9 +463,14 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
colorScheme: prefs?.colorScheme ?? 'auto',
persisted: prefs != null
})
} catch (error: any) {
} catch (error: unknown) {
if (isMissingAppearancePrefsTable(error)) {
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
return res.json({ ...DEFAULT_APPEARANCE_PREFS })
}
console.error('Error reading appearance prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
}
})
@@ -482,9 +502,16 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
colorScheme: prefs.colorScheme,
persisted: true
})
} catch (error: any) {
} catch (error: unknown) {
if (isMissingAppearancePrefsTable(error)) {
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
return res.status(503).json({
error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
})
}
console.error('Error updating appearance prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
}
})
+1 -1
View File
@@ -5,7 +5,7 @@ import { analyzeFeedbackSpam, feedbackLimiter } from '../middleware/feedbackProt
const router = Router()
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general'])
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general', 'translation'])
const MAX_MESSAGE_LENGTH = 2000
const MAX_EMAIL_LENGTH = 254
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+418
View File
@@ -0,0 +1,418 @@
; Kapteins Daagbok Test-NMEA — Kieler Förde Kiellinie → Laboe, 5 sm
; Datum: 2026-05-29, passend zu testdata/tracks/kieler-foerde-5sm.gpx
; Import-Tipp: Reisetag-Datum auf 2026-05-29 setzen
; Sätze: RMC, GGA, VTG, HDT, MWV, DPT, MDA, RPM (Motorphase), MTW, VLW
$GPRMC,101500.00,A,5419.7280,N,01008.7360,E,2.5,42.2,290526,,*00
$GPGGA,101500.00,5419.7280,N,01008.7360,E,1,08,1.0,12.5,M,46.0,M,,*5B
$GPVTG,42.2,T,,M,2.5,N,4.6,K*51
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.5,0.0*61
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,101637.00,A,5419.7815,N,01008.8193,E,2.9,42.2,290526,,*0C
$GPGGA,101637.00,5419.7815,N,01008.8193,E,1,08,1.0,12.4,M,46.0,M,,*5A
$GPVTG,42.2,T,,M,2.9,N,5.3,K*59
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.4,0.0*60
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,101829.00,A,5419.8529,N,01008.9305,E,3.3,42.2,290526,,*07
$GPGGA,101829.00,5419.8529,N,01008.9305,E,1,08,1.0,12.3,M,46.0,M,,*5D
$GPVTG,42.2,T,,M,3.3,N,6.2,K*50
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.3,0.0*67
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,102006.00,A,5419.9242,N,01009.0416,E,3.8,42.2,290526,,*0C
$GPGGA,102006.00,5419.9242,N,01009.0416,E,1,08,1.0,12.2,M,46.0,M,,*5C
$GPVTG,42.2,T,,M,3.8,N,7.1,K*59
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.2,0.0*66
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,102152.00,A,5420.0134,N,01009.1806,E,4.4,42.2,290526,,*0A
$GPGGA,102152.00,5420.0134,N,01009.1806,E,1,08,1.0,12.1,M,46.0,M,,*52
$GPVTG,42.2,T,,M,4.4,N,8.2,K*5E
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.1,0.0*65
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102329.00,A,5420.1204,N,01009.3473,E,5.9,42.2,290526,,*05
$GPGGA,102329.00,5420.1204,N,01009.3473,E,1,08,1.0,12.0,M,46.0,M,,*50
$GPVTG,42.2,T,,M,5.9,N,10.9,K*60
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.0,0.0*64
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102503.00,A,5420.2274,N,01009.5141,E,4.9,42.2,290526,,*0C
$GPGGA,102503.00,5420.2274,N,01009.5141,E,1,08,1.0,11.8,M,46.0,M,,*53
$GPVTG,42.2,T,,M,4.9,N,9.2,K*52
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.8,0.0*6F
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102637.00,A,5420.3167,N,01009.6530,E,4.6,42.2,290526,,*06
$GPGGA,102637.00,5420.3167,N,01009.6530,E,1,08,1.0,11.7,M,46.0,M,,*59
$GPVTG,42.2,T,,M,4.6,N,8.5,K*5B
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.7,0.0*60
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102818.00,A,5420.4236,N,01009.8197,E,5.8,42.2,290526,,*0D
$GPGGA,102818.00,5420.4236,N,01009.8197,E,1,08,1.0,11.6,M,46.0,M,,*5C
$GPVTG,42.2,T,,M,5.8,N,10.8,K*60
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.6,0.0*61
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,102949.00,A,5420.5307,N,01009.9865,E,5.2,42.2,290526,,*05
$GPGGA,102949.00,5420.5307,N,01009.9865,E,1,08,1.0,11.4,M,46.0,M,,*5C
$GPVTG,42.2,T,,M,5.2,N,9.6,K*5C
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.4,0.0*63
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103111.00,A,5420.6100,N,01010.1100,E,4.5,32.8,290526,,*06
$GPGGA,103111.00,5420.6100,N,01010.1100,E,1,08,1.0,11.3,M,46.0,M,,*53
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,11.3,0.0*64
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103255.00,A,5420.7314,N,01010.2446,E,5.7,32.8,290526,,*04
$GPGGA,103255.00,5420.7314,N,01010.2446,E,1,08,1.0,11.2,M,46.0,M,,*53
$GPVTG,32.8,T,,M,5.7,N,10.5,K*6F
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,11.2,0.0*65
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103425.00,A,5420.8529,N,01010.3792,E,5.4,32.8,290526,,*0A
$GPGGA,103425.00,5420.8529,N,01010.3792,E,1,08,1.0,11.0,M,46.0,M,,*5C
$GPVTG,32.8,T,,M,5.4,N,10.0,K*69
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,11.0,0.0*67
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103614.00,A,5420.9744,N,01010.5137,E,4.5,32.8,290526,,*0D
$GPGGA,103614.00,5420.9744,N,01010.5137,E,1,08,1.0,10.8,M,46.0,M,,*52
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.8,0.0*6E
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$GPRMC,103758.00,A,5421.0958,N,01010.6483,E,5.7,32.8,290526,,*05
$GPGGA,103758.00,5421.0958,N,01010.6483,E,1,08,1.0,10.7,M,46.0,M,,*56
$GPVTG,32.8,T,,M,5.7,N,10.5,K*6F
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.7,0.0*61
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$GPRMC,103929.00,A,5421.2173,N,01010.7829,E,5.4,32.8,290526,,*00
$GPGGA,103929.00,5421.2173,N,01010.7829,E,1,08,1.0,10.5,M,46.0,M,,*52
$GPVTG,32.8,T,,M,5.4,N,10.0,K*69
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.5,0.0*63
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$GPRMC,104117.00,A,5421.3387,N,01010.9175,E,4.5,32.8,290526,,*04
$GPGGA,104117.00,5421.3387,N,01010.9175,E,1,08,1.0,10.4,M,46.0,M,,*57
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.4,0.0*62
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$IERPM,E,0,1850,37.0*20
$GPRMC,104257.00,A,5421.5006,N,01011.0969,E,7.8,32.8,290526,,*0C
$GPGGA,104257.00,5421.5006,N,01011.0969,E,1,08,1.0,10.1,M,46.0,M,,*54
$GPVTG,32.8,T,,M,7.8,N,14.4,K*67
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.1,0.0*67
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.04,N,0.04,N,,*4D
$IERPM,E,0,1850,37.0*20
$GPRMC,104437.00,A,5421.6626,N,01011.2764,E,4.6,32.8,290526,,*07
$GPGGA,104437.00,5421.6626,N,01011.2764,E,1,08,1.0,9.9,M,46.0,M,,*62
$GPVTG,32.8,T,,M,4.6,N,8.5,K*56
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.9,0.0*57
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.04,N,0.04,N,,*4D
$IERPM,E,0,1850,37.0*20
$GPRMC,104607.00,A,5421.7616,N,01011.3816,E,5.0,30.2,290526,,*00
$GPGGA,104607.00,5421.7616,N,01011.3816,E,1,08,1.0,9.8,M,46.0,M,,*6B
$GPVTG,30.2,T,,M,5.0,N,9.3,K*5E
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.8,0.0*56
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.04,N,0.04,N,,*4D
$GPRMC,104740.00,A,5421.8866,N,01011.5066,E,5.9,30.2,290526,,*04
$GPGGA,104740.00,5421.8866,N,01011.5066,E,1,08,1.0,9.6,M,46.0,M,,*68
$GPVTG,30.2,T,,M,5.9,N,10.9,K*65
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.6,0.0*58
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,104918.00,A,5422.0115,N,01011.6315,E,4.7,30.2,290526,,*0A
$GPGGA,104918.00,5422.0115,N,01011.6315,E,1,08,1.0,9.5,M,46.0,M,,*6A
$GPVTG,30.2,T,,M,4.7,N,8.7,K*5D
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.5,0.0*5B
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,105049.00,A,5422.1364,N,01011.7564,E,6.9,30.2,290526,,*0E
$GPGGA,105049.00,5422.1364,N,01011.7564,E,1,08,1.0,9.3,M,46.0,M,,*64
$GPVTG,30.2,T,,M,6.9,N,12.8,K*65
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.3,0.0*5D
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,105233.00,A,5422.3030,N,01011.9230,E,5.6,30.2,290526,,*05
$GPGGA,105233.00,5422.3030,N,01011.9230,E,1,08,1.0,9.1,M,46.0,M,,*61
$GPVTG,30.2,T,,M,5.6,N,10.3,K*60
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.1,0.0*5F
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,105419.00,A,5422.4279,N,01012.0479,E,4.5,30.2,290526,,*00
$GPGGA,105419.00,5422.4279,N,01012.0479,E,1,08,1.0,8.9,M,46.0,M,,*6F
$GPVTG,30.2,T,,M,4.5,N,8.3,K*5B
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.9,0.0*56
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,105550.00,A,5422.5320,N,01012.1520,E,5.3,30.2,290526,,*0B
$GPGGA,105550.00,5422.5320,N,01012.1520,E,1,08,1.0,8.8,M,46.0,M,,*62
$GPVTG,30.2,T,,M,5.3,N,9.8,K*56
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.8,0.0*57
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,105721.00,A,5422.6570,N,01012.2770,E,5.8,30.2,290526,,*00
$GPGGA,105721.00,5422.6570,N,01012.2770,E,1,08,1.0,8.6,M,46.0,M,,*6C
$GPVTG,30.2,T,,M,5.8,N,10.7,K*6A
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.6,0.0*59
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,105856.00,A,5422.7731,N,01012.3907,E,4.5,29.3,290526,,*03
$GPGGA,105856.00,5422.7731,N,01012.3907,E,1,08,1.0,8.4,M,46.0,M,,*68
$GPVTG,29.3,T,,M,4.5,N,8.4,K*55
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.4,0.0*5B
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,110035.00,A,5422.8992,N,01012.5122,E,5.1,29.3,290526,,*0E
$GPGGA,110035.00,5422.8992,N,01012.5122,E,1,08,1.0,8.3,M,46.0,M,,*67
$GPVTG,29.3,T,,M,5.1,N,9.5,K*50
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.3,0.0*5C
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,110220.00,A,5423.0252,N,01012.6336,E,4.7,29.3,290526,,*05
$GPGGA,110220.00,5423.0252,N,01012.6336,E,1,08,1.0,8.1,M,46.0,M,,*69
$GPVTG,29.3,T,,M,4.7,N,8.8,K*5B
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.1,0.0*5E
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110355.00,A,5423.1304,N,01012.7348,E,4.4,29.3,290526,,*0E
$GPGGA,110355.00,5423.1304,N,01012.7348,E,1,08,1.0,8.0,M,46.0,M,,*60
$GPVTG,29.3,T,,M,4.4,N,8.2,K*52
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.0,0.0*5F
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110537.00,A,5423.2354,N,01012.8360,E,4.1,29.3,290526,,*0A
$GPGGA,110537.00,5423.2354,N,01012.8360,E,1,08,1.0,7.8,M,46.0,M,,*66
$GPVTG,29.3,T,,M,4.1,N,7.5,K*5F
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.8,0.0*58
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110728.00,A,5423.3405,N,01012.9372,E,3.7,29.3,290526,,*07
$GPGGA,110728.00,5423.3405,N,01012.9372,E,1,08,1.0,7.7,M,46.0,M,,*65
$GPVTG,29.3,T,,M,3.7,N,6.9,K*53
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.7,0.0*57
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110905.00,A,5423.4246,N,01013.0181,E,3.5,29.3,290526,,*04
$GPGGA,110905.00,5423.4246,N,01013.0181,E,1,08,1.0,7.6,M,46.0,M,,*65
$GPVTG,29.3,T,,M,3.5,N,6.4,K*5C
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.6,0.0*56
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,111049.00,A,5423.5087,N,01013.0991,E,3.2,29.3,290526,,*04
$GPGGA,111049.00,5423.5087,N,01013.0991,E,1,08,1.0,7.5,M,46.0,M,,*61
$GPVTG,29.3,T,,M,3.2,N,5.9,K*55
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.5,0.0*55
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111229.00,A,5423.5828,N,01013.1716,E,2.9,29.7,290526,,*03
$GPGGA,111229.00,5423.5828,N,01013.1716,E,1,08,1.0,7.4,M,46.0,M,,*69
$GPVTG,29.7,T,,M,2.9,N,5.4,K*56
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.4,0.0*54
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111401.00,A,5423.6455,N,01013.2332,E,2.7,29.7,290526,,*05
$GPGGA,111401.00,5423.6455,N,01013.2332,E,1,08,1.0,7.3,M,46.0,M,,*66
$GPVTG,29.7,T,,M,2.7,N,5.1,K*5D
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.3,0.0*53
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111540.00,A,5423.7083,N,01013.2947,E,2.5,29.7,290526,,*05
$GPGGA,111540.00,5423.7083,N,01013.2947,E,1,08,1.0,7.2,M,46.0,M,,*65
$GPVTG,29.7,T,,M,2.5,N,4.7,K*58
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.2,0.0*52
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111727.00,A,5423.7711,N,01013.3563,E,2.3,29.7,290526,,*07
$GPGGA,111727.00,5423.7711,N,01013.3563,E,1,08,1.0,7.1,M,46.0,M,,*62
$GPVTG,29.7,T,,M,2.3,N,4.3,K*5A
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.1,0.0*51
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111924.00,A,5423.8339,N,01013.4179,E,2.1,29.7,290526,,*01
$GPGGA,111924.00,5423.8339,N,01013.4179,E,1,08,1.0,7.0,M,46.0,M,,*67
$GPVTG,29.7,T,,M,2.1,N,4.0,K*5B
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.0,0.0*50
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,112048.00,A,5423.8757,N,01013.4590,E,2.0,29.7,290526,,*0F
$GPGGA,112048.00,5423.8757,N,01013.4590,E,1,08,1.0,7.0,M,46.0,M,,*68
$GPVTG,29.7,T,,M,2.0,N,3.7,K*5A
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.0,0.0*50
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D