Compare commits

...

65 Commits

Author SHA1 Message Date
elpatron 932a73ab0c chore: release v0.1.1.34 2026-06-12 15:29:53 +02:00
elpatron 5b9c1e3220 feat(tides): support role-based multi-location tide retrieval, selection, and storage 2026-06-12 13:58:38 +02:00
elpatron abd5fe1ac8 fix(tides): use 00:00 default fallback for tide times and auto-save fetched tides 2026-06-12 12:17:03 +02:00
elpatron e03163735e chore(tides): remove debug console.logs from backend 2026-06-12 12:10:40 +02:00
elpatron 0e0f045e84 fix(tides): fix stale locations in frontend and implement digraph fallback & direct BSH matching on server 2026-06-12 12:09:31 +02:00
elpatron 4f519e34b4 feat: BSH-Pegelauswahl und Fix für Eintragstag beim Gezeiten-Abruf
Bei fehlgeschlagenem Auto-Abruf nächste BSH-Stationen anbieten; Reisetag
korrekt aus dem Eintrag parsen und Vergangenheitshinweis anzeigen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 11:16:01 +02:00
elpatron 7d6c908f65 feat: Gezeiten über BSH-OGC-API mit Stations-Suche
Amtliche BSH-Wasserstandsvorhersage ersetzt Open-Meteo als Primärquelle;
nächster Pegel per Haversine, Open-Meteo nur außerhalb 75 km Reichweite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 11:03:20 +02:00
elpatron 0b46154696 feat: robots.txt, Sitemap und Staging-noindex für SEO
Google Search Console: echte robots.txt und sitemap.xml für Produktion;
Staging blockiert Crawler per X-Robots-Tag und Disallow in robots.txt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 10:58:21 +02:00
elpatron 9634370a08 fix: ungenutzten formatAppDecimal-Import entfernen
Behebt den TypeScript-Build-Fehler TS6133 nach der Gezeiten-Ort-Anzeige.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 14:47:48 +02:00
elpatron 1bad0531b5 feat: Abfrageort bei Gezeiten speichern und anzeigen
Ort oder GPS-Koordinaten werden im Entry-Payload persistiert und im
Tiden-Accordion sowie im Live-Journal-Modal als lesbare Zeile angezeigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 14:37:19 +02:00
elpatron 5d4e498528 feat: Gezeiten im Logbuch per Open-Meteo Marine
HW/NW-Felder im Reisetag und Live-Journal mit Server-Proxy auf Basis von
Open-Meteo Marine am GPS-Standort; neueste Position und frischer DB-Stand
vor dem Abruf, Bestätigung nach Übernehmen, Accordion-Layout bereinigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 14:22:25 +02:00
elpatron d667062ec2 fix: prevent DEPLOY_BRANCH collapsing in ssh args during production update 2026-06-08 10:55:21 +02:00
elpatron 0bfc38f290 chore: release v0.1.1.33 2026-06-08 10:45:36 +02:00
elpatron 943ce838af chore: release v0.1.1.32 2026-06-08 08:20:55 +02:00
elpatron f7ad7001d7 Move backup restore functionality to dashboard 2026-06-08 08:05:20 +02:00
elpatron 444d347c56 chore: release v0.1.1.31 2026-06-08 07:44:12 +02:00
elpatron a185bbaf27 feat: add local photo zip download for logged-in users and swipe navigation to maximized gallery 2026-06-07 21:42:00 +02:00
elpatron 864d45714c feat(settings): add share button next to copy button on mobile devices for public share link 2026-06-07 21:19:57 +02:00
elpatron faf3b8e3cf chore: release v0.1.1.30 2026-06-07 14:32:46 +02:00
elpatron 74ff8eb16b style: fix journal entry action buttons alignment on mobile 2026-06-07 14:27:44 +02:00
elpatron 81d3e3b777 feat: show travel day count badge on logbook dashboard 2026-06-07 14:22:17 +02:00
elpatron 97c5173e63 chore: release v0.1.1.29 2026-06-07 13:51:26 +02:00
elpatron 8b34044481 chore: switch default git remote to self-hosted Gitea instance 2026-06-07 13:46:28 +02:00
elpatron d948325a45 feat: add French and Spanish locales and update language selector 2026-06-07 13:44:27 +02:00
elpatron 8b8196f6e3 chore: release v0.1.1.28 2026-06-07 13:30:32 +02:00
elpatron 6593b320ee feat(i18n): integrate LanguageDropdown in LogbookDashboard 2026-06-07 13:26:29 +02:00
elpatron 9a931024d6 chore: revert git remote configuration to use github by default 2026-06-07 13:04:42 +02:00
elpatron 4dfe2cea4e feat(i18n): replace language cycle buttons with flag dropdown selector using inline SVGs 2026-06-07 12:59:40 +02:00
elpatron 944f4518e9 chore: update default git remote and url to point to gitea instance 2026-06-07 11:44:06 +02:00
elpatron 0c765f712c chore: release v0.1.1.27 2026-06-07 11:22:28 +02:00
elpatron 676547686b chore: update default git remote to github repository 2026-06-07 11:22:24 +02:00
elpatron 66606c5eca chore: default deployment script back to Gitea origin 2026-06-07 11:11:51 +02:00
elpatron a30fac029d chore: release v0.1.1.26 2026-06-07 11:10:43 +02:00
elpatron 796e61f4ea chore: migrate deployment script to use GitHub remote instead of origin Gitea 2026-06-07 09:09:43 +02:00
elpatron 594c65d1a5 feat: make photo capture attachments section collapsible by default 2026-06-07 09:00:14 +02:00
elpatron fafefff29b chore: release v0.1.1.25 2026-06-06 22:02:25 +02:00
elpatron 4fd7f3c6cf feat(journal): wrap Crew an diesem Reisetag card inside a collapsible accordion defaulting to collapsed 2026-06-06 21:59:25 +02:00
elpatron 262c48a01a chore: document COMPOSE_FILE in .env.example to lock environment compose stack configurations 2026-06-06 21:53:43 +02:00
elpatron 9ad3c2cf38 Add Database Size single metric and time series history chart to Admin Dashboard 2026-06-06 21:45:19 +02:00
elpatron 6848390ffa chore: release v0.1.1.24 2026-06-06 21:38:12 +02:00
elpatron 65d2215a35 Render maximized photo overlay via React Portal to resolve CSS stacking context issue 2026-06-06 21:33:47 +02:00
elpatron f321e5bbd1 Simplify photos_title localization across all languages by removing E2E encryption label 2026-06-06 21:32:01 +02:00
elpatron d2961b050a Rearrange journal cards layout according to user request order 2026-06-06 21:30:00 +02:00
elpatron 6943fd2dc4 Implement column selector customizer popover for chronological events logbook 2026-06-06 21:17:50 +02:00
elpatron f332eccf22 fix: restore click events for editing logbook title in dashboard 2026-06-06 21:11:29 +02:00
elpatron 9d2a19dbf8 feat: group freshwater, fuel, and greywater cards in collapsible Tanks section 2026-06-06 21:07:51 +02:00
elpatron e3cd89be5d feat: separate chronological events list and add event form into separate cards 2026-06-06 21:04:25 +02:00
elpatron a86da72b04 feat: implement collapsible accordions for event protocol list and form 2026-06-06 21:02:35 +02:00
elpatron 7d6f381f55 feat: implement responsive event cards for mobile viewports 2026-06-06 20:58:04 +02:00
elpatron 878be33b7c feat: add fullscreen photo viewer overlay on click & resolve appearance compat warnings 2026-06-06 20:40:13 +02:00
elpatron 318f5e65da feat: add camera/gallery choice for photos & sync AI profile pref to server 2026-06-06 20:37:21 +02:00
elpatron 8c6ab59d67 chore: release v0.1.1.23 2026-06-06 12:24:33 +02:00
elpatron a9c3e9ce3e Fix custom dialog coloring to support Light Theme via CSS variable mapping 2026-06-06 12:17:40 +02:00
elpatron 3eaf59e2b3 Implement AI consent gating, user preference settings, and Ko-fi hint 2026-06-06 12:08:46 +02:00
elpatron b1e17be7fd feat(analytics): add Plausible custom event VOICE_MEMO_TRANSCRIBED with status and mode properties 2026-06-06 11:51:07 +02:00
elpatron ac7e7c92d1 fix(asr): switch whisper model to whisper-large-v3-turbo 2026-06-06 11:43:09 +02:00
elpatron e10cef4b05 chore: remove parakeet service and configuration, switch completely to OpenRouter Whisper 2026-06-06 11:38:51 +02:00
elpatron 0ec5c51102 chore: configure parakeet to use 1 worker to significantly reduce memory footprint 2026-06-06 11:33:48 +02:00
elpatron 57b93b7ce7 fix: update transcribe route regex to support data URLs with codecs parameters 2026-06-06 11:14:24 +02:00
elpatron a4b3515711 feat: implement voice memo transcription with local parakeet container and fallback timeouts 2026-06-06 11:01:15 +02:00
elpatron 41acbaebac chore: release v0.1.1.22 2026-06-05 19:58:21 +02:00
elpatron 6c83cd7d36 feat: differentiate weather fetch errors by cause 2026-06-05 19:52:33 +02:00
elpatron 9089e1c6f9 feat: resolve user profile photos in chronological event log 2026-06-05 19:46:18 +02:00
elpatron 1504960d85 chore: release v0.1.1.21 2026-06-05 19:13:20 +02:00
elpatron 599f090895 fix: resolve unbound variable error on remote deploy to prod 2026-06-05 19:13:03 +02:00
86 changed files with 11080 additions and 2999 deletions
+7
View File
@@ -34,6 +34,8 @@ ORIGIN=http://localhost:5173
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
# COMPOSE_FILE=docker-compose.staging.yml
# 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
@@ -62,3 +64,8 @@ NTFY_TOKEN=tk_example_ntfy_access_token
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
PLAUSIBLE_ENABLED=true
PLAUSIBLE_HOST=https://plausible.elpatron.me
# SEO (frontend container — robots.txt, X-Robots-Tag)
# Production: ROBOTS_NOINDEX=false (default)
# Staging: ROBOTS_NOINDEX=true (default in docker-compose.staging.yml)
# ROBOTS_NOINDEX=false
+1 -1
View File
@@ -1 +1 @@
0.1.1.21
0.1.1.35
+15 -1
View File
@@ -16,8 +16,22 @@ case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
;;
esac
ROBOTS_NOINDEX="${ROBOTS_NOINDEX:-false}"
case "$(printf '%s' "$ROBOTS_NOINDEX" | tr '[:upper:]' '[:lower:]')" in
true|1|yes)
export ROBOTS_NOINDEX_HEADER=' add_header X-Robots-Tag "noindex, nofollow" always;'
cat > /usr/share/nginx/html/robots.txt <<'EOF'
User-agent: *
Disallow: /
EOF
;;
*)
export ROBOTS_NOINDEX_HEADER=''
;;
esac
export PLAUSIBLE_CSP
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
envsubst '${PLAUSIBLE_CSP} ${ROBOTS_NOINDEX_HEADER}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
cat > /usr/share/nginx/html/runtime-config.json <<EOF
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
+1
View File
@@ -9,6 +9,7 @@ server {
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
${ROBOTS_NOINDEX_HEADER}
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://kapteins-daagbok.eu/sitemap.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://kapteins-daagbok.eu/</loc>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
+601 -9
View File
@@ -1939,6 +1939,21 @@ html.scheme-dark .themed-select-option.is-selected {
pointer-events: none;
}
.logbook-card-right-group {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
position: relative;
z-index: 2;
align-self: center;
}
.logbook-card-right-group .logbook-card-chevron {
margin-left: 0;
}
.logbook-card .logbook-title-editable,
.logbook-card .logbook-title-inline-edit,
.logbook-card .card-title-row {
@@ -2090,6 +2105,7 @@ html.scheme-dark .themed-select-option.is-selected {
cursor: text;
border-radius: 4px;
transition: background-color 0.15s ease;
pointer-events: auto;
}
.logbook-title-editable:hover {
@@ -2105,6 +2121,7 @@ html.scheme-dark .themed-select-option.is-selected {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
pointer-events: auto;
}
.card-icon {
@@ -2163,6 +2180,16 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-text-subtle);
}
.entry-count-badge {
background: rgba(255, 255, 255, 0.05);
color: var(--app-text-muted);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
display: inline-flex;
align-items: center;
}
.entry-sign-badge {
position: relative;
display: inline-flex;
@@ -2956,6 +2983,12 @@ html.scheme-dark .themed-select-option.is-selected {
opacity: 1;
}
.logbook-card-right-group .btn-pdf,
.logbook-card-right-group .btn-delete {
position: static;
opacity: 1;
}
.card-meta {
flex-wrap: wrap;
}
@@ -3184,6 +3217,7 @@ html.theme-cupertino .events-scroll-container {
aspect-ratio: 16 / 9;
background: #0b0c10;
overflow: hidden;
cursor: pointer;
}
.photo-container img {
@@ -3230,6 +3264,123 @@ html.theme-cupertino .events-scroll-container {
white-space: nowrap;
}
/* Photo Maximized Overlay */
.photo-maximized-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(11, 12, 16, 0.9);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
display: flex;
align-items: center;
justify-content: center;
z-index: 11000;
}
.photo-maximized-container {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.photo-maximized-img {
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.photo-maximized-close {
position: absolute;
top: -48px;
right: 0;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #f1f5f9;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.photo-maximized-close:hover {
background: rgba(255, 255, 255, 0.2);
border-color: #ffffff;
transform: scale(1.08);
}
.photo-maximized-caption {
font-size: 15px;
color: #f1f5f9;
background: rgba(15, 23, 42, 0.75);
padding: 8px 16px;
border-radius: 20px;
max-width: 80%;
text-align: center;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.05);
word-break: break-word;
}
.photo-maximized-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #f1f5f9;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
z-index: 11005;
}
.photo-maximized-nav:hover {
background: rgba(255, 255, 255, 0.2);
border-color: #ffffff;
transform: translateY(-50%) scale(1.08);
}
.photo-maximized-prev {
left: 24px;
}
.photo-maximized-next {
right: 24px;
}
@media (max-width: 768px) {
.photo-maximized-nav {
width: 44px;
height: 44px;
}
.photo-maximized-prev {
left: 12px;
}
.photo-maximized-next {
right: 12px;
}
}
/* Custom Dialog Modals Styling */
.custom-dialog-overlay {
position: fixed;
@@ -3237,9 +3388,9 @@ html.theme-cupertino .events-scroll-container {
left: 0;
right: 0;
bottom: 0;
background: rgba(11, 12, 16, 0.75);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
background: rgba(11, 12, 16, 0.45);
backdrop-filter: var(--app-backdrop);
-webkit-backdrop-filter: var(--app-backdrop);
display: flex;
align-items: center;
justify-content: center;
@@ -3247,13 +3398,15 @@ html.theme-cupertino .events-scroll-container {
}
.custom-dialog-card {
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
background: var(--app-surface-hover, var(--app-surface));
backdrop-filter: var(--app-backdrop);
-webkit-backdrop-filter: var(--app-backdrop);
border: 1px solid var(--app-border-subtle);
border-radius: var(--app-radius-card, 16px);
padding: 28px;
width: 90%;
max-width: 420px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
box-shadow: var(--app-shadow);
text-align: center;
display: flex;
flex-direction: column;
@@ -3263,7 +3416,7 @@ html.theme-cupertino .events-scroll-container {
.custom-dialog-title {
font-size: 19px;
font-weight: 700;
color: #fbbf24;
color: var(--app-accent-light);
margin: 0 0 14px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -3271,7 +3424,7 @@ html.theme-cupertino .events-scroll-container {
.custom-dialog-message {
font-size: 15px;
color: #e2e8f0;
color: var(--app-text);
line-height: 1.5;
margin: 0 0 24px 0;
white-space: pre-line;
@@ -3728,6 +3881,45 @@ html.theme-cupertino .events-scroll-container {
line-height: 1.4;
}
.tide-station-picker__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(50vh, 320px);
overflow-y: auto;
}
.tide-station-picker__option {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 12px 14px;
border: 1px solid var(--app-border, rgba(255, 255, 255, 0.12));
border-radius: 10px;
background: var(--app-surface-elevated, rgba(255, 255, 255, 0.04));
color: inherit;
text-align: left;
cursor: pointer;
}
.tide-station-picker__option:hover {
border-color: var(--app-accent, #2dd4bf);
}
.tide-station-picker__name {
font-weight: 600;
}
.tide-station-picker__meta {
font-size: 13px;
color: var(--app-text-muted);
}
.live-log-sail-pills {
margin-bottom: 12px;
}
@@ -4362,6 +4554,7 @@ html.theme-cupertino .events-scroll-container {
.consumption-grid .input-group .input-text {
flex-shrink: 0;
-moz-appearance: textfield;
appearance: textfield;
}
.consumption-grid .input-text::-webkit-outer-spin-button,
@@ -4453,6 +4646,49 @@ html.theme-cupertino .events-scroll-container {
grid-column: 1 / -1;
}
/* Tides accordion (LogEntryEditor) */
.tides-panel {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.tides-panel__hints {
display: flex;
flex-direction: column;
gap: 8px;
}
.tides-panel__hints .form-hint {
margin: 0;
font-size: 13px;
color: var(--app-text-muted);
line-height: 1.45;
}
.tides-panel__location {
margin: 0;
font-size: 13.5px;
font-weight: 500;
color: var(--app-text);
line-height: 1.45;
}
.tides-panel__fields {
margin: 0;
}
.tides-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tides-panel__actions .btn {
width: auto;
}
.metric-range-input--compact {
gap: 0;
margin: 0;
@@ -6234,3 +6470,359 @@ body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
.crew-selection-item input {
flex-shrink: 0;
}
/* Responsive Event Cards */
.events-desktop-only {
display: block;
}
.events-mobile-only {
display: none;
}
@media (max-width: 768px) {
.events-desktop-only {
display: none !important;
}
.events-mobile-only {
display: flex !important;
flex-direction: column;
gap: 12px;
}
}
.event-mobile-card {
background: var(--app-surface-alt);
border: 1px solid var(--app-border-subtle);
border-radius: 12px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.event-mobile-card:hover {
border-color: var(--app-border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.event-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.event-card-meta {
display: flex;
align-items: center;
gap: 10px;
}
.event-card-actions {
display: flex;
align-items: center;
gap: 8px;
}
.event-card-time {
color: #fbbf24;
font-weight: 600;
font-family: monospace;
font-size: 15px;
display: flex;
align-items: center;
gap: 4px;
}
.event-card-divider {
height: 1px;
background: var(--app-border-subtle);
margin: 0;
border: none;
opacity: 0.5;
}
.event-card-grid {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.event-card-chip {
background: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
border: 1px solid var(--app-border-muted);
border-radius: 8px;
padding: 4px 8px;
font-size: 13px;
color: #cbd5e1;
display: inline-flex;
align-items: center;
gap: 6px;
}
.event-card-chip svg {
color: var(--app-text-muted, #94a3b8);
}
.event-card-weather-img {
width: 24px;
height: 24px;
object-fit: contain;
}
.event-card-remarks {
background: var(--app-surface-inset, rgba(11, 12, 16, 0.2));
border-left: 3px solid var(--app-accent, #fbbf24);
padding: 8px 12px;
border-radius: 0 8px 8px 0;
font-size: 13.5px;
color: #e2e8f0;
word-break: break-word;
}
/* Accordion Styling */
.accordion-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 8px 12px;
margin: -8px -12px;
border-radius: 8px;
transition: background-color 0.2s ease, color 0.2s ease;
}
.accordion-header:hover {
background-color: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
}
.accordion-header-title {
display: flex;
align-items: center;
gap: 8px;
}
.accordion-chevron {
color: var(--app-text-muted, #94a3b8);
transition: transform 0.2s ease;
}
/* Specific styling for nested member-editor-card header */
.member-editor-card .accordion-header {
margin: 0 0 16px 0;
padding: 8px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.01);
border: 1px solid rgba(255, 255, 255, 0.03);
}
.member-editor-card .accordion-header:hover {
background: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
}
/* Column Selector / Customizer Popover */
.column-selector-popover {
position: absolute;
top: 40px;
right: 0;
width: 240px;
max-height: 400px;
overflow-y: auto;
padding: 16px;
border-radius: 12px;
background: var(--app-surface-alt, rgba(18, 20, 26, 0.98));
border: 1px solid var(--app-border, rgba(255, 255, 255, 0.1));
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
z-index: 100;
display: flex;
flex-direction: column;
gap: 12px;
text-align: left;
}
.column-selector-title {
font-size: 13.5px;
font-weight: 600;
color: var(--app-accent, #fbbf24);
margin: 0;
padding-bottom: 6px;
border-bottom: 1px solid var(--app-border-subtle, rgba(255, 255, 255, 0.06));
}
.column-selector-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.column-selector-item {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 13px;
color: var(--app-text-muted, #cbd5e1);
padding: 4px 6px;
border-radius: 6px;
transition: background-color 0.15s ease, color 0.15s ease;
}
.column-selector-item:hover {
background: var(--app-surface-hover, rgba(255, 255, 255, 0.04));
color: var(--app-text, #ffffff);
}
.column-selector-item input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--app-accent, #fbbf24);
}
/* Language Dropdown */
.lang-dropdown {
position: relative;
display: inline-block;
}
.lang-dropdown-trigger-flag {
font-size: 20px;
line-height: 1;
display: inline-block;
}
.lang-dropdown-chevron {
flex-shrink: 0;
opacity: 0.75;
transition: transform 0.2s ease;
margin-left: 6px;
}
.lang-dropdown.is-open .lang-dropdown-chevron {
transform: rotate(180deg);
}
.lang-dropdown-menu {
position: absolute;
z-index: 1000;
top: calc(100% + 8px);
margin: 0;
padding: 4px;
list-style: none;
border: 1px solid var(--app-input-border, rgba(255, 255, 255, 0.1));
border-radius: var(--app-radius-input, 12px);
box-shadow: var(--app-card-shadow, 0 10px 30px rgba(0, 0, 0, 0.3));
min-width: 140px;
overflow: hidden;
isolation: isolate;
animation: slideDownFade 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideDownFade {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.lang-dropdown.align-right .lang-dropdown-menu {
right: 0;
left: auto;
}
.lang-dropdown.align-left .lang-dropdown-menu {
left: 0;
right: auto;
}
html.scheme-light .lang-dropdown-menu {
background: #ffffff;
color: #0f172a;
border-color: rgba(0, 0, 0, 0.08);
}
html.scheme-dark .lang-dropdown-menu {
background: #1c1c1e;
color: #f8fafc;
border-color: rgba(255, 255, 255, 0.08);
}
.lang-dropdown-option {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: calc(var(--app-radius-input, 12px) - 4px);
cursor: pointer;
font-size: 15px;
font-weight: 500;
line-height: 1.4;
transition: background-color 0.15s ease, color 0.15s ease;
text-align: left;
}
.lang-flag-svg {
width: 20px;
height: 14px;
flex-shrink: 0;
display: inline-block;
vertical-align: middle;
}
.lang-flag-svg.trigger-icon-only {
width: 24px;
height: 17px;
}
html.scheme-light .lang-dropdown-option {
color: #334155;
-webkit-text-fill-color: #334155;
}
html.scheme-dark .lang-dropdown-option {
color: #cbd5e1;
-webkit-text-fill-color: #cbd5e1;
}
.lang-dropdown-option:hover {
background: var(--app-accent-bg, rgba(217, 119, 6, 0.1));
}
html.scheme-light .lang-dropdown-option:hover {
color: var(--app-accent, #d97706);
-webkit-text-fill-color: var(--app-accent, #d97706);
}
html.scheme-dark .lang-dropdown-option:hover {
color: var(--app-accent-light, #fbbf24);
-webkit-text-fill-color: var(--app-accent-light, #fbbf24);
}
.lang-dropdown-option.is-selected {
background: var(--app-accent-bg, rgba(217, 119, 6, 0.15));
font-weight: 600;
}
html.scheme-light .lang-dropdown-option.is-selected {
color: var(--app-accent, #d97706);
-webkit-text-fill-color: var(--app-accent, #d97706);
}
html.scheme-dark .lang-dropdown-option.is-selected {
color: var(--app-accent-light, #fbbf24);
-webkit-text-fill-color: var(--app-accent-light, #fbbf24);
}
.lang-trigger-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+4 -12
View File
@@ -46,14 +46,14 @@ import { db } from './services/db.js'
import { getLogbookAccess } from './services/logbookAccess.js'
import type { LogbookAccessRole } from './services/logbook.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
import { checkAdminAccess } from './services/adminApi.js'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import LanguageDropdown from './components/LanguageDropdown.tsx'
import {
resolveTourLogbookContext,
seedDemoLogbookIfNeeded
@@ -66,7 +66,7 @@ import { requestPersistentStorage } from './utils/storagePersist.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() {
const { t, i18n } = useTranslation()
const { t } = useTranslation()
const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false)
@@ -555,10 +555,6 @@ function App() {
localStorage.removeItem('active_logbook_title')
}
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const handleExitDemo = () => {
window.history.replaceState({}, document.title, '/')
syncRouteFromLocation()
@@ -715,10 +711,7 @@ function App() {
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
<span>{online ? 'Online' : t('sync.status_offline')}</span>
</div>
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} />
</button>
<LanguageDropdown variant="icon" align="right" />
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
@@ -859,7 +852,6 @@ function App() {
{activeTab === 'settings' && (
<SettingsForm
logbookId={activeLogbookId}
onLogbookRestored={selectLogbook}
/>
)}
</main>
+15 -3
View File
@@ -7,12 +7,22 @@ import {
type AdminTimeSeriesResponse,
type AdminTimeBucket
} from '../services/adminApi.js'
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react'
import { BarChart2, Bookmark, ChevronLeft, Database, Image, MapPin, Mic, Users } from 'lucide-react'
function formatNumber(value: number): string {
return value.toLocaleString()
}
function formatBytes(bytes: number | undefined): string {
if (bytes === undefined || bytes === null) return '—'
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const num = bytes / Math.pow(k, i)
return `${num.toFixed(1)} ${sizes[i]}`
}
function KpiCard({
icon,
label,
@@ -20,14 +30,14 @@ function KpiCard({
}: {
icon: ReactNode
label: string
value: number
value: number | string
}) {
return (
<div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">{formatNumber(value)}</span>
<span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
</div>
</div>
)
@@ -194,6 +204,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
label="Einträge mit AI-Zusammenfassung"
value={summary.aiSummaryEntries}
/>
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
</section>
<section className="admin-controls">
@@ -233,6 +244,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
</section>
</main>
</div>
+4 -10
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import LanguageDropdown from './LanguageDropdown.tsx'
import {
registerUser,
loginUser,
@@ -15,7 +15,7 @@ import {
logoutUser,
resolveRestoreUsername
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import { KeyRound, ShieldAlert, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx'
@@ -37,7 +37,7 @@ export default function AuthOnboarding({
onOpenDemo,
restoreSession = false
}: AuthOnboardingProps) {
const { t, i18n } = useTranslation()
const { t } = useTranslation()
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -267,9 +267,6 @@ export default function AuthOnboarding({
setKnownUsers(getKnownUsernames())
}
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const copyToClipboard = () => {
if (recoveryPhrase) {
@@ -780,10 +777,7 @@ export default function AuthOnboarding({
</div>
<div className="auth-footer">
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<LanguageDropdown variant="text" align="left" />
<button
type="button"
className="btn-icon-text link-sec"
+32 -5
View File
@@ -45,11 +45,38 @@ export default function CreatorAvatar({
let photo: string | null = null
let role = ''
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
const snap = crewSnapshotsById[creatorId]
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
if (creatorId && crewSnapshotsById) {
let snap: PersonSnapshot | undefined = crewSnapshotsById[creatorId]
// Fallback: If not found directly by key, search by role or name or active user
if (!snap) {
if (creatorId === 'skipper') {
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
} else {
// Try to match name case-insensitively
snap = Object.values(crewSnapshotsById).find(
(s) => (s.name || '').trim().toLowerCase() === creatorId.trim().toLowerCase()
)
// Try to match active username/userid to the skipper snapshot
if (!snap) {
const activeUsername = localStorage.getItem('active_username')
const activeUserId = localStorage.getItem('active_userid')
if (
(activeUsername && creatorId.toLowerCase() === activeUsername.toLowerCase()) ||
(activeUserId && creatorId === activeUserId)
) {
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
}
}
}
}
if (snap) {
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
}
}
// Fallback to active username if owner or no crew pool matches
+4 -10
View File
@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import LanguageDropdown from './LanguageDropdown.tsx'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { Ship, Users, FileText, Lock, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import type { VesselData } from '../types/vessel.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
@@ -52,9 +52,6 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
}
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const {
title,
@@ -111,10 +108,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
<UserPlus size={14} style={{ marginRight: '4px' }} />
{t('demo.cta_register')}
</button>
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<LanguageDropdown variant="secondary-button" align="right" />
</div>
</header>
@@ -172,7 +166,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
payloadId: v.payloadId,
data: v.data as VesselData
}))}
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
preloadedSelection={logbookVesselSelection as unknown as LogbookVesselSelectionData}
/>
)}
+69 -44
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react'
import { Users, ChevronDown, ChevronUp } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
preloadedPool
}: EntryCrewSectionProps) {
const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(true)
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => {
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
return (
<div className="form-card" data-tour="entry-crew">
<div className="form-header">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
<div
className="form-header accordion-header"
onClick={() => setCollapsed(!collapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setCollapsed(!collapsed)
}
}}
role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : (
<div className="crew-selection-list">
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
<ChevronUp size={20} className="accordion-chevron" />
)}
</div>
<div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
{!collapsed && (
<>
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
) : (
<div className="crew-selection-list">
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
)}
</div>
<div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
</>
)}
</div>
)
}
+107 -7
View File
@@ -1,24 +1,97 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Mic, Loader2 } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
import { useDialog } from './ModalDialog.tsx'
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
interface EventRemarksCellProps {
event: LogEventPayload
logbookId: string
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
readOnly?: boolean
}
export default function EventRemarksCell({
event,
logbookId,
voiceMemoLookup
voiceMemoLookup,
readOnly = false
}: EventRemarksCellProps) {
const { t } = useTranslation()
const { showAlert } = useDialog()
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
const [transcribing, setTranscribing] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
const handleTranscribe = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (transcribing || !preloaded?.audio || !voiceId) return
if (!getAiAuthorized()) {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
return
}
setTranscribing(true)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
try {
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ audioDataUrl: preloaded.audio }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) {
throw new Error(`Server returned status ${res.status}`)
}
const data = await res.json()
const text = (data.text || '').trim()
if (!text) {
throw new Error('Transcription returned empty text')
}
await updateVoiceMemoTranscript(logbookId, voiceId, text)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'manual'
})
} catch (err) {
clearTimeout(timeoutId)
console.error('[EventRemarksCell] Transcription failed:', err)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'manual'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} finally {
setTranscribing(false)
}
}
let summary = formatEventSummary(event, t)
if (voiceId && preloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
@@ -28,12 +101,39 @@ export default function EventRemarksCell({
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
<span>{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={preloaded}
compact
/>
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={preloaded}
compact
/>
{!readOnly && preloaded && preloaded.transcribed === false && isOnline && (
<button
type="button"
className="btn-icon-text link-sec"
style={{
fontSize: '0.8rem',
padding: '2px 6px',
height: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
margin: 0
}}
onClick={handleTranscribe}
disabled={transcribing}
title={t('logs.live_voice_transcribe_action')}
>
{transcribing ? (
<Loader2 size={12} className="spin" />
) : (
<Mic size={12} />
)}
{transcribing ? t('logs.live_voice_transcribing') : t('logs.live_voice_transcribe_action')}
</button>
)}
</div>
)}
</div>
)
+9 -4
View File
@@ -10,18 +10,23 @@ interface EventTimeInput24hProps {
onChange: (value: string) => void
disabled?: boolean
'aria-label'?: string
fallback?: string
}
export default function EventTimeInput24h({
value,
onChange,
disabled = false,
'aria-label': ariaLabel
'aria-label': ariaLabel,
fallback
}: EventTimeInput24hProps) {
const baseId = useId()
const useNativePicker = preferNativeCameraPicker()
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
const { hours, minutes } = useMemo(() => splitTimeHHMM(value, fallback), [value, fallback])
const timeValue = useMemo(() => {
if (!value.trim()) return ''
return joinTimeHHMM(hours, minutes)
}, [value, hours, minutes])
if (useNativePicker) {
return (
@@ -34,7 +39,7 @@ export default function EventTimeInput24h({
value={timeValue}
onChange={(e) => {
const next = e.target.value
if (next) onChange(next.slice(0, 5))
onChange(next ? next.slice(0, 5) : '')
}}
disabled={disabled}
aria-label={ariaLabel}
+4 -10
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import LanguageDropdown from './LanguageDropdown.tsx'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, ArrowRight, KeyRound } from 'lucide-react'
import {
getActiveMasterKey,
registerUser,
@@ -50,7 +50,7 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
}
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
const { t, i18n } = useTranslation()
const { t } = useTranslation()
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
@@ -308,9 +308,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setIsLoggedIn(true)
}
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
if (recoveryPhrase) {
return (
@@ -510,10 +507,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
)}
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
<button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<LanguageDropdown variant="text" align="left" />
</div>
</div>
)
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Languages, Globe, ChevronDown } from 'lucide-react'
import {
SUPPORTED_LANGUAGES,
changeAppLanguage,
normalizeAppLanguage,
type AppLanguage
} from '../utils/i18nLanguages.js'
function FlagIcon({ lang, className, style }: { lang: string; className?: string; style?: React.CSSProperties }) {
const baseStyle = {
display: 'inline-block',
verticalAlign: 'middle',
borderRadius: '2px',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxSizing: 'border-box' as const,
...style
}
switch (lang) {
case 'de':
return (
<svg viewBox="0 0 5 3" className={className} style={baseStyle}>
<rect width="5" height="3" fill="#FFCE00"/>
<rect width="5" height="2" fill="#DD0000"/>
<rect width="5" height="1" fill="#000000"/>
</svg>
)
case 'en':
return (
<svg viewBox="0 0 60 30" className={className} style={baseStyle}>
<clipPath id="union-jack-clip">
<path d="M0,0 L60,30 M60,0 L0,30"/>
</clipPath>
<rect width="60" height="30" fill="#012169"/>
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6"/>
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="4" clipPath="url(#union-jack-clip)"/>
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10"/>
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6"/>
</svg>
)
case 'da':
return (
<svg viewBox="0 0 37 28" className={className} style={baseStyle}>
<rect width="37" height="28" fill="#C8102E"/>
<path d="M12,0 h4 v28 h-4 z M0,12 h37 v4 h-37 z" fill="#FFFFFF"/>
</svg>
)
case 'sv':
return (
<svg viewBox="0 0 16 10" className={className} style={baseStyle}>
<rect width="16" height="10" fill="#006AA7"/>
<path d="M5,0 h2 v10 h-2 z M0,4 h16 v2 h-16 z" fill="#FECC00"/>
</svg>
)
case 'nb':
return (
<svg viewBox="0 0 22 16" className={className} style={baseStyle}>
<rect width="22" height="16" fill="#BA0C2F"/>
<path d="M6,0 h4 v16 h-4 z M0,6 h22 v4 h-22 z" fill="#FFFFFF"/>
<path d="M7,0 h2 v16 h-2 z M0,7 h22 v2 h-22 z" fill="#00205B"/>
</svg>
)
case 'fr':
return (
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
<rect width="3" height="2" fill="#FFFFFF"/>
<rect width="1" height="2" fill="#002395"/>
<rect x="2" width="1" height="2" fill="#ED2939"/>
</svg>
)
case 'es':
return (
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
<rect width="3" height="2" fill="#C1272D"/>
<rect y="0.5" width="3" height="1" fill="#FEE100"/>
</svg>
)
default:
return null
}
}
interface LanguageDropdownProps {
variant?: 'icon' | 'text' | 'secondary-button'
align?: 'left' | 'right'
}
export default function LanguageDropdown({
variant = 'icon',
align = 'right'
}: LanguageDropdownProps) {
const { t, i18n } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const activeLang = normalizeAppLanguage(i18n.language)
useEffect(() => {
if (!isOpen) return
const closeOnOutsideClick = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false)
}
document.addEventListener('mousedown', closeOnOutsideClick)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('mousedown', closeOnOutsideClick)
document.removeEventListener('keydown', closeOnEscape)
}
}, [isOpen])
const selectLanguage = (lang: AppLanguage) => {
changeAppLanguage(i18n, lang)
setIsOpen(false)
}
// Trigger button content based on variant
const renderTriggerContent = () => {
const name = t(`languages.${activeLang}`)
if (variant === 'icon') {
return (
<span className="lang-dropdown-trigger-flag" aria-hidden="true">
<FlagIcon lang={activeLang} className="lang-flag-svg trigger-icon-only" />
</span>
)
}
if (variant === 'secondary-button') {
return (
<>
<Globe size={14} style={{ marginRight: '4px' }} />
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ marginRight: '4px' }} />
<span className="lang-trigger-name">{name}</span>
<ChevronDown size={12} className="lang-dropdown-chevron" />
</>
)
}
// Default or "text" variant (used in footer)
return (
<>
<Languages size={18} />
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ margin: '0 4px' }} />
<span>{name}</span>
<ChevronDown size={14} className="lang-dropdown-chevron" />
</>
)
}
const triggerClass =
variant === 'icon'
? 'btn-icon'
: variant === 'secondary-button'
? 'btn secondary compact'
: 'btn-icon-text'
return (
<div
className={`lang-dropdown ${isOpen ? 'is-open' : ''} align-${align}`}
ref={rootRef}
>
<button
type="button"
className={triggerClass}
onClick={() => setIsOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
title="Switch Language"
style={variant === 'secondary-button' ? { width: 'auto', padding: '6px 12px', fontSize: '13px' } : undefined}
>
{renderTriggerContent()}
</button>
{isOpen && (
<ul className="lang-dropdown-menu" role="listbox">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang === activeLang
return (
<li
key={lang}
role="option"
aria-selected={isSelected}
className={`lang-dropdown-option ${isSelected ? 'is-selected' : ''}`}
onClick={() => selectLanguage(lang)}
>
<FlagIcon lang={lang} className="lang-flag-svg" />
<span className="lang-option-name">{t(`languages.${lang}`)}</span>
</li>
)
})}
</ul>
)}
</div>
)
}
+325 -26
View File
@@ -19,19 +19,21 @@ import {
Radio,
Sailboat,
Undo2,
Waves,
Zap
} from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
import {
appendQuickEvent as apiAppendQuickEvent,
appendQuickEvents as apiAppendQuickEvents,
appendTankRefill as apiAppendTankRefill,
findOrCreateTodayEntry,
loadEntry,
patchEntryTides,
removeLastEvent
} from '../services/quickEventLog.js'
import CreatorAvatar from './CreatorAvatar.tsx'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import {
getLastAutoPositionMs,
getLastLoggedPositionWithin,
@@ -43,7 +45,6 @@ import {
liveFuelRemark,
livePhotoRemark,
liveVoiceRemark,
parseLiveVoiceRemark,
livePrecipRemark,
liveSailsRemark,
liveSogRemark,
@@ -57,6 +58,23 @@ const formatSpeedKn = (speedKn: number) =>
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { TidesApiError, type TideStation } from '../services/tides.js'
import { TideStationPickerModal } from './TideStationPickerModal.tsx'
import { TideLocationPickerModal } from './TideLocationPickerModal.tsx'
import {
buildTideLocationMeta,
formatTideLocationLabel,
getAvailableTideLocations,
type TideLocationOption,
type TideFetchLocation
} from '../utils/tideLocation.js'
import type { TideRole } from '../utils/logEntryPayload.js'
import {
fetchTidesForEntry,
fetchTidesForStationChoice,
type TideFetchNeedsStationPick,
type TideFetchResult
} from '../utils/tideFetch.js'
import {
geolocationErrorI18nKey,
getCurrentPosition,
@@ -80,7 +98,7 @@ import CourseDialInput from './CourseDialInput.tsx'
import GpsSignalHint from './GpsSignalHint.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx'
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
import EventRemarksCell from './EventRemarksCell.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
@@ -109,6 +127,8 @@ type LiveModal =
| 'sog'
| 'stw'
| 'position'
| 'tides'
| 'tides_picker'
| 'photo'
| 'voice'
@@ -191,6 +211,8 @@ export default function LiveLogView({
const [entryId, setEntryId] = useState<string | null>(null)
const [dayOfTravel, setDayOfTravel] = useState('')
const [date, setDate] = useState('')
const [departure, setDeparture] = useState('')
const [destination, setDestination] = useState('')
const [events, setEvents] = useState<LogEventPayload[]>([])
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
@@ -201,6 +223,15 @@ export default function LiveLogView({
const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false)
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
const [tidesLoading, setTidesLoading] = useState(false)
const [tidePreview, setTidePreview] = useState<{
highWater: string
lowWater: string
location: ReturnType<typeof buildTideLocationMeta>
role: TideRole
} | null>(null)
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
const [tideLocationPickerOptions, setTideLocationPickerOptions] = useState<TideLocationOption[] | null>(null)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('')
@@ -302,6 +333,8 @@ export default function LiveLogView({
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || ''))
setDeparture(String(loaded.data.departure || ''))
setDestination(String(loaded.data.destination || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
@@ -713,13 +746,27 @@ export default function LiveLogView({
{ analyticsSource: 'live_log' }
)
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
if (err instanceof WeatherApiError) {
if (err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'UNAUTHORIZED') {
void showAlert(t('settings.weather_unauthorized'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'NOT_FOUND') {
void showAlert(t('settings.weather_not_found'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'BAD_REQUEST') {
void showAlert(t('settings.weather_bad_request'), t('logs.live_weather_owm_btn'))
return
}
}
console.error('Live log OWM weather failed:', err)
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
@@ -771,6 +818,145 @@ export default function LiveLogView({
})()
}
const getRoleForLocationSource = (source: string): TideRole => {
if (source === 'gps') return 'gps'
if (source === 'destination') return 'destination'
return 'departure'
}
const handleTideStationPick = (pick: TideFetchNeedsStationPick, station: TideStation) => {
setTidesLoading(true)
void (async () => {
try {
const result = await fetchTidesForStationChoice({
stationId: station.id,
entryDate: pick.entryDate,
fetchLocation: pick.fetchLocation,
queryLat: pick.queryLat,
queryLng: pick.queryLng,
analyticsSource: 'live_log'
})
setTideStationPicker(null)
setTidePreview({
highWater: result.highWater,
lowWater: result.lowWater,
location: result.location,
role: getRoleForLocationSource(pick.fetchLocation.source)
})
setModal('tides')
} catch (err) {
if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') {
void showAlert(t('logs.tide_no_data_for_date', { date: pick.entryDate }), t('logs.tides'))
return
}
console.error('Live log tide station fetch failed:', err)
void showAlert(t('logs.tide_fetch_failed'), t('logs.tides'))
} finally {
setTidesLoading(false)
}
})()
}
const startTideFetchForLocation = (fetchLocation: TideFetchLocation) => {
setTidesLoading(true)
setError(null)
void (async () => {
try {
const outcome = await fetchTidesForEntry({
fetchLocation,
entryDate: date,
analyticsSource: 'live_log'
})
if ('kind' in outcome && outcome.kind === 'pick_station') {
setTideStationPicker(outcome as TideFetchNeedsStationPick)
return
}
const result = outcome as TideFetchResult
setTidePreview({
highWater: result.highWater,
lowWater: result.lowWater,
location: result.location,
role: getRoleForLocationSource(fetchLocation.source)
})
setModal('tides')
} catch (err) {
if (err instanceof TidesApiError) {
if (err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.tides'))
return
}
if (err.code === 'PLACE_NOT_FOUND') {
const query = fetchLocation.mode === 'by-place' ? fetchLocation.query : ''
void showAlert(t('logs.tide_place_not_found', { place: query.trim() }), t('logs.tides'))
return
}
if (err.code === 'NO_DATA_FOR_DATE') {
void showAlert(t('logs.tide_no_data_for_date', { date }), t('logs.tides'))
return
}
if (err.code === 'NOT_FOUND') {
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
return
}
}
console.error('Live log tide fetch failed:', err)
void showAlert(t('logs.tide_fetch_failed'), t('logs.tides'))
} finally {
setTidesLoading(false)
}
})()
}
const handleFetchTides = () => {
if (!entryId || busy || tidesLoading) return
if (!isOnline) {
void showAlert(t('logs.weather_offline'), t('logs.tides'))
return
}
const available = getAvailableTideLocations({
departure,
destination,
events,
entryDate: date
})
if (available.length === 0) {
void showAlert(t('logs.tide_location_required'), t('logs.tides'))
return
}
if (available.length === 1) {
startTideFetchForLocation(available[0].fetchLocation)
} else {
setTideLocationPickerOptions(available)
setModal('tides_picker')
}
}
const confirmTides = () => {
if (!entryId || !tidePreview || busy) return
const preview = tidePreview
void runQuickAction(async () => {
await patchEntryTides(logbookId, entryId, preview.role, {
highWater: preview.highWater,
lowWater: preview.lowWater,
...preview.location
})
setTidePreview(null)
setModal('none')
void showAlert(
t('logs.tide_applied_success', {
highWater: preview.highWater || '—',
lowWater: preview.lowWater || '—'
}),
t('logs.tides')
)
}, 'tides', false)
}
const handleUndo = () => {
if (!entryId || busy) return
const photoId = undoPhotoIdRef.current
@@ -822,13 +1008,50 @@ export default function LiveLogView({
void (async () => {
try {
const audioDataUrl = await blobToAudioDataUrl(blob)
const authorized = getAiAuthorized()
let transcriptionText = ''
let transcribed = true
let transcriptionError = false
if (authorized) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 4000)
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audioDataUrl }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) throw new Error(`Status ${res.status}`)
const data = await res.json()
transcriptionText = (data.text || '').trim()
} catch (err) {
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
transcriptionError = true
transcribed = false
}
} else {
transcribed = false
}
let finalCaption = caption
if (transcriptionText) {
finalCaption = caption
? `${caption}\n(Transkript: ${transcriptionText})`
: transcriptionText
}
const voiceId = await saveEntryVoiceMemo({
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption,
caption: finalCaption,
transcribed,
analyticsContext: 'live_log'
})
await appendQuickEvent(logbookId, entryId, {
@@ -840,6 +1063,23 @@ export default function LiveLogView({
setVoiceCaption('')
showUndo('voice')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
if (transcriptionError) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'auto'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} else if (authorized) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'auto'
})
} else {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
}
} catch (err: unknown) {
console.error('Live log voice save failed:', err)
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
@@ -1190,6 +1430,10 @@ export default function LiveLogView({
<MapPin size={18} />
{t('logs.live_position')}
</button>
<button type="button" className="live-log-action-btn" onClick={handleFetchTides} disabled={busy || tidesLoading}>
<Waves size={18} />
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tides')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
<MessageSquare size={18} />
{t('logs.live_comment_btn')}
@@ -1211,12 +1455,6 @@ export default function LiveLogView({
) : (
<ol className="live-log-stream">
{events.map((event, index) => {
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
let summary = formatEventSummary(event, t)
if (voiceId && voicePreloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption })
}
return (
<li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time>
@@ -1226,15 +1464,12 @@ export default function LiveLogView({
size={24}
/>
<div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={voicePreloaded}
compact
/>
)}
<EventRemarksCell
event={event}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
readOnly={false}
/>
</div>
</li>
)
@@ -1397,6 +1632,70 @@ export default function LiveLogView({
</div>
)}
{tideStationPicker ? (
<TideStationPickerModal
title={t('logs.tide_pick_station_title')}
hint={t('logs.tide_pick_station_hint')}
cancelLabel={t('logs.live_cancel')}
stations={tideStationPicker.stations}
onCancel={() => setTideStationPicker(null)}
onSelect={(station) => handleTideStationPick(tideStationPicker, station)}
/>
) : null}
{modal === 'tides_picker' && tideLocationPickerOptions ? (
<TideLocationPickerModal
title={t('logs.tide_location_picker_title')}
hint={t('logs.tide_location_picker_hint')}
cancelLabel={t('logs.live_cancel')}
options={tideLocationPickerOptions}
onCancel={() => {
setTideLocationPickerOptions(null)
closeModal()
}}
onSelect={(option) => {
setTideLocationPickerOptions(null)
closeModal()
startTideFetchForLocation(option.fetchLocation)
}}
/>
) : null}
{modal === 'tides' && tidePreview && (
<div
className="live-log-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.tides')}</h3>
<p className="live-log-modal-hint" role="note">
{t('logs.tide_disclaimer')}
</p>
{formatTideLocationLabel(tidePreview.location, t) ? (
<p className="live-log-modal-hint" role="status">
{formatTideLocationLabel(tidePreview.location, t)}
</p>
) : null}
<dl className="live-log-tide-preview">
<div>
<dt>{t('logs.tide_high_water')}</dt>
<dd>{tidePreview.highWater || '—'}</dd>
</div>
<div>
<dt>{t('logs.tide_low_water')}</dt>
<dd>{tidePreview.lowWater || '—'}</dd>
</div>
</dl>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmTides} disabled={busy}>
{t('logs.tide_apply')}
</button>
</div>
</div>
</div>
)}
{modal === 'comment' && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
+148 -12
View File
@@ -1,12 +1,13 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getActiveMasterKey, hasUnlockedLocalCrypto } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson } from '../services/crypto.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { buildZipArchive } from '../services/logbookBackup/zipArchive.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
@@ -59,6 +60,42 @@ interface DecryptedEntryItem {
skipperSignStatus: SkipperSignStatus
}
// Helper to convert data URL to Uint8Array for zip packaging
function dataUrlToUint8Array(dataUrl: string): { data: Uint8Array; ext: string } {
const parts = dataUrl.split(',')
if (parts.length < 2) {
throw new Error('Invalid data URL')
}
const meta = parts[0]
const base64Data = parts[1]
let ext = 'jpg'
const mimeMatch = meta.match(/data:([^;]+)/)
if (mimeMatch) {
const mime = mimeMatch[1]
if (mime === 'image/png') ext = 'png'
else if (mime === 'image/gif') ext = 'gif'
else if (mime === 'image/webp') ext = 'webp'
else if (mime === 'image/heic') ext = 'heic'
else if (mime === 'image/heif') ext = 'heif'
}
const binaryString = atob(base64Data)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return { data: bytes, ext }
}
function sanitizeFilename(str: string): string {
return str
.replace(/[^\w\s-]/gi, '')
.trim()
.replace(/\s+/g, '_')
.slice(0, 30)
}
export default function LogEntriesList({
logbookId,
readOnly = false,
@@ -257,6 +294,90 @@ export default function LogEntriesList({
}
}
const handleDownloadPhotosZip = async () => {
setExporting(true)
setError(null)
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
// Fetch all photos for this logbook from IndexedDB
const localPhotos = await db.photos.where({ logbookId }).toArray()
if (localPhotos.length === 0) {
setError(t('logs.no_photos_to_download'))
return
}
// Build a map of entry ID to entry info for filename lookup
const entryMap = new Map<string, DecryptedEntryItem>()
entries.forEach((e) => entryMap.set(e.id, e))
const files: Record<string, Uint8Array> = {}
const usedNames = new Set<string>()
for (const photo of localPhotos) {
// Decrypt photo payload (contains base64 image data and caption)
const decrypted = await decryptJson(photo.encryptedData, photo.iv, photo.tag, masterKey)
if (!decrypted || !decrypted.image) continue
const { data, ext } = dataUrlToUint8Array(decrypted.image)
// Construct unique, friendly filename
let fileBase = `photo_${photo.payloadId}`
const entry = entryMap.get(photo.entryId)
if (entry) {
const dateStr = entry.date || 'unknown-date'
const travelDay = entry.dayOfTravel ? `day-${entry.dayOfTravel}` : ''
const sanitizedCaption = decrypted.caption ? sanitizeFilename(decrypted.caption) : ''
const parts = [dateStr]
if (travelDay) parts.push(travelDay)
if (sanitizedCaption) parts.push(sanitizedCaption)
fileBase = parts.join('_')
} else if (decrypted.caption) {
fileBase = `photo_${sanitizeFilename(decrypted.caption)}`
}
// De-duplicate name
let candidate = `${fileBase}.${ext}`
let counter = 1
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${fileBase}_${counter}.${ext}`
counter++
}
usedNames.add(candidate.toLowerCase())
files[candidate] = data
}
if (Object.keys(files).length === 0) {
setError(t('logs.no_photos_to_download'))
return
}
const zipBytes = buildZipArchive(files)
const blob = new Blob([zipBytes as any], { type: 'application/zip' })
const url = URL.createObjectURL(blob)
const yachtName = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
const safeTitle = yachtName.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-photos-${datePart}.zip`
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
} catch (err: any) {
console.error('Failed to download photos ZIP:', err)
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
}
const handleCreate = async () => {
if (readOnly) return
setError(null)
@@ -488,6 +609,21 @@ export default function LogEntriesList({
<span className="hide-mobile">{t('logs.share_csv')}</span>
</button>
{hasUnlockedLocalCrypto() && (
<button
className="btn secondary"
onClick={handleDownloadPhotosZip}
disabled={loading || exporting || entries.length === 0}
style={{ width: 'auto', padding: '8px 16px' }}
title={t('logs.export_photos_zip')}
>
<Download size={16} />
<span className="hide-mobile">
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
</span>
</button>
)}
{!readOnly && (
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<Plus size={16} />
@@ -541,17 +677,17 @@ export default function LogEntriesList({
</div>
</div>
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} />
</button>
{!readOnly && (
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} />
<div className="logbook-card-right-group">
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} />
</button>
)}
{!readOnly && (
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} />
</button>
)}
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
</div>
</div>
))}
</div>
File diff suppressed because it is too large Load Diff
+5 -215
View File
@@ -1,24 +1,14 @@
import { useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import { Archive, Download, Check, AlertTriangle } from 'lucide-react'
import {
downloadBackupBlob,
exportLogbookBackup,
formatBackupBytes,
parseLogbookBackupFile,
previewLogbookBackup,
restoreLogbookBackup,
BACKUP_SIZE_CONFIRM_BYTES,
type ParsedLogbookBackup,
type LogbookBackupPreview
exportLogbookBackup
} from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
interface LogbookBackupPanelProps {
logbookId: string
onRestored?: (logbookId: string, title: string) => void
}
function mapBackupError(code: string, t: (key: string) => string): string {
@@ -49,21 +39,12 @@ function mapBackupError(code: string, t: (key: string) => string): string {
}
}
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
const { t, i18n } = useTranslation()
const { showConfirm } = useDialog()
const fileInputRef = useRef<HTMLInputElement>(null)
export default function LogbookBackupPanel({ logbookId }: LogbookBackupPanelProps) {
const { t } = useTranslation()
const [exportPassphrase, setExportPassphrase] = useState('')
const [exportConfirm, setExportConfirm] = useState('')
const [exporting, setExporting] = useState(false)
const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
const [importing, setImporting] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [exportProgress, setExportProgress] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
@@ -76,11 +57,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
await handleExport()
}
const handleImportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleRestore()
}
const handleExport = async () => {
setError(null)
setSuccess(null)
@@ -128,105 +104,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
setError(null)
setSuccess(null)
setImportPreview(null)
setParsedBackup(null)
const file = e.target.files?.[0]
setImportFile(file ?? null)
if (!file) return
try {
const backup = await parseLogbookBackupFile(file)
setParsedBackup(backup)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t))
setImportFile(null)
}
}
const handlePreviewImport = async () => {
if (!parsedBackup || !importPassphrase) return
setPreviewing(true)
setError(null)
try {
const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
setImportPreview(preview)
} catch (err: unknown) {
setImportPreview(null)
setError(t('settings.backup_wrong_passphrase'))
} finally {
setPreviewing(false)
}
}
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
if (!parsedBackup || !importPassphrase) return
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
const ok = await showConfirm(
t('settings.backup_import_size_confirm', {
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
}),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (!ok) return
}
setImporting(true)
setError(null)
try {
const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
setSuccess(t('settings.backup_restore_success', { title: result.title }))
setImportFile(null)
setImportPassphrase('')
setImportPreview(null)
setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.manifest.counts.entries,
photos: parsedBackup.manifest.counts.photos,
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
bytes: parsedBackup.manifest.totalUncompressedBytes,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
})
onRestored?.(result.logbookId, result.title)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
if (message === 'BACKUP_ID_CONFLICT') {
const overwrite = await showConfirm(
t('settings.backup_overwrite_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (overwrite) {
setImporting(false)
return handleRestore({ overwrite: true })
}
const asNew = await showConfirm(
t('settings.backup_new_id_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (asNew) {
setImporting(false)
return handleRestore({ assignNewId: true })
}
setError(t('settings.backup_restore_cancelled'))
} else {
setError(mapBackupError(message, t))
}
} finally {
setImporting(false)
}
}
return (
<div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
@@ -306,93 +183,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
)}
</form>
</section>
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
<h4 id="backup-import-heading" className="backup-section-title">
<Upload size={16} aria-hidden="true" />
{t('settings.backup_restore_title')}
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
<form onSubmit={handleImportSubmit} className="backup-import-form">
<div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok,application/zip"
className="input-text"
onChange={handleFileChange}
disabled={importing}
/>
</div>
{importFile && (
<>
<div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-import-passphrase"
name="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
required
/>
</div>
<div className="backup-actions-row">
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="submit"
className="btn primary"
disabled={importing || !importPassphrase}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
</form>
{importPreview && (
<div className="backup-preview glass">
<p className="backup-preview-title">{importPreview.title}</p>
<ul className="backup-preview-stats">
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
<li className="text-muted">
{t('settings.backup_stat_size', {
size: formatBackupBytes(importPreview.totalUncompressedBytes)
})}
</li>
</ul>
<p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', {
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
})}
</p>
</div>
)}
</section>
</div>
)
}
+36 -14
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
import LanguageDropdown from './LanguageDropdown.tsx'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
@@ -11,11 +11,12 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown, Upload } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
import AdminHeaderButton from './AdminHeaderButton.tsx'
import LogbookRestorePanel from './LogbookRestorePanel.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -35,10 +36,14 @@ function sortLogbooks(
): DecryptedLogbook[] {
const sorted = [...items]
sorted.sort((a, b) => {
const cmp =
sortBy === 'name'
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
let cmp = 0
if (sortBy === 'name') {
cmp = a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
} else {
const timeA = a.lastTravelDate ? new Date(a.lastTravelDate).getTime() : new Date(a.updatedAt).getTime()
const timeB = b.lastTravelDate ? new Date(b.lastTravelDate).getTime() : new Date(b.updatedAt).getTime()
cmp = timeA - timeB
}
return direction === 'asc' ? cmp : -cmp
})
return sorted
@@ -63,6 +68,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null)
const [online, setOnline] = useState(navigator.onLine)
const [showRestore, setShowRestore] = useState(false)
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
@@ -198,9 +204,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
onLogout()
}
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
@@ -291,8 +294,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
{lb.isDemo && (
<span className="demo-badge">{t('demo.badge')}</span>
)}
<span className="entry-count-badge" title={t('dashboard.travel_days_count', { count: lb.entryCount ?? 0 })}>
<CalendarDays size={12} style={{ marginRight: '4px' }} />
{lb.entryCount ?? 0}
</span>
<span className="date-badge">
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
{new Date(lb.lastTravelDate || lb.updatedAt).toLocaleDateString(i18n.language, {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -392,10 +399,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
{/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} />
</button>
<LanguageDropdown variant="icon" align="right" />
<DisclaimerHeaderButton />
@@ -432,6 +436,24 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
</form>
{error && <div className="auth-error mt-4">{error}</div>}
<div style={{ marginTop: '20px', borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', textAlign: 'center' }}>
<button
type="button"
className="btn-link"
style={{ fontSize: '13.5px', color: 'var(--app-text-muted)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
onClick={() => setShowRestore(!showRestore)}
>
<Upload size={14} />
{t('settings.backup_restore_title')}
</button>
</div>
{showRestore && (
<div style={{ marginTop: '16px', textAlign: 'left' }}>
<LogbookRestorePanel onRestored={onSelectLogbook} />
</div>
)}
</section>
{/* Right Side: Logbooks list */}
@@ -0,0 +1,275 @@
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Upload, Check, AlertTriangle } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import {
parseLogbookBackupFile,
previewLogbookBackup,
restoreLogbookBackup,
formatBackupBytes,
BACKUP_SIZE_CONFIRM_BYTES,
type ParsedLogbookBackup,
type LogbookBackupPreview
} from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
interface LogbookRestorePanelProps {
onRestored?: (logbookId: string, title: string) => void
}
function mapBackupError(code: string, t: (key: string) => string): string {
switch (code) {
case 'BACKUP_PASSPHRASE_TOO_SHORT':
return t('settings.backup_passphrase_short')
case 'BACKUP_NOT_OWNER':
return t('settings.backup_not_owner')
case 'BACKUP_INVALID_JSON':
return t('settings.backup_invalid_json')
case 'BACKUP_INVALID_ARCHIVE':
return t('settings.backup_invalid_archive')
case 'BACKUP_VERSION_UNSUPPORTED':
return t('settings.backup_version_unsupported')
case 'BACKUP_WRONG_PASSPHRASE':
return t('settings.backup_wrong_passphrase')
case 'BACKUP_INVALID_FORMAT':
return t('settings.backup_invalid_format')
case 'BACKUP_NOT_AUTHENTICATED':
return t('settings.backup_not_authenticated')
case 'BACKUP_ID_CONFLICT':
return t('settings.backup_id_conflict')
default:
if (code.includes('decrypt') || code.includes('operation')) {
return t('settings.backup_wrong_passphrase')
}
return code
}
}
export default function LogbookRestorePanel({ onRestored }: LogbookRestorePanelProps) {
const { t, i18n } = useTranslation()
const { showConfirm } = useDialog()
const fileInputRef = useRef<HTMLInputElement>(null)
const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
const [importing, setImporting] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const handleImportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleRestore()
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
setError(null)
setSuccess(null)
setImportPreview(null)
setParsedBackup(null)
const file = e.target.files?.[0]
setImportFile(file ?? null)
if (!file) return
try {
const backup = await parseLogbookBackupFile(file)
setParsedBackup(backup)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t))
setImportFile(null)
}
}
const handlePreviewImport = async () => {
if (!parsedBackup || !importPassphrase) return
setPreviewing(true)
setError(null)
try {
const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
setImportPreview(preview)
} catch (err: unknown) {
setImportPreview(null)
setError(t('settings.backup_wrong_passphrase'))
} finally {
setPreviewing(false)
}
}
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
if (!parsedBackup || !importPassphrase) return
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
const ok = await showConfirm(
t('settings.backup_import_size_confirm', {
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
}),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (!ok) return
}
setImporting(true)
setError(null)
try {
const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
setSuccess(t('settings.backup_restore_success', { title: result.title }))
setImportFile(null)
setImportPassphrase('')
setImportPreview(null)
setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.manifest.counts.entries,
photos: parsedBackup.manifest.counts.photos,
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
bytes: parsedBackup.manifest.totalUncompressedBytes,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
})
onRestored?.(result.logbookId, result.title)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
if (message === 'BACKUP_ID_CONFLICT') {
const overwrite = await showConfirm(
t('settings.backup_overwrite_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (overwrite) {
setImporting(false)
return handleRestore({ overwrite: true })
}
const asNew = await showConfirm(
t('settings.backup_new_id_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (asNew) {
setImporting(false)
return handleRestore({ assignNewId: true })
}
setError(t('settings.backup_restore_cancelled'))
} else {
setError(mapBackupError(message, t))
}
} finally {
setImporting(false)
}
}
return (
<div className="backup-section backup-section--import" aria-labelledby="backup-import-heading" style={{ marginTop: '8px' }}>
<p className="text-muted backup-section-desc" style={{ fontSize: '13px', margin: '0 0 16px 0', textAlign: 'left', lineHeight: '1.4' }}>
{t('settings.backup_restore_desc')}
</p>
{error && (
<div className="auth-error mb-4" role="alert" style={{ textAlign: 'left' }}>
<AlertTriangle size={16} style={{ display: 'inline', marginRight: 6, verticalAlign: 'text-bottom' }} />
{error}
</div>
)}
{success && (
<div className="success-toast mb-4" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Check size={16} />
<span>{success}</span>
</div>
)}
<form onSubmit={handleImportSubmit} className="backup-import-form" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div className="input-group" style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<label htmlFor="backup-import-file" style={{ fontSize: '12px', fontWeight: 600, color: 'var(--app-text-muted)', textAlign: 'left' }}>
{t('settings.backup_file_label')}
</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok,application/zip"
className="input-text"
onChange={handleFileChange}
disabled={importing}
style={{ width: '100%', boxSizing: 'border-box' }}
/>
</div>
{importFile && (
<>
<div className="input-group" style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<label htmlFor="backup-import-passphrase" style={{ fontSize: '12px', fontWeight: 600, color: 'var(--app-text-muted)', textAlign: 'left' }}>
{t('settings.backup_passphrase')}
</label>
<input
id="backup-import-passphrase"
name="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
required
style={{ width: '100%', boxSizing: 'border-box' }}
/>
</div>
<div className="backup-actions-row" style={{ display: 'flex', gap: '10px' }}>
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
style={{ flex: 1, padding: '10px' }}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="submit"
className="btn primary"
disabled={importing || !importPassphrase}
style={{ flex: 1, padding: '10px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
</form>
{importPreview && (
<div className="backup-preview glass" style={{ marginTop: '16px', padding: '16px', borderRadius: '12px', border: '1px solid var(--app-border-subtle)', background: 'var(--app-surface-inset, rgba(0, 0, 0, 0.2))', textAlign: 'left' }}>
<p className="backup-preview-title" style={{ fontWeight: 600, margin: '0 0 10px 0', fontSize: '14px', color: 'var(--app-text-heading)' }}>{importPreview.title}</p>
<ul className="backup-preview-stats" style={{ listStyle: 'none', padding: 0, margin: '0 0 10px 0', display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px', color: 'var(--app-text)' }}>
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
<li style={{ color: 'var(--app-text-muted)' }}>
{t('settings.backup_stat_size', {
size: formatBackupBytes(importPreview.totalUncompressedBytes)
})}
</li>
</ul>
<p className="text-muted backup-preview-date" style={{ fontSize: '11px', margin: 0, color: 'var(--app-text-muted)' }}>
{t('settings.backup_exported_at', {
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
})}
</p>
</div>
)}
</div>
)
}
+281 -66
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -8,7 +9,8 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react'
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
interface PhotoCaptureProps {
entryId: string
@@ -27,12 +29,87 @@ interface DecryptedPhoto {
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [collapsed, setCollapsed] = useState(true)
const [caption, setCaption] = useState('')
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
const [hasCamera, setHasCamera] = useState(false)
const [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
const touchStartX = useRef<number>(0)
const touchEndX = useRef<number>(0)
const goToNext = () => {
if (!maximizedPhoto || decryptedPhotos.length <= 1) return
const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId)
if (currentIndex === -1) return
const nextIndex = (currentIndex + 1) % decryptedPhotos.length
setMaximizedPhoto(decryptedPhotos[nextIndex])
}
const goToPrev = () => {
if (!maximizedPhoto || decryptedPhotos.length <= 1) return
const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId)
if (currentIndex === -1) return
const prevIndex = (currentIndex - 1 + decryptedPhotos.length) % decryptedPhotos.length
setMaximizedPhoto(decryptedPhotos[prevIndex])
}
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.targetTouches[0].clientX
touchEndX.current = e.targetTouches[0].clientX
}
const handleTouchMove = (e: React.TouchEvent) => {
touchEndX.current = e.targetTouches[0].clientX
}
const handleTouchEnd = () => {
if (!touchStartX.current || !touchEndX.current) return
const diffX = touchStartX.current - touchEndX.current
const threshold = 50
if (diffX > threshold) {
goToNext()
} else if (diffX < -threshold) {
goToPrev()
}
touchStartX.current = 0
touchEndX.current = 0
}
useEffect(() => {
if (!maximizedPhoto) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setMaximizedPhoto(null)
} else if (e.key === 'ArrowLeft' || e.key === 'Left') {
goToPrev()
} else if (e.key === 'ArrowRight' || e.key === 'Right') {
goToNext()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [maximizedPhoto, decryptedPhotos])
useEffect(() => {
let cancelled = false
probeCameraAvailability().then((avail) => {
if (!cancelled) {
setHasCamera(avail === 'available')
}
})
return () => {
cancelled = true
}
}, [])
// Reactively query local photos database
const localPhotos = useLiveQuery(
@@ -119,93 +196,231 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
}
}
const triggerSelect = () => {
const triggerGallerySelect = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
const triggerCameraSelect = () => {
if (cameraInputRef.current) {
cameraInputRef.current.click()
}
}
return (
<div className="form-card mt-6">
<div className="form-header mb-4">
<Camera size={20} className="form-icon" />
<h3>{t('logs.photos_title')}</h3>
<div
className="form-header accordion-header"
onClick={() => setCollapsed(!collapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setCollapsed(!collapsed)
}
}}
role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Camera size={20} className="form-icon" />
<h3>{t('logs.photos_title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : (
<ChevronUp size={20} className="accordion-chevron" />
)}
</div>
{error && <div className="auth-error mb-4">{error}</div>}
{!collapsed && (
<div style={{ marginTop: '16px' }}>
{error && <div className="auth-error mb-4">{error}</div>}
{/* Upload area */}
{/* Upload Form */}
{!readOnly && (
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
placeholder={t('logs.photo_caption_placeholder')}
className="input-text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
disabled={uploading}
/>
</div>
{/* Upload area */}
{/* Upload Form */}
{!readOnly && (
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
placeholder={t('logs.photo_caption_placeholder')}
className="input-text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
disabled={uploading}
/>
</div>
<input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<button
type="button"
className="btn primary"
onClick={triggerSelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
</div>
</div>
)}
<input
type="file"
accept="image/*"
capture="environment"
ref={cameraInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{/* Photo Grid */}
{decryptedPhotos.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
) : (
<div className="photo-attachments-grid">
{decryptedPhotos.map((photo) => (
<div key={photo.payloadId} className="photo-card glass">
<div className="photo-container">
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
{!readOnly && (
{hasCamera ? (
<>
<button
type="button"
className="btn primary"
onClick={triggerCameraSelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
</button>
<button
type="button"
className="btn secondary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Image size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
</button>
</>
) : (
<button
type="button"
className="photo-btn-delete"
onClick={() => handleDelete(photo.payloadId)}
title="Remove photo"
className="btn primary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
<Trash2 size={16} />
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
)}
</div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</div>
)}
</div>
))}
)}
{/* Photo Grid */}
{decryptedPhotos.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
) : (
<div className="photo-attachments-grid">
{decryptedPhotos.map((photo) => (
<div
key={photo.payloadId}
className="photo-card glass"
onClick={() => setMaximizedPhoto(photo)}
style={{ cursor: 'pointer' }}
>
<div className="photo-container">
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
{!readOnly && (
<button
type="button"
className="photo-btn-delete"
onClick={(e) => {
e.stopPropagation()
handleDelete(photo.payloadId)
}}
title="Remove photo"
>
<Trash2 size={16} />
</button>
)}
</div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{maximizedPhoto && createPortal(
<div
className="photo-maximized-overlay"
onClick={() => setMaximizedPhoto(null)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{decryptedPhotos.length > 1 && (
<>
<button
type="button"
className="photo-maximized-nav photo-maximized-prev"
onClick={(e) => {
e.stopPropagation()
goToPrev()
}}
aria-label={t('common.previous') || 'Previous'}
>
<ChevronLeft size={32} />
</button>
<button
type="button"
className="photo-maximized-nav photo-maximized-next"
onClick={(e) => {
e.stopPropagation()
goToNext()
}}
aria-label={t('common.next') || 'Next'}
>
<ChevronRight size={32} />
</button>
</>
)}
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="photo-maximized-close"
onClick={() => setMaximizedPhoto(null)}
aria-label={t('common.close') || 'Close'}
>
<X size={24} />
</button>
<img
src={maximizedPhoto.image}
alt={maximizedPhoto.caption || 'Maximized Attachment'}
className="photo-maximized-img"
/>
{maximizedPhoto.caption && (
<div className="photo-maximized-caption">
{maximizedPhoto.caption}
</div>
)}
</div>
</div>,
document.body
)}
</div>
)
}
+4 -9
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { isGermanLocale } from '../utils/i18nLanguages.js'
import LanguageDropdown from './LanguageDropdown.tsx'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
@@ -12,7 +13,7 @@ import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
import { Ship, Users, FileText, Lock, AlertCircle } from 'lucide-react'
interface ReadOnlyViewerProps {
token: string
@@ -215,9 +216,6 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
}
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
if (loading) {
return (
@@ -258,10 +256,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
</div>
<div className="header-actions">
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<LanguageDropdown variant="secondary-button" align="right" />
</div>
</header>
+32 -4
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon, Share2 } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import LinkQrCode from './LinkQrCode.tsx'
@@ -17,7 +17,6 @@ import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
interface SettingsFormProps {
logbookId?: string | null
onLogbookRestored?: (logbookId: string, title: string) => void
}
interface Collaborator {
@@ -34,7 +33,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
.join('')
}
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
export default function SettingsForm({ logbookId }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
@@ -131,6 +130,24 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
}
const isShareSupported = typeof navigator !== 'undefined' && !!navigator.share
const handleShareLink = async () => {
if (shareLink) {
try {
await navigator.share({
title: t('seo.title') || 'Kapteins Daagbok',
text: t('settings.share_desc'),
url: shareLink
})
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Sharing link failed:', err)
}
}
}
}
const loadCollaborators = async () => {
setLoadingCollabs(true)
setCollabError(null)
@@ -337,6 +354,17 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
{isShareSupported && (
<button
type="button"
className="btn secondary"
onClick={() => void handleShareLink()}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_btn')}
>
<Share2 size={16} />
</button>
)}
</div>
<LinkQrCode value={shareLink} />
</div>
@@ -345,7 +373,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
)}
{logbookId && isOwner && (
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
<LogbookBackupPanel logbookId={logbookId} />
)}
{logbookId && isOwner && (
@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next'
import type { TideLocationOption } from '../utils/tideLocation.js'
type TideLocationPickerModalProps = {
title: string
hint: string
cancelLabel: string
options: TideLocationOption[]
onSelect: (option: TideLocationOption) => void
onCancel: () => void
}
export function TideLocationPickerModal({
title,
hint,
cancelLabel,
options,
onSelect,
onCancel
}: TideLocationPickerModalProps) {
const { t } = useTranslation()
return (
<div
className="live-log-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel()
}}
>
<div className="live-log-modal tide-station-picker" onClick={(e) => e.stopPropagation()}>
<h3>{title}</h3>
<p className="live-log-modal-hint" role="note">
{hint}
</p>
<ul className="tide-station-picker__list">
{options.map((option) => (
<li key={option.role}>
<button
type="button"
className="tide-station-picker__option"
onClick={() => onSelect(option)}
>
<span className="tide-station-picker__name">{option.displayLabel}</span>
<span className="tide-station-picker__meta">
{t(option.labelKey)}
</span>
</button>
</li>
))}
</ul>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={onCancel}>
{cancelLabel}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,57 @@
import type { TideStation } from '../services/tides.js'
type TideStationPickerModalProps = {
title: string
hint: string
cancelLabel: string
stations: TideStation[]
onSelect: (station: TideStation) => void
onCancel: () => void
}
export function TideStationPickerModal({
title,
hint,
cancelLabel,
stations,
onSelect,
onCancel
}: TideStationPickerModalProps) {
return (
<div
className="live-log-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel()
}}
>
<div className="live-log-modal tide-station-picker" onClick={(e) => e.stopPropagation()}>
<h3>{title}</h3>
<p className="live-log-modal-hint" role="note">
{hint}
</p>
<ul className="tide-station-picker__list">
{stations.map((station) => (
<li key={station.id}>
<button
type="button"
className="tide-station-picker__option"
onClick={() => onSelect(station)}
>
<span className="tide-station-picker__name">{station.name}</span>
<span className="tide-station-picker__meta">
{station.distanceKm} km
{station.area ? ` · ${station.area}` : ''}
</span>
</button>
</li>
))}
</ul>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={onCancel}>
{cancelLabel}
</button>
</div>
</div>
</div>
)
}
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
import ThemedSelect from './ThemedSelect.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
@@ -13,7 +13,9 @@ import {
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
setThemePreference,
getAiAuthorized,
setAiAuthorized
} from '../services/userPreferences.js'
interface UserProfilePreferencesProps {
@@ -28,12 +30,25 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
const [savingOwm, setSavingOwm] = useState(false)
const [owmSaved, setOwmSaved] = useState(false)
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
useEffect(() => {
const handleChanged = () => {
setTheme(getThemePreference(userId))
setColorScheme(getColorSchemePreference(userId))
setAiAuthorizedState(getAiAuthorized(userId))
}
window.addEventListener('appearance-changed', handleChanged)
return () => {
window.removeEventListener('appearance-changed', handleChanged)
}
}, [userId])
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
setThemePreference(userId, nextTheme)
setColorSchemePreference(userId, nextColorScheme)
notifyAppearanceChanged()
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
void saveAppearancePrefsToServer(nextTheme, nextColorScheme, aiAuthorized, userId).catch((err) => {
console.warn('Failed to save appearance prefs to server:', err)
})
}
@@ -58,6 +73,15 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
window.setTimeout(() => setOwmSaved(false), 3000)
}
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextVal = e.target.checked
setAiAuthorizedState(nextVal)
setAiAuthorized(userId, nextVal)
void saveAppearancePrefsToServer(theme, colorScheme, nextVal, userId).catch((err) => {
console.warn('Failed to save ai preference to server:', err)
})
}
return (
<>
<section className="member-editor-card glass">
@@ -152,6 +176,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
</form>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('profile.ai_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
{t('profile.ai_desc')}
</p>
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
{t('profile.ai_help')}
</p>
<label
className="switch-label"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: 'pointer',
fontSize: '14px',
color: '#f1f5f9'
}}
>
<input
id="profile-ai-authorize"
type="checkbox"
checked={aiAuthorized}
onChange={handleAiToggle}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>{t('profile.ai_enable_label')}</span>
</label>
</section>
<PushNotificationSettings />
<PwaInstallPrompt variant="inline" />
</>
@@ -11,6 +11,7 @@ export interface PreloadedVoiceMemo {
mimeType?: string
durationSec?: number
caption?: string
transcribed?: boolean
}
interface VoiceMemoPlayerProps {
+2 -1
View File
@@ -48,7 +48,8 @@ export function useEntryVoiceMemos(
audio: String(decrypted.audio),
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
caption: decrypted.caption ? String(decrypted.caption) : ''
caption: decrypted.caption ? String(decrypted.caption) : '',
transcribed: decrypted.transcribed !== false
})
} catch {
// skip corrupt memo
+5 -1
View File
@@ -6,6 +6,8 @@ import deJson from './locales/de.json'
import daJson from './locales/da.json'
import svJson from './locales/sv.json'
import nbJson from './locales/nb.json'
import frJson from './locales/fr.json'
import esJson from './locales/es.json'
import { initSeo } from '../utils/seo.js'
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
@@ -15,7 +17,9 @@ const resources = {
de: { translation: deJson.translation },
da: { translation: daJson.translation },
sv: { translation: svJson.translation },
nb: { translation: nbJson.translation }
nb: { translation: nbJson.translation },
fr: { translation: frJson.translation },
es: { translation: esJson.translation }
}
i18n
+5 -1
View File
@@ -4,6 +4,8 @@ import enJson from '../i18n/locales/en.json'
import daJson from '../i18n/locales/da.json'
import svJson from '../i18n/locales/sv.json'
import nbJson from '../i18n/locales/nb.json'
import frJson from '../i18n/locales/fr.json'
import esJson from '../i18n/locales/es.json'
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = []
@@ -23,7 +25,9 @@ const bundles = {
en: enJson.translation,
da: daJson.translation,
sv: svJson.translation,
nb: nbJson.translation
nb: nbJson.translation,
fr: frJson.translation,
es: esJson.translation
} as const
describe('i18n locale key parity', () => {
File diff suppressed because it is too large Load Diff
+60 -3
View File
@@ -15,7 +15,9 @@
"en": "English",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
"nb": "Norsk",
"fr": "Français",
"es": "Español"
},
"dialog": {
"ok": "OK",
@@ -34,7 +36,9 @@
"unsaved_changes_stay": "Bleiben",
"unsaved_changes_save_leave": "Speichern & verlassen",
"unsaved_changes_discard": "Verwerfen",
"unsaved_changes_leave": "Verlassen"
"unsaved_changes_leave": "Verlassen",
"previous": "Zurück",
"next": "Weiter"
},
"nav": {
"dashboard": "Dashboard",
@@ -186,6 +190,38 @@
"departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach",
"tides": "Tiden",
"tide_high_water": "Hochwasser",
"tide_low_water": "Niedrigwasser",
"tide_fetch_btn": "Gezeiten abrufen",
"tide_fetch_loading": "Gezeiten werden geladen…",
"tide_disclaimer": "BSH-Wasserstandsvorhersage — überprüfe zeitkritische Manöver anhand offizieller Quellen!",
"tide_location_required": "Für den Gezeiten-Abruf wird eine aktuelle Position (max. 2 Stunden alt) oder ein Abfahrtsort benötigt.",
"tide_position_stale": "Die letzte Position ist älter als 2 Stunden. Bitte Position erneut setzen oder Abfahrtsort eintragen.",
"tide_fetch_failed": "Gezeiten konnten nicht abgerufen werden.",
"tide_no_data": "Für diesen Ort liegen keine Gezeitendaten vor.",
"tide_no_data_for_date": "Für den Reisetag {{date}} liegt keine BSH-Vorhersage vor (nur zukünftige Termine).",
"tide_pick_station_title": "Pegel auswählen",
"tide_pick_station_hint": "Wähle den nächstgelegenen BSH-Pegel für die Gezeiten-Vorhersage.",
"tide_place_not_found": "„{{place}}“ konnte nicht geortet werden — bitte einen Küstenort oder Hafen angeben.",
"tide_fetched_at_position": "Amtliche BSH-Vorhersage vom nächstgelegenen Pegel.",
"tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — keine BSH-Station in Reichweite.",
"tide_data_for_position": "Abfrage für Position {{lat}}, {{lng}}",
"tide_data_for_place": "Abfrage für {{place}}",
"tide_data_for_place_and_position": "Abfrage für {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Daten von {{place}} (ca. {{distance}} km entfernt)",
"tide_fetched_from_departure": "Gezeiten basierend auf Abfahrtsort „{{place}}“ (keine aktuelle GPS-Position).",
"tide_fetched_from_destination": "Gezeiten basierend auf Zielort „{{place}}“.",
"tide_role_departure": "Abfahrthafen",
"tide_role_destination": "Ankunftshafen",
"tide_role_gps": "GPS-Position",
"tide_location_picker_title": "Gezeiten-Position auswählen",
"tide_location_picker_hint": "Wähle die Position aus, für die die Gezeiten ermittelt werden sollen:",
"tide_applied_success": "Gezeiten übernommen: Hochwasser {{highWater}}, Niedrigwasser {{lowWater}}. Im Reisetag-Editor unter „Tiden“ sichtbar.",
"tide_apply": "Übernehmen",
"tanks": "Tanks",
"customize_columns": "Spalten anpassen",
"column_selector_title": "Anzuzeigende Spalten",
"freshwater": "Frischwasser (Liter)",
"fuel": "Treibstoff / Fuel (Liter)",
"greywater": "Grauwasser (Liter)",
@@ -297,6 +333,9 @@
"live_voice_entry_plain": "Sprachnotiz",
"live_voice_caption_label": "Beschriftung (optional)",
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
"live_voice_transcribe_action": "Transkribieren",
"live_voice_transcribing": "Transkribiere...",
"live_voice_transcribe_failed": "Sprachmemo gespeichert, aber Transkription fehlgeschlagen.",
"live_undo_voice_hint": "Sprachnotiz gespeichert",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…",
@@ -436,10 +475,15 @@
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
"photos_title": "Foto-Anhänge",
"export_photos_zip": "Fotos herunterladen (ZIP)",
"exporting_photos_zip": "ZIP wird erstellt...",
"no_photos_to_download": "Keine Fotos in diesem Logbuch vorhanden.",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
"photo_btn": "Foto aufnehmen / Hochladen",
"photo_camera_btn": "Foto aufnehmen",
"photo_gallery_btn": "Aus Galerie wählen",
"photo_processing": "Wird verarbeitet...",
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
@@ -532,6 +576,9 @@
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...",
"travel_days_count_zero": "Keine Reisetage",
"travel_days_count_one": "1 Reisetag",
"travel_days_count_other": "{{count}} Reisetage",
"status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen",
@@ -669,6 +716,12 @@
"integrations_title": "Integrationen",
"owm_key": "OpenWeatherMap API-Schlüssel",
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"ai_title": "KI-Funktionen & Datenschutz",
"ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.",
"ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.",
"ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren",
"ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert",
"ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.",
"prefs_save": "Speichern",
"prefs_saving": "Wird gespeichert…",
"prefs_saved": "Gespeichert",
@@ -790,6 +843,9 @@
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
"weather_unauthorized": "Wetterdatenabruf fehlgeschlagen. Der API-Schlüssel ist ungültig oder nicht autorisiert.",
"weather_not_found": "Wetterdatenabruf fehlgeschlagen. Der angegebene Ort oder die Koordinaten wurden nicht gefunden.",
"weather_bad_request": "Wetterdatenabruf fehlgeschlagen. Es wurde kein Ort und keine GPS-Position angegeben.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
"share_title": "Logbuch teilen (Schreibgeschützt)",
@@ -798,6 +854,7 @@
"share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren",
"share_btn": "Link teilen",
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
"link_qr_alt": "QR-Code für den Link",
"danger_zone_title": "Gefahrenzone",
+60 -3
View File
@@ -15,7 +15,9 @@
"en": "English",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
"nb": "Norsk",
"fr": "French",
"es": "Spanish"
},
"dialog": {
"ok": "OK",
@@ -34,7 +36,9 @@
"unsaved_changes_stay": "Stay",
"unsaved_changes_save_leave": "Save & leave",
"unsaved_changes_discard": "Discard",
"unsaved_changes_leave": "Leave"
"unsaved_changes_leave": "Leave",
"previous": "Previous",
"next": "Next"
},
"nav": {
"dashboard": "Dashboard",
@@ -186,6 +190,38 @@
"departure": "Departure Port (von)",
"destination": "Destination Port (nach)",
"route": "Route / Journey",
"tides": "Tides",
"tide_high_water": "High water",
"tide_low_water": "Low water",
"tide_fetch_btn": "Fetch tides",
"tide_fetch_loading": "Loading tides…",
"tide_disclaimer": "BSH water level forecast — verify time-critical manoeuvres against official sources!",
"tide_location_required": "Tide lookup needs a current position (max. 2 hours old) or a departure port.",
"tide_position_stale": "The last position is older than 2 hours. Log position again or enter a departure port.",
"tide_fetch_failed": "Could not fetch tide data.",
"tide_no_data": "No tide data available for this location.",
"tide_no_data_for_date": "No BSH forecast for travel day {{date}} (future dates only).",
"tide_pick_station_title": "Select tide gauge",
"tide_pick_station_hint": "Choose the nearest BSH gauge for the tide forecast.",
"tide_place_not_found": "“{{place}}” could not be geocoded — please use a coastal place or harbour name.",
"tide_fetched_at_position": "Official BSH forecast from the nearest tide gauge.",
"tide_open_meteo_fallback": "Model forecast (Open-Meteo) — no BSH station within range.",
"tide_data_for_position": "Query for position {{lat}}, {{lng}}",
"tide_data_for_place": "Query for {{place}}",
"tide_data_for_place_and_position": "Query for {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Data from {{place}} (about {{distance}} km away)",
"tide_fetched_from_departure": "Tides based on departure “{{place}}” (no current GPS position).",
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
"tide_role_departure": "Departure Port",
"tide_role_destination": "Destination Port",
"tide_role_gps": "GPS Position",
"tide_location_picker_title": "Select Tide Position",
"tide_location_picker_hint": "Select the position to fetch tides for:",
"tide_applied_success": "Tides applied: high water {{highWater}}, low water {{lowWater}}. Visible in the travel day editor under “Tides”.",
"tide_apply": "Apply",
"tanks": "Tanks",
"customize_columns": "Customize columns",
"column_selector_title": "Columns to Show",
"freshwater": "Freshwater (Liters)",
"fuel": "Fuel (Liters)",
"greywater": "Greywater (Liters)",
@@ -297,6 +333,9 @@
"live_voice_entry_plain": "Voice memo",
"live_voice_caption_label": "Caption (optional)",
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
"live_voice_transcribe_action": "Transcribe",
"live_voice_transcribing": "Transcribing…",
"live_voice_transcribe_failed": "Voice memo saved, but transcription failed.",
"live_undo_voice_hint": "Voice memo saved",
"live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…",
@@ -436,10 +475,15 @@
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
"photos_title": "Photo Attachments (E2E Encrypted)",
"photos_title": "Photo Attachments",
"export_photos_zip": "Download Photos (ZIP)",
"exporting_photos_zip": "Creating ZIP...",
"no_photos_to_download": "No photos found in this logbook.",
"photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
"photo_btn": "Take Photo / Upload",
"photo_camera_btn": "Take Photo",
"photo_gallery_btn": "Choose from Gallery",
"photo_processing": "Processing...",
"no_photos": "No photos attached to this journal entry yet.",
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
@@ -532,6 +576,9 @@
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...",
"travel_days_count_zero": "No travel days",
"travel_days_count_one": "1 travel day",
"travel_days_count_other": "{{count}} travel days",
"status_synced": "Synced",
"status_local": "Local Cache Only",
"delete_btn": "Delete logbook",
@@ -669,6 +716,12 @@
"integrations_title": "Integrations",
"owm_key": "OpenWeatherMap API key",
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"ai_title": "AI Features & Privacy",
"ai_desc": "Authorize artificial intelligence integrations for your logbooks.",
"ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.",
"ai_enable_label": "Enable transcription and travel day summaries",
"ai_unauthorized_alert_title": "AI Features Not Authorized",
"ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.",
"prefs_save": "Save",
"prefs_saving": "Saving…",
"prefs_saved": "Saved",
@@ -790,6 +843,9 @@
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
"weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.",
"weather_unauthorized": "Failed to fetch weather. The API key is invalid or unauthorized.",
"weather_not_found": "Failed to fetch weather. The specified location or coordinates were not found.",
"weather_bad_request": "Failed to fetch weather. No location or GPS position was specified.",
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
"gps_error": "Please enter a location or fetch GPS coordinates first.",
"share_title": "Share Logbook (Read-Only)",
@@ -798,6 +854,7 @@
"share_enable": "Enable Public Link",
"share_copied": "Link copied!",
"share_copy_btn": "Copy Link",
"share_btn": "Share Link",
"link_qr_hint": "Scan this QR code with your phone",
"link_qr_alt": "QR code for the link",
"danger_zone_title": "Danger Zone",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@ export interface AdminSummary {
totalCollaborations: number
totalInvitations: number
aiSummaryEntries: number
dbSize: number
}
export type AdminTimeBucket = 'day' | 'week' | 'month'
+4
View File
@@ -42,7 +42,9 @@ export const PlausibleEvents = {
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
TIDE_FETCHED: 'Tide Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
@@ -53,6 +55,8 @@ export const PlausibleEvents = {
/** 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 TideAnalyticsSource = 'live_log' | 'entry_editor'
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
+8 -4
View File
@@ -26,6 +26,7 @@ describe('appearancePrefs', () => {
await expect(fetchAppearancePrefs()).resolves.toEqual({
theme: 'auto',
colorScheme: 'auto',
aiAuthorized: false,
persisted: false
})
expect(mockedApiJson).not.toHaveBeenCalled()
@@ -36,6 +37,7 @@ describe('appearancePrefs', () => {
mockedApiJson.mockResolvedValueOnce({
theme: 'ocean',
colorScheme: 'dark',
aiAuthorized: true,
persisted: true
})
@@ -46,6 +48,7 @@ describe('appearancePrefs', () => {
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
expect(localStorage.getItem(`user_pref_ai_authorized_${USER_ID}`)).toBe('true')
expect(changed).toHaveBeenCalledTimes(1)
})
@@ -53,20 +56,20 @@ describe('appearancePrefs', () => {
localStorage.setItem('active_userid', USER_ID)
setThemePreference(USER_ID, 'material')
mockedApiJson
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false })
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, persisted: true })
await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).toHaveBeenCalledTimes(2)
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
method: 'PUT',
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
body: JSON.stringify({ theme: 'material', colorScheme: 'auto', aiAuthorized: false })
})
})
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
await saveAppearancePrefsToServer('ocean', 'light')
await saveAppearancePrefsToServer('ocean', 'light', true)
expect(mockedApiJson).not.toHaveBeenCalled()
})
@@ -76,6 +79,7 @@ describe('appearancePrefs', () => {
mockedApiJson.mockResolvedValue({
theme: 'material',
colorScheme: 'dark',
aiAuthorized: false,
persisted: true
})
+16 -5
View File
@@ -5,7 +5,9 @@ import {
getColorSchemePreference,
getThemePreference,
setColorSchemePreference,
setThemePreference
setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js'
const API_BASE = '/api/auth/appearance-prefs'
@@ -13,13 +15,15 @@ const API_BASE = '/api/auth/appearance-prefs'
export interface AppearancePrefs {
theme: string
colorScheme: string
aiAuthorized: boolean
persisted: boolean
}
function hasLocalAppearancePrefs(userId: string): boolean {
return (
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null ||
localStorage.getItem(`user_pref_ai_authorized_${userId}`) != null
)
}
@@ -35,7 +39,7 @@ function resolveSyncedUserId(userId?: string | null): string | null {
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
if (!resolveSyncedUserId(userId)) {
return { theme: 'auto', colorScheme: 'auto', persisted: false }
return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }
}
return apiJson<AppearancePrefs>(API_BASE)
@@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise<Appe
export async function saveAppearancePrefsToServer(
theme: string,
colorScheme: string,
aiAuthorized: boolean,
userId?: string | null
): Promise<void> {
if (!resolveSyncedUserId(userId)) return
await apiJson<AppearancePrefs>(API_BASE, {
method: 'PUT',
body: JSON.stringify({ theme, colorScheme })
body: JSON.stringify({ theme, colorScheme, aiAuthorized })
})
}
@@ -65,8 +70,14 @@ export async function syncAppearancePrefs(userId?: string | null): Promise<void>
if (server.persisted) {
setThemePreference(id, server.theme)
setColorSchemePreference(id, server.colorScheme)
setAiAuthorized(id, server.aiAuthorized)
} else if (hasLocalAppearancePrefs(id)) {
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
await saveAppearancePrefsToServer(
getThemePreference(id),
getColorSchemePreference(id),
getAiAuthorized(id),
id
)
}
} catch (err) {
console.warn('Failed to sync appearance preferences:', err)
+20 -2
View File
@@ -34,6 +34,8 @@ export interface DecryptedLogbook {
isShared: boolean
accessRole: LogbookAccessRole
isDemo?: boolean
lastTravelDate?: string
entryCount?: number
}
// Helper to decrypt a logbook's title using the active logbook key or master key
@@ -142,10 +144,24 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Retrieve all from Dexie cache
const cachedLogbooks = await db.logbooks.toArray()
// Decrypt titles
// Decrypt titles and query last travel dates
const decrypted: DecryptedLogbook[] = []
for (const lb of cachedLogbooks) {
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
// Find latest travel date from local entries cache
const entries = await db.entries.where({ logbookId: lb.id }).toArray()
let lastTravelDate: string | undefined = undefined
if (entries.length > 0) {
const dates = entries
.map((e) => e.listCache?.date)
.filter((d): d is string => typeof d === 'string' && d.length > 0)
if (dates.length > 0) {
dates.sort()
lastTravelDate = dates[dates.length - 1]
}
}
decrypted.push({
id: lb.id,
title,
@@ -155,7 +171,9 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
accessRole: lb.isShared === 1
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
: 'OWNER',
isDemo: lb.isDemo === 1
isDemo: lb.isDemo === 1,
lastTravelDate,
entryCount: entries.length
})
}
+32 -1
View File
@@ -7,10 +7,14 @@ import { putEntryRecord } from '../utils/entryListCache.js'
import {
buildLogEntryPayload,
normalizeLogEvent,
readLogEntryTidesMap,
sortLogEventsByTime,
currentLocalTimeHHMM,
localDateString,
type LogEventPayload
type LogEntryTides,
type LogEntryTidesMap,
type LogEventPayload,
type TideRole
} from '../utils/logEntryPayload.js'
import {
carryOverFromPreviousDay,
@@ -75,6 +79,7 @@ function buildEncryptedPayload(
destination?: string
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
tides?: LogEntryTidesMap
clearSignatures?: boolean
}
): Record<string, unknown> {
@@ -113,6 +118,7 @@ function buildEncryptedPayload(
freshwater,
fuel: fuelLevels,
greywater: gw ? { level: gw.level || 0 } : undefined,
tides: options.tides ?? readLogEntryTidesMap(data),
trackDistanceNm:
trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance))
@@ -398,6 +404,31 @@ export async function appendQuickEvents(
return { events: nextEvents, hadSignature }
}
export async function patchEntryTides(
logbookId: string,
entryId: string,
role: TideRole,
tides: LogEntryTides
): Promise<void> {
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 currentTidesMap = readLogEntryTidesMap(loaded.data)
const nextTidesMap = {
...currentTidesMap,
[role]: tides
}
await persistEntry(logbookId, entryId, loaded.data, {
events: currentEvents,
tides: nextTidesMap,
clearSignatures: hadSignature
})
}
async function persistEntry(
logbookId: string,
entryId: string,
+174
View File
@@ -0,0 +1,174 @@
import { apiFetch } from './api.js'
import {
type TideAnalyticsSource,
PlausibleEvents,
trackPlausibleEvent
} from './analytics.js'
export interface TideStation {
id: string
name: string
lat: number
lon: number
distanceKm: number
area?: string
}
export class TidesApiError extends Error {
code:
| 'OFFLINE'
| 'NOT_FOUND'
| 'NO_DATA_FOR_DATE'
| 'PLACE_NOT_FOUND'
| 'BAD_REQUEST'
| 'REQUEST_FAILED'
stations?: TideStation[]
constructor(
message: string,
code:
| 'OFFLINE'
| 'NOT_FOUND'
| 'NO_DATA_FOR_DATE'
| 'PLACE_NOT_FOUND'
| 'BAD_REQUEST'
| 'REQUEST_FAILED' = 'REQUEST_FAILED',
stations?: TideStation[]
) {
super(message)
this.name = 'TidesApiError'
this.code = code
this.stations = stations
}
}
const TIDES_FETCH_TIMEOUT_MS = 20_000
function readStations(data: Record<string, unknown>): TideStation[] | undefined {
if (!Array.isArray(data.stations)) return undefined
const stations: TideStation[] = []
for (const item of data.stations) {
if (!item || typeof item !== 'object') continue
const row = item as Record<string, unknown>
const id = String(row.id ?? '').trim()
const name = String(row.name ?? '').trim()
const lat = Number(row.lat)
const lon = Number(row.lon)
const distanceKm = Number(row.distanceKm)
if (!id || !name || Number.isNaN(lat) || Number.isNaN(lon) || Number.isNaN(distanceKm)) {
continue
}
stations.push({
id,
name,
lat,
lon,
distanceKm,
area: row.area ? String(row.area) : undefined
})
}
return stations.length > 0 ? stations : undefined
}
async function fetchTides(path: string): Promise<Record<string, unknown>> {
if (!navigator.onLine) {
throw new TidesApiError('Offline', 'OFFLINE')
}
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), TIDES_FETCH_TIMEOUT_MS)
let res: Response
try {
res = await apiFetch(path, { signal: controller.signal })
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new TidesApiError('Tide request timed out')
}
throw err
} finally {
window.clearTimeout(timeoutId)
}
const data = await res.json().catch(() => ({}))
if (res.status === 400) {
throw new TidesApiError('Invalid tide request parameters', 'BAD_REQUEST')
}
if (res.status === 404) {
const stations = readStations(data as Record<string, unknown>)
const code =
typeof data?.error === 'string' && data.error === 'place_not_found'
? 'PLACE_NOT_FOUND'
: 'NOT_FOUND'
throw new TidesApiError('Tide data not found', code, stations)
}
if (!res.ok) {
throw new TidesApiError(
typeof data?.error === 'string' ? data.error : 'Tide API rejected the request'
)
}
return data as Record<string, unknown>
}
export async function fetchNearbyTideStations(
lat: string,
lon: string,
limit = 8
): Promise<TideStation[]> {
const searchParams = new URLSearchParams({ lat, lon, limit: String(limit) })
const data = await fetchTides(`/api/tides/stations/nearby?${searchParams.toString()}`)
return readStations(data) ?? []
}
export async function fetchTidesNearby(
lat: string,
lon: string,
options?: { analyticsSource?: TideAnalyticsSource; locationSource?: 'gps' | 'departure' }
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams({ lat, lon })
const data = await fetchTides(`/api/tides/nearby?${searchParams.toString()}`)
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.TIDE_FETCHED, {
source: options.analyticsSource,
location_source: options.locationSource ?? 'gps'
})
}
return data
}
export async function fetchTidesByStation(
stationId: string,
options?: {
queryLat?: string
queryLng?: string
analyticsSource?: TideAnalyticsSource
}
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams()
if (options?.queryLat) searchParams.set('lat', options.queryLat)
if (options?.queryLng) searchParams.set('lon', options.queryLng)
const suffix = searchParams.toString() ? `?${searchParams.toString()}` : ''
const data = await fetchTides(`/api/tides/station/${encodeURIComponent(stationId)}${suffix}`)
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.TIDE_FETCHED, {
source: options.analyticsSource,
location_source: 'gps'
})
}
return data
}
export async function fetchTidesByPlace(
placeQuery: string,
options?: { analyticsSource?: TideAnalyticsSource }
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams({ q: placeQuery.trim() })
const data = await fetchTides(`/api/tides/by-place?${searchParams.toString()}`)
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.TIDE_FETCHED, {
source: options.analyticsSource,
location_source: 'departure'
})
}
return data
}
+12 -1
View File
@@ -6,7 +6,9 @@ import {
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js'
const USER_ID = 'test-user-123'
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
expect(getThemePreference(USER_ID)).toBe('ocean')
expect(getColorSchemePreference(USER_ID)).toBe('light')
})
it('stores AI authorization preference per user', () => {
localStorage.setItem('active_userid', USER_ID)
expect(getAiAuthorized()).toBe(false)
setAiAuthorized(USER_ID, true)
expect(getAiAuthorized()).toBe(true)
expect(getAiAuthorized(USER_ID)).toBe(true)
expect(getAiAuthorized('other-user')).toBe(false)
})
})
+17
View File
@@ -89,3 +89,20 @@ export function setOwmApiKey(userId: string, value: string): void {
localStorage.removeItem(owmKey(userId))
}
}
function aiAuthorizedKey(userId: string): string {
return `user_pref_ai_authorized_${userId}`
}
export function getAiAuthorized(userId?: string | null): boolean {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(aiAuthorizedKey(id)) === 'true'
}
return false
}
export function setAiAuthorized(userId: string, value: boolean): void {
localStorage.setItem(aiAuthorizedKey(userId), String(value))
}
+57 -2
View File
@@ -1,7 +1,7 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { encryptJson, decryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
@@ -18,6 +18,7 @@ export async function saveEntryVoiceMemo(options: {
mimeType: string
durationSec: number
caption?: string
transcribed?: boolean
analyticsContext?: string
}): Promise<string> {
const {
@@ -27,6 +28,7 @@ export async function saveEntryVoiceMemo(options: {
mimeType,
durationSec,
caption = '',
transcribed = true,
analyticsContext = 'logbook'
} = options
const masterKey = await getEncryptionKey(logbookId)
@@ -35,7 +37,8 @@ export async function saveEntryVoiceMemo(options: {
audio: audioDataUrl,
mimeType,
durationSec,
caption: caption.trim()
caption: caption.trim(),
transcribed: !!transcribed
}
const encrypted = await encryptJson(voicePayload, masterKey)
@@ -98,3 +101,55 @@ export async function removeLastVoiceMemoForEntry(
await deleteEntryVoiceMemo(logbookId, lastId)
return lastId
}
/** Updates an existing voice memo payload with a new transcript and sets transcribed: true. */
export async function updateVoiceMemoTranscript(
logbookId: string,
voiceId: string,
transcript: string
): Promise<void> {
const masterKey = await getEncryptionKey(logbookId)
const record = await db.voiceMemos.get(voiceId)
if (!record) throw new Error('Voice memo not found')
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (!decrypted) throw new Error('Failed to decrypt voice memo')
const manualCaption = decrypted.caption ? String(decrypted.caption).trim() : ''
const finalCaption = manualCaption
? `${manualCaption}\n(Transkript: ${transcript.trim()})`
: transcript.trim()
const updatedPayload = {
...decrypted,
caption: finalCaption,
transcribed: true
}
const encrypted = await encryptJson(updatedPayload, masterKey)
const now = new Date().toISOString()
await db.voiceMemos.put({
...record,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId: record.entryId
}),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
+47
View File
@@ -69,4 +69,51 @@ describe('fetchOpenWeatherCurrent', () => {
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
it('throws UNAUTHORIZED when status is 401', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: 'Unauthorized' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('UNAUTHORIZED')
})
it('throws NOT_FOUND when status is 404', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ error: 'Not Found' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('NOT_FOUND')
})
it('throws BAD_REQUEST when status is 400', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'Bad Request' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('BAD_REQUEST')
})
it('throws BAD_REQUEST when coordinates or query are missing', async () => {
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({}).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('BAD_REQUEST')
expect(apiFetch).not.toHaveBeenCalled()
})
})
+15 -3
View File
@@ -7,9 +7,12 @@ import {
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST'
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
constructor(
message: string,
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'WeatherApiError'
this.code = code
@@ -38,7 +41,7 @@ export async function fetchOpenWeatherCurrent(
} else if (params.q?.trim()) {
searchParams.set('q', params.q.trim())
} else {
throw new WeatherApiError('lat/lon or location query required')
throw new WeatherApiError('lat/lon or location query required', 'BAD_REQUEST')
}
const userKey = getOwmApiKeyForActiveUser().trim()
@@ -65,6 +68,15 @@ export async function fetchOpenWeatherCurrent(
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
}
if (res.status === 401) {
throw new WeatherApiError('Invalid OpenWeatherMap API key', 'UNAUTHORIZED')
}
if (res.status === 404) {
throw new WeatherApiError('Location or coordinates not found', 'NOT_FOUND')
}
if (res.status === 400) {
throw new WeatherApiError('Invalid or missing location parameters', 'BAD_REQUEST')
}
const data = await res.json()
if (!res.ok) {
+6 -7
View File
@@ -20,14 +20,13 @@ vi.mock('../services/analytics.js', async (importOriginal) => {
})
function createMockI18n(language: string): I18nInstance {
let current = language
return {
language: current,
const mock = {
language,
changeLanguage: vi.fn(async (lng: string) => {
current = lng
;(this as { language: string }).language = lng
mock.language = lng
})
} as unknown as I18nInstance
return mock
}
describe('i18nLanguages', () => {
@@ -72,11 +71,11 @@ describe('i18nLanguages', () => {
})
it('cycleAppLanguage tracks the next language', () => {
const i18n = createMockI18n('nb')
const i18n = createMockI18n('es')
cycleAppLanguage(i18n)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
from: 'nb',
from: 'es',
to: 'de'
})
})
+11 -1
View File
@@ -2,10 +2,20 @@ 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
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb', 'fr', 'es'] as const
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
export const LANGUAGE_FLAGS: Record<AppLanguage, string> = {
de: '🇩🇪',
en: '🇬🇧',
da: '🇩🇰',
sv: '🇸🇪',
nb: '🇳🇴',
fr: '🇫🇷',
es: '🇪🇸'
}
export function normalizeAppLanguage(language?: string): AppLanguage {
const base = (language ?? 'en').split('-')[0].toLowerCase()
if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) {
+20 -3
View File
@@ -154,6 +154,9 @@ export function getLastAutoPositionMs(
/** Max age of a logged position for OpenWeatherMap lookups in live log. */
export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000
/** Max age of a logged position for tide lookups (TideTurtle). */
export const LIVE_LOG_TIDE_POSITION_MAX_AGE_MS = 2 * 60 * 60 * 1000
export type LiveLogPositionSource = 'position' | 'auto_position'
export interface LiveLogPosition {
@@ -176,7 +179,10 @@ export function getLatestLoggedPosition(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string
): LiveLogPosition | null {
for (let i = events.length - 1; i >= 0; i--) {
let best: LiveLogPosition | null = null
let bestIndex = -1
for (let i = 0; i < events.length; i++) {
const event = events[i]
const code = event.remarks.trim()
if (!isPositionEventCode(code)) continue
@@ -185,14 +191,25 @@ export function getLatestLoggedPosition(
if (!lat || !lng) continue
const loggedAtMs = eventTimestampMs(entryDate, event.time)
if (loggedAtMs == null) continue
return {
const candidate: LiveLogPosition = {
lat,
lng,
loggedAtMs,
source: isManualPositionEventCode(code) ? 'position' : 'auto_position'
}
if (
!best ||
candidate.loggedAtMs > best.loggedAtMs ||
(candidate.loggedAtMs === best.loggedAtMs && i > bestIndex)
) {
best = candidate
bestIndex = i
}
}
return null
return best
}
/** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */
+10
View File
@@ -19,6 +19,16 @@ describe('live log position', () => {
expect(position?.source).toBe('position')
})
it('picks latest position by event time even when array is not sorted', () => {
const entryDate = '2026-06-01'
const events = [
{ remarks: LIVE_EVENT_CODES.POSITION, time: '14:16', gpsLat: '54.12', gpsLng: '10.65' },
{ remarks: LIVE_EVENT_CODES.POSITION, time: '14:03', gpsLat: '53.62', gpsLng: '7.15' }
]
const position = getLatestLoggedPosition(events, entryDate)
expect(position?.lat).toBe('54.12')
})
it('reads legacy __live:fix remarks', () => {
const entryDate = '2026-06-01'
const events = [
+139
View File
@@ -5,6 +5,8 @@ import {
isLogEventDraftEmpty,
localDateString,
normalizeLogEvent,
splitTimeHHMM,
readLogEntryTidesMap,
type LogEventPayload
} from './logEntryPayload.js'
@@ -72,3 +74,140 @@ describe('buildLogEntryPayload greywater', () => {
expect(payload.greywater).toBeUndefined()
})
})
describe('buildLogEntryPayload tides map', () => {
const base = {
date: '2026-06-11',
dayOfTravel: '1',
departure: 'Norddeich',
destination: 'Juist',
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
events: [] as LogEventPayload[]
}
it('persists multiple tide roles (departure and destination)', () => {
const payload = buildLogEntryPayload({
...base,
tides: {
departure: { highWater: '18:34', lowWater: '12:05' },
destination: { highWater: '19:00', lowWater: '12:30' }
}
})
expect(payload.tides).toEqual({
departure: { highWater: '18:34', lowWater: '12:05' },
destination: { highWater: '19:00', lowWater: '12:30' }
})
})
it('persists tide location metadata', () => {
const payload = buildLogEntryPayload({
...base,
tides: {
gps: {
highWater: '06:00',
lowWater: '00:04',
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263'
}
}
})
expect(payload.tides).toEqual({
gps: {
highWater: '06:00',
lowWater: '00:04',
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263'
}
})
})
})
describe('readLogEntryTidesMap backward compatibility', () => {
it('reads old flat schema as departure role', () => {
const oldData = {
tides: {
highWater: '12:30',
lowWater: '06:15',
locationSource: 'departure',
placeName: 'Kiel'
}
}
const map = readLogEntryTidesMap(oldData)
expect(map.departure).toEqual({
highWater: '12:30',
lowWater: '06:15',
locationSource: 'departure',
placeName: 'Kiel'
})
expect(map.gps).toBeUndefined()
expect(map.destination).toBeUndefined()
})
it('reads old flat schema with gps locationSource as gps role', () => {
const oldData = {
tides: {
highWater: '12:30',
lowWater: '06:15',
locationSource: 'gps',
lat: '54.3',
lng: '10.1'
}
}
const map = readLogEntryTidesMap(oldData)
expect(map.gps).toEqual({
highWater: '12:30',
lowWater: '06:15',
locationSource: 'gps',
lat: '54.3',
lng: '10.1'
})
expect(map.departure).toBeUndefined()
expect(map.destination).toBeUndefined()
})
it('reads new nested schema correctly', () => {
const newData = {
tides: {
departure: { highWater: '12:00', lowWater: '06:00', placeName: 'Kiel' },
gps: { highWater: '13:00', lowWater: '07:00', lat: '54.3' }
}
}
const map = readLogEntryTidesMap(newData)
expect(map.departure).toEqual({
highWater: '12:00',
lowWater: '06:00',
placeName: 'Kiel'
})
expect(map.gps).toEqual({
highWater: '13:00',
lowWater: '07:00',
lat: '54.3'
})
expect(map.destination).toBeUndefined()
})
})
describe('splitTimeHHMM', () => {
it('splits valid time HH:MM correctly', () => {
const result = splitTimeHHMM('15:45')
expect(result).toEqual({ hours: '15', minutes: '45' })
})
it('uses fallback value when time is empty', () => {
const result = splitTimeHHMM('', '00:00')
expect(result).toEqual({ hours: '00', minutes: '00' })
})
it('falls back to current local time when empty and no fallback is specified', () => {
const result = splitTimeHHMM('')
const hours = parseInt(result.hours, 10)
const minutes = parseInt(result.minutes, 10)
expect(hours).toBeGreaterThanOrEqual(0)
expect(hours).toBeLessThanOrEqual(23)
expect(minutes).toBeGreaterThanOrEqual(0)
expect(minutes).toBeLessThanOrEqual(59)
})
})
+107 -2
View File
@@ -72,8 +72,8 @@ export function isValidTimeHHMM(value: string): boolean {
return parseTimeToHHMM(value) !== null
}
export function splitTimeHHMM(value: string): { hours: string; minutes: string } {
const parsed = parseTimeToHHMM(value) ?? currentLocalTimeHHMM()
export function splitTimeHHMM(value: string, fallback?: string): { hours: string; minutes: string } {
const parsed = parseTimeToHHMM(value) ?? fallback ?? currentLocalTimeHHMM()
return { hours: parsed.slice(0, 2), minutes: parsed.slice(3, 5) }
}
@@ -150,6 +150,23 @@ export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[]
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
}
export type TideRole = 'departure' | 'destination' | 'gps'
export type TideLocationSource = 'gps' | 'departure' | 'geocoded' | 'destination'
export interface LogEntryTides {
highWater: string
lowWater: string
locationSource?: TideLocationSource
placeName?: string
lat?: string
lng?: string
distanceKm?: string
tideFallback?: 'open_meteo'
}
export type LogEntryTidesMap = Partial<Record<TideRole, LogEntryTides>>
export interface LogEntryPayloadInput {
date: string
dayOfTravel: string
@@ -158,6 +175,7 @@ export interface LogEntryPayloadInput {
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
greywater?: { level: number }
tides?: LogEntryTidesMap
trackDistanceNm?: number
trackSpeedMaxKn?: number
trackSpeedAvgKn?: number
@@ -166,6 +184,64 @@ export interface LogEntryPayloadInput {
entryCrew?: EntryCrewFields
}
function readTideLocationSource(value: unknown): TideLocationSource | undefined {
const source = String(value ?? '').trim()
if (source === 'gps' || source === 'departure' || source === 'geocoded' || source === 'destination') return source
return undefined
}
export function readLogEntryTides(data: Record<string, unknown>): LogEntryTides {
const tides = data.tides as Record<string, unknown> | undefined
const highRaw = String(tides?.highWater ?? '').trim()
const lowRaw = String(tides?.lowWater ?? '').trim()
const placeName = String(tides?.placeName ?? '').trim()
const lat = String(tides?.lat ?? '').trim()
const lng = String(tides?.lng ?? '').trim()
const distanceKm = String(tides?.distanceKm ?? '').trim()
const locationSource = readTideLocationSource(tides?.locationSource)
const tideFallback = tides?.tideFallback === 'open_meteo' ? 'open_meteo' as const : undefined
return {
highWater: parseTimeToHHMM(highRaw) ?? '',
lowWater: parseTimeToHHMM(lowRaw) ?? '',
...(locationSource ? { locationSource } : {}),
...(placeName ? { placeName } : {}),
...(lat ? { lat } : {}),
...(lng ? { lng } : {}),
...(distanceKm ? { distanceKm } : {}),
...(tideFallback ? { tideFallback } : {})
}
}
export function readLogEntryTidesMap(data: Record<string, unknown>): LogEntryTidesMap {
const tidesRaw = data.tides as Record<string, unknown> | undefined
if (!tidesRaw) return {}
// Check if it's the old schema (flat object with highWater/lowWater)
const isOldSchema = ('highWater' in tidesRaw || 'lowWater' in tidesRaw)
if (isOldSchema) {
const parsedOld = readLogEntryTides({ tides: tidesRaw })
let role: TideRole = 'departure'
if (parsedOld.locationSource === 'gps') {
role = 'gps'
} else if (parsedOld.locationSource === 'destination') {
role = 'destination'
}
return { [role]: parsedOld }
}
// Otherwise, it's the new schema mapping roles to tide values
const map: LogEntryTidesMap = {}
const roles: TideRole[] = ['departure', 'destination', 'gps']
for (const role of roles) {
if (tidesRaw[role] && typeof tidesRaw[role] === 'object') {
map[role] = readLogEntryTides({ tides: tidesRaw[role] })
}
}
return map
}
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
const payload: Record<string, unknown> = {
date: input.date,
@@ -191,6 +267,35 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
}
}
if (input.tides) {
const serializedMap: Record<string, unknown> = {}
const roles: TideRole[] = ['departure', 'destination', 'gps']
for (const role of roles) {
const tideData = input.tides[role]
if (tideData) {
const highWater = parseTimeToHHMM(tideData.highWater) ?? ''
const lowWater = parseTimeToHHMM(tideData.lowWater) ?? ''
if (highWater || lowWater) {
const tidesObj: Record<string, string> = { highWater, lowWater }
if (tideData.locationSource) tidesObj.locationSource = tideData.locationSource
const placeName = tideData.placeName?.trim()
if (placeName) tidesObj.placeName = placeName
const lat = tideData.lat?.trim()
if (lat) tidesObj.lat = lat
const lng = tideData.lng?.trim()
if (lng) tidesObj.lng = lng
const distanceKm = tideData.distanceKm?.trim()
if (distanceKm) tidesObj.distanceKm = distanceKm
if (tideData.tideFallback === 'open_meteo') tidesObj.tideFallback = 'open_meteo'
serializedMap[role] = tidesObj
}
}
}
if (Object.keys(serializedMap).length > 0) {
payload.tides = serializedMap
}
}
if (input.entryCrew) {
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
+3 -1
View File
@@ -10,7 +10,9 @@ const OG_LOCALES: Record<SeoLang, string> = {
en: 'en_GB',
da: 'da_DK',
sv: 'sv_SE',
nb: 'nb_NO'
nb: 'nb_NO',
fr: 'fr_FR',
es: 'es_ES'
}
let i18nRef: I18nInstance | null = null
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as tidesService from '../services/tides.js'
import { fetchTidesForEntry } from './tideFetch.js'
describe('fetchTidesForEntry', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns tide times when nearby fetch succeeds for entry date', async () => {
vi.spyOn(tidesService, 'fetchTidesNearby').mockResolvedValue({
distanceKm: 8,
location: { name: 'Norderney, Riffgat', source: 'bsh_station' },
tides: {
data: {
timezone: 'Europe/Berlin',
extrema: [
{
time: '2026-06-12T07:20:00.000Z',
date: '2026-06-12',
height: 6.16,
isHigh: true
},
{
time: '2026-06-12T13:39:00.000Z',
date: '2026-06-12',
height: 4.03,
isHigh: false
}
]
}
}
})
const outcome = await fetchTidesForEntry({
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
entryDate: '2026-06-12',
analyticsSource: 'entry_editor'
})
expect(outcome).toMatchObject({
highWater: '09:20',
lowWater: '15:39'
})
})
it('offers station picker when fetch succeeds but entry date has no extrema', async () => {
vi.spyOn(tidesService, 'fetchTidesNearby').mockResolvedValue({
tides: {
data: {
timezone: 'Europe/Berlin',
extrema: [
{
time: '2026-06-12T07:20:00.000Z',
date: '2026-06-12',
height: 6.16,
isHigh: true
}
]
}
}
})
await expect(
fetchTidesForEntry({
fetchLocation: { mode: 'nearby', lat: '53.62', lng: '7.15', source: 'gps' },
entryDate: '2026-06-01',
analyticsSource: 'entry_editor'
})
).rejects.toMatchObject({ code: 'NO_DATA_FOR_DATE' })
})
it('offers station picker when nearby fetch returns not found', async () => {
vi.spyOn(tidesService, 'fetchTidesNearby').mockRejectedValue(
new tidesService.TidesApiError('Tide data not found', 'NOT_FOUND', [
{
id: 'norderney_riffgat',
name: 'Norderney, Riffgat',
lat: 53.69,
lon: 7.15,
distanceKm: 8
}
])
)
const outcome = await fetchTidesForEntry({
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
entryDate: '2026-06-12',
analyticsSource: 'entry_editor'
})
expect(outcome).toEqual({
kind: 'pick_station',
entryDate: '2026-06-12',
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
stations: [
{
id: 'norderney_riffgat',
name: 'Norderney, Riffgat',
lat: 53.69,
lon: 7.15,
distanceKm: 8
}
],
lat: '53.624526',
lng: '7.155263'
})
})
})
+145
View File
@@ -0,0 +1,145 @@
import {
fetchNearbyTideStations,
fetchTidesByPlace,
fetchTidesByStation,
fetchTidesNearby,
type TideStation,
TidesApiError
} from '../services/tides.js'
import type { TideFetchLocation } from './tideLocation.js'
import { buildTideLocationMeta, type TideLocationMeta } from './tideLocation.js'
import { extractTideTurtlePayload, parseTideTurtleForDate } from './tideTurtle.js'
export type TideFetchResult = {
highWater: string
lowWater: string
location: TideLocationMeta
apiData: Record<string, unknown>
}
export type TideFetchNeedsStationPick = {
kind: 'pick_station'
entryDate: string
fetchLocation: TideFetchLocation
stations: TideStation[]
queryLat?: string
queryLng?: string
}
export type TideFetchOutcome = TideFetchResult | TideFetchNeedsStationPick
function readQueryCoords(fetchLocation: TideFetchLocation): { lat?: string; lng?: string } {
if (fetchLocation.mode === 'nearby') {
return { lat: fetchLocation.lat, lng: fetchLocation.lng }
}
return {}
}
function hasTideTimesForDate(data: Record<string, unknown>, entryDate: string): boolean {
const parsed = parseTideTurtleForDate(data, entryDate)
return Boolean(parsed.highWater || parsed.lowWater)
}
function toResult(
data: Record<string, unknown>,
entryDate: string,
fetchLocation: TideFetchLocation
): TideFetchResult | null {
const parsed = parseTideTurtleForDate(data, entryDate)
if (!parsed.highWater && !parsed.lowWater) return null
return {
highWater: parsed.highWater,
lowWater: parsed.lowWater,
location: buildTideLocationMeta(fetchLocation, data),
apiData: data
}
}
async function loadNearbyStations(
fetchLocation: TideFetchLocation,
stationsFromError?: TideStation[]
): Promise<TideStation[]> {
if (stationsFromError && stationsFromError.length > 0) {
return stationsFromError
}
if (fetchLocation.mode !== 'nearby') return []
return fetchNearbyTideStations(fetchLocation.lat, fetchLocation.lng)
}
export async function fetchTidesForEntry(options: {
fetchLocation: TideFetchLocation
entryDate: string
analyticsSource: 'entry_editor' | 'live_log'
}): Promise<TideFetchOutcome> {
const { fetchLocation, entryDate, analyticsSource } = options
const queryCoords = readQueryCoords(fetchLocation)
let stationsFromError: TideStation[] | undefined
try {
const data =
fetchLocation.mode === 'nearby'
? await fetchTidesNearby(fetchLocation.lat, fetchLocation.lng, {
analyticsSource,
locationSource: fetchLocation.source
})
: await fetchTidesByPlace(fetchLocation.query, { analyticsSource })
const result = toResult(data, entryDate, fetchLocation)
if (result) return result
const { extrema } = extractTideTurtlePayload(data)
if (extrema.length > 0) {
throw new TidesApiError('No tide data for entry date', 'NO_DATA_FOR_DATE')
}
} catch (error) {
if (error instanceof TidesApiError && error.code === 'NO_DATA_FOR_DATE') {
throw error
}
if (error instanceof TidesApiError && error.stations?.length) {
stationsFromError = error.stations
} else if (!(error instanceof TidesApiError) || error.code !== 'NOT_FOUND') {
throw error
}
}
const stations = await loadNearbyStations(fetchLocation, stationsFromError)
if (stations.length > 0) {
return {
kind: 'pick_station',
entryDate,
fetchLocation,
stations,
...queryCoords
}
}
throw new TidesApiError('Tide data not found', 'NOT_FOUND')
}
export async function fetchTidesForStationChoice(options: {
stationId: string
entryDate: string
fetchLocation: TideFetchLocation
queryLat?: string
queryLng?: string
analyticsSource: 'entry_editor' | 'live_log'
}): Promise<TideFetchResult> {
const data = await fetchTidesByStation(options.stationId, {
queryLat: options.queryLat,
queryLng: options.queryLng,
analyticsSource: options.analyticsSource
})
const result = toResult(data, options.entryDate, options.fetchLocation)
if (!result) {
throw new TidesApiError('Tide data not found', 'NOT_FOUND')
}
return result
}
export function tideDataHasForecastForDate(
data: Record<string, unknown>,
entryDate: string
): boolean {
return hasTideTimesForDate(data, entryDate)
}
+237
View File
@@ -0,0 +1,237 @@
import { describe, expect, it } from 'vitest'
import { LIVE_EVENT_CODES } from './liveEventCodes.js'
import {
buildTideLocationMeta,
formatTideLocationLabel,
resolveTideFetchLocation,
getAvailableTideLocations
} from './tideLocation.js'
const entryDate = '2026-06-11'
const nowMs = new Date('2026-06-11T12:00:00').getTime()
describe('resolveTideFetchLocation', () => {
it('uses chronologically latest position when several are logged', () => {
const result = resolveTideFetchLocation({
events: [
{
time: '14:03',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '53.624526',
gpsLng: '7.155263'
},
{
time: '14:16',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.120000',
gpsLng: '10.650000'
}
],
entryDate,
departure: 'Norddeich',
nowMs
})
expect(result).toEqual({
mode: 'nearby',
lat: '54.120000',
lng: '10.650000',
source: 'gps'
})
})
it('prefers fresh GPS position', () => {
const result = resolveTideFetchLocation({
events: [
{
time: '11:30',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.32',
gpsLng: '10.14'
}
],
entryDate,
departure: 'Kiel',
nowMs
})
expect(result).toEqual({
mode: 'nearby',
lat: '54.32',
lng: '10.14',
source: 'gps'
})
})
it('falls back to departure when no position', () => {
const result = resolveTideFetchLocation({
events: [],
entryDate,
departure: 'Sylt',
nowMs
})
expect(result).toEqual({
mode: 'by-place',
query: 'Sylt',
source: 'departure'
})
})
it('falls back to departure when position is stale', () => {
const result = resolveTideFetchLocation({
events: [
{
time: '08:00',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.32',
gpsLng: '10.14'
}
],
entryDate,
departure: 'Kiel',
nowMs
})
expect(result).toEqual({
mode: 'by-place',
query: 'Kiel',
source: 'departure'
})
})
it('returns stale without departure', () => {
const result = resolveTideFetchLocation({
events: [
{
time: '08:00',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.32',
gpsLng: '10.14'
}
],
entryDate,
departure: '',
nowMs
})
expect(result).toEqual({ error: 'stale' })
})
it('builds GPS location metadata from nearby fetch', () => {
const meta = buildTideLocationMeta(
{ mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
{ location: { name: 'Norddeich', lat: 53.62, lon: 7.15, source: 'coordinates' } }
)
expect(meta).toEqual({
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263',
placeName: 'Norddeich'
})
})
it('formats coordinate and place labels', () => {
const t = (key: string, options?: Record<string, string | undefined>) =>
`${key}:${JSON.stringify(options ?? {})}`
expect(
formatTideLocationLabel(
{
locationSource: 'gps',
lat: '53.62',
lng: '7.15',
placeName: 'Norderney, Riffgat',
distanceKm: '8'
},
t
)
).toContain('tide_fetched_from')
expect(
formatTideLocationLabel({ locationSource: 'gps', lat: '53.62', lng: '7.15' }, t)
).toContain('tide_data_for_position')
expect(
formatTideLocationLabel({ locationSource: 'gps', tideFallback: 'open_meteo' }, t)
).toContain('tide_open_meteo_fallback')
})
it('stores distance from BSH API metadata', () => {
const meta = buildTideLocationMeta(
{ mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
{
distanceKm: 8.1,
location: {
name: 'Norderney, Riffgat',
lat: 53.696389,
lon: 7.157778,
source: 'bsh_station'
}
}
)
expect(meta.distanceKm).toBe('8.1')
expect(meta.placeName).toBe('Norderney, Riffgat')
})
it('returns missing without position or departure', () => {
const result = resolveTideFetchLocation({
events: [],
entryDate,
departure: '',
nowMs
})
expect(result).toEqual({ error: 'missing' })
})
})
describe('getAvailableTideLocations', () => {
it('returns empty list when no locations are available', () => {
const list = getAvailableTideLocations({
departure: '',
destination: '',
events: [],
entryDate
})
expect(list).toEqual([])
})
it('returns departure and destination when they are non-empty', () => {
const list = getAvailableTideLocations({
departure: 'Büsum',
destination: 'Helgoland',
events: [],
entryDate
})
expect(list).toHaveLength(2)
expect(list[0]).toEqual({
role: 'departure',
labelKey: 'logs.tide_role_departure',
displayLabel: 'Büsum',
fetchLocation: { mode: 'by-place', query: 'Büsum', source: 'departure' }
})
expect(list[1]).toEqual({
role: 'destination',
labelKey: 'logs.tide_role_destination',
displayLabel: 'Helgoland',
fetchLocation: { mode: 'by-place', query: 'Helgoland', source: 'destination' }
})
})
it('returns gps when fresh position is present in events', () => {
const list = getAvailableTideLocations({
departure: 'Büsum',
destination: '',
events: [
{
time: '11:30',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.1',
gpsLng: '8.8'
}
],
entryDate,
nowMs
})
expect(list).toHaveLength(2)
expect(list[0].role).toBe('departure')
expect(list[1]).toEqual({
role: 'gps',
labelKey: 'logs.tide_role_gps',
displayLabel: '54.1, 8.8',
fetchLocation: { mode: 'nearby', lat: '54.1', lng: '8.8', source: 'gps' }
})
})
})
+205
View File
@@ -0,0 +1,205 @@
import {
getLastLoggedPositionWithin,
getLatestLoggedPosition,
LIVE_LOG_TIDE_POSITION_MAX_AGE_MS
} from './liveEventCodes.js'
import type { LogEntryTides, LogEventPayload, TideLocationSource, TideRole } from './logEntryPayload.js'
export type { TideLocationSource }
export type TideLocationMeta = Pick<
LogEntryTides,
'locationSource' | 'placeName' | 'lat' | 'lng' | 'distanceKm' | 'tideFallback'
>
export type TideFetchLocation =
| { mode: 'nearby'; lat: string; lng: string; source: 'gps' }
| { mode: 'by-place'; query: string; source: 'departure' | 'destination' }
export interface TideLocationOption {
role: TideRole
labelKey: string
displayLabel: string
fetchLocation: TideFetchLocation
}
export type TideLocationError = 'stale' | 'missing'
export function resolveTideFetchLocation(options: {
events: Array<Pick<LogEventPayload, 'remarks' | 'time' | 'gpsLat' | 'gpsLng'>>
entryDate: string
departure: string
maxAgeMs?: number
nowMs?: number
}): TideFetchLocation | { error: TideLocationError } {
const maxAgeMs = options.maxAgeMs ?? LIVE_LOG_TIDE_POSITION_MAX_AGE_MS
const nowMs = options.nowMs ?? Date.now()
const departure = options.departure.trim()
const fresh = getLastLoggedPositionWithin(
options.events,
options.entryDate,
maxAgeMs,
nowMs
)
if (fresh) {
return { mode: 'nearby', lat: fresh.lat, lng: fresh.lng, source: 'gps' }
}
if (departure) {
return { mode: 'by-place', query: departure, source: 'departure' }
}
const latest = getLatestLoggedPosition(options.events, options.entryDate)
if (latest && nowMs - latest.loggedAtMs > maxAgeMs) {
return { error: 'stale' }
}
return { error: 'missing' }
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function readDistanceKm(apiData: Record<string, unknown>): string | undefined {
if (apiData.distanceKm == null || apiData.distanceKm === '') return undefined
const km = Number(apiData.distanceKm)
if (Number.isNaN(km)) return undefined
return String(Math.round(km * 10) / 10)
}
function readTideFallback(apiData: Record<string, unknown>): 'open_meteo' | undefined {
return apiData.fallback === 'open_meteo' ? 'open_meteo' : undefined
}
export function buildTideLocationMeta(
fetchLocation: TideFetchLocation,
apiData: Record<string, unknown>
): TideLocationMeta {
const apiLocation = asRecord(apiData.location)
const distanceKm = readDistanceKm(apiData)
const tideFallback = readTideFallback(apiData)
if (fetchLocation.mode === 'nearby') {
return {
locationSource: 'gps',
lat: fetchLocation.lat,
lng: fetchLocation.lng,
placeName: apiLocation?.name ? String(apiLocation.name) : undefined,
...(distanceKm ? { distanceKm } : {}),
...(tideFallback ? { tideFallback } : {})
}
}
const placeName = apiLocation?.name ? String(apiLocation.name) : fetchLocation.query
const lat = apiLocation?.lat != null && apiLocation.lat !== '' ? String(apiLocation.lat) : undefined
const lng = apiLocation?.lon != null && apiLocation.lon !== '' ? String(apiLocation.lon) : undefined
return {
locationSource: apiLocation?.source === 'geocoded' ? 'geocoded' : 'departure',
placeName,
lat,
lng,
...(distanceKm ? { distanceKm } : {}),
...(tideFallback ? { tideFallback } : {})
}
}
type TideLocationLabelT = (
key: string,
options?: Record<string, string | undefined>
) => string
export function formatTideLocationLabel(
tides: TideLocationMeta,
t: TideLocationLabelT
): string {
const placeName = tides.placeName?.trim()
const lat = tides.lat?.trim()
const lng = tides.lng?.trim()
const distanceKm = tides.distanceKm?.trim()
if (tides.tideFallback === 'open_meteo') {
return t('logs.tide_open_meteo_fallback')
}
if (placeName && distanceKm) {
return t('logs.tide_fetched_from', { place: placeName, distance: distanceKm })
}
if (placeName && lat && lng) {
return t('logs.tide_data_for_place_and_position', { place: placeName, lat, lng })
}
if (lat && lng) {
return t('logs.tide_data_for_position', { lat, lng })
}
if (placeName) {
if (tides.locationSource === 'departure') {
return t('logs.tide_fetched_from_departure', { place: placeName })
}
if (tides.locationSource === 'destination') {
return t('logs.tide_fetched_from_destination', { place: placeName })
}
return t('logs.tide_data_for_place', { place: placeName })
}
return ''
}
export function pickTideLocationMeta(tides: LogEntryTides): TideLocationMeta {
return {
locationSource: tides.locationSource,
placeName: tides.placeName,
lat: tides.lat,
lng: tides.lng,
distanceKm: tides.distanceKm,
tideFallback: tides.tideFallback
}
}
export function getAvailableTideLocations(options: {
departure: string
destination: string
events: Array<Pick<LogEventPayload, 'remarks' | 'time' | 'gpsLat' | 'gpsLng'>>
entryDate: string
maxAgeMs?: number
nowMs?: number
}): TideLocationOption[] {
const optionsList: TideLocationOption[] = []
const departure = options.departure.trim()
if (departure) {
optionsList.push({
role: 'departure',
labelKey: 'logs.tide_role_departure',
displayLabel: departure,
fetchLocation: { mode: 'by-place', query: departure, source: 'departure' }
})
}
const destination = options.destination.trim()
if (destination) {
optionsList.push({
role: 'destination',
labelKey: 'logs.tide_role_destination',
displayLabel: destination,
fetchLocation: { mode: 'by-place', query: destination, source: 'destination' }
})
}
const maxAgeMs = options.maxAgeMs ?? LIVE_LOG_TIDE_POSITION_MAX_AGE_MS
const nowMs = options.nowMs ?? Date.now()
const freshGps = getLastLoggedPositionWithin(options.events, options.entryDate, maxAgeMs, nowMs)
if (freshGps) {
optionsList.push({
role: 'gps',
labelKey: 'logs.tide_role_gps',
displayLabel: `${freshGps.lat}, ${freshGps.lng}`,
fetchLocation: { mode: 'nearby', lat: freshGps.lat, lng: freshGps.lng, source: 'gps' }
})
}
return optionsList
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { parseTideTurtleForDate } from './tideTurtle.js'
const sampleNearby = {
distanceKm: 1.2,
place: { name: 'Kiel' },
tides: {
data: {
timezone: 'Europe/Berlin',
extrema: [
{ time: '2026-06-11T08:50:00.000Z', date: '2026-06-11', height: 0.5, isHigh: true },
{ time: '2026-06-11T14:34:00.000Z', date: '2026-06-11', height: -0.2, isHigh: false },
{ time: '2026-06-12T09:00:00.000Z', date: '2026-06-12', height: 0.6, isHigh: true }
]
}
}
}
describe('parseTideTurtleForDate', () => {
it('returns first high and low on entry date in local timezone', () => {
const parsed = parseTideTurtleForDate(sampleNearby, '2026-06-11')
expect(parsed.highWater).toBe('10:50')
expect(parsed.lowWater).toBe('16:34')
expect(parsed.placeName).toBe('Kiel')
expect(parsed.distanceKm).toBe(1.2)
})
it('reads BSH coordinate response with distance to nearest station', () => {
const parsed = parseTideTurtleForDate(
{
distanceKm: 8,
location: {
source: 'bsh_station',
name: 'Norderney, Riffgat',
lat: 53.696389,
lon: 7.157778,
stationId: 'norderney_riffgat'
},
tides: sampleNearby.tides
},
'2026-06-11'
)
expect(parsed.highWater).toBe('10:50')
expect(parsed.distanceKm).toBe(8)
expect(parsed.placeName).toBe('Norderney, Riffgat')
})
it('leaves missing tide type empty', () => {
const parsed = parseTideTurtleForDate(
{
data: {
timezone: 'UTC',
extrema: [{ time: '2026-06-11T12:00:00.000Z', date: '2026-06-11', height: 1, isHigh: true }]
}
},
'2026-06-11'
)
expect(parsed.highWater).toBe('12:00')
expect(parsed.lowWater).toBe('')
})
})
+106
View File
@@ -0,0 +1,106 @@
export interface TideExtreme {
time: string
date: string
height: number
isHigh: boolean
}
export interface ParsedTideTimes {
highWater: string
lowWater: string
placeName?: string
distanceKm?: number
timezone: string
}
function isoToHHMM(iso: string, timeZone: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hour12: false
}).formatToParts(d)
const hour = parts.find((p) => p.type === 'hour')?.value ?? '00'
const minute = parts.find((p) => p.type === 'minute')?.value ?? '00'
return `${hour}:${minute}`
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function readExtrema(data: Record<string, unknown>): TideExtreme[] {
const raw = data.extrema
if (!Array.isArray(raw)) return []
const out: TideExtreme[] = []
for (const item of raw) {
const row = asRecord(item)
if (!row) continue
const time = String(row.time ?? '').trim()
const date = String(row.date ?? '').trim()
if (!time || !date) continue
out.push({
time,
date,
height: Number(row.height ?? 0),
isHigh: row.isHigh === true || row.type === 'high'
})
}
return out
}
/** Normalize TideTurtle nearby or place JSON into extrema + metadata. */
export function extractTideTurtlePayload(data: Record<string, unknown>): {
extrema: TideExtreme[]
timezone: string
placeName?: string
distanceKm?: number
} {
const place = asRecord(data.place)
const location = asRecord(data.location)
const tidesRoot = asRecord(data.tides) ?? data
const tidesData = asRecord(tidesRoot.data) ?? tidesRoot
const spatial = asRecord(tidesData.spatialCoverage) ?? asRecord(data.spatialCoverage)
const timezone = String(tidesData.timezone ?? 'UTC')
const extrema = readExtrema(tidesData)
let placeName = place?.name ? String(place.name) : undefined
if (!placeName && location?.name) placeName = String(location.name)
if (!placeName && spatial?.name) placeName = String(spatial.name)
const distanceKm =
data.distanceKm != null && data.distanceKm !== ''
? Number(data.distanceKm)
: undefined
return { extrema, timezone, placeName, distanceKm }
}
/** First high and first low tide on entryDate (YYYY-MM-DD). */
export function parseTideTurtleForDate(
data: Record<string, unknown>,
entryDate: string
): ParsedTideTimes {
const { extrema, timezone, placeName, distanceKm } = extractTideTurtlePayload(data)
let highWater = ''
let lowWater = ''
for (const extreme of extrema) {
if (extreme.date !== entryDate) continue
if (extreme.isHigh && !highWater) {
highWater = isoToHHMM(extreme.time, timezone)
}
if (!extreme.isHigh && !lowWater) {
lowWater = isoToHHMM(extreme.time, timezone)
}
if (highWater && lowWater) break
}
return { highWater, lowWater, placeName, distanceKm, timezone }
}
+1
View File
@@ -60,6 +60,7 @@ services:
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ROBOTS_NOINDEX: ${ROBOTS_NOINDEX:-true}
ports:
- "80:80"
depends_on:
+1 -1
View File
@@ -83,7 +83,7 @@ Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest s
| Forward Port | `80` |
| SSL | Let's Encrypt |
Empfohlen: Custom Header `X-Robots-Tag: noindex, nofollow` (Staging nicht indexieren).
Staging ist per Default nicht indexierbar: `ROBOTS_NOINDEX=true` im Frontend-Container setzt `X-Robots-Tag: noindex, nofollow` und liefert `robots.txt` mit `Disallow: /` (siehe `docker-compose.staging.yml`).
Details zu Proxy-Headern und Security: [npm-security.md](npm-security.md).
+4 -1
View File
@@ -47,7 +47,9 @@ Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der ak
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
| Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| Tide Fetched | Erfolgreicher TideTurtle-Abruf (`tides.ts`) | `source`: `live_log` \| `entry_editor`; `location_source`: `gps` \| `departure` |
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes`, `mode`: `same_id` \| `overwrite` \| `new_id` |
@@ -147,7 +149,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
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. `position`, `course`, `motor_start`) → Photo Uploaded / Voice Memo Uploaded (Filter `context`: `live_log`)
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
11. **OpenWeatherMap / Gezeiten:** OWM Weather Fetched (Verteilung `source`); Tide Fetched (Verteilung `location_source`)
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
## Entwicklung
@@ -161,6 +163,7 @@ trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, { status: 'success', mode: 'auto' })
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 })
+3 -1
View File
@@ -23,7 +23,9 @@ const defaultSource = resolve(repoRoot, 'client/src/i18n/locales/de.json')
const TARGETS = {
da: 'DA',
sv: 'SV',
nb: 'NB'
nb: 'NB',
fr: 'FR',
es: 'ES'
}
/** Keys whose values stay identical to source (language names, brand). */
+32 -19
View File
@@ -70,6 +70,11 @@ DEFAULT_VERSION="0.1.0.0"
MAX_WAIT=90
REMOTE_USER="${REMOTE_USER:-root}"
# GIT_REMOTE="${GIT_REMOTE:-github}"
# GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://github.com/elpatron68/kapteins-daagbok.git}"
GIT_REMOTE="${GIT_REMOTE:-origin}"
GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://gitea.elpatron.me/elpatron/kapteins-daagbok.git}"
if [[ "$DEST" == "stage" ]]; then
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
@@ -85,7 +90,7 @@ else
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
APP_URL="${APP_URL:-https://kapteins-daagbok.eu}"
DEPLOY_BRANCH=""
DEPLOY_BRANCH="none"
ENV_LABEL="Production"
fi
@@ -186,34 +191,34 @@ ensure_local_sync_with_origin() {
exit 1
fi
echo "Syncing with origin..."
git fetch --tags origin
echo "Syncing with ${GIT_REMOTE}..."
git fetch --tags "${GIT_REMOTE}"
if [ $? -ne 0 ]; then
echo "Error: git fetch origin failed." >&2
echo "Error: git fetch ${GIT_REMOTE} failed." >&2
exit 1
fi
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
echo "Error: origin/${branch} does not exist." >&2
if ! git rev-parse --verify "${GIT_REMOTE}/${branch}" >/dev/null 2>&1; then
echo "Error: ${GIT_REMOTE}/${branch} does not exist." >&2
exit 1
fi
local_sha="$(git rev-parse HEAD)"
origin_sha="$(git rev-parse "origin/${branch}")"
origin_sha="$(git rev-parse "${GIT_REMOTE}/${branch}")"
if [ "$local_sha" = "$origin_sha" ]; then
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))."
echo "Local branch '$branch' matches ${GIT_REMOTE}/${branch} ($(git rev-parse --short HEAD))."
return 0
fi
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2
echo "Error: Local '$branch' is not in sync with ${GIT_REMOTE}/${branch}." >&2
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2
echo " ${GIT_REMOTE}: $(git rev-parse --short "${GIT_REMOTE}/${branch}") $(git log -1 --format='%s' "${GIT_REMOTE}/${branch}")" >&2
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then
if git merge-base --is-ancestor "$local_sha" "${GIT_REMOTE}/${branch}" 2>/dev/null; then
echo "Hint: run 'git pull' to fast-forward." >&2
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
echo "Hint: run 'git push origin ${branch}' before deploying." >&2
elif git merge-base --is-ancestor "${GIT_REMOTE}/${branch}" "$local_sha" 2>/dev/null; then
echo "Hint: run 'git push ${GIT_REMOTE} ${branch}' before deploying." >&2
else
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
fi
@@ -246,12 +251,12 @@ prepare_release() {
echo " Next prep: v${next_version}"
echo ""
read -r -p "Push commit and tag to origin? [Y/n] " push_answer
read -r -p "Push commit and tag to ${GIT_REMOTE}? [Y/n] " push_answer
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
current_branch="$(git branch --show-current)"
git push origin "$current_branch"
git push origin "$tag_name"
echo "Pushed ${current_branch} and ${tag_name} to origin."
git push "${GIT_REMOTE}" "$current_branch"
git push "${GIT_REMOTE}" "$tag_name"
echo "Pushed ${current_branch} and ${tag_name} to ${GIT_REMOTE}."
else
echo "Skipped push. Remote host must receive this commit/tag manually."
fi
@@ -281,7 +286,7 @@ echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "=================================================="
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT'
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" "$GIT_REMOTE_URL" <<'REMOTE_SCRIPT'
set -uo pipefail
REMOTE_DIR="$1"
@@ -291,10 +296,18 @@ MAX_WAIT="$4"
APP_URL="$5"
APP_VERSION="$6"
DEST="$7"
DEPLOY_BRANCH="$8"
DEPLOY_BRANCH="${8:-}"
GIT_REMOTE_URL="${9:-https://github.com/elpatron68/kapteins-daagbok.git}"
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
echo "Configuring git remote 'origin' URL to ${GIT_REMOTE_URL} on remote host..."
if git remote | grep -q "^origin$"; then
git remote set-url origin "$GIT_REMOTE_URL"
else
git remote add origin "$GIT_REMOTE_URL"
fi
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded."
fi
+1 -1
View File
@@ -11,7 +11,7 @@ import { flattenTranslation } from './lib/deepl-translate.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const localesDir = resolve(__dirname, '../client/src/i18n/locales')
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json']
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json', 'fr.json', 'es.json']
async function loadKeys(filename) {
const raw = await readFile(resolve(localesDir, filename), 'utf8')
+5 -4
View File
@@ -52,10 +52,11 @@ model UserNotificationPrefs {
}
model UserAppearancePrefs {
userId String @id
theme String @default("auto")
colorScheme String @default("auto")
updatedAt DateTime @updatedAt
userId String @id
theme String @default("auto")
colorScheme String @default("auto")
aiAuthorized Boolean @default(false)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
+8
View File
@@ -59,4 +59,12 @@ describe('API smoke', () => {
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
it('POST /api/ai/transcribe requires session', async () => {
const res = await request(app)
.post('/api/ai/transcribe')
.send({ audioDataUrl: 'data:audio/webm;base64,abcdef' })
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
})
+2
View File
@@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import tidesRouter from './routes/tides.js'
import aiRouter from './routes/ai.js'
import feedbackRouter from './routes/feedback.js'
import adminRouter from './routes/admin.js'
@@ -120,6 +121,7 @@ export function createApp(): express.Express {
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/tides', tidesRouter)
app.use('/api/ai', aiRouter)
app.use('/api/feedback', feedbackRouter)
app.use('/api/admin', adminRouter)
@@ -0,0 +1 @@
{"type": "Feature", "id": "norderney_riffgat", "geometry": {"type": "Point", "coordinates": [7.157778, 53.696389]}, "properties": {"gauge_label": "Norderney, Riffgat", "latitude": 53.696389, "longitude": 7.157778, "area": "Jade und Ostfriesland", "forecast_timestamp": "2026-06-12 08:09:54+02:00", "high_water_low_water": [{"event_timestamp": "2026-06-12 09:20:00+02:00", "event": "HW", "tidal_prediction_value": "606", "forecast_value": 616, "forecast_uncertainty": 10.0, "forecast_deviation": "-0,1 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00"}, {"event_timestamp": "2026-06-12 15:39:00+02:00", "event": "NW", "tidal_prediction_value": "377", "forecast_value": 403, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,2 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 415, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 409, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 412, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 414, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 411, "mos_forecast_r4_deviation": "+0,3 m", "mos_forecast_r5_value": 400, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-12 21:41:00+02:00", "event": "HW", "tidal_prediction_value": "629", "forecast_value": 666, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,4 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 653, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 653, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 658, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 657, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 653, "mos_forecast_r4_deviation": "+0,3 m", "mos_forecast_r5_value": 651, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 04:14:00+02:00", "event": "NW", "tidal_prediction_value": "362", "forecast_value": 393, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,1 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 403, "mos_forecast_r0_deviation": "+0,2 m", "mos_forecast_r1_value": 395, "mos_forecast_r1_deviation": "+0,1 m", "mos_forecast_r2_value": 404, "mos_forecast_r2_deviation": "+0,2 m", "mos_forecast_r3_value": 400, "mos_forecast_r3_deviation": "+0,2 m", "mos_forecast_r4_value": 394, "mos_forecast_r4_deviation": "+0,1 m", "mos_forecast_r5_value": 388, "mos_forecast_r5_deviation": "+/-0,0 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 10:21:00+02:00", "event": "HW", "tidal_prediction_value": "617", "mos_forecast_r0_value": 655, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 649, "mos_forecast_r1_deviation": "+0,2 m", "mos_forecast_r2_value": 656, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 657, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 649, "mos_forecast_r4_deviation": "+0,2 m", "mos_forecast_r5_value": 652, "mos_forecast_r5_deviation": "+0,3 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 16:47:00+02:00", "event": "NW", "tidal_prediction_value": "366", "mos_forecast_r0_value": 421, "mos_forecast_r0_deviation": "+0,4 m", "mos_forecast_r1_value": 416, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 424, "mos_forecast_r2_deviation": "+0,4 m", "mos_forecast_r3_value": 410, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 436, "mos_forecast_r4_deviation": "+0,5 m", "mos_forecast_r5_value": 405, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}], "copyright": {"de": "@Bundesamt für Seeschifffahrt und Hydrographie (BSH). Das BSH übernimmt für die angegebenen Informationen keine Gewähr. Amtliche Wasserstandsvorhersage des Bundes gemäß §1 SeeAufG.", "en": "@Federal Maritime and Hydrographic Agency (BSH). The BSH accepts no liability for the information provided here. Official water level forecast of the federal government according to §1 SeeAufG."}}}
@@ -0,0 +1,42 @@
[
{
"id": "bensersiel",
"name": "Bensersiel",
"lat": 53.674722,
"lon": 7.575,
"area": "Jade und Ostfriesland",
"hasHwnw": true
},
{
"id": "emden_grosse_seeschleuse",
"name": "Emden, Ems, Große Seeschleuse",
"lat": 53.336667,
"lon": 7.186389,
"area": "Ems",
"hasHwnw": true
},
{
"id": "kiel-holtenau",
"name": "Kiel-Holtenau",
"lat": 54.3720866822911,
"lon": 10.1570496121807,
"area": "Kieler Bucht",
"hasHwnw": false
},
{
"id": "leyhoern_leybucht",
"name": "Leyhörn, Leybucht",
"lat": 53.549167,
"lon": 7.036111,
"area": "Jade und Ostfriesland",
"hasHwnw": true
},
{
"id": "norderney_riffgat",
"name": "Norderney, Riffgat",
"lat": 53.696389,
"lon": 7.157778,
"area": "Jade und Ostfriesland",
"hasHwnw": true
}
]
+76 -3
View File
@@ -23,6 +23,11 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
prisma.aiSummaryUsage.count()
])
const rawDbSize = await prisma.$queryRaw<[{ size: string }]>`
SELECT pg_database_size(current_database())::text as size
`
const dbSize = Number(rawDbSize[0]?.size || '0')
res.json({
totalUsers,
totalLogbooks,
@@ -31,7 +36,8 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
totalGpsTracks,
totalCollaborations,
totalInvitations,
aiSummaryEntries
aiSummaryEntries,
dbSize
})
} catch (error: unknown) {
console.error('admin/summary error', error)
@@ -91,7 +97,7 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
const since = new Date()
since.setUTCDate(since.getUTCDate() - windowDays)
const [users, logbooks, photos] = await Promise.all([
const [users, logbooks, photos, dbSizeRaw, photosSize, voiceSize, tracksSize, entriesSize] = await Promise.all([
prisma.user.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true }
@@ -103,9 +109,72 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
prisma.photoPayload.findMany({
where: { updatedAt: { gte: since } },
select: { updatedAt: true }
}),
prisma.$queryRaw<[{ size: string }]>`
SELECT pg_database_size(current_database())::text as size
`,
prisma.photoPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.voiceMemoPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.gpsTrackPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.entryPayload.findMany({
select: { updatedAt: true, encryptedData: true }
})
])
const dbSizeVal = Number(dbSizeRaw[0]?.size || '0')
const payloads: { date: Date; size: number }[] = []
for (const p of photosSize) {
payloads.push({ date: p.updatedAt, size: p.encryptedData.length })
}
for (const v of voiceSize) {
payloads.push({ date: v.updatedAt, size: v.encryptedData.length })
}
for (const g of tracksSize) {
payloads.push({ date: g.updatedAt, size: g.encryptedData.length })
}
for (const e of entriesSize) {
payloads.push({ date: e.updatedAt, size: e.encryptedData.length })
}
const totalPayloadsSize = payloads.reduce((acc, p) => acc + p.size, 0)
const baseDbSize = Math.max(0, dbSizeVal - totalPayloadsSize)
payloads.sort((a, b) => a.date.getTime() - b.date.getTime())
// Generate complete list of date keys for the window
const dateKeys: string[] = []
const current = new Date(since)
const todayStr = bucketDate(new Date(), bucket)
while (true) {
const key = bucketDate(current, bucket)
if (!dateKeys.includes(key)) {
dateKeys.push(key)
}
if (key >= todayStr) break
current.setUTCDate(current.getUTCDate() + 1)
}
const dbSizePoints = dateKeys.map((key) => {
let sizeSum = 0
for (const p of payloads) {
if (bucketDate(p.date, bucket) <= key) {
sizeSum += p.size
} else {
break
}
}
const totalBytes = baseDbSize + sizeSum
const sizeInMb = Math.round((totalBytes / (1024 * 1024)) * 10) / 10
return { date: key, count: sizeInMb }
})
function aggregate(dates: Date[], metric: string): TimeSeries {
const map = new Map<string, number>()
for (const d of dates) {
@@ -130,7 +199,11 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
aggregate(
photos.map((p) => p.updatedAt),
'photos_updated'
)
),
{
metric: 'database_size',
points: dbSizePoints
}
]
}
+74 -1
View File
@@ -3,7 +3,6 @@ import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
const MAX_ATTEMPTS_PER_ENTRY = 3
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
@@ -230,4 +229,78 @@ router.post('/summary', async (req: any, res) => {
}
})
router.post('/transcribe', async (req: any, res) => {
try {
const { audioDataUrl } = req.body ?? {}
if (!audioDataUrl || typeof audioDataUrl !== 'string') {
return res.status(400).json({ error: 'audioDataUrl is required' })
}
const match = audioDataUrl.match(/^data:(.+);base64,(.+)$/)
if (!match) {
return res.status(400).json({ error: 'Invalid audio data URL format' })
}
const [, fullMimeType, base64Data] = match
const mimeType = fullMimeType.split(';')[0]
let ext = 'webm'
if (mimeType.includes('mp4')) ext = 'mp4'
else if (mimeType.includes('ogg')) ext = 'ogg'
else if (mimeType.includes('wav')) ext = 'wav'
const apiKey = resolveOpenRouterApiKey()
if (!apiKey) {
console.warn('[server] OpenRouter API key not configured, transcription unavailable')
return res.status(503).json({ error: 'Transcription service not configured' })
}
console.log(`[server] Forwarding ASR request to OpenRouter (${ext}, ${base64Data.length} chars)`)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
try {
const openRouterRes = await fetch('https://openrouter.ai/api/v1/audio/transcriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'openai/whisper-large-v3-turbo',
input_audio: {
data: base64Data,
format: ext
}
}),
signal: controller.signal
})
if (!openRouterRes.ok) {
const errorText = await openRouterRes.text().catch(() => '')
console.error(`[server] OpenRouter ASR error response (status=${openRouterRes.status}):`, errorText)
throw new Error(`OpenRouter returned status ${openRouterRes.status}`)
}
const data: any = await openRouterRes.json()
const text = (data?.text || '').trim()
console.log(`[server] OpenRouter ASR completed successfully: "${text}"`)
return res.json({ text })
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
console.error('[server] OpenRouter ASR request timed out')
return res.status(504).json({ error: 'Transcription request timed out' })
}
throw error
} finally {
clearTimeout(timeoutId)
}
} catch (error: unknown) {
console.error('ASR transcription failed:', error)
return res.status(503).json({ error: 'Transcription service unavailable' })
}
})
export default router
+6
View File
@@ -63,6 +63,7 @@ function isMissingAppearancePrefsTable(error: unknown): boolean {
const DEFAULT_APPEARANCE_PREFS = {
theme: 'auto',
colorScheme: 'auto',
aiAuthorized: false,
persisted: false
} as const
@@ -454,6 +455,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
return res.json({
theme: prefs?.theme ?? 'auto',
colorScheme: prefs?.colorScheme ?? 'auto',
aiAuthorized: prefs?.aiAuthorized ?? false,
persisted: prefs != null
})
} catch (error: unknown) {
@@ -469,6 +471,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
try {
const theme = parseThemePreference(req.body?.theme)
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
const aiAuthorized = req.body?.aiAuthorized === true
if (!theme || !colorScheme) {
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
}
@@ -479,11 +482,13 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
userId: req.userId,
theme,
colorScheme,
aiAuthorized,
updatedAt: new Date()
},
update: {
theme,
colorScheme,
aiAuthorized,
updatedAt: new Date()
}
})
@@ -491,6 +496,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
return res.json({
theme: prefs.theme,
colorScheme: prefs.colorScheme,
aiAuthorized: prefs.aiAuthorized,
persisted: true
})
} catch (error: unknown) {
+121
View File
@@ -0,0 +1,121 @@
import { Router } from 'express'
import { requireUser } from '../middleware/auth.js'
import {
fetchTidesForCoordinates,
fetchTidesForPlace,
fetchTidesForStation,
listNearbyTideStations
} from '../utils/tideProvider.js'
const router = Router()
function parseLatLon(lat: unknown, lon: unknown): { lat: number; lon: number } | null {
const latNum = Number(lat)
const lonNum = Number(lon)
if (Number.isNaN(latNum) || Number.isNaN(lonNum)) return null
if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) return null
return { lat: latNum, lon: lonNum }
}
function parseLimit(value: unknown, fallback = 8): number {
const n = Number(value)
if (Number.isNaN(n)) return fallback
return Math.min(20, Math.max(1, Math.floor(n)))
}
async function noTideDataResponse(lat: number, lon: number) {
const stations = await listNearbyTideStations(lat, lon, 8)
if (stations.length > 0) {
return { error: 'no_tide_data', stations }
}
return { error: 'no_tide_data' }
}
router.get('/stations/nearby', requireUser, async (req, res) => {
try {
const coords = parseLatLon(req.query.lat, req.query.lon)
if (!coords) {
return res.status(400).json({ error: 'lat and lon are required' })
}
const stations = await listNearbyTideStations(coords.lat, coords.lon, parseLimit(req.query.limit))
return res.json({ stations })
} catch (error: unknown) {
console.error('Error listing nearby tide stations:', error)
return res.status(502).json({ error: 'station_list_failed' })
}
})
router.get('/station/:stationId', requireUser, async (req, res) => {
try {
const stationId = String(req.params.stationId ?? '').trim()
if (!stationId) {
return res.status(400).json({ error: 'stationId is required' })
}
const coords = parseLatLon(req.query.lat, req.query.lon)
const data = await fetchTidesForStation(
stationId,
coords ? { queryLat: coords.lat, queryLon: coords.lon } : undefined
)
return res.json(data)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Tide lookup failed'
if (message === 'bsh_invalid_station') {
return res.status(404).json({ error: 'station_not_found' })
}
if (message === 'no_tide_data') {
return res.status(404).json({ error: 'no_tide_data' })
}
console.error('Error fetching station tides:', error)
return res.status(502).json({ error: message })
}
})
router.get('/nearby', requireUser, async (req, res) => {
try {
const coords = parseLatLon(req.query.lat, req.query.lon)
if (!coords) {
return res.status(400).json({ error: 'lat and lon are required' })
}
const data = await fetchTidesForCoordinates(coords.lat, coords.lon)
return res.json(data)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Tide lookup failed'
if (message === 'no_tide_data') {
const coords = parseLatLon(req.query.lat, req.query.lon)
if (coords) {
return res.status(404).json(await noTideDataResponse(coords.lat, coords.lon))
}
return res.status(404).json({ error: 'no_tide_data' })
}
console.error('Error fetching nearby tides:', error)
return res.status(502).json({ error: message })
}
})
router.get('/by-place', requireUser, async (req, res) => {
try {
const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
if (!query) {
return res.status(400).json({ error: 'q is required' })
}
const data = await fetchTidesForPlace(query)
return res.json(data)
} catch (error: unknown) {
const status = (error as { status?: number }).status
const message = error instanceof Error ? error.message : 'Tide lookup failed'
if (status === 404 || message === 'place_not_found') {
return res.status(404).json({ error: 'place_not_found' })
}
if (message === 'no_tide_data') {
return res.status(404).json({ error: 'no_tide_data' })
}
console.error('Error fetching place tides:', error)
return res.status(502).json({ error: message })
}
})
export default router
+85
View File
@@ -0,0 +1,85 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
findNearestBshStation,
findNearestBshStations,
haversineKm,
parseBshFeatureToExtrema,
parseBshHwnwForecast,
setBshStationCacheForTests,
type BshStation
} from './bshTides.js'
const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '../fixtures')
function loadJson<T>(name: string): T {
return JSON.parse(readFileSync(join(fixturesDir, name), 'utf8')) as T
}
const stationIndex = loadJson<BshStation[]>('bsh-station-index.json')
describe('haversineKm', () => {
it('returns zero for identical points', () => {
expect(haversineKm(53.62, 7.15, 53.62, 7.15)).toBe(0)
})
})
describe('findNearestBshStations', () => {
it('returns multiple ranked stations', () => {
const nearest = findNearestBshStations(53.624526, 7.155263, stationIndex, 3)
expect(nearest).toHaveLength(3)
expect(nearest[0].id).toBe('norderney_riffgat')
expect(nearest[1].distanceKm).toBeGreaterThanOrEqual(nearest[0].distanceKm)
})
})
describe('findNearestBshStation', () => {
it('picks Norderney Riffgat for Norddeich coordinates', () => {
const nearest = findNearestBshStation(53.624526, 7.155263, stationIndex)
expect(nearest?.station.id).toBe('norderney_riffgat')
expect(nearest?.distanceKm).toBeGreaterThan(5)
expect(nearest?.distanceKm).toBeLessThan(12)
})
it('picks Kiel-Holtenau for Kiel coordinates', () => {
const nearest = findNearestBshStation(54.32, 10.14, stationIndex)
expect(nearest?.station.id).toBe('kiel-holtenau')
expect(nearest?.distanceKm).toBeLessThan(10)
})
})
describe('parseBshHwnwForecast', () => {
it('maps HW/NW events to extrema with Europe/Berlin dates', () => {
const feature = loadJson<{ properties: Record<string, unknown> }>('bsh-norderney_riffgat.json')
const extrema = parseBshHwnwForecast(feature)
expect(extrema.length).toBeGreaterThan(0)
const high = extrema.find((e) => e.isHigh)
const low = extrema.find((e) => !e.isHigh)
expect(high?.date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(low?.date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(high?.time).toContain('T')
expect(high?.height).toBeGreaterThan(0)
})
})
describe('parseBshFeatureToExtrema', () => {
it('uses hwnw_forecast when available', () => {
const feature = loadJson('bsh-norderney_riffgat.json')
const extrema = parseBshFeatureToExtrema(feature)
expect(extrema.some((e) => e.isHigh)).toBe(true)
expect(extrema.some((e) => !e.isHigh)).toBe(true)
})
})
describe('setBshStationCacheForTests', () => {
it('allows injecting station cache', () => {
setBshStationCacheForTests(stationIndex)
expect(findNearestBshStation(53.624526, 7.155263, stationIndex)?.station.id).toBe(
'norderney_riffgat'
)
setBshStationCacheForTests(null)
})
})
+382
View File
@@ -0,0 +1,382 @@
import type { TideExtreme, TideLookupResult } from './openMeteoTides.js'
export const MAX_BSH_DISTANCE_KM = 75
export const BSH_TIMEZONE = 'Europe/Berlin'
const API_BASE =
'https://gdi.bsh.de/ldproxy/rest/services/WaterLevelForecast/collections/waterlevelforecastdata/items'
const LIST_LIMIT = 1000
const MAX_PAGES = 20
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
const FETCH_TIMEOUT_MS = 15_000
export interface BshStation {
id: string
name: string
lat: number
lon: number
area?: string
}
interface OgcFeatureCollection {
features?: OgcFeature[]
links?: Array<{ rel?: string; href?: string }>
}
interface OgcFeature {
type?: string
id?: string
geometry?: { coordinates?: [number, number] }
properties?: Record<string, unknown>
}
interface HwnwEvent {
event?: string
event_timestamp?: string
forecast_value?: number | string | null
tidal_prediction_value?: number | string | null
}
interface CurvePoint {
timestamp?: string
automated_curve_forecast?: number | string | null
}
let stationCache: { stations: BshStation[]; loadedAt: number } | null = null
async function fetchJson<T>(url: string): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const res = await fetch(url, { signal: controller.signal, redirect: 'follow' })
const data = await res.json()
if (!res.ok) {
throw new Error(`BSH HTTP ${res.status}`)
}
return data as T
} finally {
clearTimeout(timeout)
}
}
function parseNum(value: unknown): number | null {
if (value == null || value === '') return null
if (typeof value === 'number') return value
const n = Number(value)
return Number.isNaN(n) ? null : n
}
function stationFromFeature(feature: OgcFeature): BshStation | null {
const id = feature.id
const props = feature.properties
if (!id || !props) return null
const name = String(props.gauge_label ?? '').trim()
if (!name) return null
const geom = feature.geometry?.coordinates
const lat = parseNum(props.latitude) ?? (geom ? geom[1] : null)
const lon = parseNum(props.longitude) ?? (geom ? geom[0] : null)
if (lat == null || lon == null) return null
return {
id,
name,
lat,
lon,
area: props.area ? String(props.area) : undefined
}
}
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371
const p = Math.PI / 180
const dLat = (lat2 - lat1) * p
const dLon = (lon2 - lon1) * p
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * p) * Math.cos(lat2 * p) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(a))
}
export interface BshStationSuggestion {
id: string
name: string
lat: number
lon: number
distanceKm: number
area?: string
}
export function findNearestBshStations(
lat: number,
lon: number,
stations: BshStation[],
limit = 8
): BshStationSuggestion[] {
const ranked = stations
.map((station) => ({
id: station.id,
name: station.name,
lat: station.lat,
lon: station.lon,
area: station.area,
distanceKm: Number(haversineKm(lat, lon, station.lat, station.lon).toFixed(1))
}))
.sort((a, b) => a.distanceKm - b.distanceKm)
return ranked.slice(0, Math.max(1, limit))
}
export function findNearestBshStation(
lat: number,
lon: number,
stations: BshStation[]
): { station: BshStation; distanceKm: number } | null {
const nearest = findNearestBshStations(lat, lon, stations, 1)[0]
if (!nearest) return null
return {
station: {
id: nearest.id,
name: nearest.name,
lat: nearest.lat,
lon: nearest.lon,
area: nearest.area
},
distanceKm: nearest.distanceKm
}
}
export async function loadBshStationIndex(): Promise<BshStation[]> {
if (stationCache && Date.now() - stationCache.loadedAt < CACHE_TTL_MS) {
return stationCache.stations
}
const stations: BshStation[] = []
let nextUrl: string | null = `${API_BASE}?f=json&limit=${LIST_LIMIT}`
for (let page = 0; page < MAX_PAGES && nextUrl; page += 1) {
const currentUrl = nextUrl
const payload: OgcFeatureCollection = await fetchJson<OgcFeatureCollection>(currentUrl)
const features = payload.features ?? []
for (const feature of features) {
const station = stationFromFeature(feature)
if (station) stations.push(station)
}
nextUrl = null
const links = payload.links ?? []
for (let i = 0; i < links.length; i += 1) {
const link = links[i]
if (link.rel === 'next' && link.href) {
nextUrl = link.href
break
}
}
}
if (stations.length === 0) {
throw new Error('bsh_empty_station_list')
}
stationCache = { stations, loadedAt: Date.now() }
return stations
}
/** Test helper: inject a pre-built station list and skip live index fetch. */
export function setBshStationCacheForTests(stations: BshStation[] | null): void {
stationCache = stations ? { stations, loadedAt: Date.now() } : null
}
function localDateFromIso(iso: string, timeZone: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(date)
}
function bshTimestampToIso(timestamp: string): string {
const normalized = timestamp.trim().replace(' ', 'T')
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) return ''
return date.toISOString()
}
function heightMetresFromCm(value: unknown): number {
const cm = parseNum(value)
if (cm == null) return 0
return Number((cm / 100).toFixed(2))
}
export function parseBshHwnwForecast(
feature: OgcFeature,
timeZone = BSH_TIMEZONE
): TideExtreme[] {
const props = feature.properties ?? {}
const hwnw = props.high_water_low_water
if (!Array.isArray(hwnw) || hwnw.length === 0) return []
const extrema: TideExtreme[] = []
for (const raw of hwnw as HwnwEvent[]) {
const event = String(raw.event ?? '').toUpperCase()
const timestamp = String(raw.event_timestamp ?? '').trim()
if (!timestamp || (event !== 'HW' && event !== 'NW')) continue
const iso = bshTimestampToIso(timestamp)
if (!iso) continue
const value = raw.forecast_value ?? raw.tidal_prediction_value
extrema.push({
time: iso,
date: localDateFromIso(iso, timeZone),
height: heightMetresFromCm(value),
isHigh: event === 'HW'
})
}
return extrema
}
function parseBshCurveForecast(
feature: OgcFeature,
timeZone = BSH_TIMEZONE
): TideExtreme[] {
const curve = feature.properties?.curve
if (!Array.isArray(curve) || curve.length < 3) return []
const points = (curve as CurvePoint[])
.map((p) => ({
timestamp: String(p.timestamp ?? '').trim(),
level: parseNum(p.automated_curve_forecast)
}))
.filter((p) => p.timestamp && p.level != null) as Array<{
timestamp: string
level: number
}>
const extrema: TideExtreme[] = []
for (let i = 1; i < points.length - 1; i += 1) {
const prev = points[i - 1].level
const curr = points[i].level
const next = points[i + 1].level
const isHigh = curr >= prev && curr >= next && (curr > prev || curr > next)
const isLow = curr <= prev && curr <= next && (curr < prev || curr < next)
if (!isHigh && !isLow) continue
const iso = bshTimestampToIso(points[i].timestamp)
if (!iso) continue
extrema.push({
time: iso,
date: localDateFromIso(iso, timeZone),
height: Number((curr / 100).toFixed(2)),
isHigh
})
}
return extrema
}
export function parseBshFeatureToExtrema(feature: OgcFeature): TideExtreme[] {
const hwnw = parseBshHwnwForecast(feature)
if (hwnw.length > 0) return hwnw
return parseBshCurveForecast(feature)
}
async function fetchBshStationFeature(stationId: string): Promise<OgcFeature> {
const feature = await fetchJson<OgcFeature>(`${API_BASE}/${stationId}?f=json`)
if (feature.type !== 'Feature' || !feature.properties) {
throw new Error('bsh_invalid_station')
}
return feature
}
export interface BshTideLookupResult extends TideLookupResult {
distanceKm: number
}
export async function listNearbyBshStations(
lat: number,
lon: number,
limit = 8
): Promise<BshStationSuggestion[]> {
const stations = await loadBshStationIndex()
return findNearestBshStations(lat, lon, stations, limit)
}
function buildBshTideResult(
station: BshStation,
distanceKm: number,
feature: OgcFeature
): BshTideLookupResult {
const extrema = parseBshFeatureToExtrema(feature)
if (extrema.length === 0) {
throw new Error('no_tide_data')
}
const copyright = feature.properties?.copyright
let sourceNote = 'BSH Wasserstandsvorhersage (© BSH, CC BY 4.0)'
if (copyright && typeof copyright === 'object' && copyright !== null) {
const cr = copyright as Record<string, string>
sourceNote = cr.de || cr.en || sourceNote
}
return {
distanceKm: Number(distanceKm.toFixed(1)),
location: {
name: station.name,
lat: station.lat,
lon: station.lon,
source: 'bsh_station',
stationId: station.id
},
tides: {
data: {
timezone: BSH_TIMEZONE,
datum: 'gauge',
source: sourceNote,
extrema
}
}
}
}
export async function fetchBshTidesForStation(
stationId: string,
options?: { queryLat?: number; queryLon?: number }
): Promise<BshTideLookupResult> {
const stations = await loadBshStationIndex()
const station = stations.find((item) => item.id === stationId)
if (!station) {
throw new Error('bsh_invalid_station')
}
const feature = await fetchBshStationFeature(stationId)
const distanceKm =
options?.queryLat != null && options?.queryLon != null
? haversineKm(options.queryLat, options.queryLon, station.lat, station.lon)
: 0
return buildBshTideResult(station, distanceKm, feature)
}
export async function fetchBshTidesForCoordinates(
lat: number,
lon: number
): Promise<BshTideLookupResult> {
const stations = await loadBshStationIndex()
const nearest = findNearestBshStation(lat, lon, stations)
if (!nearest) {
throw new Error('no_bsh_station')
}
if (nearest.distanceKm > MAX_BSH_DISTANCE_KM) {
const err = new Error('bsh_station_too_far') as Error & { distanceKm?: number }
err.distanceKm = nearest.distanceKm
throw err
}
const feature = await fetchBshStationFeature(nearest.station.id)
return buildBshTideResult(nearest.station, nearest.distanceKm, feature)
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { findSeaLevelExtrema } from './openMeteoTides.js'
describe('findSeaLevelExtrema', () => {
it('detects one high and one low from a simple sinusoidal day', () => {
const times = [
'2026-06-11T00:00',
'2026-06-11T01:00',
'2026-06-11T02:00',
'2026-06-11T03:00',
'2026-06-11T04:00',
'2026-06-11T05:00',
'2026-06-11T06:00'
]
const levels = [1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0]
const extrema = findSeaLevelExtrema(times, levels, 'Europe/Berlin')
expect(extrema.some((e) => e.isHigh)).toBe(true)
expect(extrema.some((e) => !e.isHigh)).toBe(true)
expect(extrema.every((e) => e.date === '2026-06-11')).toBe(true)
})
})
+285
View File
@@ -0,0 +1,285 @@
const MARINE_API = 'https://marine-api.open-meteo.com/v1/marine'
const GEOCODING_API = 'https://geocoding-api.open-meteo.com/v1/search'
const FETCH_TIMEOUT_MS = 15_000
const FORECAST_DAYS = 7
export interface TideExtreme {
time: string
date: string
height: number
isHigh: boolean
}
export type TideLocationSource = 'coordinates' | 'geocoded' | 'bsh_station'
export interface TideLookupResult {
location: {
name?: string
lat: number
lon: number
source: TideLocationSource
stationId?: string
}
tides: {
data: {
timezone: string
datum: 'MSL' | 'gauge'
source: string
extrema: TideExtreme[]
}
}
}
interface MarineResponse {
timezone?: string
utc_offset_seconds?: number
hourly?: {
time?: string[]
sea_level_height_msl?: Array<number | null>
}
}
interface GeocodingResult {
name: string
latitude: number
longitude: number
country_code?: string
admin1?: string
}
async function fetchJson<T>(url: string): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const res = await fetch(url, { signal: controller.signal })
const data = await res.json()
if (!res.ok) {
const message =
typeof (data as { reason?: string })?.reason === 'string'
? (data as { reason: string }).reason
: `Upstream HTTP ${res.status}`
throw new Error(message)
}
return data as T
} finally {
clearTimeout(timeout)
}
}
function localDateFromIso(iso: string, timeZone: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(date)
}
function interpolateExtremumTime(
t0: number,
y0: number,
t1: number,
y1: number,
t2: number,
y2: number
): { timeOffsetHours: number; height: number } {
const denom = y0 - 2 * y1 + y2
if (Math.abs(denom) < 1e-6) {
return { timeOffsetHours: t1, height: y1 }
}
const offset = 0.5 * (y0 - y2) / denom
const clamped = Math.max(t0, Math.min(t2, offset))
const height = y1 + 0.25 * (y0 - y2) * (clamped - t1)
return { timeOffsetHours: clamped, height }
}
function localHourlyTimeToUtcIso(localIso: string, utcOffsetSeconds: number): string {
const [datePart, timePart] = localIso.split('T')
if (!datePart || !timePart) return localIso
const [year, month, day] = datePart.split('-').map(Number)
const [hour, minute] = timePart.split(':').map(Number)
if ([year, month, day, hour, minute].some((n) => Number.isNaN(n))) return localIso
const utcMs = Date.UTC(year, month - 1, day, hour, minute) - utcOffsetSeconds * 1000
return new Date(utcMs).toISOString()
}
function addFractionalHoursToLocalIso(localIso: string, deltaHours: number): string {
const [datePart, timePart] = localIso.split('T')
if (!datePart || !timePart) return localIso
const [year, month, day] = datePart.split('-').map(Number)
const [hour, minute] = timePart.split(':').map(Number)
if ([year, month, day, hour, minute].some((n) => Number.isNaN(n))) return localIso
const totalMinutes = hour * 60 + minute + Math.round(deltaHours * 60)
const dayOffset = Math.floor(totalMinutes / (24 * 60))
const minutesInDay = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60)
const nextDay = new Date(Date.UTC(year, month - 1, day + dayOffset))
const y = nextDay.getUTCFullYear()
const m = String(nextDay.getUTCMonth() + 1).padStart(2, '0')
const d = String(nextDay.getUTCDate()).padStart(2, '0')
const hh = String(Math.floor(minutesInDay / 60)).padStart(2, '0')
const mm = String(minutesInDay % 60).padStart(2, '0')
return `${y}-${m}-${d}T${hh}:${mm}`
}
export function findSeaLevelExtrema(
times: string[],
levels: Array<number | null>,
timeZone: string,
utcOffsetSeconds = 0
): TideExtreme[] {
const extrema: TideExtreme[] = []
if (times.length < 3) return extrema
for (let i = 1; i < times.length - 1; i += 1) {
const prev = levels[i - 1]
const curr = levels[i]
const next = levels[i + 1]
if (prev == null || curr == null || next == null) continue
const isHigh = curr >= prev && curr >= next && (curr > prev || curr > next)
const isLow = curr <= prev && curr <= next && (curr < prev || curr < next)
if (!isHigh && !isLow) continue
const { timeOffsetHours, height } = interpolateExtremumTime(
i - 1,
prev,
i,
curr,
i + 1,
next
)
const localIso = addFractionalHoursToLocalIso(times[i], timeOffsetHours - i)
const iso = localHourlyTimeToUtcIso(localIso, utcOffsetSeconds)
extrema.push({
time: iso,
date: localDateFromIso(iso, timeZone),
height: Number(height.toFixed(2)),
isHigh
})
}
return extrema
}
export async function fetchTidesForCoordinates(
lat: number,
lon: number,
options?: { name?: string; source?: 'coordinates' | 'geocoded' }
): Promise<TideLookupResult> {
const url = new URL(MARINE_API)
url.searchParams.set('latitude', String(lat))
url.searchParams.set('longitude', String(lon))
url.searchParams.set('hourly', 'sea_level_height_msl')
url.searchParams.set('timezone', 'auto')
url.searchParams.set('forecast_days', String(FORECAST_DAYS))
const data = await fetchJson<MarineResponse>(url.toString())
const times = data.hourly?.time ?? []
const levels = data.hourly?.sea_level_height_msl ?? []
const timezone = data.timezone || 'UTC'
const utcOffsetSeconds = data.utc_offset_seconds ?? 0
if (times.length === 0 || levels.length === 0) {
throw new Error('no_tide_data')
}
const extrema = findSeaLevelExtrema(times, levels, timezone, utcOffsetSeconds)
if (extrema.length === 0) {
throw new Error('no_tide_data')
}
return {
location: {
name: options?.name,
lat,
lon,
source: options?.source ?? 'coordinates'
},
tides: {
data: {
timezone,
datum: 'MSL',
source:
'Open-Meteo Marine (MeteoFrance SMOC, 0.08° grid) — model-derived, MSL not chart datum',
extrema
}
}
}
}
function scoreGeocodingResult(query: string, result: GeocodingResult): number {
const q = query.trim().toLowerCase()
const name = result.name.toLowerCase()
let score = 0
if (name === q) score += 100
if (name.startsWith(q) || q.startsWith(name)) score += 40
if (result.country_code === 'DE' || result.country_code === 'NO' || result.country_code === 'DK') {
score += 10
}
if (result.admin1?.toLowerCase().includes('niedersachsen') || result.admin1?.toLowerCase().includes('lower saxony')) {
score += 5
}
return score
}
function replaceGermanDigraphs(str: string): string {
return str
.replace(/ae/g, 'ä')
.replace(/oe/g, 'ö')
.replace(/ue/g, 'ü')
.replace(/Ae/g, 'Ä')
.replace(/Oe/g, 'Ö')
.replace(/Ue/g, 'Ü')
.replace(/AE/g, 'Ä')
.replace(/OE/g, 'Ö')
.replace(/UE/g, 'Ü');
}
async function doGeocode(q: string): Promise<GeocodingResult | null> {
const url = new URL(GEOCODING_API)
url.searchParams.set('name', q.trim())
url.searchParams.set('count', '10')
url.searchParams.set('language', 'de')
try {
const data = await fetchJson<{ results?: GeocodingResult[] }>(url.toString())
const results = data.results ?? []
if (results.length === 0) {
return null
}
const sorted = [...results].sort((a, b) => scoreGeocodingResult(q, b) - scoreGeocodingResult(q, a))
return sorted[0]
} catch (err) {
console.error(`[geocodePlace] Geocoding API request failed for "${q}":`, err)
return null
}
}
export async function geocodePlace(query: string): Promise<GeocodingResult | null> {
let match = await doGeocode(query)
if (!match) {
const fallbackQuery = replaceGermanDigraphs(query)
if (fallbackQuery !== query) {
match = await doGeocode(fallbackQuery)
}
}
return match
}
export async function fetchTidesForPlace(query: string): Promise<TideLookupResult> {
const place = await geocodePlace(query)
if (!place) {
const err = new Error('place_not_found') as Error & { status?: number }
err.status = 404
throw err
}
return fetchTidesForCoordinates(place.latitude, place.longitude, {
name: place.name,
source: 'geocoded'
})
}
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as bshTides from './bshTides.js'
import * as openMeteoTides from './openMeteoTides.js'
import { fetchTidesForCoordinates, fetchTidesForPlace } from './tideProvider.js'
describe('fetchTidesForCoordinates', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns BSH data when station is within range', async () => {
vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockResolvedValue({
distanceKm: 8,
location: {
name: 'Norderney, Riffgat',
lat: 53.696389,
lon: 7.157778,
source: 'bsh_station',
stationId: 'norderney_riffgat'
},
tides: {
data: {
timezone: 'Europe/Berlin',
datum: 'gauge',
source: 'BSH',
extrema: [
{
time: '2026-06-12T07:20:00.000Z',
date: '2026-06-12',
height: 6.16,
isHigh: true
}
]
}
}
})
const result = await fetchTidesForCoordinates(53.62, 7.15)
expect(result.distanceKm).toBe(8)
expect(result.location.source).toBe('bsh_station')
expect(result.fallback).toBeUndefined()
})
it('falls back to Open-Meteo when BSH station is too far', async () => {
vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockRejectedValue(
Object.assign(new Error('bsh_station_too_far'), { distanceKm: 120 })
)
vi.spyOn(openMeteoTides, 'fetchTidesForCoordinates').mockResolvedValue({
location: { lat: 62, lon: 5, source: 'coordinates' },
tides: {
data: {
timezone: 'Europe/Oslo',
datum: 'MSL',
source: 'Open-Meteo Marine',
extrema: [
{
time: '2026-06-12T10:00:00.000Z',
date: '2026-06-12',
height: 1.2,
isHigh: true
}
]
}
}
})
const result = await fetchTidesForCoordinates(62, 5)
expect(result.fallback).toBe('open_meteo')
expect(result.tides.data.source).toContain('Fallback')
})
})
describe('fetchTidesForPlace', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('matches BSH station directly by name startsWith', async () => {
vi.spyOn(bshTides, 'loadBshStationIndex').mockResolvedValue([
{ id: 'buesum_schleuse', name: 'Büsum, Schleuse', lat: 54.12, lon: 8.85 }
])
const fetchSpy = vi.spyOn(bshTides, 'fetchBshTidesForStation').mockResolvedValue({
distanceKm: 0,
location: { name: 'Büsum, Schleuse', lat: 54.12, lon: 8.85, source: 'bsh_station', stationId: 'buesum_schleuse' },
tides: { data: { timezone: 'Europe/Berlin', datum: 'gauge', source: 'BSH', extrema: [] } }
})
const result = await fetchTidesForPlace('Buesum')
expect(fetchSpy).toHaveBeenCalledWith('buesum_schleuse', undefined)
expect(result.location.name).toBe('Büsum, Schleuse')
})
it('falls back to geocoding if BSH station index does not match', async () => {
vi.spyOn(bshTides, 'loadBshStationIndex').mockResolvedValue([
{ id: 'buesum_schleuse', name: 'Büsum, Schleuse', lat: 54.12, lon: 8.85 }
])
vi.spyOn(openMeteoTides, 'geocodePlace').mockResolvedValue({
name: 'Kiel',
latitude: 54.32,
longitude: 10.13
})
const coordSpy = vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockResolvedValue({
distanceKm: 0,
location: { name: 'Kiel-Holtenau', lat: 54.37, lon: 10.15, source: 'bsh_station', stationId: 'kiel_holtenau' },
tides: { data: { timezone: 'Europe/Berlin', datum: 'gauge', source: 'BSH', extrema: [] } }
})
const result = await fetchTidesForPlace('Kiel')
expect(coordSpy).toHaveBeenCalledWith(54.32, 10.13)
expect(result.location.name).toBe('Kiel-Holtenau')
})
})
+134
View File
@@ -0,0 +1,134 @@
import {
fetchBshTidesForCoordinates,
fetchBshTidesForStation,
listNearbyBshStations,
loadBshStationIndex,
MAX_BSH_DISTANCE_KM,
type BshStationSuggestion
} from './bshTides.js'
import {
fetchTidesForCoordinates as fetchOpenMeteoTidesForCoordinates,
fetchTidesForPlace as fetchOpenMeteoTidesForPlace,
geocodePlace,
type TideLookupResult
} from './openMeteoTides.js'
export type TideProviderResult = TideLookupResult & {
distanceKm?: number
fallback?: 'open_meteo'
}
export async function fetchTidesForCoordinates(
lat: number,
lon: number,
options?: { name?: string; source?: 'coordinates' | 'geocoded' }
): Promise<TideProviderResult> {
try {
const bsh = await fetchBshTidesForCoordinates(lat, lon)
return bsh
} catch (error: unknown) {
const message = error instanceof Error ? error.message : ''
const tooFar = message === 'bsh_station_too_far'
const noStation = message === 'no_bsh_station' || message === 'bsh_empty_station_list'
const noData = message === 'no_tide_data'
if (!tooFar && !noStation && !noData) {
console.warn('BSH tide lookup failed, trying Open-Meteo fallback:', error)
}
const fallback = await fetchOpenMeteoTidesForCoordinates(lat, lon, options)
return {
...fallback,
fallback: 'open_meteo',
tides: {
data: {
...fallback.tides.data,
source: `${fallback.tides.data.source} (Fallback — keine BSH-Station innerhalb ${MAX_BSH_DISTANCE_KM} km)`
}
}
}
}
}
export async function listNearbyTideStations(
lat: number,
lon: number,
limit = 8
): Promise<BshStationSuggestion[]> {
try {
return await listNearbyBshStations(lat, lon, limit)
} catch {
return []
}
}
export async function fetchTidesForStation(
stationId: string,
options?: { queryLat?: number; queryLon?: number }
): Promise<TideProviderResult> {
try {
return await fetchBshTidesForStation(stationId, options)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : ''
if (message === 'bsh_invalid_station' || message === 'no_tide_data') {
throw error
}
console.warn('BSH station tide lookup failed:', error)
throw new Error('no_tide_data')
}
}
function normalizeForMatching(s: string): string {
return s
.toLowerCase()
.trim()
.replace(/ae/g, 'ä')
.replace(/oe/g, 'ö')
.replace(/ue/g, 'ü')
.replace(/ss/g, 'ß');
}
export async function fetchTidesForPlace(query: string): Promise<TideProviderResult> {
const normQuery = normalizeForMatching(query)
if (normQuery) {
try {
const stations = await loadBshStationIndex()
let match = stations.find(s => normalizeForMatching(s.name) === normQuery)
if (!match) {
match = stations.find(s => normalizeForMatching(s.name).startsWith(normQuery))
}
if (match) {
return await fetchTidesForStation(match.id)
}
} catch (err) {
console.warn('[tideProvider] Direct BSH station lookup failed:', err)
}
}
const place = await geocodePlace(query)
if (!place) {
if (normQuery) {
try {
const stations = await loadBshStationIndex()
const match = stations.find(s =>
normalizeForMatching(s.name).includes(normQuery) ||
normQuery.includes(normalizeForMatching(s.name))
)
if (match) {
return await fetchTidesForStation(match.id)
}
} catch (err) {
console.warn('[tideProvider] Fallback BSH station lookup failed:', err)
}
}
const err = new Error('place_not_found') as Error & { status?: number }
err.status = 404
throw err
}
return fetchTidesForCoordinates(place.latitude, place.longitude, {
name: place.name,
source: 'geocoded'
})
}