Compare commits
38 Commits
v0.1.0.110
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fae7b20f90 | |||
| 73e7613a1b | |||
| 6c8aa5af4c | |||
| 9554f4b66e | |||
| 5c77bbfdc3 | |||
| 979b572136 | |||
| f189317dfc | |||
| c54f834311 | |||
| 9d05005bb7 | |||
| 40c4874156 | |||
| 2de0636608 | |||
| 9e7c6f4397 | |||
| 6600ceafce | |||
| d7a497a4a2 | |||
| 4c04086d63 | |||
| 79ce42bec6 | |||
| 72c956162c | |||
| 3080b59dc8 | |||
| d054e42cc0 | |||
| d299fc1d93 | |||
| 6447e95d7d | |||
| 7ec5a1eccc | |||
| 4cf70a3431 | |||
| 6ed8b2a8e7 | |||
| bff00cf0a3 | |||
| 3cab735754 | |||
| 79762a0baf | |||
| 24160b6c5d | |||
| 1326045b25 | |||
| e014e997de | |||
| 1bc449687d | |||
| 35ee705510 | |||
| 9f76c200b0 | |||
| ac627a022f | |||
| 9ae24aa6fb | |||
| 91cf2674f7 | |||
| b7a9df6ae0 | |||
| 7bc3c25ba4 |
+1
-1
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react'
|
||||
|
||||
interface PersonSnapshot {
|
||||
name: string
|
||||
photo?: string | null
|
||||
role?: string
|
||||
}
|
||||
|
||||
interface CreatorAvatarProps {
|
||||
creatorId?: string
|
||||
crewSnapshotsById?: Record<string, PersonSnapshot>
|
||||
fallbackName?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
const colors = [
|
||||
'#2563eb', // blue
|
||||
'#059669', // emerald
|
||||
'#d97706', // amber
|
||||
'#dc2626', // red
|
||||
'#7c3aed', // violet
|
||||
'#db2777', // pink
|
||||
'#0891b2', // cyan
|
||||
'#4f46e5', // indigo
|
||||
'#0f766e', // teal
|
||||
'#9333ea', // purple
|
||||
]
|
||||
|
||||
function getAvatarColor(name: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const index = Math.abs(hash) % colors.length
|
||||
return colors[index]
|
||||
}
|
||||
|
||||
export default function CreatorAvatar({
|
||||
creatorId,
|
||||
crewSnapshotsById,
|
||||
fallbackName,
|
||||
size = 28
|
||||
}: CreatorAvatarProps) {
|
||||
let name = ''
|
||||
let photo: string | null = null
|
||||
let role = ''
|
||||
|
||||
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
|
||||
const snap = crewSnapshotsById[creatorId]
|
||||
name = snap.name || ''
|
||||
photo = snap.photo || null
|
||||
role = snap.role || ''
|
||||
}
|
||||
|
||||
// Fallback to active username if owner or no crew pool matches
|
||||
if (!name) {
|
||||
if (creatorId === 'skipper') {
|
||||
name = fallbackName || localStorage.getItem('active_username') || 'Skipper'
|
||||
role = 'skipper'
|
||||
} else if (fallbackName) {
|
||||
name = fallbackName
|
||||
} else if (creatorId) {
|
||||
// If creatorId is a username itself (fallback from LiveLogView)
|
||||
name = creatorId
|
||||
} else {
|
||||
name = '?'
|
||||
}
|
||||
}
|
||||
|
||||
const initial = name ? name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?' : '?'
|
||||
const bgColor = name === '?' ? '#64748b' : getAvatarColor(name)
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderRadius: '50%',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: `${Math.round(size * 0.45)}px`,
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
backgroundColor: bgColor,
|
||||
flexShrink: 0,
|
||||
verticalAlign: 'middle',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
|
||||
const roleText = role ? (role === 'skipper' ? 'Skipper' : 'Crew') : ''
|
||||
const tooltip = name + (roleText ? ` (${roleText})` : '')
|
||||
|
||||
if (photo) {
|
||||
return (
|
||||
<img
|
||||
src={photo}
|
||||
alt={name}
|
||||
title={tooltip}
|
||||
style={{
|
||||
...style,
|
||||
objectFit: 'cover',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style} title={tooltip} className="creator-avatar-fallback">
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Signal } from 'lucide-react'
|
||||
import {
|
||||
formatGpsAccuracyMeters,
|
||||
gpsQualityI18nKey,
|
||||
type GpsSignalQuality
|
||||
} from '../utils/geolocation.js'
|
||||
|
||||
const SIGNAL_BARS: Record<GpsSignalQuality, number> = {
|
||||
excellent: 4,
|
||||
good: 3,
|
||||
fair: 2,
|
||||
poor: 1,
|
||||
unknown: 0
|
||||
}
|
||||
|
||||
interface GpsSignalHintProps {
|
||||
quality: GpsSignalQuality
|
||||
accuracyM: number | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function GpsSignalHint({ quality, accuracyM, className = '' }: GpsSignalHintProps) {
|
||||
const { t } = useTranslation()
|
||||
const bars = SIGNAL_BARS[quality]
|
||||
const i18nParams = accuracyM != null ? { accuracy: formatGpsAccuracyMeters(accuracyM) } : undefined
|
||||
|
||||
return (
|
||||
<p
|
||||
className={`gps-signal-hint gps-signal-${quality} ${className}`.trim()}
|
||||
role="status"
|
||||
>
|
||||
<span className="gps-signal-hint-label">
|
||||
<Signal size={14} aria-hidden className="gps-signal-icon" />
|
||||
<span className="gps-signal-bars" aria-hidden>
|
||||
{[1, 2, 3, 4].map((level) => (
|
||||
<span
|
||||
key={level}
|
||||
className={`gps-signal-bar ${level <= bars ? 'is-active' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span>{t(gpsQualityI18nKey(quality), i18nParams)}</span>
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Camera, X } from 'lucide-react'
|
||||
import {
|
||||
cameraErrorKeyFromDomException,
|
||||
probeCameraAvailability
|
||||
} from '../utils/cameraAvailability.js'
|
||||
import {
|
||||
captureVideoFrame,
|
||||
preferNativeCameraPicker
|
||||
@@ -15,7 +19,7 @@ interface LiveCameraCaptureProps {
|
||||
onCapture: (blob: Blob) => void
|
||||
}
|
||||
|
||||
type Phase = 'live' | 'preview' | 'native'
|
||||
type Phase = 'checking' | 'live' | 'preview' | 'native'
|
||||
|
||||
export default function LiveCameraCapture({
|
||||
open,
|
||||
@@ -34,7 +38,7 @@ export default function LiveCameraCapture({
|
||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
|
||||
const [phase, setPhase] = useState<Phase>('checking')
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||
const [streamGeneration, setStreamGeneration] = useState(0)
|
||||
@@ -87,12 +91,37 @@ export default function LiveCameraCapture({
|
||||
clearPreview()
|
||||
setCameraError(null)
|
||||
setCapturing(false)
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
setPhase('checking')
|
||||
return
|
||||
}
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
|
||||
let cancelled = false
|
||||
clearPreview()
|
||||
}, [open, stopStream, clearPreview])
|
||||
setCameraError(null)
|
||||
setCapturing(false)
|
||||
setPhase('checking')
|
||||
|
||||
const probe = async () => {
|
||||
const availability = await probeCameraAvailability()
|
||||
if (cancelled) return
|
||||
|
||||
if (availability === 'unsupported') {
|
||||
setCameraError(t('logs.live_photo_camera_unavailable'))
|
||||
return
|
||||
}
|
||||
if (availability === 'none') {
|
||||
setCameraError(t('logs.live_photo_no_camera'))
|
||||
return
|
||||
}
|
||||
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
}
|
||||
|
||||
void probe()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, clearPreview, stopStream, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || phase !== 'live') {
|
||||
@@ -105,11 +134,6 @@ export default function LiveCameraCapture({
|
||||
const start = async () => {
|
||||
setCameraError(null)
|
||||
setReady(false)
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setCameraError(t('logs.live_photo_camera_unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
@@ -141,7 +165,7 @@ export default function LiveCameraCapture({
|
||||
} catch (err) {
|
||||
console.error('Camera access failed:', err)
|
||||
if (!cancelled) {
|
||||
setCameraError(t('logs.live_photo_camera_denied'))
|
||||
setCameraError(t(cameraErrorKeyFromDomException(err)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,7 +240,7 @@ export default function LiveCameraCapture({
|
||||
className="btn secondary live-camera-close"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label={t('logs.confirm_no')}
|
||||
aria-label={t('logs.live_cancel')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -243,6 +267,12 @@ export default function LiveCameraCapture({
|
||||
className="live-camera-preview live-camera-preview-still"
|
||||
/>
|
||||
</div>
|
||||
) : cameraError ? (
|
||||
<div className="live-camera-preview-wrap">
|
||||
<p className="live-camera-loading">{cameraError}</p>
|
||||
</div>
|
||||
) : phase === 'checking' ? (
|
||||
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||
) : phase === 'native' ? (
|
||||
<div className="live-camera-native-prompt">
|
||||
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
|
||||
@@ -256,7 +286,7 @@ export default function LiveCameraCapture({
|
||||
{t('logs.live_photo_open_camera_btn')}
|
||||
</button>
|
||||
</div>
|
||||
) : cameraError && !ready ? null : (
|
||||
) : phase === 'live' ? (
|
||||
<div className="live-camera-preview-wrap">
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -269,7 +299,7 @@ export default function LiveCameraCapture({
|
||||
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{onCaptionChange && (
|
||||
<div className="input-group live-camera-caption">
|
||||
@@ -287,7 +317,7 @@ export default function LiveCameraCapture({
|
||||
|
||||
<div className="live-log-modal-actions live-camera-actions">
|
||||
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
||||
{t('logs.confirm_no')}
|
||||
{t('logs.live_cancel')}
|
||||
</button>
|
||||
|
||||
{showPreview ? (
|
||||
|
||||
@@ -23,18 +23,19 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import {
|
||||
appendQuickEvent,
|
||||
appendQuickEvents,
|
||||
appendTankRefill,
|
||||
appendQuickEvent as apiAppendQuickEvent,
|
||||
appendQuickEvents as apiAppendQuickEvents,
|
||||
appendTankRefill as apiAppendTankRefill,
|
||||
findOrCreateTodayEntry,
|
||||
loadEntry,
|
||||
removeLastEvent
|
||||
} from '../services/quickEventLog.js'
|
||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||
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 +51,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 +77,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 +108,7 @@ type LiveModal =
|
||||
| 'water'
|
||||
| 'sog'
|
||||
| 'stw'
|
||||
| 'fix'
|
||||
| 'position'
|
||||
| 'photo'
|
||||
| 'voice'
|
||||
|
||||
@@ -142,18 +154,46 @@ 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')}`
|
||||
}
|
||||
|
||||
function findActiveCreatorId(
|
||||
activeUsername: string | null,
|
||||
crewSnapshotsById: Record<string, any>,
|
||||
selectedSkipperId: string | null
|
||||
): string {
|
||||
const username = (activeUsername || '').trim()
|
||||
if (username) {
|
||||
const matchEntry = Object.entries(crewSnapshotsById).find(
|
||||
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
|
||||
)
|
||||
if (matchEntry) {
|
||||
return matchEntry[0]
|
||||
}
|
||||
return username
|
||||
}
|
||||
return selectedSkipperId || 'skipper'
|
||||
}
|
||||
|
||||
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('')
|
||||
const [date, setDate] = useState('')
|
||||
const [events, setEvents] = useState<LogEventPayload[]>([])
|
||||
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
|
||||
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
|
||||
const [yachtSails, setYachtSails] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -167,10 +207,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('')
|
||||
@@ -190,6 +235,51 @@ export default function LiveLogView({
|
||||
dateRef.current = date
|
||||
busyRef.current = busy
|
||||
|
||||
const getActiveCreatorId = useCallback(() => {
|
||||
const activeUsername = localStorage.getItem('active_username')
|
||||
return findActiveCreatorId(activeUsername, crewSnapshotsById, selectedSkipperId)
|
||||
}, [crewSnapshotsById, selectedSkipperId])
|
||||
|
||||
const appendQuickEvent = useCallback((
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
partialEvent: Partial<LogEventPayload>,
|
||||
headerPatch?: { departure?: string; destination?: string }
|
||||
) => {
|
||||
return apiAppendQuickEvent(
|
||||
logbookId,
|
||||
entryId,
|
||||
{ creatorId: getActiveCreatorId(), ...partialEvent },
|
||||
headerPatch
|
||||
)
|
||||
}, [getActiveCreatorId])
|
||||
|
||||
const appendQuickEvents = useCallback((
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
partialEvents: Partial<LogEventPayload>[]
|
||||
) => {
|
||||
const creatorId = getActiveCreatorId()
|
||||
const mapped = partialEvents.map((p) => ({ creatorId, ...p }))
|
||||
return apiAppendQuickEvents(logbookId, entryId, mapped)
|
||||
}, [getActiveCreatorId])
|
||||
|
||||
const appendTankRefill = useCallback((
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
tank: 'fuel' | 'freshwater',
|
||||
addLiters: number,
|
||||
event: Partial<LogEventPayload>
|
||||
) => {
|
||||
return apiAppendTankRefill(
|
||||
logbookId,
|
||||
entryId,
|
||||
tank,
|
||||
addLiters,
|
||||
{ creatorId: getActiveCreatorId(), ...event }
|
||||
)
|
||||
}, [getActiveCreatorId])
|
||||
|
||||
const defaultSails = useMemo(
|
||||
() => (i18n.language === 'de'
|
||||
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
||||
@@ -202,8 +292,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)
|
||||
@@ -213,6 +303,8 @@ export default function LiveLogView({
|
||||
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
||||
setDate(String(loaded.data.date || ''))
|
||||
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
||||
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
||||
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
|
||||
}, [])
|
||||
|
||||
const refreshEntry = useCallback(async (id: string) => {
|
||||
@@ -310,6 +402,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 +500,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 +519,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 +595,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 +622,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 +649,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 +672,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 +684,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 +993,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 +1095,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 +1186,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} />
|
||||
@@ -1070,6 +1220,11 @@ export default function LiveLogView({
|
||||
return (
|
||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||
<time className="live-log-time">{event.time}</time>
|
||||
<CreatorAvatar
|
||||
creatorId={event.creatorId}
|
||||
crewSnapshotsById={crewSnapshotsById}
|
||||
size={24}
|
||||
/>
|
||||
<div className="live-log-summary-block">
|
||||
<span className="live-log-summary">{summary}</span>
|
||||
{voiceId && (
|
||||
@@ -1145,7 +1300,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 +1316,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 +1403,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 +1437,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 +1459,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 +1504,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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import EventRemarksCell from './EventRemarksCell.tsx'
|
||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||
@@ -67,6 +68,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 +104,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 +149,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>)
|
||||
@@ -154,6 +174,24 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
})
|
||||
}
|
||||
|
||||
function findActiveCreatorId(
|
||||
activeUsername: string | null,
|
||||
crewSnapshotsById: Record<string, any>,
|
||||
selectedSkipperId: string | null
|
||||
): string {
|
||||
const username = (activeUsername || '').trim()
|
||||
if (username) {
|
||||
const matchEntry = Object.entries(crewSnapshotsById).find(
|
||||
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
|
||||
)
|
||||
if (matchEntry) {
|
||||
return matchEntry[0]
|
||||
}
|
||||
return username
|
||||
}
|
||||
return selectedSkipperId || 'skipper'
|
||||
}
|
||||
|
||||
interface LogEntryEditorProps {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
@@ -263,6 +301,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 +354,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 +392,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]
|
||||
)
|
||||
|
||||
@@ -395,8 +437,17 @@ export default function LogEntryEditor({
|
||||
})
|
||||
}, [buildPayloadForSigning, signSkipper, signCrew])
|
||||
|
||||
const buildEventFromForm = (): LogEvent =>
|
||||
normalizeLogEvent({
|
||||
const buildEventFromForm = (): LogEvent => {
|
||||
let creatorId: string | undefined = undefined
|
||||
if (editingEventIndex !== null && events[editingEventIndex]) {
|
||||
creatorId = events[editingEventIndex].creatorId
|
||||
}
|
||||
if (!creatorId) {
|
||||
const activeUsername = localStorage.getItem('active_username')
|
||||
creatorId = findActiveCreatorId(activeUsername, entryCrew.crewSnapshotsById, entryCrew.selectedSkipperId)
|
||||
}
|
||||
|
||||
return normalizeLogEvent({
|
||||
time: evTime,
|
||||
mgk: evMgk,
|
||||
rwk: evRwk,
|
||||
@@ -413,8 +464,10 @@ export default function LogEntryEditor({
|
||||
distance: evDistance,
|
||||
gpsLat: evGpsLat,
|
||||
gpsLng: evGpsLng,
|
||||
remarks: evRemarks
|
||||
remarks: evRemarks,
|
||||
creatorId
|
||||
})
|
||||
}
|
||||
|
||||
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
|
||||
if (editingEventIndex !== null) {
|
||||
@@ -686,18 +739,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 +761,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 +775,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 +796,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 +1059,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 +1083,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 +1098,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 +1165,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 +1213,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
|
||||
@@ -1774,6 +1845,7 @@ export default function LogEntryEditor({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('logs.event_time')}</th>
|
||||
<th>{t('logs.event_creator')}</th>
|
||||
<th>{t('logs.event_mgk')}</th>
|
||||
<th>{t('logs.event_rwk')}</th>
|
||||
<th>{t('logs.event_wind_direction')}</th>
|
||||
@@ -1790,6 +1862,13 @@ export default function LogEntryEditor({
|
||||
{events.map((ev, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="font-mono">{ev.time}</td>
|
||||
<td style={{ textAlign: 'center', width: '40px', verticalAlign: 'middle' }}>
|
||||
<CreatorAvatar
|
||||
creatorId={ev.creatorId}
|
||||
crewSnapshotsById={entryCrew.crewSnapshotsById}
|
||||
size={24}
|
||||
/>
|
||||
</td>
|
||||
<td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
|
||||
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
|
||||
<td>{ev.windDirection || '—'}</td>
|
||||
@@ -1820,23 +1899,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 +2017,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 +2025,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 +2054,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 +2348,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 +2382,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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../services/statsAggregation.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||
import {
|
||||
loadLogbookEventSeries,
|
||||
type EventSeriesPoint,
|
||||
@@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="stats-propulsion-labels">
|
||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
|
||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span>
|
||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
|
||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
|
||||
{totals.unknownPropulsionNm > 0 && (
|
||||
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||
|
||||
interface TankLiterInputProps {
|
||||
id?: string
|
||||
@@ -13,10 +14,8 @@ interface TankLiterInputProps {
|
||||
}
|
||||
|
||||
function parseInputLiters(value: string): number {
|
||||
const trimmed = value.trim().replace(',', '.')
|
||||
if (!trimmed) return 0
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
if (!value.trim()) return 0
|
||||
return parseAppDecimalOrZero(value)
|
||||
}
|
||||
|
||||
export default function TankLiterInput({
|
||||
@@ -34,8 +33,7 @@ export default function TankLiterInput({
|
||||
const emitValue = useCallback(
|
||||
(liters: number) => {
|
||||
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
||||
const str =
|
||||
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
|
||||
const str = formatTankLiters(clamped)
|
||||
onChange(str)
|
||||
},
|
||||
[onChange, maxLiters, useSlider]
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function VesselPoolForm() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
className="btn-icon danger"
|
||||
onClick={() => void handleDelete(v.payloadId)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -338,6 +344,7 @@
|
||||
"carry_over_tanks_yes": "Tag over",
|
||||
"carry_over_tanks_no": "Start med 0",
|
||||
"event_title": "Kronologisk hændelseslog",
|
||||
"event_creator": "Indtastet af",
|
||||
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
||||
"event_time": "Tidspunkt på dagen",
|
||||
"event_mgk": "MgK-kursus",
|
||||
@@ -375,6 +382,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 +505,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -338,6 +344,7 @@
|
||||
"carry_over_tanks_yes": "Übernehmen",
|
||||
"carry_over_tanks_no": "Mit 0 starten",
|
||||
"event_title": "Chronologisches Ereignisprotokoll",
|
||||
"event_creator": "Eingetragen von",
|
||||
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
||||
"event_time": "Uhrzeit",
|
||||
"event_mgk": "MgK Kurs",
|
||||
@@ -375,6 +382,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 +495,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -338,6 +344,7 @@
|
||||
"carry_over_tanks_yes": "Carry over",
|
||||
"carry_over_tanks_no": "Start at 0",
|
||||
"event_title": "Chronological Event Logbook",
|
||||
"event_creator": "Entered by",
|
||||
"no_events": "No events logged for this travel day yet.",
|
||||
"event_time": "Time",
|
||||
"event_mgk": "MgK Course",
|
||||
@@ -375,6 +382,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 +495,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -338,6 +344,7 @@
|
||||
"carry_over_tanks_yes": "Ta over",
|
||||
"carry_over_tanks_no": "Begynn med 0",
|
||||
"event_title": "Kronologisk hendelseslogg",
|
||||
"event_creator": "Registrert av",
|
||||
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
||||
"event_time": "Tid på døgnet",
|
||||
"event_mgk": "MgK-kurs",
|
||||
@@ -375,6 +382,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 +505,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -338,6 +344,7 @@
|
||||
"carry_over_tanks_yes": "Ta över",
|
||||
"carry_over_tanks_no": "Börja med 0",
|
||||
"event_title": "Kronologisk händelselogg",
|
||||
"event_creator": "Registrerad av",
|
||||
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
||||
"event_time": "Tid på dygnet",
|
||||
"event_mgk": "MgK-kurs",
|
||||
@@ -375,6 +382,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 +505,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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
||||
'Skipper Signature', 'Crew Signature',
|
||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||
'Event Time', 'MgK Course', 'RwK Course',
|
||||
'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
|
||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||
'Latitude', 'Longitude', 'Remarks',
|
||||
@@ -122,6 +122,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const greywaterLevel = entry.greywater?.level ?? '';
|
||||
const aiSummary = entry.aiSummary ?? '';
|
||||
|
||||
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||
const eventsList = entry.events || [];
|
||||
if (eventsList.length === 0) {
|
||||
// Create one row even if there are no events for the day
|
||||
@@ -129,7 +130,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
dateVal, travelDay, dep, dest, aiSummary,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
'', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '',
|
||||
@@ -142,11 +143,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
// Sort events chronologically by time
|
||||
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||
for (const ev of sortedEvents) {
|
||||
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
|
||||
let creatorName = '';
|
||||
if (creatorSnap) {
|
||||
creatorName = creatorSnap.name || '';
|
||||
} else if (ev.creatorId === 'skipper') {
|
||||
creatorName = 'Skipper';
|
||||
} else if (ev.creatorId) {
|
||||
creatorName = ev.creatorId;
|
||||
}
|
||||
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest, aiSummary,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||
ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
|
||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||
ev.visibility || '',
|
||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string {
|
||||
}
|
||||
|
||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
let entry: any = null;
|
||||
|
||||
if (preloadedData) {
|
||||
const yacht = preloadedData.yacht || {};
|
||||
yachtName = yacht.name || '';
|
||||
owner = yacht.owner || '';
|
||||
homePort = yacht.port || '';
|
||||
registration = yacht.registrationNumber || yacht.registration || '';
|
||||
callsign = yacht.callSign || '';
|
||||
@@ -35,6 +36,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
const yacht = await resolveVesselForLogbook(logbookId)
|
||||
if (yacht) {
|
||||
yachtName = yacht.name || ''
|
||||
owner = yacht.owner || ''
|
||||
homePort = yacht.homePort || ''
|
||||
registration = yacht.registrationNumber || ''
|
||||
callsign = yacht.callSign || ''
|
||||
@@ -74,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.setFontSize(8.5);
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
|
||||
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21);
|
||||
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21);
|
||||
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21);
|
||||
doc.text(`ATIS: ${atis || '—'}`, 210, 21);
|
||||
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
|
||||
doc.text(`Eigner: ${owner || '—'}`, 55, 21);
|
||||
doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
|
||||
doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
|
||||
doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
|
||||
doc.text(`ATIS: ${atis || '—'}`, 230, 21);
|
||||
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
|
||||
|
||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
|
||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
|
||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
|
||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
|
||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
|
||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
|
||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
|
||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24);
|
||||
|
||||
// Format Crew names with initials
|
||||
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {}
|
||||
const crewList: string[] = []
|
||||
|
||||
if (entry.selectedSkipperId && crewSnapshots[entry.selectedSkipperId]) {
|
||||
const name = crewSnapshots[entry.selectedSkipperId].name || 'Skipper'
|
||||
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || 'S'
|
||||
crewList.push(`${name} [${initial}] (Skipper)`)
|
||||
} else if (crewSnapshots['skipper']) {
|
||||
const name = crewSnapshots['skipper'].name || 'Skipper'
|
||||
crewList.push(`${name} [S] (Skipper)`)
|
||||
}
|
||||
|
||||
if (Array.isArray(entry.selectedCrewIds)) {
|
||||
for (const crewId of entry.selectedCrewIds) {
|
||||
const snap = crewSnapshots[crewId]
|
||||
if (snap) {
|
||||
const name = snap.name || ''
|
||||
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?'
|
||||
crewList.push(`${name} [${initial}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const crewText = crewList.length > 0 ? `Besatzung (Crew): ${crewList.join(', ')}` : ''
|
||||
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
if (entry.trackDistanceNm) {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(
|
||||
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
||||
10,
|
||||
27
|
||||
);
|
||||
if (crewText) {
|
||||
doc.text(crewText, 140, 27);
|
||||
}
|
||||
} else if (crewText) {
|
||||
doc.text(crewText, 10, 27);
|
||||
}
|
||||
|
||||
// Divider line
|
||||
@@ -175,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.text(gps, writeX + 1, y + 4.2);
|
||||
writeX += colWidths[11];
|
||||
|
||||
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||
let initial = '';
|
||||
if (ev.creatorId) {
|
||||
const snap = crewSnapshots[ev.creatorId];
|
||||
let name = '';
|
||||
if (snap) {
|
||||
name = snap.name || '';
|
||||
} else if (ev.creatorId === 'skipper') {
|
||||
name = 'Skipper';
|
||||
} else {
|
||||
name = ev.creatorId;
|
||||
}
|
||||
if (name) {
|
||||
initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Clip remarks to fit within the 94mm bounds
|
||||
const remarks = ev.remarks || '';
|
||||
let remarks = ev.remarks || '';
|
||||
if (initial) {
|
||||
remarks = `[${initial}] ${remarks}`;
|
||||
}
|
||||
const maxChars = 65;
|
||||
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
|
||||
doc.text(clippedRemarks, writeX + 1, y + 4.2);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizeLogEvent,
|
||||
sortLogEventsByTime,
|
||||
currentLocalTimeHHMM,
|
||||
localDateString,
|
||||
type LogEventPayload
|
||||
} from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
@@ -96,6 +97,14 @@ function buildEncryptedPayload(
|
||||
consumption: fuel.consumption ?? 0
|
||||
}
|
||||
|
||||
const entryCrew = data.selectedSkipperId
|
||||
? {
|
||||
selectedSkipperId: String(data.selectedSkipperId),
|
||||
selectedCrewIds: Array.isArray(data.selectedCrewIds) ? data.selectedCrewIds.map(String) : [],
|
||||
crewSnapshotsById: (data.crewSnapshotsById as Record<string, any>) || {}
|
||||
}
|
||||
: undefined
|
||||
|
||||
const payload = buildLogEntryPayload({
|
||||
date: String(data.date || ''),
|
||||
dayOfTravel: String(data.dayOfTravel || ''),
|
||||
@@ -120,7 +129,8 @@ function buildEncryptedPayload(
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? parseFloat(String(motorHoursRaw))
|
||||
: undefined,
|
||||
events: options.events
|
||||
events: options.events,
|
||||
entryCrew
|
||||
})
|
||||
|
||||
const clear = options.clearSignatures
|
||||
@@ -151,18 +161,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 +263,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 +305,36 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
return localId
|
||||
}
|
||||
|
||||
const findOrCreateTodayEntryInflight = new Map<string, Promise<string>>()
|
||||
|
||||
async function findOrCreateTodayEntryOnce(logbookId: string): Promise<string> {
|
||||
await ensureLogbookKey(logbookId)
|
||||
|
||||
let entryId = await findTodayEntryId(logbookId)
|
||||
if (!entryId) {
|
||||
entryId = await createTodayEntry(logbookId)
|
||||
}
|
||||
|
||||
await pruneEmptyTodayDuplicates(logbookId, entryId)
|
||||
return entryId
|
||||
}
|
||||
|
||||
/** One travel day per local calendar date; concurrent callers share one in-flight create. */
|
||||
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
|
||||
const id = logbookId.trim()
|
||||
if (!id) throw new Error('Logbook id required')
|
||||
|
||||
await ensureLogbookKey(id)
|
||||
|
||||
const entryCount = await db.entries.where({ logbookId: id }).count()
|
||||
if (entryCount === 0) {
|
||||
return createTodayEntry(id)
|
||||
let inflight = findOrCreateTodayEntryInflight.get(id)
|
||||
if (!inflight) {
|
||||
inflight = findOrCreateTodayEntryOnce(id)
|
||||
findOrCreateTodayEntryInflight.set(id, inflight)
|
||||
void inflight.finally(() => {
|
||||
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
|
||||
findOrCreateTodayEntryInflight.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await findTodayEntryId(id)
|
||||
if (existing) return existing
|
||||
return createTodayEntry(id)
|
||||
return inflight
|
||||
}
|
||||
|
||||
export interface AppendQuickEventResult {
|
||||
|
||||
@@ -258,14 +258,4 @@ export function getTrackColor(index: number): string {
|
||||
return TRACK_COLORS[index % TRACK_COLORS.length]
|
||||
}
|
||||
|
||||
export function formatNm(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
export function formatLiters(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||
}
|
||||
|
||||
export function formatHours(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||
}
|
||||
export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,7 +7,4 @@ export function computeFuelPerMotorHour(
|
||||
return Number((fuelConsumptionL / motorHours).toFixed(2))
|
||||
}
|
||||
|
||||
export function formatFuelPerMotorHour(value: number | null | undefined): string {
|
||||
if (value == null) return '—'
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||
}
|
||||
export { formatFuelPerMotorHour } from './numberFormat.js'
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
classifyGpsAccuracyMeters,
|
||||
formatGpsAccuracyMeters,
|
||||
geolocationErrorI18nKey,
|
||||
GEOLOCATION_LIVE_INTRO_STORAGE_KEY,
|
||||
getCurrentPosition,
|
||||
getGeolocationErrorReason,
|
||||
hasSeenGeolocationLiveIntro,
|
||||
markGeolocationLiveIntroSeen,
|
||||
normalizeGpsCoordinates,
|
||||
parseGpsCoordinate,
|
||||
queryGeolocationPermission
|
||||
} from './geolocation.js'
|
||||
|
||||
describe('geolocation helpers', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY)
|
||||
})
|
||||
|
||||
it('tracks Live-Log geolocation intro in localStorage', () => {
|
||||
expect(hasSeenGeolocationLiveIntro()).toBe(false)
|
||||
markGeolocationLiveIntroSeen()
|
||||
expect(hasSeenGeolocationLiveIntro()).toBe(true)
|
||||
})
|
||||
|
||||
it('parses coordinates with comma decimals', () => {
|
||||
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
|
||||
})
|
||||
@@ -50,7 +67,7 @@ describe('geolocation helpers', () => {
|
||||
geolocation: {
|
||||
getCurrentPosition: (success: PositionCallback) => {
|
||||
success({
|
||||
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
|
||||
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5, accuracy: 12 }
|
||||
} as GeolocationPosition)
|
||||
}
|
||||
}
|
||||
@@ -59,10 +76,36 @@ describe('geolocation helpers', () => {
|
||||
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
|
||||
lat: '59.910000',
|
||||
lng: '10.750000',
|
||||
speedKn: 4.9
|
||||
speedKn: 4.9,
|
||||
accuracyM: 12,
|
||||
signalQuality: 'excellent'
|
||||
})
|
||||
})
|
||||
|
||||
it('formats GPS accuracy for display', () => {
|
||||
expect(formatGpsAccuracyMeters(12.4)).toBe('12')
|
||||
expect(formatGpsAccuracyMeters(87)).toBe('87')
|
||||
expect(formatGpsAccuracyMeters(105)).toBe('110')
|
||||
expect(formatGpsAccuracyMeters(247)).toBe('250')
|
||||
})
|
||||
|
||||
it('classifies GPS accuracy into signal quality', () => {
|
||||
expect(classifyGpsAccuracyMeters(8)).toBe('excellent')
|
||||
expect(classifyGpsAccuracyMeters(30)).toBe('good')
|
||||
expect(classifyGpsAccuracyMeters(80)).toBe('fair')
|
||||
expect(classifyGpsAccuracyMeters(250)).toBe('poor')
|
||||
expect(classifyGpsAccuracyMeters(null)).toBe('unknown')
|
||||
})
|
||||
|
||||
it('maps GeolocationPositionError codes to reasons', () => {
|
||||
expect(getGeolocationErrorReason({ code: 1 } as GeolocationPositionError)).toBe('permission_denied')
|
||||
expect(getGeolocationErrorReason({ code: 2 } as GeolocationPositionError)).toBe('position_unavailable')
|
||||
expect(getGeolocationErrorReason({ code: 3 } as GeolocationPositionError)).toBe('timeout')
|
||||
expect(getGeolocationErrorReason(new Error('geolocation_timeout'))).toBe('timeout')
|
||||
expect(getGeolocationErrorReason(new Error('geolocation_unavailable'))).toBe('unavailable')
|
||||
expect(geolocationErrorI18nKey('permission_denied')).toBe('logs.gps_permission_denied')
|
||||
})
|
||||
|
||||
it('reads permission state when supported', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
geolocation: {},
|
||||
|
||||
@@ -1,17 +1,80 @@
|
||||
import {
|
||||
formatAppCoordinate,
|
||||
formatCanonicalCoordinate,
|
||||
formatGpsAccuracyMeters,
|
||||
parseAppDecimal
|
||||
} from './numberFormat.js'
|
||||
|
||||
const MPS_TO_KNOTS = 1.9438444924406
|
||||
|
||||
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
||||
const TIMEOUT_GRACE_MS = 750
|
||||
|
||||
/** Estimated fix quality from browser accuracy (metres). Real satellite count is not exposed to web apps. */
|
||||
export type GpsSignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'unknown'
|
||||
|
||||
export interface GeoCoordinates {
|
||||
lat: string
|
||||
lng: string
|
||||
/** SOG from GPS when available (kn), otherwise null. */
|
||||
speedKn: number | null
|
||||
/** Estimated horizontal accuracy in metres, when reported by the browser. */
|
||||
accuracyM: number | null
|
||||
/** Derived signal quality indicator for UI hints. */
|
||||
signalQuality: GpsSignalQuality
|
||||
}
|
||||
|
||||
/** Classifies GPS fix quality from reported accuracy (lower metres = better). */
|
||||
export function classifyGpsAccuracyMeters(accuracyM: number | null | undefined): GpsSignalQuality {
|
||||
if (accuracyM == null || !Number.isFinite(accuracyM) || accuracyM < 0) return 'unknown'
|
||||
if (accuracyM <= 15) return 'excellent'
|
||||
if (accuracyM <= 40) return 'good'
|
||||
if (accuracyM <= 100) return 'fair'
|
||||
return 'poor'
|
||||
}
|
||||
|
||||
export function gpsQualityI18nKey(quality: GpsSignalQuality): string {
|
||||
return `logs.gps_quality_${quality}`
|
||||
}
|
||||
|
||||
export type GeolocationPermissionState = PermissionState | 'unsupported'
|
||||
|
||||
export type GeolocationErrorReason =
|
||||
| 'unavailable'
|
||||
| 'timeout'
|
||||
| 'permission_denied'
|
||||
| 'position_unavailable'
|
||||
| 'unknown'
|
||||
|
||||
/** Maps browser / wrapper errors to a stable reason for i18n. */
|
||||
export function getGeolocationErrorReason(error: unknown): GeolocationErrorReason {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'geolocation_unavailable') return 'unavailable'
|
||||
if (error.message === 'geolocation_timeout') return 'timeout'
|
||||
}
|
||||
const code = (error as GeolocationPositionError | undefined)?.code
|
||||
if (code === 1) return 'permission_denied'
|
||||
if (code === 2) return 'position_unavailable'
|
||||
if (code === 3) return 'timeout'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** i18n key (full path, e.g. logs.gps_timeout) for a geolocation failure reason. */
|
||||
export function geolocationErrorI18nKey(reason: GeolocationErrorReason): string {
|
||||
switch (reason) {
|
||||
case 'unavailable':
|
||||
return 'logs.gps_unavailable'
|
||||
case 'timeout':
|
||||
return 'logs.gps_timeout'
|
||||
case 'permission_denied':
|
||||
return 'logs.gps_permission_denied'
|
||||
case 'position_unavailable':
|
||||
return 'logs.gps_position_unavailable'
|
||||
default:
|
||||
return 'logs.gps_failed'
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetPositionOptions {
|
||||
timeoutMs?: number
|
||||
/** Manual fixes may use high accuracy; background auto-position should not. */
|
||||
@@ -19,11 +82,10 @@ export interface GetPositionOptions {
|
||||
maximumAge?: number
|
||||
}
|
||||
|
||||
export { formatGpsAccuracyMeters }
|
||||
|
||||
export function parseGpsCoordinate(value: string): number | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const n = parseFloat(trimmed.replace(',', '.'))
|
||||
return Number.isFinite(n) ? n : null
|
||||
return parseAppDecimal(value.trim())
|
||||
}
|
||||
|
||||
/** Validates lat/lng and returns normalized strings for storage, or null. */
|
||||
@@ -35,7 +97,26 @@ export function normalizeGpsCoordinates(
|
||||
const lngN = parseGpsCoordinate(lng)
|
||||
if (latN == null || lngN == null) return null
|
||||
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
|
||||
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
|
||||
return { lat: formatCanonicalCoordinate(latN), lng: formatCanonicalCoordinate(lngN) }
|
||||
}
|
||||
|
||||
/** localStorage: user has seen the Live-Log geolocation intro (allow or dismiss). */
|
||||
export const GEOLOCATION_LIVE_INTRO_STORAGE_KEY = 'kdb_geolocation_live_intro_seen'
|
||||
|
||||
export function hasSeenGeolocationLiveIntro(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function markGeolocationLiveIntroSeen(): void {
|
||||
try {
|
||||
localStorage.setItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY, '1')
|
||||
} catch {
|
||||
// Private mode / quota — non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
|
||||
@@ -65,10 +146,15 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
|
||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||
: null
|
||||
const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy)
|
||||
? pos.coords.accuracy
|
||||
: null
|
||||
return {
|
||||
lat: pos.coords.latitude.toFixed(6),
|
||||
lng: pos.coords.longitude.toFixed(6),
|
||||
speedKn
|
||||
lat: formatAppCoordinate(pos.coords.latitude),
|
||||
lng: formatAppCoordinate(pos.coords.longitude),
|
||||
speedKn,
|
||||
accuracyM,
|
||||
signalQuality: classifyGpsAccuracyMeters(accuracyM)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export const LIVE_EVENT_CODES = {
|
||||
MOTOR_STOP: '__live:motor_stop',
|
||||
CAST_OFF: '__live:cast_off',
|
||||
MOOR: '__live:moor',
|
||||
FIX: '__live:fix',
|
||||
POSITION: '__live:position',
|
||||
AUTO_POSITION: '__live:auto_position',
|
||||
COURSE: '__live:course',
|
||||
WIND: '__live:wind',
|
||||
@@ -13,6 +13,9 @@ export const LIVE_EVENT_CODES = {
|
||||
VISIBILITY: '__live:visibility'
|
||||
} as const
|
||||
|
||||
/** @deprecated Stored in older log entries; still recognized when reading events. */
|
||||
export const LEGACY_LIVE_POSITION_REMARK = '__live:fix'
|
||||
|
||||
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
|
||||
|
||||
export function liveSailsRemark(sails: string): string {
|
||||
@@ -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
|
||||
|
||||
@@ -1,54 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getLastPositionFixWithin,
|
||||
getLatestPositionFix,
|
||||
getLastLoggedPositionWithin,
|
||||
getLatestLoggedPosition,
|
||||
LEGACY_LIVE_POSITION_REMARK,
|
||||
LIVE_EVENT_CODES,
|
||||
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
|
||||
} from './liveEventCodes.js'
|
||||
|
||||
const entryDate = '2026-06-01'
|
||||
|
||||
describe('live log position fix', () => {
|
||||
it('returns latest fix with coordinates', () => {
|
||||
describe('live log position', () => {
|
||||
it('returns latest position with coordinates', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
|
||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
|
||||
{ remarks: LIVE_EVENT_CODES.POSITION, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
|
||||
{ remarks: LIVE_EVENT_CODES.POSITION, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
|
||||
]
|
||||
const fix = getLatestPositionFix(events, entryDate)
|
||||
expect(fix?.lat).toBe('54.2')
|
||||
expect(fix?.source).toBe('fix')
|
||||
const position = getLatestLoggedPosition(events, entryDate)
|
||||
expect(position?.lat).toBe('54.2')
|
||||
expect(position?.source).toBe('position')
|
||||
})
|
||||
|
||||
it('accepts auto-position with GPS', () => {
|
||||
it('reads legacy __live:fix remarks', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const events = [
|
||||
{ remarks: LEGACY_LIVE_POSITION_REMARK, time: '09:00', gpsLat: '54.5', gpsLng: '10.5' }
|
||||
]
|
||||
const position = getLatestLoggedPosition(events, entryDate)
|
||||
expect(position?.lat).toBe('54.5')
|
||||
expect(position?.source).toBe('position')
|
||||
})
|
||||
|
||||
it('prefers auto-position source when applicable', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const events = [
|
||||
{
|
||||
remarks: LIVE_EVENT_CODES.AUTO_POSITION,
|
||||
time: '14:00',
|
||||
gpsLat: '55.0',
|
||||
gpsLng: '11.0'
|
||||
gpsLat: '54.3',
|
||||
gpsLng: '10.4'
|
||||
}
|
||||
]
|
||||
expect(getLatestPositionFix(events, entryDate)?.source).toBe('auto_position')
|
||||
expect(getLatestLoggedPosition(events, entryDate)?.source).toBe('auto_position')
|
||||
})
|
||||
|
||||
it('rejects fix older than max age for weather', () => {
|
||||
const noon = new Date(`${entryDate}T12:00:00`).getTime()
|
||||
it('rejects position older than max age for weather', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const noon = new Date('2026-06-01T12:00:00').getTime()
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
|
||||
{ remarks: LIVE_EVENT_CODES.POSITION, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
|
||||
]
|
||||
expect(
|
||||
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
||||
getLastLoggedPositionWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
||||
).toBeNull()
|
||||
expect(getLatestPositionFix(events, entryDate)).not.toBeNull()
|
||||
expect(getLatestLoggedPosition(events, entryDate)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('accepts fix within six hours', () => {
|
||||
const noon = new Date(`${entryDate}T12:00:00`).getTime()
|
||||
it('accepts position within six hours', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const noon = new Date('2026-06-01T12:00:00').getTime()
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
|
||||
{ remarks: LIVE_EVENT_CODES.POSITION, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
|
||||
]
|
||||
expect(
|
||||
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
||||
getLastLoggedPositionWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
||||
).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildLogEntryPayload,
|
||||
hasUnsavedEventDraft,
|
||||
isLogEventDraftEmpty,
|
||||
localDateString,
|
||||
normalizeLogEvent,
|
||||
type LogEventPayload
|
||||
} from './logEntryPayload.js'
|
||||
@@ -13,6 +14,14 @@ const emptyDraft = (): LogEventPayload =>
|
||||
const filledDraft = (): LogEventPayload =>
|
||||
normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' })
|
||||
|
||||
describe('localDateString', () => {
|
||||
it('uses local calendar date, not UTC', () => {
|
||||
const date = new Date(2026, 5, 4, 1, 30, 0)
|
||||
expect(localDateString(date)).toBe('2026-06-04')
|
||||
expect(date.toISOString().substring(0, 10)).toBe('2026-06-03')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logEntryPayload event drafts', () => {
|
||||
it('treats time-only draft as empty', () => {
|
||||
expect(isLogEventDraftEmpty(emptyDraft())).toBe(true)
|
||||
|
||||
@@ -22,6 +22,15 @@ export interface LogEventPayload {
|
||||
gpsLat: string
|
||||
gpsLng: string
|
||||
remarks: string
|
||||
creatorId?: 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). */
|
||||
@@ -77,7 +86,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
|
||||
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||
'gpsLat', 'gpsLng', 'remarks'
|
||||
'gpsLat', 'gpsLng', 'remarks', 'creatorId'
|
||||
]
|
||||
|
||||
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
|
||||
@@ -101,10 +110,11 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
|
||||
distance: '',
|
||||
gpsLat: '',
|
||||
gpsLng: '',
|
||||
remarks: ''
|
||||
remarks: '',
|
||||
creatorId: e.creatorId ? String(e.creatorId).trim() : undefined
|
||||
}
|
||||
for (const key of LOG_EVENT_FIELDS) {
|
||||
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue
|
||||
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection' || key === 'creatorId') continue
|
||||
normalized[key] = String(e[key] ?? '').trim()
|
||||
}
|
||||
return normalized
|
||||
@@ -114,7 +124,7 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
|
||||
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
||||
}
|
||||
|
||||
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time')
|
||||
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time' && key !== 'creatorId')
|
||||
|
||||
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
|
||||
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
|
||||
|
||||
@@ -56,10 +56,7 @@ export function emptyTankLevels(morning = 0): TankLevels {
|
||||
return { morning, refilled: 0, evening: 0, consumption: 0 }
|
||||
}
|
||||
|
||||
export function formatTankLiters(liters: number): string {
|
||||
if (!Number.isFinite(liters) || liters <= 0) return '0'
|
||||
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
||||
}
|
||||
export { formatTankLiters } from './numberFormat.js'
|
||||
|
||||
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
|
||||
return Number(greywater?.level) || 0
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatAppCoordinate,
|
||||
formatAppDecimal,
|
||||
formatGpsAccuracyMeters,
|
||||
formatTankLiters,
|
||||
getNumberFormatSymbols,
|
||||
parseAppDecimal,
|
||||
resolveDeviceLocale
|
||||
} from './numberFormat.js'
|
||||
|
||||
describe('numberFormat (device locale)', () => {
|
||||
it('resolveDeviceLocale returns a non-empty BCP 47 tag', () => {
|
||||
expect(resolveDeviceLocale().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reads decimal separator from Intl for de-DE and en-US', () => {
|
||||
expect(getNumberFormatSymbols('de-DE').decimal).toBe(',')
|
||||
expect(getNumberFormatSymbols('en-US').decimal).toBe('.')
|
||||
})
|
||||
|
||||
it('formats decimals per locale without grouping', () => {
|
||||
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('12,5')
|
||||
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'en-US' })).toBe('12.5')
|
||||
expect(formatAppDecimal(1234.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('1234,5')
|
||||
})
|
||||
|
||||
it('parses device-locale decimals and tolerates the other separator', () => {
|
||||
expect(parseAppDecimal('12,5', 'de-DE')).toBe(12.5)
|
||||
expect(parseAppDecimal('12.5', 'en-US')).toBe(12.5)
|
||||
expect(parseAppDecimal('12,5', 'en-US')).toBe(12.5)
|
||||
expect(parseAppDecimal('1.234,5', 'de-DE')).toBe(1234.5)
|
||||
expect(parseAppDecimal('', 'de-DE')).toBeNull()
|
||||
})
|
||||
|
||||
it('formats coordinates for form display', () => {
|
||||
expect(formatAppCoordinate(59.912345, 'de-DE')).toBe('59,912345')
|
||||
expect(formatTankLiters(12.5)).toBe(formatAppDecimal(12.5, { minimumFractionDigits: 1, maximumFractionDigits: 1 }))
|
||||
})
|
||||
|
||||
it('formats GPS accuracy with coarse step from 100 m', () => {
|
||||
expect(formatGpsAccuracyMeters(12.4)).toBe(formatAppDecimal(12, { maximumFractionDigits: 0 }))
|
||||
expect(formatGpsAccuracyMeters(105)).toBe(formatAppDecimal(110, { maximumFractionDigits: 0 }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Number formatting and parsing follow the device (browser) locale from Intl,
|
||||
* not the app UI language — e.g. de-DE phone with English UI still uses comma decimals.
|
||||
*/
|
||||
|
||||
export function resolveDeviceLocale(): string {
|
||||
try {
|
||||
const locale = new Intl.NumberFormat().resolvedOptions().locale
|
||||
if (locale) return locale
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||
return navigator.language
|
||||
}
|
||||
return 'en-GB'
|
||||
}
|
||||
|
||||
interface NumberSymbols {
|
||||
decimal: string
|
||||
group: string
|
||||
}
|
||||
|
||||
const symbolCache = new Map<string, NumberSymbols>()
|
||||
|
||||
export function getNumberFormatSymbols(locale = resolveDeviceLocale()): NumberSymbols {
|
||||
const cached = symbolCache.get(locale)
|
||||
if (cached) return cached
|
||||
const parts = new Intl.NumberFormat(locale).formatToParts(1234567.89)
|
||||
const symbols: NumberSymbols = {
|
||||
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
|
||||
group: parts.find((p) => p.type === 'group')?.value ?? ''
|
||||
}
|
||||
symbolCache.set(locale, symbols)
|
||||
return symbols
|
||||
}
|
||||
|
||||
export interface FormatAppDecimalOptions {
|
||||
minimumFractionDigits?: number
|
||||
maximumFractionDigits?: number
|
||||
locale?: string
|
||||
}
|
||||
|
||||
/** User-visible decimal without thousands grouping. */
|
||||
export function formatAppDecimal(value: number, options: FormatAppDecimalOptions = {}): string {
|
||||
if (!Number.isFinite(value)) return ''
|
||||
const locale = options.locale ?? resolveDeviceLocale()
|
||||
const min = options.minimumFractionDigits ?? 0
|
||||
const max = options.maximumFractionDigits ?? min
|
||||
return new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: min,
|
||||
maximumFractionDigits: max,
|
||||
useGrouping: false
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a decimal typed by the user for the device locale.
|
||||
* Also accepts the other common separator for simple values (e.g. 12,5 on en-US).
|
||||
*/
|
||||
export function parseAppDecimal(input: string, locale = resolveDeviceLocale()): number | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const { decimal, group } = getNumberFormatSymbols(locale)
|
||||
const simpleComma = /^-?\d+,\d+$/.test(trimmed)
|
||||
const simpleDot = /^-?\d+\.\d+$/.test(trimmed)
|
||||
|
||||
// Values without grouping: accept locale decimal and the other common separator.
|
||||
if (simpleComma && decimal === ',') {
|
||||
return Number(trimmed.replace(',', '.'))
|
||||
}
|
||||
if (simpleDot && decimal === '.') {
|
||||
return Number(trimmed)
|
||||
}
|
||||
if (simpleComma && decimal === '.') {
|
||||
return Number(trimmed.replace(',', '.'))
|
||||
}
|
||||
if (simpleDot && decimal === ',') {
|
||||
return Number(trimmed)
|
||||
}
|
||||
|
||||
let normalized = trimmed
|
||||
if (group) {
|
||||
normalized = normalized.split(group).join('')
|
||||
}
|
||||
if (decimal !== '.') {
|
||||
normalized = normalized.replace(decimal, '.')
|
||||
}
|
||||
|
||||
const n = Number(normalized)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function parseAppDecimalOrZero(input: string, locale?: string): number {
|
||||
return parseAppDecimal(input, locale) ?? 0
|
||||
}
|
||||
|
||||
/** Canonical storage/API coordinate string (always dot, 6 decimals). */
|
||||
export function formatCanonicalCoordinate(value: number): string {
|
||||
return value.toFixed(6)
|
||||
}
|
||||
|
||||
/** Coordinate string for form fields (device decimal separator). */
|
||||
export function formatAppCoordinate(value: number, locale?: string): string {
|
||||
return formatAppDecimal(value, { minimumFractionDigits: 6, maximumFractionDigits: 6, locale })
|
||||
}
|
||||
|
||||
export function formatNm(value: number): string {
|
||||
return formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
export function formatLiters(value: number): string {
|
||||
return Number.isInteger(value)
|
||||
? formatAppDecimal(value, { maximumFractionDigits: 0 })
|
||||
: formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
}
|
||||
|
||||
export function formatHours(value: number): string {
|
||||
return formatLiters(value)
|
||||
}
|
||||
|
||||
export function formatTankLiters(liters: number): string {
|
||||
if (!Number.isFinite(liters) || liters <= 0) return formatAppDecimal(0, { maximumFractionDigits: 0 })
|
||||
return formatLiters(liters)
|
||||
}
|
||||
|
||||
export function formatFuelPerMotorHour(value: number | null | undefined): string {
|
||||
if (value == null) return '—'
|
||||
return Number.isInteger(value)
|
||||
? formatAppDecimal(value, { maximumFractionDigits: 0 })
|
||||
: formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
/** GPS accuracy for i18n (±{{accuracy}} m): 1 m below 100 m, 10 m from 100 m upward. */
|
||||
export function formatGpsAccuracyMeters(accuracyM: number): string {
|
||||
const rounded = accuracyM < 100 ? Math.round(accuracyM) : Math.round(accuracyM / 10) * 10
|
||||
return formatAppDecimal(rounded, { maximumFractionDigits: 0 })
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { degreesToCardinal } from './courseAngle.js'
|
||||
import { formatAppDecimal } from './numberFormat.js'
|
||||
import { formatVisibilityMeters } from './weatherMetrics.js'
|
||||
|
||||
/** @deprecated Use formatVisibilityMeters */
|
||||
@@ -33,7 +34,7 @@ export function mpsToBeaufort(mps: number): number {
|
||||
|
||||
export function formatWindStrengthBeaufort(mps: number): string {
|
||||
const bft = mpsToBeaufort(mps)
|
||||
return `${bft} Bft (${mps.toFixed(1)} m/s)`
|
||||
return `${bft} Bft (${formatAppDecimal(mps, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} m/s)`
|
||||
}
|
||||
|
||||
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
|
||||
@@ -49,7 +50,7 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
|
||||
|
||||
let tempC: string | null = null
|
||||
if (main?.temp != null && Number.isFinite(main.temp)) {
|
||||
tempC = Number(main.temp).toFixed(1)
|
||||
tempC = formatAppDecimal(main.temp, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
}
|
||||
|
||||
let precipText: string | null = null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatTankLiters } from './logEntryTankLevels.js'
|
||||
import { formatTankLiters, parseAppDecimal } from './numberFormat.js'
|
||||
|
||||
export interface VesselTankCapacities {
|
||||
freshwaterCapacityL?: number
|
||||
@@ -7,10 +7,10 @@ export interface VesselTankCapacities {
|
||||
}
|
||||
|
||||
export function parseOptionalTankLiters(input: string): number | undefined {
|
||||
const trimmed = input.trim().replace(',', '.')
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
const parsed = parseAppDecimal(trimmed)
|
||||
if (parsed == null || parsed < 0) {
|
||||
throw new Error('invalid_tank_liters')
|
||||
}
|
||||
return parsed
|
||||
@@ -24,10 +24,10 @@ function capacityFromStored(value: unknown): number | undefined {
|
||||
if (value == null || value === '') return undefined
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim().replace(',', '.')
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
||||
const parsed = parseAppDecimal(trimmed)
|
||||
if (parsed != null && parsed >= 0) return parsed
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
import { formatAppDecimal } from './numberFormat.js'
|
||||
|
||||
const NM_IN_METERS = 1852
|
||||
const MAX_PLAUSIBLE_KNOTS = 50
|
||||
@@ -100,8 +101,14 @@ export function formatTrackStats(stats: TrackStats): {
|
||||
speedAvgKn: string
|
||||
} {
|
||||
return {
|
||||
distanceNm: stats.distanceNm.toFixed(2),
|
||||
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '',
|
||||
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : ''
|
||||
distanceNm: formatAppDecimal(stats.distanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
||||
speedMaxKn:
|
||||
stats.speedMaxKn > 0
|
||||
? formatAppDecimal(stats.speedMaxKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
: '',
|
||||
speedAvgKn:
|
||||
stats.speedAvgKn > 0
|
||||
? formatAppDecimal(stats.speedAvgKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
|
||||
import { formatAppDecimal, parseAppDecimal } from './numberFormat.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
|
||||
export function metricInputFromStored(value: unknown): string {
|
||||
if (value == null || value === '') return ''
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return formatAppDecimal(value, { maximumFractionDigits: 6 })
|
||||
}
|
||||
if (typeof value === 'string') return value.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
export function parseOptionalMetricMeters(input: string): number | undefined {
|
||||
const trimmed = input.trim().replace(',', '.')
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
const parsed = parseAppDecimal(trimmed)
|
||||
if (parsed == null || parsed < 0) {
|
||||
throw new Error('invalid_metric')
|
||||
}
|
||||
return parsed
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatAppDecimal } from './numberFormat.js'
|
||||
|
||||
/** Barometric pressure (hPa), typical marine range. */
|
||||
export const PRESSURE_MIN_HPA = 960
|
||||
export const PRESSURE_MAX_HPA = 1050
|
||||
@@ -90,7 +92,9 @@ export function formatVisibilityMeters(meters: number): string {
|
||||
if (meters >= 1000) {
|
||||
const km = meters / 1000
|
||||
const rounded = Math.round(km * 10) / 10
|
||||
return Number.isInteger(rounded) ? `${rounded} km` : `${rounded.toFixed(1)} km`
|
||||
return Number.isInteger(rounded)
|
||||
? `${formatAppDecimal(rounded, { maximumFractionDigits: 0 })} km`
|
||||
: `${formatAppDecimal(rounded, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`
|
||||
}
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user