Compare commits

...

36 Commits

Author SHA1 Message Date
elpatron 6c8aa5af4c chore: release v0.1.1.10 2026-06-03 19:17:02 +02:00
elpatron 9554f4b66e style(client): center PWA update and install banners properly 2026-06-03 19:16:56 +02:00
elpatron 5c77bbfdc3 style(client): hide version footer on mobile when bottom navigation is active 2026-06-03 19:15:09 +02:00
elpatron 979b572136 chore: release v0.1.1.9 2026-06-03 19:11:29 +02:00
elpatron f189317dfc chore: remove visual debug logs panel from voice recording modal 2026-06-03 19:11:25 +02:00
elpatron c54f834311 chore: release v0.1.1.8 2026-06-03 19:07:07 +02:00
elpatron 9d05005bb7 fix: allow blob and data urls in Content-Security-Policy media-src directive 2026-06-03 19:07:03 +02:00
elpatron 40c4874156 chore: release v0.1.1.7 2026-06-03 18:56:39 +02:00
elpatron 2de0636608 fix: call load() to force mobile browsers to fetch blob URL metadata and fix player duration 2026-06-03 18:56:32 +02:00
elpatron 9e7c6f4397 chore: release v0.1.1.6 2026-06-03 18:51:14 +02:00
elpatron 6600ceafce debug: add verbose console logging and on-screen logs area to LiveVoiceCapture 2026-06-03 18:51:08 +02:00
elpatron d7a497a4a2 chore: release v0.1.1.5 2026-06-03 18:44:56 +02:00
elpatron 4c04086d63 fix: solve audio recording on iOS/Safari and fix Dockerfile health check 2026-06-03 18:44:51 +02:00
elpatron 79ce42bec6 chore: release v0.1.1.4 2026-06-03 18:33:39 +02:00
elpatron 72c956162c fix: resolve 0-second duration issue on WebM voice recordings in Chrome/Android 2026-06-03 18:33:35 +02:00
elpatron 3080b59dc8 chore: release v0.1.1.3 2026-06-03 18:27:00 +02:00
elpatron d054e42cc0 style: add sunset background image to login screen 2026-06-03 18:26:52 +02:00
elpatron d299fc1d93 chore: release v0.1.1.2 2026-06-03 18:23:23 +02:00
elpatron 6447e95d7d fix: defer stopping media stream tracks until media recorder finishes stopping 2026-06-03 18:22:30 +02:00
elpatron 7ec5a1eccc chore: release v0.1.1.1 2026-06-03 18:14:13 +02:00
elpatron 4cf70a3431 style: increase footer and Ko-Fi badge font-size 2026-06-03 18:14:07 +02:00
elpatron 6ed8b2a8e7 chore: release v0.1.1.0 2026-06-03 18:10:37 +02:00
elpatron bff00cf0a3 fix: camera error modal rendering and voice memo player box-sizing 2026-06-03 18:10:20 +02:00
elpatron 3cab735754 refactor: replace parseFloat with parseAppDecimal and formatAppDecimal for improved number handling
Updated various components to utilize parseAppDecimal and formatAppDecimal for consistent decimal parsing and formatting. This change enhances the handling of numeric inputs across the application, ensuring better accuracy and user experience in forms and displays.
2026-06-03 18:07:22 +02:00
elpatron 79762a0baf fix(gps): Genauigkeitsanzeige unter und ab 100 m unterscheiden
Der tote Ternär lieferte in beiden Zweigen dieselbe Rundung; ab 100 m
wird jetzt auf 10 m gerundet, damit schwache Fixes nicht falsch präzise wirken.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:57:11 +02:00
elpatron 24160b6c5d feat(gps): klare Fehlerhinweise, Empfangsqualität und Live-Log-Freigabe
Nutzer sehen spezifische Meldungen bei GPS-Problemen, eine Schätzung des
Empfangs aus der Browser-Genauigkeit und beim ersten Live-Log-Besuch nur
dann einen Freigabe-Hinweis, wenn die Standortberechtigung noch offen ist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:53:58 +02:00
elpatron 1326045b25 fix(live-log): Fehlermeldung wenn keine Systemkamera vorhanden ist
Prüft videoinput-Geräte beim Öffnen des Foto-Modals und zeigt eine
klare Meldung statt leerem Kamera-UI; getUserMedia-Fehler differenziert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:44:11 +02:00
elpatron e014e997de refactor(live-log): Position-Terminologie und Modal-UX vereinheitlichen
Fix/Standort heißen überall Position (__live:position, Legacy __live:fix).
Nachfüll-Buttons + Diesel/+ Wasser, Abbruch statt Nein in Live-Modals.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:42:08 +02:00
elpatron 1bc449687d style: add spacing around voice memo player controls
Widen native audio controls and separate remarks column from event actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:31:33 +02:00
elpatron 35ee705510 fix: Klammern bei Signatur-Scoring in scoreTodayEntry
Stellt sicher, dass signSkipper und signCrew gemeinsam für den
Punktewert bei der Auswahl des Tageseintrags ausgewertet werden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:30:46 +02:00
elpatron 9f76c200b0 style: highlight delete actions and align event row buttons
Add btn-icon danger styling, strengthen btn-delete and photo delete, and fix events table action layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:27:46 +02:00
elpatron ac627a022f fix: ensure only one travel day per calendar date
Serialize Live-log day creation, prune empty duplicates, and use local dates for "today".

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:24:51 +02:00
elpatron 9ae24aa6fb fix: allow microphone access for voice memos in PWA
Permissions-Policy blocked getUserMedia; allow microphone on same origin like camera.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:18:49 +02:00
elpatron 91cf2674f7 chore: release v0.1.0.111 2026-06-03 15:51:52 +02:00
elpatron b7a9df6ae0 refactor: drop redundant Live Log photo/voice Plausible events
Live-journal uploads are tracked only via Photo Uploaded and Voice Memo Uploaded with context live_log.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:40:22 +02:00
elpatron 7bc3c25ba4 feat: add discreet Ko-fi support badge in app footer
Let users support project development and running costs via ko-fi.com/kapteinsdaagbok, with i18n tooltips and Plausible tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:33:56 +02:00
54 changed files with 1680 additions and 486 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.111
0.1.1.11
+1 -1
View File
@@ -29,4 +29,4 @@ EXPOSE 80
# Health check to verify Nginx is actively running
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
+6 -6
View File
@@ -7,8 +7,8 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
@@ -17,8 +17,8 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
@@ -27,8 +27,8 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+210 -40
View File
@@ -36,6 +36,10 @@ code {
min-height: 100svh;
padding: 24px 16px calc(48px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
background-image: linear-gradient(rgba(15, 23, 42, 0.3), rgba(15, 23, 42, 0.5)), url('/login-bg.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* Glassmorphism Auth Card */
@@ -1180,12 +1184,29 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-accent-light);
}
.btn-icon.logout:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
.btn-icon.danger {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.35);
color: #f87171;
}
.btn-icon.danger:hover {
background: rgba(239, 68, 68, 0.3);
border-color: #ef4444;
color: #fca5a5;
}
.btn-icon.danger:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-icon.logout:hover {
background: var(--app-accent-bg);
border-color: var(--app-accent);
color: var(--app-accent-light);
}
.dashboard-main {
display: grid;
grid-template-columns: 350px 1fr;
@@ -2181,12 +2202,12 @@ html.scheme-dark .themed-select-option.is-selected {
}
.btn-delete {
background: none;
border: none;
color: #475569;
background: rgba(239, 68, 68, 0.18);
border: 1px solid rgba(239, 68, 68, 0.35);
color: #f87171;
cursor: pointer;
padding: 6px;
border-radius: 6px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
@@ -2202,8 +2223,9 @@ html.scheme-dark .themed-select-option.is-selected {
}
.btn-delete:hover {
color: #f43f5e;
background: rgba(244, 63, 94, 0.1);
color: #fca5a5;
background: rgba(239, 68, 68, 0.3);
border-color: #ef4444;
}
.btn-pdf {
@@ -2684,15 +2706,18 @@ html.scheme-dark .themed-select-option.is-selected {
}
.events-actions-td {
width: 1%;
min-width: 88px;
white-space: nowrap;
vertical-align: middle;
}
.events-actions-td .btn-icon {
margin-left: 4px;
}
.events-actions-td .btn-icon:first-child {
margin-left: 0;
.events-actions-cell {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: nowrap;
}
.events-table tbody tr:hover {
@@ -3172,9 +3197,9 @@ html.theme-cupertino .events-scroll-container {
position: absolute;
top: 8px;
right: 8px;
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #f43f5e;
background: rgba(239, 68, 68, 0.22);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #fca5a5;
border-radius: 50%;
width: 32px;
height: 32px;
@@ -3186,9 +3211,10 @@ html.theme-cupertino .events-scroll-container {
}
.photo-btn-delete:hover {
background: #f43f5e;
background: rgba(239, 68, 68, 0.45);
border-color: #ef4444;
color: #ffffff;
transform: scale(1.1);
transform: scale(1.08);
}
.photo-caption-bar {
@@ -3470,6 +3496,84 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text, inherit);
}
.live-log-gps-error-modal {
color: var(--app-warning-text, #b45309);
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 8px;
padding: 8px 10px;
}
.gps-signal-hint {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.45;
}
.gps-signal-hint-label {
display: inline-flex;
align-items: flex-start;
gap: 8px;
}
.gps-signal-icon {
flex-shrink: 0;
margin-top: 2px;
}
.gps-signal-bars {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 14px;
flex-shrink: 0;
}
.gps-signal-bar {
width: 4px;
border-radius: 1px;
background: var(--app-border, rgba(148, 163, 184, 0.45));
}
.gps-signal-bar:nth-child(1) { height: 4px; }
.gps-signal-bar:nth-child(2) { height: 7px; }
.gps-signal-bar:nth-child(3) { height: 10px; }
.gps-signal-bar:nth-child(4) { height: 14px; }
.gps-signal-bar.is-active {
background: var(--app-accent-light, #22c55e);
}
.gps-signal-excellent .gps-signal-bar.is-active {
background: #22c55e;
}
.gps-signal-good .gps-signal-bar.is-active {
background: #84cc16;
}
.gps-signal-fair .gps-signal-bar.is-active,
.gps-signal-unknown .gps-signal-bar.is-active {
background: #eab308;
}
.gps-signal-poor .gps-signal-bar.is-active {
background: #f97316;
}
.gps-signal-poor,
.gps-signal-fair {
color: var(--app-warning-text, #b45309);
}
.gps-signal-hint-editor {
margin-top: 6px;
}
.gps-signal-hint-modal {
margin: 0 0 10px;
}
.live-log-layout {
display: grid;
grid-template-columns: minmax(148px, 200px) 1fr;
@@ -3645,10 +3749,14 @@ html.theme-cupertino .events-scroll-container {
.live-log-summary-block {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
min-width: 0;
}
.live-log-summary-block .voice-memo-player-shell {
margin-top: 2px;
}
.live-voice-modal .live-voice-modal-header {
display: flex;
align-items: center;
@@ -3697,14 +3805,27 @@ html.theme-cupertino .events-scroll-container {
font-size: 13px;
}
.voice-memo-player-shell {
display: block;
max-width: 100%;
padding: 2px 4px 2px 0;
box-sizing: border-box;
}
.voice-memo-player {
display: block;
width: 100%;
max-width: 280px;
height: 32px;
max-width: 300px;
min-width: 260px;
height: 36px;
box-sizing: border-box;
padding-inline: 2px 12px;
}
.voice-memo-player--compact {
max-width: 220px;
max-width: 280px;
min-width: 240px;
padding-inline-end: 14px;
}
.voice-memo-player-unavailable {
@@ -3720,6 +3841,18 @@ html.theme-cupertino .events-scroll-container {
align-items: flex-start;
}
.event-remarks-cell--voice {
gap: 8px;
}
.events-table .remarks-td {
padding-right: 20px;
}
.events-table .remarks-td + .events-actions-td {
padding-left: 12px;
}
@media (max-width: 720px) {
.live-log-layout {
grid-template-columns: 1fr;
@@ -3814,14 +3947,14 @@ html.theme-cupertino .events-scroll-container {
max-width: min(100%, 420px);
}
.live-log-fix-coords {
.live-log-position-coords {
margin: 0;
padding: 0;
border: none;
min-width: 0;
}
.live-log-fix-label {
.live-log-position-label {
display: block;
margin: 0 0 10px;
padding: 0;
@@ -3830,35 +3963,35 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text-muted);
}
.live-log-fix-coords-row {
.live-log-position-coords-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
min-width: 0;
}
.live-log-fix-field {
.live-log-position-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.live-log-fix-field-label {
.live-log-position-field-label {
font-size: 12px;
color: var(--app-text-muted);
}
.live-log-fix-field .input-text {
.live-log-position-field .input-text {
width: 100%;
box-sizing: border-box;
}
.live-log-fix-gps-row {
.live-log-position-gps-row {
margin-top: 10px;
}
.live-log-fix-gps-btn {
.live-log-position-gps-btn {
width: 100%;
box-sizing: border-box;
display: flex;
@@ -4083,15 +4216,23 @@ html.theme-cupertino .events-scroll-container {
min-height: auto !important;
}
.btn-inline-icon {
width: auto !important;
display: inline-flex !important;
align-items: center;
gap: 6px;
}
.btn.danger {
background: rgba(239, 68, 68, 0.15) !important;
background: rgba(239, 68, 68, 0.2) !important;
color: #fca5a5 !important;
border: 1px solid rgba(239, 68, 68, 0.3) !important;
border: 1px solid rgba(239, 68, 68, 0.38) !important;
}
.btn.danger:hover {
background: rgba(239, 68, 68, 0.25) !important;
background: rgba(239, 68, 68, 0.32) !important;
border-color: #ef4444 !important;
color: #fecaca !important;
}
/* Crew Avatar Card Styling */
@@ -5156,8 +5297,9 @@ html.theme-cupertino .events-scroll-container {
/* PWA install prompt */
.pwa-install-banner {
position: fixed;
left: 16px;
right: 16px;
left: 0;
right: 0;
width: calc(100% - 32px);
bottom: calc(36px + env(safe-area-inset-bottom, 0px));
z-index: 1200;
display: grid;
@@ -5320,8 +5462,9 @@ html.theme-cupertino .events-scroll-container {
.pwa-update-banner {
position: fixed;
top: calc(12px + env(safe-area-inset-top, 0px));
left: 16px;
right: 16px;
left: 0;
right: 0;
width: calc(100% - 32px);
z-index: 1300;
display: grid;
grid-template-columns: auto 1fr auto;
@@ -5436,7 +5579,7 @@ html.theme-cupertino .events-scroll-container {
flex-wrap: wrap;
gap: 6px;
padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0px));
font-size: 11px;
font-size: 13px;
line-height: 1.4;
color: #64748b;
background: rgba(11, 12, 16, 0.72);
@@ -5444,6 +5587,12 @@ html.theme-cupertino .events-scroll-container {
pointer-events: none;
}
@media (max-width: 768px) {
body:has(.app-bottom-nav) .app-version-footer {
display: none;
}
}
.app-version-footer a,
.app-version-footer button {
pointer-events: auto;
@@ -5472,6 +5621,27 @@ html.theme-cupertino .events-scroll-container {
text-decoration: underline;
}
.kofi-footer-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: #94a3b8;
text-decoration: none;
background: rgba(255, 94, 91, 0.08);
border: 1px solid rgba(255, 94, 91, 0.18);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.kofi-footer-badge:hover {
color: #fecaca;
background: rgba(255, 94, 91, 0.14);
border-color: rgba(255, 94, 91, 0.32);
}
.demo-badge {
display: inline-flex;
align-items: center;
+20
View File
@@ -1,8 +1,13 @@
import { Coffee } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
export default function AppFooter() {
const { t } = useTranslation()
return (
<footer className="app-version-footer">
<span className="app-version-footer__version">v{APP_VERSION}</span>
@@ -18,6 +23,21 @@ export default function AppFooter() {
Markus F.J. Busche
</a>
</span>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="kofi-footer-badge"
href={KOFI_URL}
target="_blank"
rel="noopener noreferrer"
title={t('footer.kofi_title')}
aria-label={t('footer.kofi_title')}
onClick={() => trackPlausibleEvent(PlausibleEvents.KOFI_LINK_CLICKED)}
>
<Coffee size={14} aria-hidden="true" />
<span>{t('footer.kofi_label')}</span>
</a>
</footer>
)
}
+1 -1
View File
@@ -818,7 +818,7 @@ export default function CrewForm({
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
<Edit2 size={14} />
</button>
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
<button className="btn-icon danger" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
<Trash2 size={14} />
</button>
</div>
+3 -2
View File
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { Compass, Save, Check } from 'lucide-react'
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
interface DeviationFormProps {
logbookId: string
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
const sanitizedDeviations: Record<number, number> = {}
headings.forEach((h) => {
const val = deviations[h] || ''
const parsed = parseFloat(val.replace('+', '').trim())
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed
const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
sanitizedDeviations[h] = parsed
})
const dataToSave = {
+1 -1
View File
@@ -25,7 +25,7 @@ export default function EventRemarksCell({
}
return (
<div className="event-remarks-cell">
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
<span>{summary}</span>
{voiceId && (
<VoiceMemoPlayer
+47
View File
@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import { Signal } from 'lucide-react'
import {
formatGpsAccuracyMeters,
gpsQualityI18nKey,
type GpsSignalQuality
} from '../utils/geolocation.js'
const SIGNAL_BARS: Record<GpsSignalQuality, number> = {
excellent: 4,
good: 3,
fair: 2,
poor: 1,
unknown: 0
}
interface GpsSignalHintProps {
quality: GpsSignalQuality
accuracyM: number | null
className?: string
}
export default function GpsSignalHint({ quality, accuracyM, className = '' }: GpsSignalHintProps) {
const { t } = useTranslation()
const bars = SIGNAL_BARS[quality]
const i18nParams = accuracyM != null ? { accuracy: formatGpsAccuracyMeters(accuracyM) } : undefined
return (
<p
className={`gps-signal-hint gps-signal-${quality} ${className}`.trim()}
role="status"
>
<span className="gps-signal-hint-label">
<Signal size={14} aria-hidden className="gps-signal-icon" />
<span className="gps-signal-bars" aria-hidden>
{[1, 2, 3, 4].map((level) => (
<span
key={level}
className={`gps-signal-bar ${level <= bars ? 'is-active' : ''}`}
/>
))}
</span>
<span>{t(gpsQualityI18nKey(quality), i18nParams)}</span>
</span>
</p>
)
}
+45 -15
View File
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react'
import {
cameraErrorKeyFromDomException,
probeCameraAvailability
} from '../utils/cameraAvailability.js'
import {
captureVideoFrame,
preferNativeCameraPicker
@@ -15,7 +19,7 @@ interface LiveCameraCaptureProps {
onCapture: (blob: Blob) => void
}
type Phase = 'live' | 'preview' | 'native'
type Phase = 'checking' | 'live' | 'preview' | 'native'
export default function LiveCameraCapture({
open,
@@ -34,7 +38,7 @@ export default function LiveCameraCapture({
const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const [capturing, setCapturing] = useState(false)
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
const [phase, setPhase] = useState<Phase>('checking')
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0)
@@ -87,12 +91,37 @@ export default function LiveCameraCapture({
clearPreview()
setCameraError(null)
setCapturing(false)
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
setPhase('checking')
return
}
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
let cancelled = false
clearPreview()
}, [open, stopStream, clearPreview])
setCameraError(null)
setCapturing(false)
setPhase('checking')
const probe = async () => {
const availability = await probeCameraAvailability()
if (cancelled) return
if (availability === 'unsupported') {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
if (availability === 'none') {
setCameraError(t('logs.live_photo_no_camera'))
return
}
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
}
void probe()
return () => {
cancelled = true
}
}, [open, clearPreview, stopStream, t])
useEffect(() => {
if (!open || phase !== 'live') {
@@ -105,11 +134,6 @@ export default function LiveCameraCapture({
const start = async () => {
setCameraError(null)
setReady(false)
if (!navigator.mediaDevices?.getUserMedia) {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
@@ -141,7 +165,7 @@ export default function LiveCameraCapture({
} catch (err) {
console.error('Camera access failed:', err)
if (!cancelled) {
setCameraError(t('logs.live_photo_camera_denied'))
setCameraError(t(cameraErrorKeyFromDomException(err)))
}
}
}
@@ -216,7 +240,7 @@ export default function LiveCameraCapture({
className="btn secondary live-camera-close"
onClick={onClose}
disabled={busy}
aria-label={t('logs.confirm_no')}
aria-label={t('logs.live_cancel')}
>
<X size={18} />
</button>
@@ -243,6 +267,12 @@ export default function LiveCameraCapture({
className="live-camera-preview live-camera-preview-still"
/>
</div>
) : cameraError ? (
<div className="live-camera-preview-wrap">
<p className="live-camera-loading">{cameraError}</p>
</div>
) : phase === 'checking' ? (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
) : phase === 'native' ? (
<div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
@@ -256,7 +286,7 @@ export default function LiveCameraCapture({
{t('logs.live_photo_open_camera_btn')}
</button>
</div>
) : cameraError && !ready ? null : (
) : phase === 'live' ? (
<div className="live-camera-preview-wrap">
<video
ref={videoRef}
@@ -269,7 +299,7 @@ export default function LiveCameraCapture({
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)}
</div>
)}
) : null}
{onCaptionChange && (
<div className="input-group live-camera-caption">
@@ -287,7 +317,7 @@ export default function LiveCameraCapture({
<div className="live-log-modal-actions live-camera-actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.confirm_no')}
{t('logs.live_cancel')}
</button>
{showPreview ? (
+189 -96
View File
@@ -33,8 +33,8 @@ import {
import { formatEventSummary } from '../utils/formatEventSummary.js'
import {
getLastAutoPositionMs,
getLastPositionFixWithin,
getLatestPositionFix,
getLastLoggedPositionWithin,
getLatestLoggedPosition,
isMotorRunningFromEvents,
LIVE_EVENT_CODES,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
@@ -50,12 +50,22 @@ import {
liveTempRemark,
liveWaterRemark
} from '../utils/liveEventCodes.js'
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
const formatSpeedKn = (speedKn: number) =>
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import {
geolocationErrorI18nKey,
getCurrentPosition,
getGeolocationErrorReason,
hasSeenGeolocationLiveIntro,
markGeolocationLiveIntroSeen,
normalizeGpsCoordinates,
queryGeolocationPermission
queryGeolocationPermission,
type GeolocationErrorReason,
type GpsSignalQuality
} from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import {
@@ -66,6 +76,7 @@ import {
} from '../utils/sailSelection.js'
import { useDialog } from './ModalDialog.tsx'
import CourseDialInput from './CourseDialInput.tsx'
import GpsSignalHint from './GpsSignalHint.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx'
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
@@ -96,7 +107,7 @@ type LiveModal =
| 'water'
| 'sog'
| 'stw'
| 'fix'
| 'position'
| 'photo'
| 'voice'
@@ -142,13 +153,21 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
return ''
}
function gpsFailureAlertBody(
t: (key: string) => string,
reason: GeolocationErrorReason
): string {
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
}
export default function LiveLogView({
logbookId,
onOpenEditor,
onSwitchToList
}: LiveLogViewProps) {
const { t, i18n } = useTranslation()
const { showAlert } = useDialog()
const { showAlert, showConfirm } = useDialog()
const [geolocationAccessEpoch, setGeolocationAccessEpoch] = useState(0)
const [entryId, setEntryId] = useState<string | null>(null)
const [dayOfTravel, setDayOfTravel] = useState('')
@@ -167,10 +186,15 @@ export default function LiveLogView({
const [valueInputSecondary, setValueInputSecondary] = useState('')
const [selectedSails, setSelectedSails] = useState<string[]>([])
const [undoVisible, setUndoVisible] = useState(false)
const [fixLat, setFixLat] = useState('')
const [fixLng, setFixLng] = useState('')
const [fixGpsLoading, setFixGpsLoading] = useState(false)
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false)
const [positionLat, setPositionLat] = useState('')
const [positionLng, setPositionLng] = useState('')
const [positionGpsLoading, setPositionGpsLoading] = useState(false)
const [positionGpsUnavailable, setPositionGpsUnavailable] = useState(false)
const [positionGpsErrorReason, setPositionGpsErrorReason] = useState<GeolocationErrorReason | null>(null)
const [positionGpsSignal, setPositionGpsSignal] = useState<{
quality: GpsSignalQuality
accuracyM: number | null
} | null>(null)
const [photoCaption, setPhotoCaption] = useState('')
const [photoSaving, setPhotoSaving] = useState(false)
const [voiceCaption, setVoiceCaption] = useState('')
@@ -202,8 +226,8 @@ export default function LiveLogView({
)
const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion')
const hasPositionFix = useMemo(
() => (date ? getLatestPositionFix(events, date) != null : false),
const hasLoggedPosition = useMemo(
() => (date ? getLatestLoggedPosition(events, date) != null : false),
[events, date]
)
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
@@ -310,6 +334,56 @@ export default function LiveLogView({
}
}, [loading, entryId])
useEffect(() => {
if (loading || !entryId || !navigator.geolocation) return
let cancelled = false
void (async () => {
const permission = await queryGeolocationPermission()
if (cancelled) return
if (permission === 'granted') {
markGeolocationLiveIntroSeen()
setGeolocationAccessEpoch((n) => n + 1)
return
}
// Only ask when the browser has not granted location yet (state "prompt").
if (permission !== 'prompt' || hasSeenGeolocationLiveIntro()) return
const allow = await showConfirm(
t('logs.gps_live_intro_body'),
t('logs.gps_live_intro_title'),
t('logs.gps_live_intro_allow'),
t('logs.gps_live_intro_later')
)
markGeolocationLiveIntroSeen()
if (cancelled || !allow) return
try {
await getCurrentPosition({
timeoutMs: 15_000,
enableHighAccuracy: false,
maximumAge: 0
})
if (!cancelled) setGeolocationAccessEpoch((n) => n + 1)
} catch (err) {
const reason = getGeolocationErrorReason(err)
if (reason === 'permission_denied') {
await showAlert(
`${t('logs.gps_permission_denied')}\n\n${t('logs.gps_enable_in_settings_hint')}`,
t('logs.live_title')
)
}
}
})()
return () => {
cancelled = true
}
}, [loading, entryId, showAlert, showConfirm, t])
useEffect(() => {
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [events.length])
@@ -358,7 +432,7 @@ export default function LiveLogView({
})
await refreshEntry(entryId)
} catch {
// Best-effort; hint banner shows when no position fix exists yet.
// Best-effort; hint banner shows when no position has been logged yet.
} finally {
autoPositionBusyRef.current = false
}
@@ -377,7 +451,7 @@ export default function LiveLogView({
if (startTimer !== undefined) window.clearTimeout(startTimer)
if (intervalRef !== undefined) window.clearInterval(intervalRef)
}
}, [entryId, loading, logbookId, refreshEntry])
}, [entryId, loading, logbookId, refreshEntry, geolocationAccessEpoch])
const runQuickAction = async (
action: () => Promise<boolean | void>,
@@ -453,16 +527,26 @@ export default function LiveLogView({
}, 'moor')
}
const openFixModal = async () => {
setFixLat('')
setFixLng('')
setFixGpsUnavailable(false)
setFixGpsLoading(true)
setModal('fix')
const reportPositionGpsFailure = async (reason: GeolocationErrorReason) => {
setPositionGpsUnavailable(true)
setPositionGpsErrorReason(reason)
setPositionGpsSignal(null)
await showAlert(gpsFailureAlertBody(t, reason), t('logs.live_position'))
}
const openPositionModal = async () => {
setPositionLat('')
setPositionLng('')
setPositionGpsUnavailable(false)
setPositionGpsErrorReason(null)
setPositionGpsSignal(null)
setPositionGpsLoading(true)
setModal('position')
try {
const permission = await queryGeolocationPermission()
if (permission !== 'granted') {
setFixGpsUnavailable(true)
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
await reportPositionGpsFailure(reason)
return
}
const coords = await getCurrentPosition({
@@ -470,26 +554,26 @@ export default function LiveLogView({
enableHighAccuracy: false,
maximumAge: 60_000
})
setFixLat(coords.lat)
setFixLng(coords.lng)
} catch {
setFixGpsUnavailable(true)
setPositionLat(coords.lat)
setPositionLng(coords.lng)
setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
} catch (err) {
await reportPositionGpsFailure(getGeolocationErrorReason(err))
} finally {
setFixGpsLoading(false)
setPositionGpsLoading(false)
}
}
const retryFixGps = async () => {
setFixGpsLoading(true)
setFixGpsUnavailable(false)
const retryPositionGps = async () => {
setPositionGpsLoading(true)
setPositionGpsUnavailable(false)
setPositionGpsErrorReason(null)
setPositionGpsSignal(null)
try {
const permission = await queryGeolocationPermission()
if (permission !== 'granted') {
setFixGpsUnavailable(true)
await showAlert(
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
await reportPositionGpsFailure(reason)
return
}
const coords = await getCurrentPosition({
@@ -497,23 +581,21 @@ export default function LiveLogView({
enableHighAccuracy: false,
maximumAge: 60_000
})
setFixLat(coords.lat)
setFixLng(coords.lng)
} catch {
setFixGpsUnavailable(true)
await showAlert(
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
setPositionLat(coords.lat)
setPositionLng(coords.lng)
setPositionGpsUnavailable(false)
setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
} catch (err) {
await reportPositionGpsFailure(getGeolocationErrorReason(err))
} finally {
setFixGpsLoading(false)
setPositionGpsLoading(false)
}
}
const confirmFix = () => {
const coords = normalizeGpsCoordinates(fixLat, fixLng)
const confirmPosition = () => {
const coords = normalizeGpsCoordinates(positionLat, positionLng)
if (!coords) {
void showAlert(t('logs.live_fix_invalid'), t('logs.live_fix'))
void showAlert(t('logs.live_position_invalid'), t('logs.live_position'))
return
}
setModal('none')
@@ -522,9 +604,9 @@ export default function LiveLogView({
await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat,
gpsLng: coords.lng,
remarks: LIVE_EVENT_CODES.FIX
remarks: LIVE_EVENT_CODES.POSITION
})
}, 'fix')
}, 'position')
}
const handleFetchOwmWeather = () => {
@@ -534,17 +616,17 @@ export default function LiveLogView({
return
}
const position = getLastPositionFixWithin(
const position = getLastLoggedPositionWithin(
events,
date,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
)
if (!position) {
const latest = getLatestPositionFix(events, date)
const latest = getLatestLoggedPosition(events, date)
void showAlert(
latest
? t('logs.live_weather_fix_stale')
: t('logs.live_weather_fix_required'),
? t('logs.live_weather_position_stale')
: t('logs.live_weather_position_required'),
t('logs.live_weather_owm_btn')
)
return
@@ -843,45 +925,45 @@ export default function LiveLogView({
break
}
case 'fuel': {
const liters = parseFloat(primary)
if (!Number.isFinite(liters) || liters <= 0) return
const liters = parseAppDecimal(primary)
if (liters == null || liters <= 0) return
setModal('none')
void runQuickAction(async () => {
await appendTankRefill(logbookId, entryId, 'fuel', liters, {
remarks: liveFuelRemark(String(liters))
remarks: liveFuelRemark(formatTankLiters(liters))
})
}, 'fuel')
break
}
case 'water': {
const liters = parseFloat(primary)
if (!Number.isFinite(liters) || liters <= 0) return
const liters = parseAppDecimal(primary)
if (liters == null || liters <= 0) return
setModal('none')
void runQuickAction(async () => {
await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
remarks: liveWaterRemark(String(liters))
remarks: liveWaterRemark(formatTankLiters(liters))
})
}, 'water')
break
}
case 'sog': {
const speedKn = parseFloat(primary.replace(',', '.'))
if (!Number.isFinite(speedKn) || speedKn < 0) return
const speedKn = parseAppDecimal(primary)
if (speedKn == null || speedKn < 0) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, {
remarks: liveSogRemark(String(speedKn))
remarks: liveSogRemark(formatSpeedKn(speedKn))
})
}, 'sog')
break
}
case 'stw': {
const speedKn = parseFloat(primary.replace(',', '.'))
if (!Number.isFinite(speedKn) || speedKn < 0) return
const speedKn = parseAppDecimal(primary)
if (speedKn == null || speedKn < 0) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, {
remarks: liveStwRemark(String(speedKn))
remarks: liveStwRemark(formatSpeedKn(speedKn))
})
}, 'stw')
break
@@ -945,7 +1027,7 @@ export default function LiveLogView({
{error && <div className="auth-error mb-4">{error}</div>}
{!hasPositionFix && (
{!hasLoggedPosition && (
<p className="live-log-gps-hint" role="status">
<MapPin size={16} aria-hidden />
{t('logs.live_gps_start_hint')}
@@ -1036,9 +1118,9 @@ export default function LiveLogView({
)}
</div>
<button type="button" className="live-log-action-btn" onClick={() => void openFixModal()} disabled={busy}>
<button type="button" className="live-log-action-btn" onClick={() => void openPositionModal()} disabled={busy}>
<MapPin size={18} />
{t('logs.live_fix')}
{t('logs.live_position')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
<MessageSquare size={18} />
@@ -1145,7 +1227,7 @@ export default function LiveLogView({
</p>
)}
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button>
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button
type="button"
className="btn primary"
@@ -1161,68 +1243,79 @@ export default function LiveLogView({
</div>
)}
{modal === 'fix' && (
{modal === 'position' && (
<div
className="live-log-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_fix')}</h3>
{fixGpsUnavailable && (
<h3>{t('logs.live_position')}</h3>
{positionGpsUnavailable && (
<>
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p>
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
{positionGpsErrorReason && (
<p className="live-log-modal-hint live-log-gps-error-modal" role="alert">
{t(geolocationErrorI18nKey(positionGpsErrorReason))}
</p>
)}
<p className="live-log-modal-hint">{t('logs.live_position_manual_hint')}</p>
</>
)}
<fieldset className="live-log-fix-coords" disabled={busy}>
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
<div className="live-log-fix-coords-row">
<label className="live-log-fix-field">
<span className="live-log-fix-field-label">{t('logs.live_fix_lat_placeholder')}</span>
<fieldset className="live-log-position-coords" disabled={busy}>
<legend className="live-log-position-label">{t('logs.event_gps')}</legend>
<div className="live-log-position-coords-row">
<label className="live-log-position-field">
<span className="live-log-position-field-label">{t('logs.live_position_lat_placeholder')}</span>
<input
type="text"
inputMode="decimal"
className="input-text"
placeholder="54.123456"
value={fixLat}
onChange={(e) => setFixLat(e.target.value)}
value={positionLat}
onChange={(e) => { setPositionGpsSignal(null); setPositionLat(e.target.value) }}
autoFocus
/>
</label>
<label className="live-log-fix-field">
<span className="live-log-fix-field-label">{t('logs.live_fix_lng_placeholder')}</span>
<label className="live-log-position-field">
<span className="live-log-position-field-label">{t('logs.live_position_lng_placeholder')}</span>
<input
type="text"
inputMode="decimal"
className="input-text"
placeholder="10.654321"
value={fixLng}
onChange={(e) => setFixLng(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmFix() }}
value={positionLng}
onChange={(e) => { setPositionGpsSignal(null); setPositionLng(e.target.value) }}
onKeyDown={(e) => { if (e.key === 'Enter') confirmPosition() }}
/>
</label>
</div>
<div className="live-log-fix-gps-row">
{positionGpsSignal && (
<GpsSignalHint
quality={positionGpsSignal.quality}
accuracyM={positionGpsSignal.accuracyM}
className="gps-signal-hint-modal"
/>
)}
<div className="live-log-position-gps-row">
<button
type="button"
className="btn secondary live-log-fix-gps-btn"
onClick={() => void retryFixGps()}
className="btn secondary live-log-position-gps-btn"
onClick={() => void retryPositionGps()}
title={t('logs.gps_btn')}
disabled={fixGpsLoading}
disabled={positionGpsLoading}
aria-label={t('logs.gps_btn')}
>
<MapPin size={16} />
<span>{fixGpsLoading ? t('logs.live_fix_gps_loading') : t('logs.gps_btn')}</span>
<span>{positionGpsLoading ? t('logs.live_position_gps_loading') : t('logs.gps_btn')}</span>
</button>
</div>
</fieldset>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button>
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button
type="button"
className="btn primary"
onClick={confirmFix}
disabled={busy || !normalizeGpsCoordinates(fixLat, fixLng)}
onClick={confirmPosition}
disabled={busy || !normalizeGpsCoordinates(positionLat, positionLng)}
>
{t('logs.live_sails_confirm')}
</button>
@@ -1237,7 +1330,7 @@ export default function LiveLogView({
<h3>{t('logs.live_comment_btn')}</h3>
<input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} />
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button>
</div>
</div>
@@ -1271,7 +1364,7 @@ export default function LiveLogView({
/>
</div>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div>
</div>
@@ -1293,7 +1386,7 @@ export default function LiveLogView({
/>
</div>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div>
</div>
@@ -1338,7 +1431,7 @@ export default function LiveLogView({
onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }}
/>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div>
</div>
+118 -19
View File
@@ -43,6 +43,53 @@ export default function LiveVoiceCapture({
const [previewMime, setPreviewMime] = useState('audio/webm')
const [previewDurationSec, setPreviewDurationSec] = useState(0)
const [saving, setSaving] = useState(false)
const log = useCallback((msg: string) => {
console.log(`[VoiceDebug] ${msg}`)
}, [])
const previewAudioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
const el = previewAudioRef.current
if (!el) {
log('previewAudioRef is null')
return
}
log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src)
const handleLoadedMetadata = () => {
log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration)
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...')
el.currentTime = 1e10
const onTimeUpdate = () => {
log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration)
el.currentTime = 0
el.removeEventListener('timeupdate', onTimeUpdate)
log('currentTime reset to 0. Final duration=' + el.duration)
}
el.addEventListener('timeupdate', onTimeUpdate)
} else {
log('Duration correction skipped (duration is valid)')
}
}
if (el.readyState >= 1) {
log('readyState >= 1. Executing hack immediately...')
handleLoadedMetadata()
} else {
log('readyState = 0. Adding loadedmetadata event listener...')
el.addEventListener('loadedmetadata', handleLoadedMetadata)
}
log('Calling el.load() to force loading of the media resource...')
el.load()
return () => {
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [previewUrl, log])
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
@@ -110,24 +157,51 @@ export default function LiveVoiceCapture({
if (!recorder || recorder.state !== 'recording') return
recorder.stop()
clearTimer()
stopStream()
}, [clearTimer, stopStream])
}, [clearTimer])
const startRecording = async () => {
setMicError(null)
chunksRef.current = []
log('startRecording flow triggered')
if (!navigator.mediaDevices?.getUserMedia) {
log('navigator.mediaDevices.getUserMedia is unavailable')
setMicError(t('logs.live_voice_mic_denied'))
return
}
try {
log('Requesting getUserMedia audio stream...')
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
log('Stream obtained successfully. active=' + stream.active)
stream.getTracks().forEach((track, i) => {
log(`Track ${i}: label="${track.label}" enabled=${track.enabled} readyState=${track.readyState} muted=${track.muted}`)
})
const mimeType = pickMediaRecorderMimeType()
log('MIME type candidates support check:')
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
MIME_CANDIDATES.forEach(mime => {
log(` - ${mime}: ${MediaRecorder.isTypeSupported(mime) ? 'SUPPORTED' : 'UNSUPPORTED'}`)
})
log('Selected MIME from picker: ' + mimeType)
const recorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream)
mediaRecorderRef.current = recorder
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
log('MediaRecorder created. Resolved mime=' + resolvedMime)
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunksRef.current.push(ev.data)
log(`ondataavailable event: data size=${ev.data?.size} bytes`)
if (ev.data && ev.data.size > 0) {
chunksRef.current.push(ev.data)
}
}
recorder.onstop = () => {
@@ -135,44 +209,67 @@ export default function LiveVoiceCapture({
VOICE_MEMO_MAX_DURATION_SEC,
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
)
const blob = new Blob(chunksRef.current, { type: resolvedMime })
chunksRef.current = []
try {
assertVoiceMemoBlobSize(blob)
finishRecording(blob, resolvedMime, durationSec)
} catch {
setMicError(t('logs.live_voice_too_large'))
setPhase('idle')
}
log(`onstop triggered. durationSec=${durationSec}. Wrapping in 50ms timeout...`)
setTimeout(() => {
log(`Creating Blob from ${chunksRef.current.length} chunks. Resolved mime=${resolvedMime}`)
const totalChunksSize = chunksRef.current.reduce((acc, chunk) => acc + chunk.size, 0)
log(`Total raw chunks size: ${totalChunksSize} bytes`)
const blob = new Blob(chunksRef.current, { type: resolvedMime })
chunksRef.current = []
stopStream()
log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`)
try {
assertVoiceMemoBlobSize(blob)
log('Blob size assertion passed. Calling finishRecording...')
finishRecording(blob, resolvedMime, durationSec)
} catch (err) {
log('Blob size assertion failed (too large)')
setMicError(t('logs.live_voice_too_large'))
setPhase('idle')
}
}, 50)
}
recorder.onerror = () => {
recorder.onerror = (ev) => {
log('MediaRecorder onerror triggered: ' + JSON.stringify(ev))
setMicError(t('logs.live_voice_record_failed'))
resetAll()
}
startedAtRef.current = Date.now()
recorder.start(200)
log('Calling recorder.start()...')
recorder.start()
log('recorder.start() called. State=' + recorder.state)
setPhase('recording')
setElapsedSec(0)
timerRef.current = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
setElapsedSec(sec)
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
log('Max duration reached. Stopping recording...')
stopRecording()
}
}, 250)
} catch {
} catch (err: any) {
log('Error in startRecording try-catch block: ' + (err instanceof Error ? err.stack || err.message : String(err)))
setMicError(t('logs.live_voice_mic_denied'))
stopStream()
}
}
const handleSave = async () => {
if (!previewBlob || saving || busy) return
if (!previewBlob || saving || busy) {
log('handleSave ignored. previewBlob=' + (previewBlob ? 'PRESENT' : 'NULL') + ' saving=' + saving + ' busy=' + busy)
return
}
log('handleSave triggered. Saving blob size=' + previewBlob.size + ' mime=' + previewMime + ' duration=' + previewDurationSec)
setSaving(true)
try {
onSave(previewBlob, previewMime, previewDurationSec)
log('Invoking onSave callback...')
await onSave(previewBlob, previewMime, previewDurationSec)
log('onSave callback successfully finished!')
} catch (err: any) {
log('Error during onSave execution: ' + (err instanceof Error ? err.stack || err.message : String(err)))
} finally {
setSaving(false)
}
@@ -195,7 +292,7 @@ export default function LiveVoiceCapture({
className="btn-icon"
onClick={onClose}
disabled={busy || saving || phase === 'recording'}
aria-label={t('logs.confirm_no')}
aria-label={t('logs.live_cancel')}
>
<X size={18} />
</button>
@@ -237,7 +334,7 @@ export default function LiveVoiceCapture({
{phase === 'preview' && previewUrl && (
<>
<audio className="voice-memo-player" controls src={previewUrl} preload="auto" />
<audio ref={previewAudioRef} className="voice-memo-player" controls src={previewUrl} preload="auto" />
{onCaptionChange && (
<label className="live-voice-caption-field">
<span>{t('logs.live_voice_caption_label')}</span>
@@ -274,6 +371,8 @@ export default function LiveVoiceCapture({
</div>
</>
)}
</div>
</div>
)
+8 -2
View File
@@ -9,7 +9,8 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId, tryDecryptEntryPayload } from '../services/quickEventLog.js'
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
import { localDateString } from '../utils/logEntryPayload.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -123,6 +124,11 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const todayEntryId = await findTodayEntryId(logbookId)
if (todayEntryId) {
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
}
const local = await db.entries.where({ logbookId }).toArray()
const list: DecryptedEntryItem[] = []
@@ -300,7 +306,7 @@ export default function LogEntriesList({
const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
const todayStr = localDateString()
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
const entryCrew = await loadDefaultEntryCrewForNewDay(
+139 -86
View File
@@ -67,6 +67,14 @@ import {
} from '../services/nmeaArchive.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import GpsSignalHint from './GpsSignalHint.tsx'
import {
geolocationErrorI18nKey,
getCurrentPosition,
getGeolocationErrorReason,
queryGeolocationPermission,
type GpsSignalQuality
} from '../utils/geolocation.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
import TankLiterInput from './TankLiterInput.tsx'
import MetricRangeInput from './MetricRangeInput.tsx'
@@ -95,6 +103,17 @@ import {
formatTankLitersForInput,
type VesselTankCapacities
} from '../utils/tankCapacity.js'
import {
formatAppCoordinate,
parseAppDecimal,
parseAppDecimalOrZero
} from '../utils/numberFormat.js'
function parseOptionalFormDecimal(input: string): number | undefined {
const trimmed = input.trim()
if (!trimmed) return undefined
return parseAppDecimal(trimmed) ?? undefined
}
function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
@@ -129,19 +148,19 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
greywater: gw ? { level: gw.level || 0 } : undefined,
trackDistanceNm:
trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance))
? (parseAppDecimal(String(trackDistance)) ?? undefined)
: undefined,
trackSpeedMaxKn:
trackSpeedMax != null && trackSpeedMax !== ''
? parseFloat(String(trackSpeedMax))
? (parseAppDecimal(String(trackSpeedMax)) ?? undefined)
: undefined,
trackSpeedAvgKn:
trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg))
? (parseAppDecimal(String(trackSpeedAvg)) ?? undefined)
: undefined,
motorHours:
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
? (parseAppDecimal(String(motorHoursRaw)) ?? undefined)
: undefined,
events: (decrypted.events as LogEventPayload[]) || [],
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
@@ -263,6 +282,10 @@ export default function LogEntryEditor({
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [gpsSignal, setGpsSignal] = useState<{
quality: GpsSignalQuality
accuracyM: number | null
} | null>(null)
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
// Track file upload
@@ -312,22 +335,22 @@ export default function LogEntryEditor({
departure,
destination,
freshwater: {
morning: parseFloat(fwMorning) || 0,
refilled: parseFloat(fwRefilled) || 0,
evening: parseFloat(fwEvening) || 0,
consumption: parseFloat(fwConsumption) || 0
morning: parseAppDecimalOrZero(fwMorning),
refilled: parseAppDecimalOrZero(fwRefilled),
evening: parseAppDecimalOrZero(fwEvening),
consumption: parseAppDecimalOrZero(fwConsumption)
},
fuel: {
morning: parseFloat(fuelMorning) || 0,
refilled: parseFloat(fuelRefilled) || 0,
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
morning: parseAppDecimalOrZero(fuelMorning),
refilled: parseAppDecimalOrZero(fuelRefilled),
evening: parseAppDecimalOrZero(fuelEvening),
consumption: parseAppDecimalOrZero(fuelConsumption)
},
greywater: { level: parseFloat(greywaterLevel) || 0 },
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
motorHours: parseOptionalFormDecimal(motorHours),
events: eventsOverride ?? events,
entryCrew
})
@@ -350,7 +373,7 @@ export default function LogEntryEditor({
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
const fuelPerMotorHour = useMemo(
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
() => computeFuelPerMotorHour(parseAppDecimalOrZero(fuelConsumption), parseAppDecimalOrZero(motorHours)),
[fuelConsumption, motorHours]
)
@@ -686,18 +709,18 @@ export default function LogEntryEditor({
// Auto-calculate Freshwater Consumption
useEffect(() => {
const morning = parseFloat(fwMorning) || 0
const refilled = parseFloat(fwRefilled) || 0
const evening = parseFloat(fwEvening) || 0
const morning = parseAppDecimalOrZero(fwMorning)
const refilled = parseAppDecimalOrZero(fwRefilled)
const evening = parseAppDecimalOrZero(fwEvening)
const cons = morning + refilled - evening
setFwConsumption(cons >= 0 ? String(cons) : '0')
}, [fwMorning, fwRefilled, fwEvening])
// Auto-calculate Fuel Consumption
useEffect(() => {
const morning = parseFloat(fuelMorning) || 0
const refilled = parseFloat(fuelRefilled) || 0
const evening = parseFloat(fuelEvening) || 0
const morning = parseAppDecimalOrZero(fuelMorning)
const refilled = parseAppDecimalOrZero(fuelRefilled)
const evening = parseAppDecimalOrZero(fuelEvening)
const cons = morning + refilled - evening
setFuelConsumption(cons >= 0 ? String(cons) : '0')
}, [fuelMorning, fuelRefilled, fuelEvening])
@@ -708,7 +731,7 @@ export default function LogEntryEditor({
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
useEffect(() => {
const refilled = parseFloat(fwRefilled) || 0
const refilled = parseAppDecimalOrZero(fwRefilled)
if (fwRefilledMax == null) {
if (fwRefilledNoCapacity && refilled > 0) {
setFwRefilled(formatTankLitersForInput(0))
@@ -722,14 +745,14 @@ export default function LogEntryEditor({
useEffect(() => {
if (fwEveningMax == null) return
const evening = parseFloat(fwEvening) || 0
const evening = parseAppDecimalOrZero(fwEvening)
if (evening > fwEveningMax) {
setFwEvening(formatTankLitersForInput(fwEveningMax))
}
}, [fwEveningMax, fwEvening])
useEffect(() => {
const refilled = parseFloat(fuelRefilled) || 0
const refilled = parseAppDecimalOrZero(fuelRefilled)
if (fuelRefilledMax == null) {
if (fuelRefilledNoCapacity && refilled > 0) {
setFuelRefilled(formatTankLitersForInput(0))
@@ -743,7 +766,7 @@ export default function LogEntryEditor({
useEffect(() => {
if (fuelEveningMax == null) return
const evening = parseFloat(fuelEvening) || 0
const evening = parseAppDecimalOrZero(fuelEvening)
if (evening > fuelEveningMax) {
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
}
@@ -1006,12 +1029,16 @@ export default function LogEntryEditor({
}
}
const handleGetGps = () => {
const clearGpsSignal = () => setGpsSignal(null)
const handleGetGps = async () => {
if (readOnly) return
const lookupFallback = async () => {
clearGpsSignal()
const locationQuery = evLocationName.trim() || departure.trim() || destination.trim()
if (!locationQuery) {
showAlert('GPS capturing failed, and no location name is entered in "Ort / Hafen" or "Start-Hafen" to look up coordinates.')
showAlert(t('logs.gps_fallback_no_location'))
return
}
if (!isOnline) {
@@ -1026,9 +1053,11 @@ export default function LogEntryEditor({
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
if (coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
setEvGpsLng(Number(coord.lon).toFixed(6))
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
setEvGpsLat(formatAppCoordinate(Number(coord.lat)))
setEvGpsLng(formatAppCoordinate(Number(coord.lon)))
showAlert(t('logs.gps_fallback_success', { location: locationQuery }))
} else {
showAlert(t('logs.gps_fallback_failed'))
}
} catch (e) {
if (e instanceof WeatherApiError && e.code === 'OFFLINE') {
@@ -1039,25 +1068,37 @@ export default function LogEntryEditor({
showAlert(t('settings.no_key'))
return
}
showAlert('Failed to retrieve GPS location or look up coordinates by location name.')
showAlert(t('logs.gps_fallback_failed'))
}
}
if (!navigator.geolocation) {
lookupFallback()
return
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setEvGpsLat(pos.coords.latitude.toFixed(6))
setEvGpsLng(pos.coords.longitude.toFixed(6))
},
(err) => {
console.warn('GPS capturing failed, trying fallback:', err)
lookupFallback()
try {
const permission = await queryGeolocationPermission()
if (permission === 'denied' || permission === 'unsupported') {
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
showAlert(
`${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
)
await lookupFallback()
return
}
)
const coords = await getCurrentPosition({
timeoutMs: 15_000,
enableHighAccuracy: false,
maximumAge: 60_000
})
setEvGpsLat(coords.lat)
setEvGpsLng(coords.lng)
setGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
} catch (err) {
console.warn('GPS capture failed:', err)
const reason = getGeolocationErrorReason(err)
showAlert(
`${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
)
await lookupFallback()
}
}
const handleFetchWeather = async () => {
@@ -1094,8 +1135,8 @@ export default function LogEntryEditor({
const coord = data.coord as { lat?: number; lon?: number } | undefined
// If fetched by location, automatically pre-fill GPS coordinates
if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
setEvGpsLng(Number(coord.lon).toFixed(6))
setEvGpsLat(formatAppCoordinate(Number(coord.lat)))
setEvGpsLng(formatAppCoordinate(Number(coord.lon)))
}
const parsed = parseOwmCurrentWeather(data)
@@ -1142,23 +1183,23 @@ export default function LogEntryEditor({
dayOfTravel,
departure,
destination,
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
motorHours: parseOptionalFormDecimal(motorHours),
freshwater: {
morning: parseFloat(fwMorning) || 0,
refilled: parseFloat(fwRefilled) || 0,
evening: parseFloat(fwEvening) || 0,
consumption: parseFloat(fwConsumption) || 0
morning: parseAppDecimalOrZero(fwMorning),
refilled: parseAppDecimalOrZero(fwRefilled),
evening: parseAppDecimalOrZero(fwEvening),
consumption: parseAppDecimalOrZero(fwConsumption)
},
fuel: {
morning: parseFloat(fuelMorning) || 0,
refilled: parseFloat(fuelRefilled) || 0,
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
morning: parseAppDecimalOrZero(fuelMorning),
refilled: parseAppDecimalOrZero(fuelRefilled),
evening: parseAppDecimalOrZero(fuelEvening),
consumption: parseAppDecimalOrZero(fuelConsumption)
},
greywaterLevel: parseFloat(greywaterLevel) || 0,
greywaterLevel: parseAppDecimalOrZero(greywaterLevel),
events
},
t
@@ -1820,23 +1861,25 @@ export default function LogEntryEditor({
</td>
{!readOnly && (
<td className="events-actions-td">
<button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} />
</button>
<div className="events-actions-cell">
<button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon danger"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} />
</button>
</div>
</td>
)}
</tr>
@@ -1936,7 +1979,7 @@ export default function LogEntryEditor({
placeholder="Lat"
className="input-text"
value={evGpsLat}
onChange={(e) => setEvGpsLat(e.target.value)}
onChange={(e) => { clearGpsSignal(); setEvGpsLat(e.target.value) }}
disabled={saving}
/>
<input
@@ -1944,13 +1987,13 @@ export default function LogEntryEditor({
placeholder="Lng"
className="input-text"
value={evGpsLng}
onChange={(e) => setEvGpsLng(e.target.value)}
onChange={(e) => { clearGpsSignal(); setEvGpsLng(e.target.value) }}
disabled={saving}
/>
<button
type="button"
className="btn secondary"
onClick={handleGetGps}
onClick={() => void handleGetGps()}
title={t('logs.gps_btn')}
style={{ width: 'auto', padding: '12px' }}
disabled={saving}
@@ -1973,6 +2016,13 @@ export default function LogEntryEditor({
<CloudSun size={16} />
</button>
</div>
{gpsSignal && (
<GpsSignalHint
quality={gpsSignal.quality}
accuracyM={gpsSignal.accuracyM}
className="gps-signal-hint-editor"
/>
)}
</div>
</div>
@@ -2260,9 +2310,8 @@ export default function LogEntryEditor({
{!readOnly && (
<button
type="button"
className="btn secondary"
className="btn danger btn-sm btn-inline-icon"
onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
title={t('logs.gps_track_delete')}
>
<Trash2 size={14} />
@@ -2295,7 +2344,11 @@ export default function LogEntryEditor({
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={() => downloadNmeaArchive(nmeaArchive)}>
<Download size={14} />
</button>
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={handleDeleteNmeaArchive}>
<button
type="button"
className="btn danger btn-sm btn-inline-icon"
onClick={handleDeleteNmeaArchive}
>
<Trash2 size={14} />
</button>
</div>
+1 -1
View File
@@ -147,7 +147,7 @@ export default function PersonPoolForm() {
</button>
<button
type="button"
className="btn-icon logout"
className="btn-icon danger"
onClick={() => void handleDelete(person.payloadId)}
title="Delete"
>
+1 -1
View File
@@ -429,7 +429,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
<td>
<button
type="button"
className="btn-icon logout"
className="btn-icon danger"
onClick={() => handleRevoke(c.id, c.username)}
title="Revoke access"
>
+3 -2
View File
@@ -14,6 +14,7 @@ import {
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { formatAppDecimal } from '../utils/numberFormat.js'
import {
loadLogbookEventSeries,
type EventSeriesPoint,
@@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)}
</div>
<div className="stats-propulsion-labels">
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span>
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
{totals.unknownPropulsionNm > 0 && (
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
)}
+4 -6
View File
@@ -1,6 +1,7 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { clampTankLiters } from '../utils/tankCapacity.js'
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
interface TankLiterInputProps {
id?: string
@@ -13,10 +14,8 @@ interface TankLiterInputProps {
}
function parseInputLiters(value: string): number {
const trimmed = value.trim().replace(',', '.')
if (!trimmed) return 0
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : 0
if (!value.trim()) return 0
return parseAppDecimalOrZero(value)
}
export default function TankLiterInput({
@@ -34,8 +33,7 @@ export default function TankLiterInput({
const emitValue = useCallback(
(liters: number) => {
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
const str =
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
const str = formatTankLiters(clamped)
onChange(str)
},
[onChange, maxLiters, useSlider]
+1 -1
View File
@@ -193,7 +193,7 @@ export default function VesselPoolForm() {
</button>
<button
type="button"
className="btn-icon logout"
className="btn-icon danger"
onClick={() => void handleDelete(v.payloadId)}
>
<Trash2 size={14} />
+40 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -30,6 +30,38 @@ export default function VoiceMemoPlayer({
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
const [error, setError] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
const el = audioRef.current
if (!el) return
const handleLoadedMetadata = () => {
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
el.currentTime = 1e10
const onTimeUpdate = () => {
el.currentTime = 0
el.removeEventListener('timeupdate', onTimeUpdate)
}
el.addEventListener('timeupdate', onTimeUpdate)
}
}
if (el.readyState >= 1) {
handleLoadedMetadata()
} else {
el.addEventListener('loadedmetadata', handleLoadedMetadata)
}
if (src) {
el.load()
}
return () => {
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [src])
useEffect(() => {
if (preloaded?.audio) {
setSrc(preloaded.audio)
@@ -69,12 +101,13 @@ export default function VoiceMemoPlayer({
)
}
const playerClass = compact
? 'voice-memo-player voice-memo-player--compact'
: 'voice-memo-player'
return (
<audio
className={compact ? 'voice-memo-player voice-memo-player--compact' : 'voice-memo-player'}
controls
preload="none"
src={src}
/>
<div className="voice-memo-player-shell">
<audio ref={audioRef} className={playerClass} controls preload="metadata" src={src} />
</div>
)
}
+37 -13
View File
@@ -6,6 +6,10 @@
"beta": "Beta",
"beta_hint": "Betaversion - funktioner kan stadig ændres"
},
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Støt projektet, videreudvikling og driftsomkostninger på Ko-fi"
},
"languages": {
"de": "Deutsch",
"en": "English",
@@ -245,13 +249,13 @@
"live_sails_confirm": "Indtast",
"live_sails_confirm_count": "Indtast ({{count}})",
"live_sails": "Sejl: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-position…",
"live_fix_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Længde (Lng)",
"live_position": "Position",
"live_position_coords": "Position {{lat}}, {{lng}}",
"live_position_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
"live_position_gps_loading": "Henter GPS-position…",
"live_position_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).",
"live_position_lat_placeholder": "Bredde (Lat)",
"live_position_lng_placeholder": "Længde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Tag billede",
"live_photo_save_btn": "Gem",
@@ -262,6 +266,7 @@
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
"live_photo_no_camera": "Der er intet kamera tilgængeligt på denne enhed.",
"live_photo_error": "Foto kunne ikke gemmes.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
@@ -293,8 +298,8 @@
"live_weather_btn": "Vejr",
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
"live_weather_owm_loading": "Henter vejr…",
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.",
"live_weather_position_required": "Log først en position (Position-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
"live_weather_position_stale": "Den seneste position er ældre end 6 timer. Log en ny position, før du henter vejr.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryk",
@@ -302,8 +307,8 @@
"live_sea_state_btn": "Søgang",
"live_visibility_btn": "Sigtbarhed",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vand",
"live_fuel_btn": "+ Diesel",
"live_water_btn": "+ Vand",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryk {{value}} hPa",
@@ -316,6 +321,7 @@
"live_auto_position": "Auto-position",
"live_undo_hint": "Indtastning gemt",
"live_undo_btn": "Fortryd",
"live_cancel": "Annuller",
"live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. let regn",
@@ -375,6 +381,24 @@
"event_location_placeholder": "z. f.eks. Kiel",
"event_remarks": "Bemærkninger / hændelser",
"gps_btn": "Hent GPS-koordinater",
"gps_permission_denied": "Adgang til placering blev nægtet. Tillad det i browser- eller enhedsindstillinger og prøv igen.",
"gps_timeout": "GPS fik timeout. Prøv igen udendørs med frit udsyn til himlen.",
"gps_position_unavailable": "Intet GPS-signal tilgængeligt. Vent og prøv igen, eller indtast koordinater manuelt.",
"gps_unavailable": "GPS understøttes ikke af denne browser eller enhed.",
"gps_failed": "GPS-position kunne ikke bestemmes.",
"gps_fallback_no_location": "GPS mislykkedes. Angiv et sted under placering/havn, afgang eller destination, eller indtast koordinater manuelt.",
"gps_fallback_success": "Koordinater for \"{{location}}\" fundet via stedsnavn (ikke GPS).",
"gps_fallback_failed": "GPS og stedsnavnssøgning mislykkedes. Indtast koordinater manuelt.",
"gps_quality_excellent": "Stærk GPS-modtagelse (±{{accuracy}} m)",
"gps_quality_good": "God GPS-modtagelse (±{{accuracy}} m)",
"gps_quality_fair": "Middel GPS-modtagelse (±{{accuracy}} m) gå udendørs for bedre signal.",
"gps_quality_poor": "Svag GPS-modtagelse (±{{accuracy}} m) sandsynligvis få satellitter. Prøv udendørs igen eller kontroller positionen.",
"gps_quality_unknown": "GPS-position overtaget (nøjagtighed ikke rapporteret af enheden).",
"gps_live_intro_title": "Placering til live-log",
"gps_live_intro_body": "Appen har brug for din placering til automatiske positionsindlæg og GPS-knappen.\n\nTryk på „Tillad placering“ og bekræft i den næste dialog. Du kan altid indtaste position manuelt via „Position“.",
"gps_live_intro_allow": "Tillad placering",
"gps_live_intro_later": "Senere",
"gps_enable_in_settings_hint": "Adgang til placering er blokeret. Du kan tillade det senere i browser- eller enhedsindstillinger (websted / app → Placering).",
"weather_btn": "OpenWeatherMap Kald vejret op",
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
"event_wind_pressure": "Lufttryk (hPa)",
@@ -480,8 +504,8 @@
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_gps_lost": "GPS-position mistet",
"nmea_change_gps_regained": "GPS-position gendannet",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
+38 -14
View File
@@ -6,6 +6,10 @@
"beta": "Beta",
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
},
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Projekt, Weiterentwicklung und Betriebskosten auf Ko-fi unterstützen"
},
"languages": {
"de": "Deutsch",
"en": "English",
@@ -245,13 +249,13 @@
"live_sails_confirm": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
"live_fix_gps_loading": "GPS-Position wird ermittelt…",
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).",
"live_fix_lat_placeholder": "Breite (Lat)",
"live_fix_lng_placeholder": "Länge (Lng)",
"live_position": "Position",
"live_position_coords": "Position {{lat}}, {{lng}}",
"live_position_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
"live_position_gps_loading": "GPS-Position wird ermittelt…",
"live_position_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).",
"live_position_lat_placeholder": "Breite (Lat)",
"live_position_lng_placeholder": "Länge (Lng)",
"live_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen",
"live_photo_save_btn": "Speichern",
@@ -262,6 +266,7 @@
"live_photo_camera_starting": "Kamera wird gestartet…",
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
"live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.",
"live_photo_error": "Foto konnte nicht gespeichert werden.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen",
@@ -288,13 +293,13 @@
"live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einer Position.",
"live_event_generic": "Ereignis",
"live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
"live_weather_owm_loading": "Wetter wird geladen…",
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.",
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.",
"live_weather_position_required": "Für Wetter von OpenWeatherMap zuerst eine Position eintragen (Schaltfläche „Position“). Die Position darf höchstens 6 Stunden alt sein.",
"live_weather_position_stale": "Die letzte Position ist älter als 6 Stunden. Bitte erneut eine Position loggen, bevor du Wetter abrufst.",
"live_wind_btn": "Wind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck",
@@ -302,8 +307,8 @@
"live_sea_state_btn": "Seegang",
"live_visibility_btn": "Sichtweite",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Wasser",
"live_fuel_btn": "+ Diesel",
"live_water_btn": "+ Wasser",
"live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Luftdruck {{value}} hPa",
@@ -316,6 +321,7 @@
"live_auto_position": "Auto-Position",
"live_undo_hint": "Eintrag gespeichert",
"live_undo_btn": "Rückgängig",
"live_cancel": "Abbruch",
"live_pressure_placeholder": "z. B. 1013",
"live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen",
@@ -375,6 +381,24 @@
"event_location_placeholder": "z. B. Kiel",
"event_remarks": "Bemerkungen / Vorkommnisse",
"gps_btn": "GPS-Koordinaten abrufen",
"gps_permission_denied": "Standortzugriff wurde verweigert. Bitte in den Browser- oder Geräteeinstellungen erlauben und erneut versuchen.",
"gps_timeout": "GPS-Zeitüberschreitung. Bitte erneut versuchen am besten im Freien mit gutem Empfang.",
"gps_position_unavailable": "Kein GPS-Signal verfügbar. Bitte warten oder Koordinaten manuell eingeben.",
"gps_unavailable": "GPS wird von diesem Browser oder Gerät nicht unterstützt.",
"gps_failed": "GPS-Position konnte nicht ermittelt werden.",
"gps_fallback_no_location": "GPS fehlgeschlagen. Bitte einen Ort unter „Ort / Hafen“, Start- oder Zielhafen eintragen, oder Koordinaten manuell eingeben.",
"gps_fallback_success": "Koordinaten für „{{location}}“ über den Ortsnamen ermittelt (nicht per GPS).",
"gps_fallback_failed": "GPS und Ortsnamen-Suche sind fehlgeschlagen. Bitte Koordinaten manuell eingeben.",
"gps_quality_excellent": "Starker GPS-Empfang (±{{accuracy}} m)",
"gps_quality_good": "Guter GPS-Empfang (±{{accuracy}} m)",
"gps_quality_fair": "Mäßiger GPS-Empfang (±{{accuracy}} m) für besseren Empfang ins Freie gehen.",
"gps_quality_poor": "Schwacher GPS-Empfang (±{{accuracy}} m) vermutlich wenig Satelliten. Im Freien erneut versuchen oder Position prüfen.",
"gps_quality_unknown": "GPS-Position übernommen (Genauigkeit vom Gerät nicht gemeldet).",
"gps_live_intro_title": "Standort für Live-Log",
"gps_live_intro_body": "Für automatische Positions-Einträge und den GPS-Knopf braucht die App Zugriff auf deinen Standort.\n\nTippe auf „Standort erlauben“ im nächsten Dialog die Freigabe bestätigen. Du kannst jederzeit manuell unter „Position“ eintragen.",
"gps_live_intro_allow": "Standort erlauben",
"gps_live_intro_later": "Später",
"gps_enable_in_settings_hint": "Standortzugriff ist blockiert. In den Browser- oder Geräteeinstellungen (Website / App → Standort) kannst du die Freigabe nachträglich erlauben.",
"weather_btn": "OpenWeatherMap Wetter abrufen",
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
"event_wind_pressure": "Luftdruck (hPa)",
@@ -470,8 +494,8 @@
"nmea_change_engine_stop": "Motor aus",
"nmea_change_autopilot_on": "Autopilot ein",
"nmea_change_autopilot_off": "Autopilot aus",
"nmea_change_gps_lost": "GPS-Fix verloren",
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
"nmea_change_gps_lost": "GPS-Position verloren",
"nmea_change_gps_regained": "GPS-Position wiederhergestellt",
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
"nmea_change_anchor": "Ankern / Stop",
+38 -14
View File
@@ -6,6 +6,10 @@
"beta": "Beta",
"beta_hint": "Beta release — features may still change"
},
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Support the project, development, and running costs on Ko-fi"
},
"languages": {
"de": "Deutsch",
"en": "English",
@@ -245,13 +249,13 @@
"live_sails_confirm": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
"live_fix_gps_loading": "Getting GPS position…",
"live_fix_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).",
"live_fix_lat_placeholder": "Latitude (Lat)",
"live_fix_lng_placeholder": "Longitude (Lng)",
"live_position": "Position",
"live_position_coords": "Position {{lat}}, {{lng}}",
"live_position_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
"live_position_gps_loading": "Getting GPS position…",
"live_position_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).",
"live_position_lat_placeholder": "Latitude (Lat)",
"live_position_lng_placeholder": "Longitude (Lng)",
"live_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture",
"live_photo_save_btn": "Save",
@@ -262,6 +266,7 @@
"live_photo_camera_starting": "Starting camera…",
"live_photo_camera_denied": "Camera access denied or unavailable.",
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
"live_photo_no_camera": "No camera is available on this device.",
"live_photo_error": "Could not save photo.",
"live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured",
@@ -288,13 +293,13 @@
"live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.",
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
"live_gps_start_hint": "Always start your day's voyage with a position.",
"live_event_generic": "Event",
"live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
"live_weather_owm_loading": "Loading weather…",
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.",
"live_weather_position_required": "Log a position first (Position button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
"live_weather_position_stale": "The last position is older than 6 hours. Log a new position before fetching weather.",
"live_wind_btn": "Wind",
"live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure",
@@ -302,8 +307,8 @@
"live_sea_state_btn": "Sea state",
"live_visibility_btn": "Visibility",
"live_course_btn": "Course",
"live_fuel_btn": "Fuel",
"live_water_btn": "Water",
"live_fuel_btn": "+ Fuel",
"live_water_btn": "+ Water",
"live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperature {{temp}} °C",
"live_pressure_entry": "Pressure {{value}} hPa",
@@ -316,6 +321,7 @@
"live_auto_position": "Auto position",
"live_undo_hint": "Entry saved",
"live_undo_btn": "Undo",
"live_cancel": "Cancel",
"live_pressure_placeholder": "e.g. 1013",
"live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain",
@@ -375,6 +381,24 @@
"event_location_placeholder": "e.g. Kiel",
"event_remarks": "Remarks / Events",
"gps_btn": "Get GPS Location",
"gps_permission_denied": "Location access was denied. Allow it in your browser or device settings and try again.",
"gps_timeout": "GPS timed out. Try again outdoors with a clear view of the sky.",
"gps_position_unavailable": "No GPS signal available. Wait and retry, or enter coordinates manually.",
"gps_unavailable": "GPS is not supported by this browser or device.",
"gps_failed": "Could not determine GPS position.",
"gps_fallback_no_location": "GPS failed. Enter a place under Location / harbour, departure, or destination, or type coordinates manually.",
"gps_fallback_success": "Coordinates for \"{{location}}\" resolved from place name (not GPS).",
"gps_fallback_failed": "GPS and place-name lookup both failed. Please enter coordinates manually.",
"gps_quality_excellent": "Strong GPS reception (±{{accuracy}} m)",
"gps_quality_good": "Good GPS reception (±{{accuracy}} m)",
"gps_quality_fair": "Fair GPS reception (±{{accuracy}} m) — move outdoors for a better fix.",
"gps_quality_poor": "Weak GPS reception (±{{accuracy}} m) — likely few satellites. Retry outdoors or verify the position.",
"gps_quality_unknown": "GPS position applied (accuracy not reported by device).",
"gps_live_intro_title": "Location for Live Log",
"gps_live_intro_body": "The app needs your location for automatic position entries and the GPS button.\n\nTap “Allow location” and confirm in the next dialog. You can always enter a position manually via “Position”.",
"gps_live_intro_allow": "Allow location",
"gps_live_intro_later": "Later",
"gps_enable_in_settings_hint": "Location access is blocked. You can allow it later in your browser or device settings (site / app → Location).",
"weather_btn": "Fetch OpenWeatherMap Weather",
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
"event_wind_pressure": "Barometer (hPa)",
@@ -470,8 +494,8 @@
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_gps_lost": "GPS position lost",
"nmea_change_gps_regained": "GPS position restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
+37 -13
View File
@@ -6,6 +6,10 @@
"beta": "Beta",
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
},
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Støtt prosjektet, videreutvikling og driftskostnader på Ko-fi"
},
"languages": {
"de": "Deutsch",
"en": "English",
@@ -245,13 +249,13 @@
"live_sails_confirm": "Loggfør",
"live_sails_confirm_count": "Loggfør ({{count}})",
"live_sails": "Seil: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-posisjon…",
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Lengde (Lng)",
"live_position": "Posisjon",
"live_position_coords": "Posisjon {{lat}}, {{lng}}",
"live_position_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
"live_position_gps_loading": "Henter GPS-posisjon…",
"live_position_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).",
"live_position_lat_placeholder": "Bredde (Lat)",
"live_position_lng_placeholder": "Lengde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta bilde",
"live_photo_save_btn": "Lagre",
@@ -262,6 +266,7 @@
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
"live_photo_no_camera": "Ingen kamera er tilgjengelig på denne enheten.",
"live_photo_error": "Kunne ikke lagre foto.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt",
@@ -293,8 +298,8 @@
"live_weather_btn": "Vær",
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
"live_weather_owm_loading": "Henter vær…",
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.",
"live_weather_position_required": "Logg først en posisjon (Posisjon-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
"live_weather_position_stale": "Siste posisjon er eldre enn 6 timer. Logg en ny posisjon før du henter vær.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttrykk",
@@ -302,8 +307,8 @@
"live_sea_state_btn": "Sjøgang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vann",
"live_fuel_btn": "+ Diesel",
"live_water_btn": "+ Vann",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttrykk {{value}} hPa",
@@ -316,6 +321,7 @@
"live_auto_position": "Auto-posisjon",
"live_undo_hint": "Oppføring lagret",
"live_undo_btn": "Angre",
"live_cancel": "Avbryt",
"live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. lett regn",
@@ -375,6 +381,24 @@
"event_location_placeholder": "z. f.eks. Kiel",
"event_remarks": "Merknader / hendelser",
"gps_btn": "Hent GPS-koordinater",
"gps_permission_denied": "Tilgang til posisjon ble nektet. Tillat det i nettleser- eller enhetsinnstillinger og prøv igjen.",
"gps_timeout": "GPS fikk tidsavbrudd. Prøv igjen utendørs med fri sikt mot himmelen.",
"gps_position_unavailable": "Ingen GPS-signal tilgjengelig. Vent og prøv igjen, eller skriv inn koordinater manuelt.",
"gps_unavailable": "GPS støttes ikke av denne nettleseren eller enheten.",
"gps_failed": "GPS-posisjon kunne ikke bestemmes.",
"gps_fallback_no_location": "GPS mislyktes. Skriv inn et sted under sted/havn, avreise eller destinasjon, eller koordinater manuelt.",
"gps_fallback_success": "Koordinater for «{{location}}» funnet via stedsnavn (ikke GPS).",
"gps_fallback_failed": "GPS og stedsnavnssøk mislyktes. Skriv inn koordinater manuelt.",
"gps_quality_excellent": "Sterk GPS-mottak (±{{accuracy}} m)",
"gps_quality_good": "God GPS-mottak (±{{accuracy}} m)",
"gps_quality_fair": "Middels GPS-mottak (±{{accuracy}} m) gå utendørs for bedre signal.",
"gps_quality_poor": "Svakt GPS-mottak (±{{accuracy}} m) sannsynligvis få satellitter. Prøv utendørs igjen eller kontroller posisjonen.",
"gps_quality_unknown": "GPS-posisjon tatt i bruk (nøyaktighet ikke rapportert av enheten).",
"gps_live_intro_title": "Posisjon for live-logg",
"gps_live_intro_body": "Appen trenger posisjonen din for automatiske posisjonsregistreringer og GPS-knappen.\n\nTrykk «Tillat posisjon» og bekreft i neste dialog. Du kan alltid legge inn posisjon manuelt via «Posisjon».",
"gps_live_intro_allow": "Tillat posisjon",
"gps_live_intro_later": "Senere",
"gps_enable_in_settings_hint": "Posisjonstilgang er blokkert. Du kan tillate det senere i nettleser- eller enhetsinnstillinger (nettsted / app → Posisjon).",
"weather_btn": "OpenWeatherMap Ring opp været",
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
"event_wind_pressure": "Lufttrykk (hPa)",
@@ -480,8 +504,8 @@
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_gps_lost": "GPS-posisjon tapt",
"nmea_change_gps_regained": "GPS-posisjon gjenopprettet",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
+37 -13
View File
@@ -6,6 +6,10 @@
"beta": "Beta",
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
},
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Stöd projektet, vidareutveckling och driftskostnader på Ko-fi"
},
"languages": {
"de": "Deutsch",
"en": "English",
@@ -245,13 +249,13 @@
"live_sails_confirm": "Logga",
"live_sails_confirm_count": "Logga ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
"live_fix_gps_loading": "Hämtar GPS-position…",
"live_fix_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).",
"live_fix_lat_placeholder": "Latitud (Lat)",
"live_fix_lng_placeholder": "Longitud (Lng)",
"live_position": "Position",
"live_position_coords": "Position {{lat}}, {{lng}}",
"live_position_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
"live_position_gps_loading": "Hämtar GPS-position…",
"live_position_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).",
"live_position_lat_placeholder": "Latitud (Lat)",
"live_position_lng_placeholder": "Longitud (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta foto",
"live_photo_save_btn": "Spara",
@@ -262,6 +266,7 @@
"live_photo_camera_starting": "Startar kamera…",
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
"live_photo_no_camera": "Ingen kamera finns på den här enheten.",
"live_photo_error": "Foto kunde inte sparas.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
@@ -293,8 +298,8 @@
"live_weather_btn": "Väder",
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
"live_weather_owm_loading": "Hämtar väder…",
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.",
"live_weather_position_required": "Logga först en position (Position-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
"live_weather_position_stale": "Senaste positionen är äldre än 6 timmar. Logga en ny position innan du hämtar väder.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryck",
@@ -302,8 +307,8 @@
"live_sea_state_btn": "Sjögang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vatten",
"live_fuel_btn": "+ Diesel",
"live_water_btn": "+ Vatten",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryck {{value}} hPa",
@@ -316,6 +321,7 @@
"live_auto_position": "Auto-position",
"live_undo_hint": "Post sparad",
"live_undo_btn": "Ångra",
"live_cancel": "Avbryt",
"live_pressure_placeholder": "t.ex. 1013",
"live_temp_placeholder": "t.ex. 18",
"live_precip_placeholder": "t.ex. lätt regn",
@@ -375,6 +381,24 @@
"event_location_placeholder": "z. t.ex. Kiel",
"event_remarks": "Anmärkningar / incidenter",
"gps_btn": "Hämta GPS-koordinater",
"gps_permission_denied": "Platstillgång nekades. Tillåt det i webbläsar- eller enhetsinställningar och försök igen.",
"gps_timeout": "GPS fick tidsgräns. Försök igen utomhus med fri sikt mot himlen.",
"gps_position_unavailable": "Ingen GPS-signal tillgänglig. Vänta och försök igen, eller ange koordinater manuellt.",
"gps_unavailable": "GPS stöds inte av denna webbläsare eller enhet.",
"gps_failed": "GPS-position kunde inte bestämmas.",
"gps_fallback_no_location": "GPS misslyckades. Ange en plats under ort/hamn, avresa eller destination, eller skriv koordinater manuellt.",
"gps_fallback_success": "Koordinater för \"{{location}}\" hittades via ortsnamn (inte GPS).",
"gps_fallback_failed": "GPS och ortnamnssökning misslyckades. Ange koordinater manuellt.",
"gps_quality_excellent": "Stark GPS-mottagning (±{{accuracy}} m)",
"gps_quality_good": "Bra GPS-mottagning (±{{accuracy}} m)",
"gps_quality_fair": "Måttlig GPS-mottagning (±{{accuracy}} m) gå utomhus för bättre signal.",
"gps_quality_poor": "Svag GPS-mottagning (±{{accuracy}} m) troligen få satelliter. Försök utomhus igen eller kontrollera positionen.",
"gps_quality_unknown": "GPS-position övertagen (noggrannhet ej rapporterad av enheten).",
"gps_live_intro_title": "Plats för live-logg",
"gps_live_intro_body": "Appen behöver din plats för automatiska positionsregistreringar och GPS-knappen.\n\nTryck på „Tillåt plats“ och bekräfta i nästa dialog. Du kan alltid ange position manuellt via „Position“.",
"gps_live_intro_allow": "Tillåt plats",
"gps_live_intro_later": "Senare",
"gps_enable_in_settings_hint": "Platstillgång är blockerad. Du kan tillåta det senare i webbläsar- eller enhetsinställningar (webbplats / app → Plats).",
"weather_btn": "OpenWeatherMap Ring upp väder",
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
"event_wind_pressure": "Lufttryck (hPa)",
@@ -480,8 +504,8 @@
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_gps_lost": "GPS-position förlorad",
"nmea_change_gps_regained": "GPS-position återställd",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
+1 -2
View File
@@ -26,6 +26,7 @@ export const PlausibleEvents = {
PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled',
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
PROFILE_OPENED: 'Profile Opened',
PASSKEY_ADDED: 'Passkey Added',
PASSKEY_REMOVED: 'Passkey Removed',
@@ -40,9 +41,7 @@ export const PlausibleEvents = {
NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
LIVE_LOG_VOICE_UPLOADED: 'Live Log Voice Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
+4 -2
View File
@@ -1,3 +1,4 @@
import { formatAppDecimal } from '../utils/numberFormat.js'
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import {
@@ -639,9 +640,10 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
/** Human-readable size for UI warnings. */
export function formatBackupBytes(bytes: number): string {
const fmt = (n: number) => formatAppDecimal(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB`
return `${fmt(bytes / (1024 * 1024))} MB`
}
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
@@ -1,7 +1,12 @@
import { formatAppDecimal } from '../../utils/numberFormat.js'
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
import { angularDelta } from './nmeaTimeSeries.js'
function formatNmeaDecimal(value: number): string {
return formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
const last = events[events.length - 1]
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
@@ -64,7 +69,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_wind_speed',
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
summaryParams: { from: formatNmeaDecimal(lastWindSpeed), to: formatNmeaDecimal(p.windSpeedKnots) },
data: p
}, config.dedupeWindowMs)
}
@@ -79,7 +84,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_pressure',
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
summaryParams: { from: formatNmeaDecimal(lastPressure), to: formatNmeaDecimal(p.pressureHpa) },
data: p
}, config.dedupeWindowMs)
}
@@ -95,7 +100,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_depth',
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
summaryParams: { from: formatNmeaDecimal(lastDepth), to: formatNmeaDecimal(p.depthM) },
data: p
}, config.dedupeWindowMs)
}
@@ -156,7 +161,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_water_temp',
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
summaryParams: { from: formatNmeaDecimal(lastWaterTemp), to: formatNmeaDecimal(p.waterTempC) },
data: p
}, config.dedupeWindowMs)
}
@@ -200,7 +205,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp,
confidence: 'low',
summaryKey: 'logs.nmea_change_speed',
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) },
data: p
}, config.dedupeWindowMs)
}
@@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
import { formatCourseAngle } from '../../utils/courseAngle.js'
import { formatAppDecimal, formatCanonicalCoordinate } from '../../utils/numberFormat.js'
import { degreesToCardinal } from '../../utils/courseAngle.js'
import type {
NmeaChangeEvent,
@@ -33,9 +34,12 @@ function pointToLogEvent(
windDirection: windDir,
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
gpsLat: point.lat != null ? formatCanonicalCoordinate(point.lat) : '',
gpsLng: point.lng != null ? formatCanonicalCoordinate(point.lng) : '',
logReading:
point.logDistanceNm != null
? formatAppDecimal(point.logDistanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '',
sailsOrMotor,
remarks
})
@@ -51,7 +55,11 @@ function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
const parts: string[] = []
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
if (change.data?.depthM != null) {
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
parts.push(
t('logs.nmea_remark_depth', {
depth: formatAppDecimal(change.data.depthM, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
})
)
}
if (change.confidence === 'low') {
parts.push(t('logs.nmea_remark_uncertain'))
-3
View File
@@ -55,9 +55,6 @@ export async function saveEntryPhoto(options: {
})
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return photoId
}
+99 -14
View File
@@ -9,6 +9,7 @@ import {
normalizeLogEvent,
sortLogEventsByTime,
currentLocalTimeHHMM,
localDateString,
type LogEventPayload
} from '../utils/logEntryPayload.js'
import {
@@ -151,18 +152,86 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
}
function scoreTodayEntry(data: Record<string, unknown>): number {
const events = (data.events as unknown[] | undefined)?.length ?? 0
const signed = (data.signSkipper || data.signCrew) ? 1 : 0
const destination = String(data.destination || '').trim() ? 1 : 0
return events * 10 + signed + destination
}
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10)
const todayStr = localDateString()
const masterKey = await getMasterKey(logbookId)
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
let bestId: string | null = null
let bestScore = -1
let bestUpdatedAt = ''
for (const entry of local) {
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted && String(decrypted.date) === todayStr) {
return entry.payloadId
if (!decrypted || String(decrypted.date) !== todayStr) continue
const score = scoreTodayEntry(decrypted)
if (
score > bestScore
|| (score === bestScore && entry.updatedAt > bestUpdatedAt)
) {
bestId = entry.payloadId
bestScore = score
bestUpdatedAt = entry.updatedAt
}
}
return null
return bestId
}
async function entryHasAttachments(logbookId: string, entryId: string): Promise<boolean> {
const [photos, voices, track] = await Promise.all([
db.photos.where({ logbookId, entryId }).count(),
db.voiceMemos.where({ logbookId, entryId }).count(),
db.gpsTracks.get(entryId)
])
return photos > 0 || voices > 0 || track != null
}
async function isEmptyTodayEntry(
logbookId: string,
entryId: string,
data: Record<string, unknown>
): Promise<boolean> {
if (((data.events as unknown[] | undefined)?.length ?? 0) > 0) return false
if (data.signSkipper || data.signCrew) return false
if (String(data.destination || '').trim()) return false
return !(await entryHasAttachments(logbookId, entryId))
}
/** Remove duplicate empty travel days for today (e.g. after parallel Live-log init). */
export async function pruneEmptyTodayDuplicates(
logbookId: string,
keepEntryId: string
): Promise<void> {
const todayStr = localDateString()
const masterKey = await getMasterKey(logbookId)
const local = await db.entries.where({ logbookId }).toArray()
const now = new Date().toISOString()
for (const entry of local) {
if (entry.payloadId === keepEntryId) continue
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (!decrypted || String(decrypted.date) !== todayStr) continue
if (!(await isEmptyTodayEntry(logbookId, entry.payloadId, decrypted))) continue
await db.entries.delete(entry.payloadId)
await db.syncQueue.put({
action: 'delete',
type: 'entry',
payloadId: entry.payloadId,
logbookId,
data: '',
updatedAt: now
})
}
}
export async function createTodayEntry(logbookId: string): Promise<string> {
@@ -185,7 +254,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
const todayStr = localDateString()
const initialPayload = {
date: todayStr,
@@ -227,20 +296,36 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
return localId
}
const findOrCreateTodayEntryInflight = new Map<string, Promise<string>>()
async function findOrCreateTodayEntryOnce(logbookId: string): Promise<string> {
await ensureLogbookKey(logbookId)
let entryId = await findTodayEntryId(logbookId)
if (!entryId) {
entryId = await createTodayEntry(logbookId)
}
await pruneEmptyTodayDuplicates(logbookId, entryId)
return entryId
}
/** One travel day per local calendar date; concurrent callers share one in-flight create. */
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
const id = logbookId.trim()
if (!id) throw new Error('Logbook id required')
await ensureLogbookKey(id)
const entryCount = await db.entries.where({ logbookId: id }).count()
if (entryCount === 0) {
return createTodayEntry(id)
let inflight = findOrCreateTodayEntryInflight.get(id)
if (!inflight) {
inflight = findOrCreateTodayEntryOnce(id)
findOrCreateTodayEntryInflight.set(id, inflight)
void inflight.finally(() => {
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
findOrCreateTodayEntryInflight.delete(id)
}
})
}
const existing = await findTodayEntryId(id)
if (existing) return existing
return createTodayEntry(id)
return inflight
}
export interface AppendQuickEventResult {
+1 -11
View File
@@ -258,14 +258,4 @@ export function getTrackColor(index: number): string {
return TRACK_COLORS[index % TRACK_COLORS.length]
}
export function formatNm(value: number): string {
return value.toFixed(2)
}
export function formatLiters(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
export function formatHours(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'
-3
View File
@@ -66,9 +66,6 @@ export async function saveEntryVoiceMemo(options: {
})
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_VOICE_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return voiceId
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest'
import {
cameraErrorKeyFromDomException,
isCameraApiSupported,
probeCameraAvailability
} from './cameraAvailability.js'
describe('cameraAvailability', () => {
it('detects missing camera API', () => {
const nav = { mediaDevices: undefined }
vi.stubGlobal('navigator', nav)
expect(isCameraApiSupported()).toBe(false)
vi.unstubAllGlobals()
})
it('returns none when no videoinput devices', async () => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(),
enumerateDevices: vi.fn().mockResolvedValue([
{ kind: 'audioinput', deviceId: 'a1', label: '', groupId: '' }
])
}
})
await expect(probeCameraAvailability()).resolves.toBe('none')
vi.unstubAllGlobals()
})
it('returns available when a videoinput exists', async () => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(),
enumerateDevices: vi.fn().mockResolvedValue([
{ kind: 'videoinput', deviceId: 'v1', label: '', groupId: '' }
])
}
})
await expect(probeCameraAvailability()).resolves.toBe('available')
vi.unstubAllGlobals()
})
it('maps NotFoundError to no-camera i18n key', () => {
expect(cameraErrorKeyFromDomException(new DOMException('', 'NotFoundError'))).toBe(
'logs.live_photo_no_camera'
)
})
})
+33
View File
@@ -0,0 +1,33 @@
export type CameraAvailability = 'available' | 'none' | 'unsupported'
/** Whether the browser exposes camera APIs at all. */
export function isCameraApiSupported(): boolean {
return typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
}
/** Best-effort probe for at least one video input device (no permission prompt). */
export async function probeCameraAvailability(): Promise<CameraAvailability> {
if (!isCameraApiSupported()) return 'unsupported'
if (!navigator.mediaDevices?.enumerateDevices) {
// Cannot list devices; defer to getUserMedia attempt in the capture UI.
return 'available'
}
try {
const devices = await navigator.mediaDevices.enumerateDevices()
if (devices.some((d) => d.kind === 'videoinput')) return 'available'
return 'none'
} catch {
return 'none'
}
}
export function cameraErrorKeyFromDomException(err: unknown): string {
const name = err instanceof DOMException ? err.name : ''
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
return 'logs.live_photo_no_camera'
}
if (name === 'NotAllowedError' || name === 'NotReadableError' || name === 'SecurityError') {
return 'logs.live_photo_camera_denied'
}
return 'logs.live_photo_camera_unavailable'
}
+5 -5
View File
@@ -21,8 +21,8 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_cast_off': 'Cast off',
'logs.live_moor': 'Moor',
'logs.live_sails': `Sails: ${opts?.sails ?? ''}`,
'logs.live_fix': 'Fix',
'logs.live_fix_coords': `Fix ${opts?.lat}, ${opts?.lng}`,
'logs.live_position': 'Position',
'logs.live_position_coords': `Position ${opts?.lat}, ${opts?.lng}`,
'logs.live_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
@@ -85,14 +85,14 @@ describe('formatEventSummary', () => {
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
})
it('formats fix with coordinates', () => {
it('formats position with coordinates', () => {
const event = normalizeLogEvent({
time: '09:00',
remarks: LIVE_EVENT_CODES.FIX,
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.323000',
gpsLng: '10.145000'
})
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000')
expect(formatEventSummary(event, t)).toBe('Position 54.323000, 10.145000')
})
it('formats pressure entry', () => {
+4 -3
View File
@@ -1,6 +1,7 @@
import type { TFunction } from 'i18next'
import type { LogEventPayload } from './logEntryPayload.js'
import {
isManualPositionEventCode,
LIVE_EVENT_CODES,
parseLiveCommentRemark,
parseLiveFuelRemark,
@@ -58,16 +59,16 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
const stw = parseLiveStwRemark(code)
if (stw) return t('logs.live_stw_entry', { speed: stw })
if (code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION) {
if (isManualPositionEventCode(code) || code === LIVE_EVENT_CODES.AUTO_POSITION) {
if (event.gpsLat && event.gpsLng) {
const label = code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position')
: t('logs.live_fix')
: t('logs.live_position')
return `${label} ${event.gpsLat}, ${event.gpsLng}`
}
return code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position')
: t('logs.live_fix')
: t('logs.live_position')
}
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
+1 -4
View File
@@ -7,7 +7,4 @@ export function computeFuelPerMotorHour(
return Number((fuelConsumptionL / motorHours).toFixed(2))
}
export function formatFuelPerMotorHour(value: number | null | undefined): string {
if (value == null) return '—'
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
export { formatFuelPerMotorHour } from './numberFormat.js'
+46 -3
View File
@@ -1,12 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
classifyGpsAccuracyMeters,
formatGpsAccuracyMeters,
geolocationErrorI18nKey,
GEOLOCATION_LIVE_INTRO_STORAGE_KEY,
getCurrentPosition,
getGeolocationErrorReason,
hasSeenGeolocationLiveIntro,
markGeolocationLiveIntroSeen,
normalizeGpsCoordinates,
parseGpsCoordinate,
queryGeolocationPermission
} from './geolocation.js'
describe('geolocation helpers', () => {
beforeEach(() => {
localStorage.removeItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY)
})
it('tracks Live-Log geolocation intro in localStorage', () => {
expect(hasSeenGeolocationLiveIntro()).toBe(false)
markGeolocationLiveIntroSeen()
expect(hasSeenGeolocationLiveIntro()).toBe(true)
})
it('parses coordinates with comma decimals', () => {
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
})
@@ -50,7 +67,7 @@ describe('geolocation helpers', () => {
geolocation: {
getCurrentPosition: (success: PositionCallback) => {
success({
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5, accuracy: 12 }
} as GeolocationPosition)
}
}
@@ -59,10 +76,36 @@ describe('geolocation helpers', () => {
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
lat: '59.910000',
lng: '10.750000',
speedKn: 4.9
speedKn: 4.9,
accuracyM: 12,
signalQuality: 'excellent'
})
})
it('formats GPS accuracy for display', () => {
expect(formatGpsAccuracyMeters(12.4)).toBe('12')
expect(formatGpsAccuracyMeters(87)).toBe('87')
expect(formatGpsAccuracyMeters(105)).toBe('110')
expect(formatGpsAccuracyMeters(247)).toBe('250')
})
it('classifies GPS accuracy into signal quality', () => {
expect(classifyGpsAccuracyMeters(8)).toBe('excellent')
expect(classifyGpsAccuracyMeters(30)).toBe('good')
expect(classifyGpsAccuracyMeters(80)).toBe('fair')
expect(classifyGpsAccuracyMeters(250)).toBe('poor')
expect(classifyGpsAccuracyMeters(null)).toBe('unknown')
})
it('maps GeolocationPositionError codes to reasons', () => {
expect(getGeolocationErrorReason({ code: 1 } as GeolocationPositionError)).toBe('permission_denied')
expect(getGeolocationErrorReason({ code: 2 } as GeolocationPositionError)).toBe('position_unavailable')
expect(getGeolocationErrorReason({ code: 3 } as GeolocationPositionError)).toBe('timeout')
expect(getGeolocationErrorReason(new Error('geolocation_timeout'))).toBe('timeout')
expect(getGeolocationErrorReason(new Error('geolocation_unavailable'))).toBe('unavailable')
expect(geolocationErrorI18nKey('permission_denied')).toBe('logs.gps_permission_denied')
})
it('reads permission state when supported', async () => {
vi.stubGlobal('navigator', {
geolocation: {},
+94 -8
View File
@@ -1,17 +1,80 @@
import {
formatAppCoordinate,
formatCanonicalCoordinate,
formatGpsAccuracyMeters,
parseAppDecimal
} from './numberFormat.js'
const MPS_TO_KNOTS = 1.9438444924406
/** Extra ms beyond the native timeout so hung browsers still reject. */
const TIMEOUT_GRACE_MS = 750
/** Estimated fix quality from browser accuracy (metres). Real satellite count is not exposed to web apps. */
export type GpsSignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'unknown'
export interface GeoCoordinates {
lat: string
lng: string
/** SOG from GPS when available (kn), otherwise null. */
speedKn: number | null
/** Estimated horizontal accuracy in metres, when reported by the browser. */
accuracyM: number | null
/** Derived signal quality indicator for UI hints. */
signalQuality: GpsSignalQuality
}
/** Classifies GPS fix quality from reported accuracy (lower metres = better). */
export function classifyGpsAccuracyMeters(accuracyM: number | null | undefined): GpsSignalQuality {
if (accuracyM == null || !Number.isFinite(accuracyM) || accuracyM < 0) return 'unknown'
if (accuracyM <= 15) return 'excellent'
if (accuracyM <= 40) return 'good'
if (accuracyM <= 100) return 'fair'
return 'poor'
}
export function gpsQualityI18nKey(quality: GpsSignalQuality): string {
return `logs.gps_quality_${quality}`
}
export type GeolocationPermissionState = PermissionState | 'unsupported'
export type GeolocationErrorReason =
| 'unavailable'
| 'timeout'
| 'permission_denied'
| 'position_unavailable'
| 'unknown'
/** Maps browser / wrapper errors to a stable reason for i18n. */
export function getGeolocationErrorReason(error: unknown): GeolocationErrorReason {
if (error instanceof Error) {
if (error.message === 'geolocation_unavailable') return 'unavailable'
if (error.message === 'geolocation_timeout') return 'timeout'
}
const code = (error as GeolocationPositionError | undefined)?.code
if (code === 1) return 'permission_denied'
if (code === 2) return 'position_unavailable'
if (code === 3) return 'timeout'
return 'unknown'
}
/** i18n key (full path, e.g. logs.gps_timeout) for a geolocation failure reason. */
export function geolocationErrorI18nKey(reason: GeolocationErrorReason): string {
switch (reason) {
case 'unavailable':
return 'logs.gps_unavailable'
case 'timeout':
return 'logs.gps_timeout'
case 'permission_denied':
return 'logs.gps_permission_denied'
case 'position_unavailable':
return 'logs.gps_position_unavailable'
default:
return 'logs.gps_failed'
}
}
export interface GetPositionOptions {
timeoutMs?: number
/** Manual fixes may use high accuracy; background auto-position should not. */
@@ -19,11 +82,10 @@ export interface GetPositionOptions {
maximumAge?: number
}
export { formatGpsAccuracyMeters }
export function parseGpsCoordinate(value: string): number | null {
const trimmed = value.trim()
if (!trimmed) return null
const n = parseFloat(trimmed.replace(',', '.'))
return Number.isFinite(n) ? n : null
return parseAppDecimal(value.trim())
}
/** Validates lat/lng and returns normalized strings for storage, or null. */
@@ -35,7 +97,26 @@ export function normalizeGpsCoordinates(
const lngN = parseGpsCoordinate(lng)
if (latN == null || lngN == null) return null
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
return { lat: formatCanonicalCoordinate(latN), lng: formatCanonicalCoordinate(lngN) }
}
/** localStorage: user has seen the Live-Log geolocation intro (allow or dismiss). */
export const GEOLOCATION_LIVE_INTRO_STORAGE_KEY = 'kdb_geolocation_live_intro_seen'
export function hasSeenGeolocationLiveIntro(): boolean {
try {
return localStorage.getItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY) === '1'
} catch {
return false
}
}
export function markGeolocationLiveIntroSeen(): void {
try {
localStorage.setItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY, '1')
} catch {
// Private mode / quota — non-fatal
}
}
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
@@ -65,10 +146,15 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy)
? pos.coords.accuracy
: null
return {
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
lat: formatAppCoordinate(pos.coords.latitude),
lng: formatAppCoordinate(pos.coords.longitude),
speedKn,
accuracyM,
signalQuality: classifyGpsAccuracyMeters(accuracyM)
}
}
+21 -14
View File
@@ -4,7 +4,7 @@ export const LIVE_EVENT_CODES = {
MOTOR_STOP: '__live:motor_stop',
CAST_OFF: '__live:cast_off',
MOOR: '__live:moor',
FIX: '__live:fix',
POSITION: '__live:position',
AUTO_POSITION: '__live:auto_position',
COURSE: '__live:course',
WIND: '__live:wind',
@@ -13,6 +13,9 @@ export const LIVE_EVENT_CODES = {
VISIBILITY: '__live:visibility'
} as const
/** @deprecated Stored in older log entries; still recognized when reading events. */
export const LEGACY_LIVE_POSITION_REMARK = '__live:fix'
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
export function liveSailsRemark(sails: string): string {
@@ -148,27 +151,31 @@ export function getLastAutoPositionMs(
return null
}
/** Max age of a logged GPS fix for OpenWeatherMap lookups in live log. */
/** Max age of a logged position for OpenWeatherMap lookups in live log. */
export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000
export type LiveLogPositionSource = 'fix' | 'auto_position'
export type LiveLogPositionSource = 'position' | 'auto_position'
export interface LiveLogPositionFix {
export interface LiveLogPosition {
lat: string
lng: string
loggedAtMs: number
source: LiveLogPositionSource
}
function isPositionEventCode(code: string): boolean {
return code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION
export function isManualPositionEventCode(code: string): boolean {
return code === LIVE_EVENT_CODES.POSITION || code === LEGACY_LIVE_POSITION_REMARK
}
/** Latest FIX or auto-position event with GPS coordinates (any age). */
export function getLatestPositionFix(
function isPositionEventCode(code: string): boolean {
return isManualPositionEventCode(code) || code === LIVE_EVENT_CODES.AUTO_POSITION
}
/** Latest manual or auto-position event with GPS coordinates (any age). */
export function getLatestLoggedPosition(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string
): LiveLogPositionFix | null {
): LiveLogPosition | null {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i]
const code = event.remarks.trim()
@@ -182,20 +189,20 @@ export function getLatestPositionFix(
lat,
lng,
loggedAtMs,
source: code === LIVE_EVENT_CODES.FIX ? 'fix' : 'auto_position'
source: isManualPositionEventCode(code) ? 'position' : 'auto_position'
}
}
return null
}
/** GPS fix for weather if logged within `maxAgeMs` (default 6 h). */
export function getLastPositionFixWithin(
/** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */
export function getLastLoggedPositionWithin(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string,
maxAgeMs: number = LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
nowMs: number = Date.now()
): LiveLogPositionFix | null {
const latest = getLatestPositionFix(events, entryDate)
): LiveLogPosition | null {
const latest = getLatestLoggedPosition(events, entryDate)
if (!latest) return null
if (nowMs - latest.loggedAtMs > maxAgeMs) return null
return latest
+37 -24
View File
@@ -1,54 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
getLastPositionFixWithin,
getLatestPositionFix,
getLastLoggedPositionWithin,
getLatestLoggedPosition,
LEGACY_LIVE_POSITION_REMARK,
LIVE_EVENT_CODES,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
} from './liveEventCodes.js'
const entryDate = '2026-06-01'
describe('live log position fix', () => {
it('returns latest fix with coordinates', () => {
describe('live log position', () => {
it('returns latest position with coordinates', () => {
const entryDate = '2026-06-01'
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
{ remarks: LIVE_EVENT_CODES.FIX, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
{ remarks: LIVE_EVENT_CODES.POSITION, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
{ remarks: LIVE_EVENT_CODES.POSITION, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
]
const fix = getLatestPositionFix(events, entryDate)
expect(fix?.lat).toBe('54.2')
expect(fix?.source).toBe('fix')
const position = getLatestLoggedPosition(events, entryDate)
expect(position?.lat).toBe('54.2')
expect(position?.source).toBe('position')
})
it('accepts auto-position with GPS', () => {
it('reads legacy __live:fix remarks', () => {
const entryDate = '2026-06-01'
const events = [
{ remarks: LEGACY_LIVE_POSITION_REMARK, time: '09:00', gpsLat: '54.5', gpsLng: '10.5' }
]
const position = getLatestLoggedPosition(events, entryDate)
expect(position?.lat).toBe('54.5')
expect(position?.source).toBe('position')
})
it('prefers auto-position source when applicable', () => {
const entryDate = '2026-06-01'
const events = [
{
remarks: LIVE_EVENT_CODES.AUTO_POSITION,
time: '14:00',
gpsLat: '55.0',
gpsLng: '11.0'
gpsLat: '54.3',
gpsLng: '10.4'
}
]
expect(getLatestPositionFix(events, entryDate)?.source).toBe('auto_position')
expect(getLatestLoggedPosition(events, entryDate)?.source).toBe('auto_position')
})
it('rejects fix older than max age for weather', () => {
const noon = new Date(`${entryDate}T12:00:00`).getTime()
it('rejects position older than max age for weather', () => {
const entryDate = '2026-06-01'
const noon = new Date('2026-06-01T12:00:00').getTime()
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
{ remarks: LIVE_EVENT_CODES.POSITION, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
]
expect(
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
getLastLoggedPositionWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
).toBeNull()
expect(getLatestPositionFix(events, entryDate)).not.toBeNull()
expect(getLatestLoggedPosition(events, entryDate)).not.toBeNull()
})
it('accepts fix within six hours', () => {
const noon = new Date(`${entryDate}T12:00:00`).getTime()
it('accepts position within six hours', () => {
const entryDate = '2026-06-01'
const noon = new Date('2026-06-01T12:00:00').getTime()
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
{ remarks: LIVE_EVENT_CODES.POSITION, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
]
expect(
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
getLastLoggedPositionWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
).not.toBeNull()
})
})
+9
View File
@@ -3,6 +3,7 @@ import {
buildLogEntryPayload,
hasUnsavedEventDraft,
isLogEventDraftEmpty,
localDateString,
normalizeLogEvent,
type LogEventPayload
} from './logEntryPayload.js'
@@ -13,6 +14,14 @@ const emptyDraft = (): LogEventPayload =>
const filledDraft = (): LogEventPayload =>
normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' })
describe('localDateString', () => {
it('uses local calendar date, not UTC', () => {
const date = new Date(2026, 5, 4, 1, 30, 0)
expect(localDateString(date)).toBe('2026-06-04')
expect(date.toISOString().substring(0, 10)).toBe('2026-06-03')
})
})
describe('logEntryPayload event drafts', () => {
it('treats time-only draft as empty', () => {
expect(isLogEventDraftEmpty(emptyDraft())).toBe(true)
+8
View File
@@ -24,6 +24,14 @@ export interface LogEventPayload {
remarks: string
}
/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */
export function localDateString(date: Date = new Date()): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** Local time as HH:MM (24-hour). */
export function currentLocalTimeHHMM(date: Date = new Date()): string {
const hours = String(date.getHours()).padStart(2, '0')
+1 -4
View File
@@ -56,10 +56,7 @@ export function emptyTankLevels(morning = 0): TankLevels {
return { morning, refilled: 0, evening: 0, consumption: 0 }
}
export function formatTankLiters(liters: number): string {
if (!Number.isFinite(liters) || liters <= 0) return '0'
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
}
export { formatTankLiters } from './numberFormat.js'
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
return Number(greywater?.level) || 0
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
formatAppCoordinate,
formatAppDecimal,
formatGpsAccuracyMeters,
formatTankLiters,
getNumberFormatSymbols,
parseAppDecimal,
resolveDeviceLocale
} from './numberFormat.js'
describe('numberFormat (device locale)', () => {
it('resolveDeviceLocale returns a non-empty BCP 47 tag', () => {
expect(resolveDeviceLocale().length).toBeGreaterThan(0)
})
it('reads decimal separator from Intl for de-DE and en-US', () => {
expect(getNumberFormatSymbols('de-DE').decimal).toBe(',')
expect(getNumberFormatSymbols('en-US').decimal).toBe('.')
})
it('formats decimals per locale without grouping', () => {
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('12,5')
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'en-US' })).toBe('12.5')
expect(formatAppDecimal(1234.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('1234,5')
})
it('parses device-locale decimals and tolerates the other separator', () => {
expect(parseAppDecimal('12,5', 'de-DE')).toBe(12.5)
expect(parseAppDecimal('12.5', 'en-US')).toBe(12.5)
expect(parseAppDecimal('12,5', 'en-US')).toBe(12.5)
expect(parseAppDecimal('1.234,5', 'de-DE')).toBe(1234.5)
expect(parseAppDecimal('', 'de-DE')).toBeNull()
})
it('formats coordinates for form display', () => {
expect(formatAppCoordinate(59.912345, 'de-DE')).toBe('59,912345')
expect(formatTankLiters(12.5)).toBe(formatAppDecimal(12.5, { minimumFractionDigits: 1, maximumFractionDigits: 1 }))
})
it('formats GPS accuracy with coarse step from 100 m', () => {
expect(formatGpsAccuracyMeters(12.4)).toBe(formatAppDecimal(12, { maximumFractionDigits: 0 }))
expect(formatGpsAccuracyMeters(105)).toBe(formatAppDecimal(110, { maximumFractionDigits: 0 }))
})
})
+139
View File
@@ -0,0 +1,139 @@
/**
* Number formatting and parsing follow the device (browser) locale from Intl,
* not the app UI language e.g. de-DE phone with English UI still uses comma decimals.
*/
export function resolveDeviceLocale(): string {
try {
const locale = new Intl.NumberFormat().resolvedOptions().locale
if (locale) return locale
} catch {
// ignore
}
if (typeof navigator !== 'undefined' && navigator.language) {
return navigator.language
}
return 'en-GB'
}
interface NumberSymbols {
decimal: string
group: string
}
const symbolCache = new Map<string, NumberSymbols>()
export function getNumberFormatSymbols(locale = resolveDeviceLocale()): NumberSymbols {
const cached = symbolCache.get(locale)
if (cached) return cached
const parts = new Intl.NumberFormat(locale).formatToParts(1234567.89)
const symbols: NumberSymbols = {
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
group: parts.find((p) => p.type === 'group')?.value ?? ''
}
symbolCache.set(locale, symbols)
return symbols
}
export interface FormatAppDecimalOptions {
minimumFractionDigits?: number
maximumFractionDigits?: number
locale?: string
}
/** User-visible decimal without thousands grouping. */
export function formatAppDecimal(value: number, options: FormatAppDecimalOptions = {}): string {
if (!Number.isFinite(value)) return ''
const locale = options.locale ?? resolveDeviceLocale()
const min = options.minimumFractionDigits ?? 0
const max = options.maximumFractionDigits ?? min
return new Intl.NumberFormat(locale, {
minimumFractionDigits: min,
maximumFractionDigits: max,
useGrouping: false
}).format(value)
}
/**
* Parses a decimal typed by the user for the device locale.
* Also accepts the other common separator for simple values (e.g. 12,5 on en-US).
*/
export function parseAppDecimal(input: string, locale = resolveDeviceLocale()): number | null {
const trimmed = input.trim()
if (!trimmed) return null
const { decimal, group } = getNumberFormatSymbols(locale)
const simpleComma = /^-?\d+,\d+$/.test(trimmed)
const simpleDot = /^-?\d+\.\d+$/.test(trimmed)
// Values without grouping: accept locale decimal and the other common separator.
if (simpleComma && decimal === ',') {
return Number(trimmed.replace(',', '.'))
}
if (simpleDot && decimal === '.') {
return Number(trimmed)
}
if (simpleComma && decimal === '.') {
return Number(trimmed.replace(',', '.'))
}
if (simpleDot && decimal === ',') {
return Number(trimmed)
}
let normalized = trimmed
if (group) {
normalized = normalized.split(group).join('')
}
if (decimal !== '.') {
normalized = normalized.replace(decimal, '.')
}
const n = Number(normalized)
return Number.isFinite(n) ? n : null
}
export function parseAppDecimalOrZero(input: string, locale?: string): number {
return parseAppDecimal(input, locale) ?? 0
}
/** Canonical storage/API coordinate string (always dot, 6 decimals). */
export function formatCanonicalCoordinate(value: number): string {
return value.toFixed(6)
}
/** Coordinate string for form fields (device decimal separator). */
export function formatAppCoordinate(value: number, locale?: string): string {
return formatAppDecimal(value, { minimumFractionDigits: 6, maximumFractionDigits: 6, locale })
}
export function formatNm(value: number): string {
return formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function formatLiters(value: number): string {
return Number.isInteger(value)
? formatAppDecimal(value, { maximumFractionDigits: 0 })
: formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}
export function formatHours(value: number): string {
return formatLiters(value)
}
export function formatTankLiters(liters: number): string {
if (!Number.isFinite(liters) || liters <= 0) return formatAppDecimal(0, { maximumFractionDigits: 0 })
return formatLiters(liters)
}
export function formatFuelPerMotorHour(value: number | null | undefined): string {
if (value == null) return '—'
return Number.isInteger(value)
? formatAppDecimal(value, { maximumFractionDigits: 0 })
: formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/** GPS accuracy for i18n (±{{accuracy}} m): 1 m below 100 m, 10 m from 100 m upward. */
export function formatGpsAccuracyMeters(accuracyM: number): string {
const rounded = accuracyM < 100 ? Math.round(accuracyM) : Math.round(accuracyM / 10) * 10
return formatAppDecimal(rounded, { maximumFractionDigits: 0 })
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { degreesToCardinal } from './courseAngle.js'
import { formatAppDecimal } from './numberFormat.js'
import { formatVisibilityMeters } from './weatherMetrics.js'
/** @deprecated Use formatVisibilityMeters */
@@ -33,7 +34,7 @@ export function mpsToBeaufort(mps: number): number {
export function formatWindStrengthBeaufort(mps: number): string {
const bft = mpsToBeaufort(mps)
return `${bft} Bft (${mps.toFixed(1)} m/s)`
return `${bft} Bft (${formatAppDecimal(mps, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} m/s)`
}
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
@@ -49,7 +50,7 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
let tempC: string | null = null
if (main?.temp != null && Number.isFinite(main.temp)) {
tempC = Number(main.temp).toFixed(1)
tempC = formatAppDecimal(main.temp, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}
let precipText: string | null = null
+7 -7
View File
@@ -1,4 +1,4 @@
import { formatTankLiters } from './logEntryTankLevels.js'
import { formatTankLiters, parseAppDecimal } from './numberFormat.js'
export interface VesselTankCapacities {
freshwaterCapacityL?: number
@@ -7,10 +7,10 @@ export interface VesselTankCapacities {
}
export function parseOptionalTankLiters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.')
const trimmed = input.trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) {
const parsed = parseAppDecimal(trimmed)
if (parsed == null || parsed < 0) {
throw new Error('invalid_tank_liters')
}
return parsed
@@ -24,10 +24,10 @@ function capacityFromStored(value: unknown): number | undefined {
if (value == null || value === '') return undefined
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
if (typeof value === 'string') {
const trimmed = value.trim().replace(',', '.')
const trimmed = value.trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (Number.isFinite(parsed) && parsed >= 0) return parsed
const parsed = parseAppDecimal(trimmed)
if (parsed != null && parsed >= 0) return parsed
}
return undefined
}
+10 -3
View File
@@ -1,4 +1,5 @@
import type { TrackWaypoint } from '../services/trackUpload.js'
import { formatAppDecimal } from './numberFormat.js'
const NM_IN_METERS = 1852
const MAX_PLAUSIBLE_KNOTS = 50
@@ -100,8 +101,14 @@ export function formatTrackStats(stats: TrackStats): {
speedAvgKn: string
} {
return {
distanceNm: stats.distanceNm.toFixed(2),
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '',
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : ''
distanceNm: formatAppDecimal(stats.distanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
speedMaxKn:
stats.speedMaxKn > 0
? formatAppDecimal(stats.speedMaxKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
: '',
speedAvgKn:
stats.speedAvgKn > 0
? formatAppDecimal(stats.speedAvgKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
: ''
}
}
+7 -4
View File
@@ -1,18 +1,21 @@
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
import { formatAppDecimal, parseAppDecimal } from './numberFormat.js'
import type { VesselData } from '../types/vessel.js'
export function metricInputFromStored(value: unknown): string {
if (value == null || value === '') return ''
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'number' && Number.isFinite(value)) {
return formatAppDecimal(value, { maximumFractionDigits: 6 })
}
if (typeof value === 'string') return value.trim()
return ''
}
export function parseOptionalMetricMeters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.')
const trimmed = input.trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) {
const parsed = parseAppDecimal(trimmed)
if (parsed == null || parsed < 0) {
throw new Error('invalid_metric')
}
return parsed
+5 -1
View File
@@ -1,3 +1,5 @@
import { formatAppDecimal } from './numberFormat.js'
/** Barometric pressure (hPa), typical marine range. */
export const PRESSURE_MIN_HPA = 960
export const PRESSURE_MAX_HPA = 1050
@@ -90,7 +92,9 @@ export function formatVisibilityMeters(meters: number): string {
if (meters >= 1000) {
const km = meters / 1000
const rounded = Math.round(km * 10) / 10
return Number.isInteger(rounded) ? `${rounded} km` : `${rounded.toFixed(1)} km`
return Number.isInteger(rounded)
? `${formatAppDecimal(rounded, { maximumFractionDigits: 0 })} km`
: `${formatAppDecimal(rounded, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`
}
return `${Math.round(meters)} m`
}
+5 -5
View File
@@ -37,9 +37,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
| Live Log Voice Uploaded | Sprachnotiz im Live-Journal gespeichert (`voiceAttachments.ts`, `analyticsContext`: `live_log`) | — |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| 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) |
@@ -47,6 +45,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
| Ko-fi Link Clicked | Klick auf Ko-fi-Unterstützen-Badge im App-Footer (`AppFooter.tsx`) | — |
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
@@ -85,7 +84,7 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
| `temp` | Temperatur |
| `precip` | Niederschlag |
| `sea_state` | Seegang |
| `fix` | GPS-Fix (manuell) |
| `position` | GPS-Position (manuell) |
| `comment` | Kommentar |
| `voice` | Sprachnotiz (Modal gespeichert) |
| `undo` | Letztes Ereignis rückgängig |
@@ -138,7 +137,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `position`, `course`, `motor_start`) → Photo Uploaded / Voice Memo Uploaded (Filter `context`: `live_log`)
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
@@ -151,7 +150,8 @@ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })