Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d94502097e | |||
| a36ca2facb | |||
| b7a1085d52 | |||
| 3925c6f822 | |||
| 0b2c1c22c6 | |||
| aa03573e1f | |||
| a0b8664e23 | |||
| 74282f50d0 | |||
| 5b47415d55 | |||
| 039e4e2736 | |||
| 35bfbc1043 | |||
| 6c866dbad5 | |||
| bb667afec8 | |||
| beee33f842 | |||
| 77a7072b77 | |||
| bd1edd89f3 |
@@ -11,3 +11,5 @@ server/dist/
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
|
||||
userfeedback/
|
||||
|
||||
@@ -611,6 +611,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: min(90vh, 820px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.feedback-modal {
|
||||
@@ -662,6 +663,182 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.registration-disclaimer.feedback-modal {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.registration-disclaimer.feedback-modal .auth-header,
|
||||
.registration-disclaimer.feedback-modal > p,
|
||||
.registration-disclaimer.feedback-modal .nmea-import-summary,
|
||||
.registration-disclaimer.feedback-modal .nmea-import-warning,
|
||||
.registration-disclaimer.feedback-modal .nmea-import-mode,
|
||||
.registration-disclaimer.feedback-modal .feedback-form__field,
|
||||
.registration-disclaimer.feedback-modal .nmea-import-checkbox,
|
||||
.registration-disclaimer.feedback-modal .nmea-preview-actions,
|
||||
.registration-disclaimer.feedback-modal .nmea-preview-list,
|
||||
.registration-disclaimer.feedback-modal .auth-actions {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nmea-import-warning {
|
||||
width: 100%;
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
color: var(--app-warning-text, #fcd34d);
|
||||
background: var(--app-warning-bg, rgba(251, 191, 36, 0.1));
|
||||
border: 1px solid var(--app-warning-border, rgba(251, 191, 36, 0.35));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nmea-import-summary {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--app-surface-inset);
|
||||
border: 1px solid var(--app-border-muted);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nmea-import-summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nmea-import-summary p + p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.nmea-import-mode {
|
||||
border: 1px solid var(--app-border-muted);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nmea-import-mode legend {
|
||||
padding: 0 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-heading, #f1f5f9);
|
||||
}
|
||||
|
||||
.nmea-import-mode label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nmea-import-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nmea-preview-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.nmea-preview-actions .btn {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nmea-preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: min(45vh, 360px);
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.nmea-preview-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--app-border-muted);
|
||||
border-radius: 8px;
|
||||
background: var(--app-surface-inset);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.nmea-preview-row:hover {
|
||||
border-color: var(--app-accent-border, rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
|
||||
.nmea-preview-row__check {
|
||||
flex-shrink: 0;
|
||||
margin: 2px 0 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--app-accent-light, #d4af37);
|
||||
}
|
||||
|
||||
.nmea-preview-row__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nmea-preview-row__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nmea-preview-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--app-accent-light, #d4af37);
|
||||
min-width: 3.25rem;
|
||||
}
|
||||
|
||||
.nmea-preview-source {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
color: var(--app-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.nmea-preview-remarks {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--app-text, #e2e8f0);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.feedback-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2990,6 +3167,304 @@ html.theme-cupertino .events-scroll-container {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
/* Live log journal mode */
|
||||
.logs-view-toggle {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.logs-view-toggle-btn.is-active {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
color: var(--app-accent-light, #93c5fd);
|
||||
}
|
||||
|
||||
.live-log-card {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.live-log-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(148px, 200px) 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.live-log-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-log-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--app-radius-btn, 10px);
|
||||
border: 1px solid var(--app-border-muted);
|
||||
background: var(--app-surface);
|
||||
color: var(--app-text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.live-log-action-btn:hover:not(:disabled) {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.live-log-action-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.live-log-action-btn.is-active {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
border-color: rgba(251, 191, 36, 0.45);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.live-log-stream-panel {
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--app-border-muted);
|
||||
border-radius: var(--app-radius-card, 12px);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.live-log-stream-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--app-accent-light);
|
||||
}
|
||||
|
||||
.live-log-empty {
|
||||
margin: 0;
|
||||
color: var(--app-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.live-log-stream {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: min(60vh, 520px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.live-log-entry {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--app-border-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.live-log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.live-log-time {
|
||||
flex-shrink: 0;
|
||||
min-width: 3.25rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-summary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.live-log-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10050;
|
||||
background: rgba(2, 6, 23, 0.78);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.live-log-modal {
|
||||
width: min(420px, 100%);
|
||||
padding: 20px;
|
||||
border-radius: var(--app-radius-card, 12px);
|
||||
background: var(--app-surface-alt);
|
||||
border: 1px solid var(--app-border-muted);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.live-log-modal--dial {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.live-log-dial-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border-radius: var(--app-radius-input, 8px);
|
||||
background: var(--app-surface-inset);
|
||||
border: 1px solid var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.live-log-dial-field label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-modal h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.live-log-modal-hint {
|
||||
margin: -8px 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.live-log-sail-pills {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.live-log-modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.live-log-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.live-log-actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.live-log-action-btn {
|
||||
width: auto;
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
min-width: 140px;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.live-log-weather-group {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.live-log-weather-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.live-log-weather-toggle {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.live-log-weather-toggle.is-expanded {
|
||||
border-color: rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.live-log-weather-submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.live-log-subaction-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--app-border-muted);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: var(--app-text-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.live-log-subaction-btn:hover:not(:disabled) {
|
||||
color: var(--app-text);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.live-log-undo-bar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10060;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--app-surface-alt);
|
||||
border: 1px solid var(--app-border-muted);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-event-series-block + .stats-event-series-block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stats-event-series-list {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stats-event-series-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--app-border-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats-event-series-when {
|
||||
color: var(--app-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats-event-series-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.grid-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
+2
-2
@@ -45,7 +45,7 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNextLanguage } from './utils/i18nLanguages.js'
|
||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||
import {
|
||||
resolveTourLogbookContext,
|
||||
seedDemoLogbookIfNeeded
|
||||
@@ -497,7 +497,7 @@ function App() {
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
i18n.changeLanguage(getNextLanguage(i18n.language))
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const handleExitDemo = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
@@ -210,7 +210,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
i18n.changeLanguage(getNextLanguage(i18n.language))
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
@@ -49,7 +49,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
||||
|
||||
const toggleLanguage = () => {
|
||||
i18n.changeLanguage(getNextLanguage(i18n.language))
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
|
||||
import {
|
||||
getActiveMasterKey,
|
||||
@@ -309,7 +309,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
i18n.changeLanguage(getNextLanguage(i18n.language))
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
if (recoveryPhrase) {
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Anchor,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
CloudSun,
|
||||
Compass,
|
||||
Droplets,
|
||||
FileText,
|
||||
Fuel,
|
||||
Gauge,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Radio,
|
||||
Sailboat,
|
||||
Undo2,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import {
|
||||
appendQuickEvent,
|
||||
appendTankRefill,
|
||||
findOrCreateTodayEntry,
|
||||
loadEntry,
|
||||
removeLastEvent
|
||||
} from '../services/quickEventLog.js'
|
||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||
import {
|
||||
getLastAutoPositionMs,
|
||||
isMotorRunningFromEvents,
|
||||
LIVE_EVENT_CODES,
|
||||
liveCommentRemark,
|
||||
liveFuelRemark,
|
||||
livePrecipRemark,
|
||||
liveSailsRemark,
|
||||
liveSogRemark,
|
||||
liveStwRemark,
|
||||
liveTempRemark,
|
||||
liveWaterRemark
|
||||
} from '../utils/liveEventCodes.js'
|
||||
import { getCurrentPosition } from '../utils/geolocation.js'
|
||||
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import CourseDialInput from './CourseDialInput.tsx'
|
||||
|
||||
interface LiveLogViewProps {
|
||||
logbookId: string
|
||||
onOpenEditor: (entryId: string) => void
|
||||
onSwitchToList: () => void
|
||||
}
|
||||
|
||||
type LiveModal =
|
||||
| 'none'
|
||||
| 'sails'
|
||||
| 'comment'
|
||||
| 'wind'
|
||||
| 'pressure'
|
||||
| 'temp'
|
||||
| 'precip'
|
||||
| 'sea_state'
|
||||
| 'course'
|
||||
| 'fuel'
|
||||
| 'water'
|
||||
| 'sog'
|
||||
| 'stw'
|
||||
|
||||
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
|
||||
const AUTO_POSITION_CHECK_MS = 60_000
|
||||
const UNDO_TIMEOUT_MS = 5000
|
||||
|
||||
function hapticPulse() {
|
||||
navigator.vibrate?.(40)
|
||||
}
|
||||
|
||||
function lastCourseFromEvents(events: LogEventPayload[]): string {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const mgk = events[i].mgk?.trim()
|
||||
if (mgk) return mgk
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const direction = events[i].windDirection?.trim()
|
||||
if (direction) return direction
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function LiveLogView({
|
||||
logbookId,
|
||||
onOpenEditor,
|
||||
onSwitchToList
|
||||
}: LiveLogViewProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
|
||||
const [entryId, setEntryId] = useState<string | null>(null)
|
||||
const [dayOfTravel, setDayOfTravel] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [events, setEvents] = useState<LogEventPayload[]>([])
|
||||
const [yachtSails, setYachtSails] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [modal, setModal] = useState<LiveModal>('none')
|
||||
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [valueInput, setValueInput] = useState('')
|
||||
const [valueInputSecondary, setValueInputSecondary] = useState('')
|
||||
const [selectedSails, setSelectedSails] = useState<string[]>([])
|
||||
const [undoVisible, setUndoVisible] = useState(false)
|
||||
|
||||
const streamEndRef = useRef<HTMLDivElement | null>(null)
|
||||
const undoTimerRef = useRef<number | null>(null)
|
||||
const autoPositionBusyRef = useRef(false)
|
||||
|
||||
const defaultSails = i18n.language === 'de'
|
||||
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
||||
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
|
||||
const sailOptions = yachtSails.length > 0 ? yachtSails : defaultSails
|
||||
const motorRunning = isMotorRunningFromEvents(events)
|
||||
const motorLabel = t('logs.motor_propulsion')
|
||||
|
||||
const refreshEntry = useCallback(async (id: string) => {
|
||||
const loaded = await loadEntry(logbookId, id)
|
||||
if (!loaded) return
|
||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
||||
setDate(String(loaded.data.date || ''))
|
||||
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
||||
}, [logbookId])
|
||||
|
||||
const showUndo = useCallback(() => {
|
||||
setUndoVisible(true)
|
||||
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
|
||||
undoTimerRef.current = window.setTimeout(() => {
|
||||
setUndoVisible(false)
|
||||
undoTimerRef.current = null
|
||||
}, UNDO_TIMEOUT_MS)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function init() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const id = await findOrCreateTodayEntry(logbookId)
|
||||
if (cancelled) return
|
||||
setEntryId(id)
|
||||
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (masterKey) {
|
||||
const yacht = await db.yachts.get(logbookId)
|
||||
if (yacht) {
|
||||
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
||||
if (decrypted?.sails && Array.isArray(decrypted.sails)) {
|
||||
setYachtSails(decrypted.sails as string[])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await refreshEntry(id)
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
console.error('Failed to init live log:', err)
|
||||
setError(err instanceof Error ? err.message : t('logs.live_load_error'))
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void init()
|
||||
return () => { cancelled = true }
|
||||
}, [logbookId, refreshEntry, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && entryId) {
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_OPENED)
|
||||
}
|
||||
}, [loading, entryId])
|
||||
|
||||
useEffect(() => {
|
||||
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [events.length])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!entryId || loading) return
|
||||
|
||||
const maybeAutoPosition = async () => {
|
||||
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return
|
||||
|
||||
const lastMs = getLastAutoPositionMs(events, date)
|
||||
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
|
||||
|
||||
autoPositionBusyRef.current = true
|
||||
try {
|
||||
const coords = await getCurrentPosition()
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
gpsLat: coords.lat,
|
||||
gpsLng: coords.lng,
|
||||
remarks: LIVE_EVENT_CODES.AUTO_POSITION
|
||||
})
|
||||
await refreshEntry(entryId)
|
||||
} catch {
|
||||
// Silent — auto-position is best-effort
|
||||
} finally {
|
||||
autoPositionBusyRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
|
||||
return () => window.clearInterval(interval)
|
||||
}, [entryId, loading, events, date, logbookId, refreshEntry, busy])
|
||||
|
||||
const runQuickAction = async (
|
||||
action: () => Promise<void>,
|
||||
trackAction?: string,
|
||||
withUndo = true
|
||||
) => {
|
||||
if (!entryId || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await action()
|
||||
await refreshEntry(entryId)
|
||||
if (withUndo) showUndo()
|
||||
if (trackAction) {
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: trackAction })
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Live log action failed:', err)
|
||||
setError(err instanceof Error ? err.message : t('logs.live_action_error'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openValueModal = (type: LiveModal, primary = '', secondary = '') => {
|
||||
setValueInput(primary)
|
||||
setValueInputSecondary(secondary)
|
||||
setModal(type)
|
||||
}
|
||||
|
||||
const openSogModal = async () => {
|
||||
let prefill = ''
|
||||
try {
|
||||
const pos = await getCurrentPosition()
|
||||
if (pos.speedKn != null) prefill = String(pos.speedKn)
|
||||
} catch {
|
||||
// Manual entry when GPS speed unavailable
|
||||
}
|
||||
openValueModal('sog', prefill)
|
||||
}
|
||||
|
||||
const handleMotorToggle = () => {
|
||||
hapticPulse()
|
||||
const starting = !motorRunning
|
||||
void runQuickAction(async () => {
|
||||
if (!entryId) return
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
sailsOrMotor: starting ? motorLabel : '',
|
||||
remarks: starting ? LIVE_EVENT_CODES.MOTOR_START : LIVE_EVENT_CODES.MOTOR_STOP
|
||||
})
|
||||
}, starting ? 'motor_start' : 'motor_stop')
|
||||
}
|
||||
|
||||
const handleCastOff = () => {
|
||||
void runQuickAction(async () => {
|
||||
if (!entryId) return
|
||||
await appendQuickEvent(logbookId, entryId, { remarks: LIVE_EVENT_CODES.CAST_OFF })
|
||||
}, 'cast_off')
|
||||
}
|
||||
|
||||
const handleMoor = () => {
|
||||
void runQuickAction(async () => {
|
||||
if (!entryId) return
|
||||
await appendQuickEvent(logbookId, entryId, { remarks: LIVE_EVENT_CODES.MOOR })
|
||||
}, 'moor')
|
||||
}
|
||||
|
||||
const handleFix = () => {
|
||||
void runQuickAction(async () => {
|
||||
if (!entryId) return
|
||||
try {
|
||||
const coords = await getCurrentPosition()
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
gpsLat: coords.lat,
|
||||
gpsLng: coords.lng,
|
||||
remarks: LIVE_EVENT_CODES.FIX
|
||||
})
|
||||
} catch {
|
||||
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
|
||||
}
|
||||
}, 'fix')
|
||||
}
|
||||
|
||||
const handleUndo = () => {
|
||||
if (!entryId || busy) return
|
||||
setUndoVisible(false)
|
||||
if (undoTimerRef.current) {
|
||||
window.clearTimeout(undoTimerRef.current)
|
||||
undoTimerRef.current = null
|
||||
}
|
||||
void runQuickAction(async () => {
|
||||
await removeLastEvent(logbookId, entryId)
|
||||
}, 'undo', false)
|
||||
}
|
||||
|
||||
const confirmSails = () => {
|
||||
if (selectedSails.length === 0) {
|
||||
setModal('none')
|
||||
return
|
||||
}
|
||||
const sailsLabel = selectedSails.join(' + ')
|
||||
setModal('none')
|
||||
setSelectedSails([])
|
||||
void runQuickAction(async () => {
|
||||
if (!entryId) return
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
sailsOrMotor: sailsLabel,
|
||||
remarks: liveSailsRemark(sailsLabel)
|
||||
})
|
||||
}, 'sails')
|
||||
}
|
||||
|
||||
const confirmComment = () => {
|
||||
const text = commentText.trim()
|
||||
if (!text) {
|
||||
setModal('none')
|
||||
return
|
||||
}
|
||||
setModal('none')
|
||||
setCommentText('')
|
||||
void runQuickAction(async () => {
|
||||
if (!entryId) return
|
||||
await appendQuickEvent(logbookId, entryId, { remarks: liveCommentRemark(text) })
|
||||
}, 'comment')
|
||||
}
|
||||
|
||||
const confirmValueModal = () => {
|
||||
if (!entryId) return
|
||||
const primary = valueInput.trim()
|
||||
const secondary = valueInputSecondary.trim()
|
||||
|
||||
switch (modal) {
|
||||
case 'wind':
|
||||
if (!primary && !secondary) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
windDirection: primary,
|
||||
windStrength: secondary,
|
||||
remarks: LIVE_EVENT_CODES.WIND
|
||||
})
|
||||
}, 'wind')
|
||||
break
|
||||
case 'pressure':
|
||||
if (!primary) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
windPressure: primary,
|
||||
remarks: LIVE_EVENT_CODES.PRESSURE
|
||||
})
|
||||
}, 'pressure')
|
||||
break
|
||||
case 'temp':
|
||||
if (!primary) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, { remarks: liveTempRemark(primary) })
|
||||
}, 'temp')
|
||||
break
|
||||
case 'precip':
|
||||
if (!primary) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, { remarks: livePrecipRemark(primary) })
|
||||
}, 'precip')
|
||||
break
|
||||
case 'sea_state':
|
||||
if (!primary) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
seaState: primary,
|
||||
remarks: LIVE_EVENT_CODES.SEA_STATE
|
||||
})
|
||||
}, 'sea_state')
|
||||
break
|
||||
case 'course': {
|
||||
const course = primary || lastCourseFromEvents(events)
|
||||
if (!course) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
mgk: course,
|
||||
remarks: LIVE_EVENT_CODES.COURSE
|
||||
})
|
||||
}, 'course')
|
||||
break
|
||||
}
|
||||
case 'fuel': {
|
||||
const liters = parseFloat(primary)
|
||||
if (!Number.isFinite(liters) || liters <= 0) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendTankRefill(logbookId, entryId, 'fuel', liters, {
|
||||
remarks: liveFuelRemark(String(liters))
|
||||
})
|
||||
}, 'fuel')
|
||||
break
|
||||
}
|
||||
case 'water': {
|
||||
const liters = parseFloat(primary)
|
||||
if (!Number.isFinite(liters) || liters <= 0) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
|
||||
remarks: liveWaterRemark(String(liters))
|
||||
})
|
||||
}, 'water')
|
||||
break
|
||||
}
|
||||
case 'sog': {
|
||||
const speedKn = parseFloat(primary.replace(',', '.'))
|
||||
if (!Number.isFinite(speedKn) || speedKn < 0) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
remarks: liveSogRemark(String(speedKn))
|
||||
})
|
||||
}, 'sog')
|
||||
break
|
||||
}
|
||||
case 'stw': {
|
||||
const speedKn = parseFloat(primary.replace(',', '.'))
|
||||
if (!Number.isFinite(speedKn) || speedKn < 0) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
remarks: liveStwRemark(String(speedKn))
|
||||
})
|
||||
}, 'stw')
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSailSelection = (sail: string) => {
|
||||
setSelectedSails((prev) =>
|
||||
prev.some((s) => s.toLowerCase() === sail.toLowerCase())
|
||||
? prev.filter((s) => s.toLowerCase() !== sail.toLowerCase())
|
||||
: [...prev, sail]
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
<Radio className="header-logo spin" size={48} />
|
||||
<p>{t('logs.live_loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-card live-log-card">
|
||||
<div className="section-title-bar mb-4">
|
||||
<div className="form-header" style={{ margin: 0 }}>
|
||||
<Radio size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('logs.live_title')}</h2>
|
||||
{date && (
|
||||
<p className="live-log-subtitle">
|
||||
{t('logs.day_of_travel')} {dayOfTravel} · {new Date(date).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-toolbar">
|
||||
<button type="button" className="btn secondary" onClick={onSwitchToList} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<ChevronLeft size={16} />
|
||||
<span className="hide-mobile">{t('logs.view_list')}</span>
|
||||
</button>
|
||||
{entryId && (
|
||||
<button type="button" className="btn secondary" onClick={() => onOpenEditor(entryId)} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<FileText size={16} />
|
||||
<span className="hide-mobile">{t('logs.live_open_editor')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
<div className="live-log-layout">
|
||||
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
|
||||
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
|
||||
<Zap size={18} />
|
||||
{motorRunning ? t('logs.live_motor_stop') : t('logs.live_motor_start')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={handleCastOff} disabled={busy}>
|
||||
<Anchor size={18} />
|
||||
{t('logs.live_cast_off')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={handleMoor} disabled={busy}>
|
||||
<Anchor size={18} style={{ transform: 'scaleX(-1)' }} />
|
||||
{t('logs.live_moor')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => { setSelectedSails([]); setModal('sails') }} disabled={busy}>
|
||||
<Sailboat size={18} />
|
||||
{t('logs.live_sails_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('course', lastCourseFromEvents(events))} disabled={busy}>
|
||||
<Compass size={18} />
|
||||
{t('logs.live_course_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => void openSogModal()} disabled={busy}>
|
||||
<Gauge size={18} />
|
||||
{t('logs.live_sog_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('stw')} disabled={busy}>
|
||||
<Gauge size={18} style={{ transform: 'scaleX(-1)' }} />
|
||||
{t('logs.live_stw_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('fuel')} disabled={busy}>
|
||||
<Fuel size={18} />
|
||||
{t('logs.live_fuel_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('water')} disabled={busy}>
|
||||
<Droplets size={18} />
|
||||
{t('logs.live_water_btn')}
|
||||
</button>
|
||||
|
||||
<div className="live-log-weather-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`live-log-action-btn live-log-weather-toggle ${weatherExpanded ? 'is-expanded' : ''}`}
|
||||
onClick={() => setWeatherExpanded((prev) => !prev)}
|
||||
disabled={busy}
|
||||
aria-expanded={weatherExpanded}
|
||||
>
|
||||
<CloudSun size={18} />
|
||||
{t('logs.live_weather_btn')}
|
||||
{weatherExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
{weatherExpanded && (
|
||||
<div className="live-log-weather-submenu">
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('wind', lastWindDirectionFromEvents(events))} disabled={busy}>
|
||||
{t('logs.live_wind_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('temp')} disabled={busy}>
|
||||
{t('logs.live_temp_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('pressure')} disabled={busy}>
|
||||
{t('logs.live_pressure_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('precip')} disabled={busy}>
|
||||
{t('logs.live_precip_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}>
|
||||
{t('logs.live_sea_state_btn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className="live-log-action-btn" onClick={handleFix} disabled={busy}>
|
||||
<MapPin size={18} />
|
||||
{t('logs.live_fix')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
|
||||
<MessageSquare size={18} />
|
||||
{t('logs.live_comment_btn')}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
|
||||
<h3 className="live-log-stream-title">{t('logs.live_stream_title')}</h3>
|
||||
{events.length === 0 ? (
|
||||
<p className="live-log-empty">{t('logs.live_no_events')}</p>
|
||||
) : (
|
||||
<ol className="live-log-stream">
|
||||
{events.map((event, index) => (
|
||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||
<time className="live-log-time">{event.time}</time>
|
||||
<span className="live-log-summary">{formatEventSummary(event, t)}</span>
|
||||
</li>
|
||||
))}
|
||||
<div ref={streamEndRef} />
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{((undoVisible && events.length > 0) || modal !== 'none') && createPortal(
|
||||
<>
|
||||
{undoVisible && events.length > 0 && (
|
||||
<div className="live-log-undo-bar" role="status">
|
||||
<span>{t('logs.live_undo_hint')}</span>
|
||||
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
|
||||
<Undo2 size={16} />
|
||||
{t('logs.live_undo_btn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal === 'sails' && (
|
||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t('logs.live_sails_pick')}</h3>
|
||||
<div className="sails-picker-pills live-log-sail-pills">
|
||||
{sailOptions.map((sail) => (
|
||||
<button
|
||||
key={sail}
|
||||
type="button"
|
||||
className={`sail-pill ${selectedSails.some((s) => s.toLowerCase() === sail.toLowerCase()) ? 'active' : ''}`}
|
||||
onClick={() => toggleSailSelection(sail)}
|
||||
>
|
||||
{sail}
|
||||
</button>
|
||||
))}
|
||||
</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 primary" onClick={confirmSails} disabled={selectedSails.length === 0}>{t('logs.live_sails_confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal === 'comment' && (
|
||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<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 primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal === 'wind' && (
|
||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||
<div className="live-log-modal live-log-modal--dial" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t('logs.live_wind_btn')}</h3>
|
||||
<div className="live-log-dial-field">
|
||||
<label>{t('logs.event_wind_direction')}</label>
|
||||
<CourseDialInput
|
||||
value={valueInput}
|
||||
onChange={setValueInput}
|
||||
disabled={busy}
|
||||
allowCardinal
|
||||
displayMode="auto"
|
||||
size="sm"
|
||||
aria-label={t('logs.event_wind_direction')}
|
||||
/>
|
||||
</div>
|
||||
<div className="live-log-dial-field">
|
||||
<label>{t('logs.event_wind_strength')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={valueInputSecondary}
|
||||
onChange={(e) => setValueInputSecondary(e.target.value)}
|
||||
placeholder="e.g. 4 Bft"
|
||||
/>
|
||||
</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 primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal === 'course' && (
|
||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||
<div className="live-log-modal live-log-modal--dial" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t('logs.live_course_btn')}</h3>
|
||||
<div className="live-log-dial-field">
|
||||
<label>{t('logs.event_mgk')}</label>
|
||||
<CourseDialInput
|
||||
value={valueInput}
|
||||
onChange={setValueInput}
|
||||
disabled={busy}
|
||||
size="sm"
|
||||
aria-label={t('logs.event_mgk')}
|
||||
/>
|
||||
</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 primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{['pressure', 'temp', 'precip', 'sea_state', 'fuel', 'water', 'sog', 'stw'].includes(modal) && (
|
||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>
|
||||
{modal === 'pressure' && t('logs.live_pressure_btn')}
|
||||
{modal === 'temp' && t('logs.live_temp_btn')}
|
||||
{modal === 'precip' && t('logs.live_precip_btn')}
|
||||
{modal === 'sea_state' && t('logs.live_sea_state_btn')}
|
||||
{modal === 'fuel' && t('logs.live_fuel_btn')}
|
||||
{modal === 'water' && t('logs.live_water_btn')}
|
||||
{modal === 'sog' && t('logs.live_sog_btn')}
|
||||
{modal === 'stw' && t('logs.live_stw_btn')}
|
||||
</h3>
|
||||
{modal === 'sog' && (
|
||||
<p className="live-log-modal-hint">{t('logs.live_sog_hint')}</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={valueInput}
|
||||
onChange={(e) => setValueInput(e.target.value)}
|
||||
placeholder={
|
||||
modal === 'pressure' ? t('logs.live_pressure_placeholder')
|
||||
: modal === 'temp' ? t('logs.live_temp_placeholder')
|
||||
: modal === 'precip' ? t('logs.live_precip_placeholder')
|
||||
: modal === 'sea_state' ? t('logs.live_sea_state_placeholder')
|
||||
: modal === 'fuel' ? t('logs.live_fuel_placeholder')
|
||||
: modal === 'water' ? t('logs.live_water_placeholder')
|
||||
: modal === 'sog' ? t('logs.live_sog_placeholder')
|
||||
: t('logs.live_stw_placeholder')
|
||||
}
|
||||
autoFocus
|
||||
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 primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import LiveLogView from './LiveLogView.tsx'
|
||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
@@ -36,6 +37,8 @@ interface LogEntriesListProps {
|
||||
highlightEntryId?: string | null
|
||||
}
|
||||
|
||||
type LogsViewMode = 'list' | 'live'
|
||||
|
||||
interface DecryptedEntryItem {
|
||||
id: string
|
||||
date: string
|
||||
@@ -75,6 +78,8 @@ export default function LogEntriesList({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
|
||||
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
|
||||
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
@@ -350,7 +355,13 @@ export default function LogEntriesList({
|
||||
<LogEntryEditor
|
||||
entryId={selectedEntryId}
|
||||
logbookId={logbookId}
|
||||
onBack={() => setSelectedEntryId(null)}
|
||||
onBack={() => {
|
||||
setSelectedEntryId(null)
|
||||
if (returnToLiveAfterEditor) {
|
||||
setViewMode('live')
|
||||
setReturnToLiveAfterEditor(false)
|
||||
}
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||
preloadedPhotos={preloadedPhotos}
|
||||
@@ -359,6 +370,19 @@ export default function LogEntriesList({
|
||||
)
|
||||
}
|
||||
|
||||
if (viewMode === 'live' && !readOnly) {
|
||||
return (
|
||||
<LiveLogView
|
||||
logbookId={logbookId}
|
||||
onOpenEditor={(entryId) => {
|
||||
setReturnToLiveAfterEditor(true)
|
||||
setSelectedEntryId(entryId)
|
||||
}}
|
||||
onSwitchToList={() => setViewMode('list')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
@@ -381,6 +405,29 @@ export default function LogEntriesList({
|
||||
<h2>{t('logs.title')}</h2>
|
||||
</div>
|
||||
<div className="section-toolbar">
|
||||
{!readOnly && (
|
||||
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('logs.view_list')}
|
||||
>
|
||||
<List size={16} />
|
||||
<span className="hide-mobile">{t('logs.view_list')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
|
||||
onClick={() => setViewMode('live')}
|
||||
title={t('logs.live_mode')}
|
||||
>
|
||||
<Radio size={16} />
|
||||
<span className="hide-mobile">{t('logs.live_mode')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
||||
<Download size={16} />
|
||||
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||
|
||||
@@ -37,8 +37,16 @@ import {
|
||||
deleteTrack,
|
||||
downloadTrackFile,
|
||||
parseTrackFile,
|
||||
type SavedTrack
|
||||
type SavedTrack,
|
||||
type TrackWaypoint
|
||||
} from '../services/trackUpload.js'
|
||||
import NmeaImportWizard from './NmeaImportWizard.tsx'
|
||||
import {
|
||||
deleteNmeaArchive,
|
||||
downloadNmeaArchive,
|
||||
getNmeaArchive,
|
||||
type NmeaArchiveRecord
|
||||
} from '../services/nmeaArchive.js'
|
||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||
@@ -210,6 +218,8 @@ export default function LogEntryEditor({
|
||||
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [nmeaWizardOpen, setNmeaWizardOpen] = useState(false)
|
||||
const [nmeaArchive, setNmeaArchive] = useState<NmeaArchiveRecord | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const lockedContentHashRef = useRef<string | null>(null)
|
||||
const contentReadyRef = useRef(false)
|
||||
@@ -762,6 +772,45 @@ export default function LogEntryEditor({
|
||||
loadTrack()
|
||||
}, [entryId, preloadedTrack])
|
||||
|
||||
const loadNmeaArchive = async () => {
|
||||
if (readOnly) return
|
||||
try {
|
||||
const archive = await getNmeaArchive(entryId)
|
||||
setNmeaArchive(archive)
|
||||
} catch {
|
||||
setNmeaArchive(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadNmeaArchive()
|
||||
}, [entryId, readOnly])
|
||||
|
||||
const handleNmeaImport = async (importedEvents: LogEventPayload[], waypoints?: TrackWaypoint[]) => {
|
||||
setEvents((prev) => sortLogEventsByTime([...prev, ...importedEvents]))
|
||||
if (waypoints && waypoints.length > 0) {
|
||||
try {
|
||||
const gpxLike = waypoints
|
||||
.map((wp) => ` <trkpt lat="${wp.lat}" lon="${wp.lng}"><time>${new Date(wp.timestamp).toISOString()}</time></trkpt>`)
|
||||
.join('\n')
|
||||
const content = `<?xml version="1.0"?><gpx><trk><trkseg>\n${gpxLike}\n</trkseg></trk></gpx>`
|
||||
await saveUploadedTrack(logbookId, entryId, content, waypoints, 'imported-from-nmea.nmea', 'nmea')
|
||||
applyTrackStats(waypoints)
|
||||
await loadTrack()
|
||||
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
|
||||
} catch (err: unknown) {
|
||||
console.warn('Failed to save NMEA track:', err)
|
||||
}
|
||||
}
|
||||
await loadNmeaArchive()
|
||||
}
|
||||
|
||||
const handleDeleteNmeaArchive = async () => {
|
||||
if (!window.confirm(t('logs.nmea_archive_delete_confirm'))) return
|
||||
await deleteNmeaArchive(entryId)
|
||||
setNmeaArchive(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!savedTrack || savedTrack.waypoints.length < 2) return
|
||||
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
|
||||
@@ -1925,6 +1974,31 @@ export default function LogEntryEditor({
|
||||
</>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<div className="nmea-import-section" style={{ marginTop: '12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => setNmeaWizardOpen(true)}
|
||||
style={{ width: 'auto', padding: '8px 14px', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
<FileText size={16} />
|
||||
{t('logs.nmea_import_btn')}
|
||||
</button>
|
||||
{nmeaArchive && (
|
||||
<div className="nmea-archive-info" style={{ marginTop: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span>{t('logs.nmea_archive_stored', { name: nmeaArchive.filename })}</span>
|
||||
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={() => downloadNmeaArchive(nmeaArchive)}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={handleDeleteNmeaArchive}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
|
||||
<div className="form-grid track-stats-grid">
|
||||
<div className="input-group">
|
||||
@@ -2030,6 +2104,19 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<NmeaImportWizard
|
||||
open={nmeaWizardOpen}
|
||||
onClose={() => {
|
||||
setNmeaWizardOpen(false)
|
||||
void loadNmeaArchive()
|
||||
}}
|
||||
logbookId={logbookId}
|
||||
entryId={entryId}
|
||||
entryDate={date}
|
||||
nmeaArchive={nmeaArchive}
|
||||
onImport={handleNmeaImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||
@@ -194,7 +194,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
i18n.changeLanguage(getNextLanguage(i18n.language))
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileText, X } from 'lucide-react'
|
||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||
import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js'
|
||||
import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js'
|
||||
import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js'
|
||||
import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js'
|
||||
import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js'
|
||||
import { nmeaFileCrc32 } from '../utils/crc32.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
|
||||
interface NmeaImportWizardProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
logbookId: string
|
||||
entryId: string
|
||||
entryDate: string
|
||||
nmeaArchive: NmeaArchiveRecord | null
|
||||
onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void
|
||||
}
|
||||
|
||||
type WizardStep = 'config' | 'preview' | 'archive'
|
||||
|
||||
export default function NmeaImportWizard({
|
||||
open,
|
||||
onClose,
|
||||
logbookId,
|
||||
entryId,
|
||||
entryDate,
|
||||
nmeaArchive,
|
||||
onImport
|
||||
}: NmeaImportWizardProps) {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<WizardStep>('config')
|
||||
const [parseResult, setParseResult] = useState<NmeaParseResult | null>(null)
|
||||
const [mode, setMode] = useState<NmeaImportMode>('both')
|
||||
const [intervalMinutes, setIntervalMinutes] = useState(60)
|
||||
const [importTrack, setImportTrack] = useState(true)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null)
|
||||
const [duplicateFile, setDuplicateFile] = useState(false)
|
||||
|
||||
const filteredPoints = useMemo(() => {
|
||||
if (!parseResult) return []
|
||||
return filterPointsForDate(parseResult.points, entryDate)
|
||||
}, [parseResult, entryDate])
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
if (!parseResult || filteredPoints.length === 0) return []
|
||||
return generateNmeaJournalCandidates({
|
||||
points: filteredPoints,
|
||||
mode,
|
||||
intervalMinutes,
|
||||
t
|
||||
}).candidates
|
||||
}, [parseResult, filteredPoints, mode, intervalMinutes, t])
|
||||
|
||||
const reset = () => {
|
||||
setStep('config')
|
||||
setParseResult(null)
|
||||
setMode('both')
|
||||
setIntervalMinutes(60)
|
||||
setImportTrack(true)
|
||||
setSelectedIds(new Set())
|
||||
setError(null)
|
||||
setDuplicateFile(false)
|
||||
setPendingRaw(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
setError(null)
|
||||
setDuplicateFile(false)
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const text = String(reader.result ?? '')
|
||||
const crc32 = nmeaFileCrc32(text)
|
||||
const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false
|
||||
setDuplicateFile(alreadyImported)
|
||||
const result = parseNmeaFile(text, file.name)
|
||||
if (result.points.length === 0) {
|
||||
setError(t('logs.nmea_error_no_samples'))
|
||||
return
|
||||
}
|
||||
setParseResult(result)
|
||||
setPendingRaw({ filename: file.name, text })
|
||||
const generated = generateNmeaJournalCandidates({
|
||||
points: filterPointsForDate(result.points, entryDate),
|
||||
mode,
|
||||
intervalMinutes,
|
||||
t
|
||||
}).candidates
|
||||
setSelectedIds(new Set(generated.map((c) => c.id)))
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, {
|
||||
duplicate: alreadyImported,
|
||||
lines: result.stats.parsedLines,
|
||||
candidates: generated.length,
|
||||
has_position: !result.warnings.includes('no_position')
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
|
||||
}
|
||||
}
|
||||
reader.onerror = () => setError(t('logs.nmea_error_read'))
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set())
|
||||
}
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const goPreview = () => {
|
||||
if (!parseResult) {
|
||||
setError(t('logs.nmea_error_no_file'))
|
||||
return
|
||||
}
|
||||
const generated = generateNmeaJournalCandidates({
|
||||
points: filteredPoints,
|
||||
mode,
|
||||
intervalMinutes,
|
||||
t
|
||||
}).candidates
|
||||
setSelectedIds(new Set(generated.map((c) => c.id)))
|
||||
setStep('preview')
|
||||
}
|
||||
|
||||
const applyImport = async () => {
|
||||
const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event)
|
||||
if (picked.length === 0) {
|
||||
setError(t('logs.nmea_error_no_selection'))
|
||||
return
|
||||
}
|
||||
const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined
|
||||
onImport(sortLogEventsByTime(picked), waypoints)
|
||||
if (pendingRaw) {
|
||||
try {
|
||||
await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
|
||||
} catch (err) {
|
||||
console.warn('NMEA import CRC record failed:', err)
|
||||
}
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
|
||||
mode,
|
||||
events: picked.length,
|
||||
track: importTrack && (waypoints?.length ?? 0) > 0
|
||||
})
|
||||
setStep('archive')
|
||||
}
|
||||
|
||||
const finishArchive = async (archive: boolean) => {
|
||||
try {
|
||||
if (archive && pendingRaw) {
|
||||
await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('NMEA archive save failed:', err)
|
||||
}
|
||||
handleClose()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') handleClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = prevOverflow
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="disclaimer-modal-overlay" onClick={handleClose}>
|
||||
<div className="disclaimer-modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close feedback-modal__close"
|
||||
onClick={handleClose}
|
||||
aria-label={t('logs.nmea_cancel')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="auth-header">
|
||||
<FileText className="auth-icon accent" size={40} />
|
||||
<h2>{t('logs.nmea_import_title')}</h2>
|
||||
</div>
|
||||
|
||||
{error && <div className="track-error-msg">{error}</div>}
|
||||
|
||||
{duplicateFile && (
|
||||
<div className="nmea-import-warning" role="status">
|
||||
{t('logs.nmea_warn_duplicate_file')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'config' && (
|
||||
<>
|
||||
<p className="registration-disclaimer__intro">{t('logs.nmea_import_intro')}</p>
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('logs.nmea_file_label')}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".nmea,.log,.txt"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{parseResult && (
|
||||
<div className="nmea-import-summary">
|
||||
<p>{t('logs.nmea_stats', {
|
||||
lines: parseResult.stats.parsedLines,
|
||||
types: parseResult.stats.sentenceTypes.join(', ')
|
||||
})}</p>
|
||||
{parseResult.warnings.includes('no_position') && (
|
||||
<p>{t('logs.nmea_warn_no_position')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<fieldset className="nmea-import-mode">
|
||||
<legend>{t('logs.nmea_mode_label')}</legend>
|
||||
<label><input type="radio" name="nmea-mode" checked={mode === 'interval'} onChange={() => setMode('interval')} /> {t('logs.nmea_mode_interval')}</label>
|
||||
<label><input type="radio" name="nmea-mode" checked={mode === 'change'} onChange={() => setMode('change')} /> {t('logs.nmea_mode_change')}</label>
|
||||
<label><input type="radio" name="nmea-mode" checked={mode === 'both'} onChange={() => setMode('both')} /> {t('logs.nmea_mode_both')}</label>
|
||||
</fieldset>
|
||||
|
||||
{(mode === 'interval' || mode === 'both') && (
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('logs.nmea_interval_label')}</span>
|
||||
<select value={intervalMinutes} onChange={(e) => setIntervalMinutes(Number(e.target.value))}>
|
||||
<option value={30}>30 min</option>
|
||||
<option value={60}>60 min</option>
|
||||
<option value={90}>90 min</option>
|
||||
<option value={120}>120 min</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="nmea-import-checkbox">
|
||||
<input type="checkbox" checked={importTrack} onChange={(e) => setImportTrack(e.target.checked)} />
|
||||
{t('logs.nmea_import_track')}
|
||||
</label>
|
||||
|
||||
<div className="auth-actions feedback-form__actions">
|
||||
<button type="button" className="btn secondary" onClick={handleClose}>{t('logs.nmea_cancel')}</button>
|
||||
<button type="button" className="btn primary" onClick={goPreview} disabled={!parseResult}>
|
||||
{t('logs.nmea_preview')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'preview' && (
|
||||
<>
|
||||
<p>{t('logs.nmea_preview_hint', { count: candidates.length })}</p>
|
||||
<div className="nmea-preview-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => toggleAll(true)}>{t('logs.nmea_select_all')}</button>
|
||||
<button type="button" className="btn secondary" onClick={() => toggleAll(false)}>{t('logs.nmea_select_none')}</button>
|
||||
</div>
|
||||
<div className="nmea-preview-list">
|
||||
{candidates.map((c) => (
|
||||
<label key={c.id} className="nmea-preview-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="nmea-preview-row__check"
|
||||
checked={selectedIds.has(c.id)}
|
||||
onChange={() => toggleOne(c.id)}
|
||||
/>
|
||||
<div className="nmea-preview-row__body">
|
||||
<div className="nmea-preview-row__meta">
|
||||
<span className="nmea-preview-time">{c.event.time}</span>
|
||||
<span className="nmea-preview-source">{t(`logs.nmea_source_${c.source}`)}</span>
|
||||
</div>
|
||||
<span className="nmea-preview-remarks">{c.event.remarks || c.event.mgk || '—'}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="auth-actions feedback-form__actions">
|
||||
<button type="button" className="btn secondary" onClick={() => setStep('config')}>{t('logs.nmea_back')}</button>
|
||||
<button type="button" className="btn primary" onClick={applyImport}>{t('logs.nmea_apply')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'archive' && (
|
||||
<>
|
||||
<p>{t('logs.nmea_archive_question')}</p>
|
||||
<div className="auth-actions feedback-form__actions">
|
||||
<button type="button" className="btn secondary" onClick={() => finishArchive(false)}>
|
||||
{t('logs.nmea_archive_discard')}
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={() => finishArchive(true)}>
|
||||
{t('logs.nmea_archive_keep')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
||||
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
@@ -137,7 +137,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
i18n.changeLanguage(getNextLanguage(i18n.language))
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
} from '../services/statsAggregation.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import {
|
||||
loadLogbookEventSeries,
|
||||
type EventSeriesPoint,
|
||||
type EventSeriesSummary
|
||||
} from '../services/eventSeriesAggregation.js'
|
||||
|
||||
interface StatsDashboardProps {
|
||||
logbookId: string
|
||||
@@ -217,7 +222,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="stats-event-series-block">
|
||||
<h4 className="stats-section-subtitle">{title}</h4>
|
||||
<p className="stats-section-sub">{emptyLabel}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stats-event-series-block">
|
||||
<h4 className="stats-section-subtitle">{title}</h4>
|
||||
<ul className="stats-event-series-list">
|
||||
{points.map((point, idx) => (
|
||||
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
|
||||
<span className="stats-event-series-when">
|
||||
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
|
||||
{' · '}
|
||||
{point.time}
|
||||
</span>
|
||||
<span className="stats-event-series-value">{point.summary}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
|
||||
const { t } = useTranslation()
|
||||
const motorPoints = series.motor.map((point) => ({
|
||||
...point,
|
||||
summary: point.summary === 'start'
|
||||
? t('logs.live_motor_start')
|
||||
: t('logs.live_motor_stop')
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
|
||||
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
|
||||
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
|
||||
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
|
||||
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LogbookScopeView({
|
||||
summary,
|
||||
eventSeries
|
||||
}: {
|
||||
summary: LogbookStatsSummary
|
||||
eventSeries: EventSeriesSummary | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { travelDays, routePorts, trackSegments, totals } = summary
|
||||
|
||||
@@ -313,6 +373,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
||||
<PropulsionBreakdown totals={totals} />
|
||||
</div>
|
||||
|
||||
{eventSeries && <EventSeriesPanel series={eventSeries} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -323,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
|
||||
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [lb, acc] = await Promise.all([
|
||||
const [lb, acc, series] = await Promise.all([
|
||||
loadLogbookStats(logbookId, logbookTitle, true),
|
||||
loadAccountStats(false)
|
||||
loadAccountStats(false),
|
||||
loadLogbookEventSeries(logbookId)
|
||||
])
|
||||
setLogbookStats(lb)
|
||||
setAccountStats(acc)
|
||||
setEventSeries(series)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load statistics:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
|
||||
@@ -397,7 +462,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
<p>{t('stats.loading')}</p>
|
||||
</div>
|
||||
) : scope === 'logbook' && logbookStats ? (
|
||||
<LogbookScopeView summary={logbookStats} />
|
||||
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
|
||||
) : scope === 'account' && accountStats ? (
|
||||
<>
|
||||
<TotalsGrid totals={accountStats.totals} />
|
||||
|
||||
@@ -197,6 +197,67 @@
|
||||
"saving": "Vil blive reddet...",
|
||||
"saved": "Logbogsside gemt med succes!",
|
||||
"loading": "Dagbogen er ved at blive indlæst.",
|
||||
"view_mode_label": "Visning",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal indlæses...",
|
||||
"live_load_error": "Live-journal kunne ikke indlæses.",
|
||||
"live_action_error": "Indtastning kunne ikke gemmes.",
|
||||
"live_open_editor": "Fuld editor",
|
||||
"live_actions_label": "Hurtighandlinger",
|
||||
"live_stream_label": "Hændelseslog",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Ingen indtastninger endnu — tryk på en handling.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stop",
|
||||
"live_cast_off": "Afsejling",
|
||||
"live_moor": "Anløb",
|
||||
"live_sails_btn": "Sejl",
|
||||
"live_sails_pick": "Vælg sejl",
|
||||
"live_sails_confirm": "Indtast",
|
||||
"live_sails": "Sejl: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Indtast tekst…",
|
||||
"live_comment_confirm": "Indtast",
|
||||
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
||||
"live_event_generic": "Hændelse",
|
||||
"live_weather_btn": "Vejr",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttryk",
|
||||
"live_precip_btn": "Nedbør",
|
||||
"live_sea_state_btn": "Søgang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vand",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttryk {{value}} hPa",
|
||||
"live_precip_entry": "Nedbør {{value}}",
|
||||
"live_sea_state_entry": "Søgang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vand +{{liters}} L",
|
||||
"live_auto_position": "Auto-position",
|
||||
"live_undo_hint": "Indtastning gemt",
|
||||
"live_undo_btn": "Fortryd",
|
||||
"live_pressure_placeholder": "f.eks. 1013",
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. let regn",
|
||||
"live_sea_state_placeholder": "f.eks. 3",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Optankede liter",
|
||||
"live_water_placeholder": "Optankede liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "f.eks. 5,2",
|
||||
"live_stw_placeholder": "f.eks. 4,8",
|
||||
"live_sog_hint": "Fart over grund (kn) — GPS-værdi forudfyldes, hvis tilgængelig.",
|
||||
"delete_entry": "Slet tag",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag permanent?",
|
||||
"carry_over_tanks_title": "Overføre data fra den foregående dag?",
|
||||
@@ -283,7 +344,57 @@
|
||||
"revoke": "Fjerne",
|
||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Linket er gyldigt i 48 timer"
|
||||
"invite_expires": "Linket er gyldigt i 48 timer",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||
"nmea_import_btn": "Import NMEA",
|
||||
"nmea_file_label": "NMEA file",
|
||||
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||
"nmea_mode_label": "Generate journal entries",
|
||||
"nmea_mode_interval": "By time interval",
|
||||
"nmea_mode_change": "On significant change",
|
||||
"nmea_mode_both": "Both (merge)",
|
||||
"nmea_interval_label": "Interval (minutes)",
|
||||
"nmea_import_track": "Import GPS track from NMEA",
|
||||
"nmea_preview": "Preview",
|
||||
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||
"nmea_select_all": "Select all",
|
||||
"nmea_select_none": "Select none",
|
||||
"nmea_source_interval": "Interval",
|
||||
"nmea_source_change": "Event",
|
||||
"nmea_apply": "Apply to journal",
|
||||
"nmea_back": "Back",
|
||||
"nmea_cancel": "Cancel",
|
||||
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||
"nmea_archive_keep": "Archive",
|
||||
"nmea_archive_discard": "Discard",
|
||||
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||
"nmea_error_parse": "Could not read NMEA file.",
|
||||
"nmea_error_read": "Could not read file.",
|
||||
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||
"nmea_remark_interval": "NMEA interval",
|
||||
"nmea_remark_uncertain": "uncertain",
|
||||
"nmea_remark_depth": "Depth {{depth}} m",
|
||||
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dine logbøger",
|
||||
@@ -663,7 +774,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}",
|
||||
"account_logbooks": "Et overblik over logbøger",
|
||||
"col_logbook": "Logbog"
|
||||
"col_logbook": "Logbog",
|
||||
"event_series_title": "Hændelsesforløb",
|
||||
"event_series_hint": "Kronologiske værdier fra hændelsesloggen.",
|
||||
"event_series_pressure": "Lufttryk",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Ingen indtastninger endnu."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Spring turen over",
|
||||
|
||||
@@ -197,6 +197,67 @@
|
||||
"saving": "Wird gespeichert...",
|
||||
"saved": "Logbuchseite erfolgreich gespeichert!",
|
||||
"loading": "Journal wird geladen...",
|
||||
"view_mode_label": "Ansicht",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-Journal",
|
||||
"live_loading": "Live-Journal wird geladen...",
|
||||
"live_load_error": "Live-Journal konnte nicht geladen werden.",
|
||||
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
|
||||
"live_open_editor": "Vollständiger Editor",
|
||||
"live_actions_label": "Schnellaktionen",
|
||||
"live_stream_label": "Ereignisprotokoll",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Noch keine Einträge — tippe auf eine Aktion.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stop",
|
||||
"live_cast_off": "Ablegen",
|
||||
"live_moor": "Anlegen",
|
||||
"live_sails_btn": "Segel",
|
||||
"live_sails_pick": "Segel wählen",
|
||||
"live_sails_confirm": "Eintragen",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Freitext eingeben…",
|
||||
"live_comment_confirm": "Eintragen",
|
||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||
"live_event_generic": "Ereignis",
|
||||
"live_weather_btn": "Wetter",
|
||||
"live_wind_btn": "Wind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Luftdruck",
|
||||
"live_precip_btn": "Niederschlag",
|
||||
"live_sea_state_btn": "Seegang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Wasser",
|
||||
"live_wind_entry": "Wind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
||||
"live_precip_entry": "Niederschlag {{value}}",
|
||||
"live_sea_state_entry": "Seegang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Wasser +{{liters}} L",
|
||||
"live_auto_position": "Auto-Position",
|
||||
"live_undo_hint": "Eintrag gespeichert",
|
||||
"live_undo_btn": "Rückgängig",
|
||||
"live_pressure_placeholder": "z. B. 1013",
|
||||
"live_temp_placeholder": "z. B. 18",
|
||||
"live_precip_placeholder": "z. B. leichter Regen",
|
||||
"live_sea_state_placeholder": "z. B. 3",
|
||||
"live_course_placeholder": "z. B. 245",
|
||||
"live_fuel_placeholder": "Nachgefüllte Liter",
|
||||
"live_water_placeholder": "Nachgefüllte Liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "z. B. 5,2",
|
||||
"live_stw_placeholder": "z. B. 4,8",
|
||||
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||
@@ -273,6 +334,56 @@
|
||||
"track_map_end": "Ziel",
|
||||
"track_map_speed_slow": "langsam",
|
||||
"track_map_speed_fast": "schnell",
|
||||
"nmea_import_title": "NMEA-Protokoll importieren",
|
||||
"nmea_import_intro": "Lade eine .nmea-Datei vom Bord-Logger. Die App schlägt Journal-Einträge vor — du entscheidest, was übernommen wird.",
|
||||
"nmea_import_btn": "NMEA importieren",
|
||||
"nmea_file_label": "NMEA-Datei",
|
||||
"nmea_stats": "{{lines}} Sätze erkannt · Typen: {{types}}",
|
||||
"nmea_warn_no_position": "Keine Positions-Sätze gefunden — Track und GPS-Felder können leer bleiben.",
|
||||
"nmea_warn_duplicate_file": "Diese NMEA-Datei wurde bereits importiert. Ein erneuter Import derselben Datei fügt doppelte Journal-Einträge hinzu.",
|
||||
"nmea_mode_label": "Journal-Einträge erzeugen",
|
||||
"nmea_mode_interval": "Nach Zeitintervall",
|
||||
"nmea_mode_change": "Bei signifikanter Änderung",
|
||||
"nmea_mode_both": "Beides (zusammenführen)",
|
||||
"nmea_interval_label": "Intervall (Minuten)",
|
||||
"nmea_import_track": "GPS-Track aus NMEA übernehmen",
|
||||
"nmea_preview": "Vorschau",
|
||||
"nmea_preview_hint": "{{count}} vorgeschlagene Journal-Einträge",
|
||||
"nmea_select_all": "Alle auswählen",
|
||||
"nmea_select_none": "Keine auswählen",
|
||||
"nmea_source_interval": "Intervall",
|
||||
"nmea_source_change": "Ereignis",
|
||||
"nmea_apply": "In Journal übernehmen",
|
||||
"nmea_back": "Zurück",
|
||||
"nmea_cancel": "Abbrechen",
|
||||
"nmea_archive_question": "Rohprotokoll lokal archivieren? (Nur auf diesem Gerät, nicht synchronisiert.)",
|
||||
"nmea_archive_keep": "Archivieren",
|
||||
"nmea_archive_discard": "Verwerfen",
|
||||
"nmea_archive_stored": "NMEA archiviert: {{name}}",
|
||||
"nmea_archive_delete_confirm": "Archiviertes NMEA-Protokoll von diesem Gerät löschen?",
|
||||
"nmea_error_no_samples": "Keine verwertbaren NMEA-Sätze in der Datei.",
|
||||
"nmea_error_parse": "NMEA-Datei konnte nicht gelesen werden.",
|
||||
"nmea_error_read": "Datei konnte nicht gelesen werden.",
|
||||
"nmea_error_no_file": "Bitte zuerst eine NMEA-Datei wählen.",
|
||||
"nmea_error_no_selection": "Bitte mindestens einen Journal-Eintrag auswählen.",
|
||||
"nmea_remark_interval": "NMEA Intervall",
|
||||
"nmea_remark_uncertain": "unsicher",
|
||||
"nmea_remark_depth": "Tiefe {{depth}} m",
|
||||
"nmea_change_course": "Kursänderung {{from}}° → {{to}}°",
|
||||
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||
"nmea_change_pressure": "Luftdruck {{from}} → {{to}} hPa",
|
||||
"nmea_change_depth": "Tiefe {{from}} → {{to}} m",
|
||||
"nmea_change_engine_start": "Motor an ({{rpm}} U/min)",
|
||||
"nmea_change_engine_stop": "Motor aus",
|
||||
"nmea_change_autopilot_on": "Autopilot ein",
|
||||
"nmea_change_autopilot_off": "Autopilot aus",
|
||||
"nmea_change_gps_lost": "GPS-Fix verloren",
|
||||
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
|
||||
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
|
||||
"nmea_change_anchor": "Ankern / Stop",
|
||||
"nmea_change_speed": "Geschw. {{from}} → {{to}} kn",
|
||||
"track_map_error": "Karte konnte nicht geladen werden.",
|
||||
"exporting": "Exportiere...",
|
||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
||||
@@ -663,7 +774,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Tag {{day}}",
|
||||
"account_logbooks": "Logbücher im Überblick",
|
||||
"col_logbook": "Logbuch"
|
||||
"col_logbook": "Logbuch",
|
||||
"event_series_title": "Ereignis-Verläufe",
|
||||
"event_series_hint": "Chronologische Werte aus dem Ereignisprotokoll.",
|
||||
"event_series_pressure": "Luftdruck",
|
||||
"event_series_wind": "Wind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Keine Einträge vorhanden."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Tour überspringen",
|
||||
|
||||
@@ -197,6 +197,67 @@
|
||||
"saving": "Saving...",
|
||||
"saved": "Logbook page saved successfully!",
|
||||
"loading": "Loading journal...",
|
||||
"view_mode_label": "View",
|
||||
"view_list": "List",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live Journal",
|
||||
"live_loading": "Loading live journal...",
|
||||
"live_load_error": "Could not load live journal.",
|
||||
"live_action_error": "Could not save entry.",
|
||||
"live_open_editor": "Full editor",
|
||||
"live_actions_label": "Quick actions",
|
||||
"live_stream_label": "Event log",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "No entries yet — tap an action.",
|
||||
"live_motor_start": "Engine Start",
|
||||
"live_motor_stop": "Engine Stop",
|
||||
"live_cast_off": "Cast off",
|
||||
"live_moor": "Moor",
|
||||
"live_sails_btn": "Sails",
|
||||
"live_sails_pick": "Select sails",
|
||||
"live_sails_confirm": "Log entry",
|
||||
"live_sails": "Sails: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_comment_btn": "Comment",
|
||||
"live_comment_placeholder": "Enter text…",
|
||||
"live_comment_confirm": "Log entry",
|
||||
"live_gps_error": "Could not determine GPS position.",
|
||||
"live_event_generic": "Event",
|
||||
"live_weather_btn": "Weather",
|
||||
"live_wind_btn": "Wind",
|
||||
"live_temp_btn": "Temp °C",
|
||||
"live_pressure_btn": "Pressure",
|
||||
"live_precip_btn": "Precipitation",
|
||||
"live_sea_state_btn": "Sea state",
|
||||
"live_course_btn": "Course",
|
||||
"live_fuel_btn": "Fuel",
|
||||
"live_water_btn": "Water",
|
||||
"live_wind_entry": "Wind {{value}}",
|
||||
"live_temp_entry": "Temperature {{temp}} °C",
|
||||
"live_pressure_entry": "Pressure {{value}} hPa",
|
||||
"live_precip_entry": "Precipitation {{value}}",
|
||||
"live_sea_state_entry": "Sea state {{value}}",
|
||||
"live_course_entry": "Course {{course}}",
|
||||
"live_fuel_entry": "Fuel +{{liters}} L",
|
||||
"live_water_entry": "Water +{{liters}} L",
|
||||
"live_auto_position": "Auto position",
|
||||
"live_undo_hint": "Entry saved",
|
||||
"live_undo_btn": "Undo",
|
||||
"live_pressure_placeholder": "e.g. 1013",
|
||||
"live_temp_placeholder": "e.g. 18",
|
||||
"live_precip_placeholder": "e.g. light rain",
|
||||
"live_sea_state_placeholder": "e.g. 3",
|
||||
"live_course_placeholder": "e.g. 245",
|
||||
"live_fuel_placeholder": "Liters refilled",
|
||||
"live_water_placeholder": "Liters refilled",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "e.g. 5.2",
|
||||
"live_stw_placeholder": "e.g. 4.8",
|
||||
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"carry_over_tanks_title": "Carry over from previous day?",
|
||||
@@ -273,6 +334,56 @@
|
||||
"track_map_end": "End",
|
||||
"track_map_speed_slow": "slow",
|
||||
"track_map_speed_fast": "fast",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||
"nmea_import_btn": "Import NMEA",
|
||||
"nmea_file_label": "NMEA file",
|
||||
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries.",
|
||||
"nmea_mode_label": "Generate journal entries",
|
||||
"nmea_mode_interval": "By time interval",
|
||||
"nmea_mode_change": "On significant change",
|
||||
"nmea_mode_both": "Both (merge)",
|
||||
"nmea_interval_label": "Interval (minutes)",
|
||||
"nmea_import_track": "Import GPS track from NMEA",
|
||||
"nmea_preview": "Preview",
|
||||
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||
"nmea_select_all": "Select all",
|
||||
"nmea_select_none": "Select none",
|
||||
"nmea_source_interval": "Interval",
|
||||
"nmea_source_change": "Event",
|
||||
"nmea_apply": "Apply to journal",
|
||||
"nmea_back": "Back",
|
||||
"nmea_cancel": "Cancel",
|
||||
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||
"nmea_archive_keep": "Archive",
|
||||
"nmea_archive_discard": "Discard",
|
||||
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||
"nmea_error_parse": "Could not read NMEA file.",
|
||||
"nmea_error_read": "Could not read file.",
|
||||
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||
"nmea_remark_interval": "NMEA interval",
|
||||
"nmea_remark_uncertain": "uncertain",
|
||||
"nmea_remark_depth": "Depth {{depth}} m",
|
||||
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||
"track_map_error": "Could not load map.",
|
||||
"exporting": "Exporting...",
|
||||
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
||||
@@ -663,7 +774,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Day {{day}}",
|
||||
"account_logbooks": "Logbooks overview",
|
||||
"col_logbook": "Logbook"
|
||||
"col_logbook": "Logbook",
|
||||
"event_series_title": "Event series",
|
||||
"event_series_hint": "Chronological values from the event log.",
|
||||
"event_series_pressure": "Barometric pressure",
|
||||
"event_series_wind": "Wind",
|
||||
"event_series_motor": "Engine",
|
||||
"event_series_empty": "No entries yet."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Skip tour",
|
||||
|
||||
@@ -197,6 +197,67 @@
|
||||
"saving": "...vil bli reddet...",
|
||||
"saved": "Loggboksiden er vellykket lagret!",
|
||||
"loading": "Tidsskriftet lastes inn...",
|
||||
"view_mode_label": "Visning",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal lastes inn...",
|
||||
"live_load_error": "Live-journal kunne ikke lastes inn.",
|
||||
"live_action_error": "Oppføringen kunne ikke lagres.",
|
||||
"live_open_editor": "Full editor",
|
||||
"live_actions_label": "Hurtighandlinger",
|
||||
"live_stream_label": "Hendelseslogg",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Ingen oppføringer ennå — trykk på en handling.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stopp",
|
||||
"live_cast_off": "Avreise",
|
||||
"live_moor": "Anløp",
|
||||
"live_sails_btn": "Seil",
|
||||
"live_sails_pick": "Velg seil",
|
||||
"live_sails_confirm": "Loggfør",
|
||||
"live_sails": "Seil: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Skriv inn tekst…",
|
||||
"live_comment_confirm": "Loggfør",
|
||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||
"live_event_generic": "Hendelse",
|
||||
"live_weather_btn": "Vær",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttrykk",
|
||||
"live_precip_btn": "Nedbør",
|
||||
"live_sea_state_btn": "Sjøgang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vann",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttrykk {{value}} hPa",
|
||||
"live_precip_entry": "Nedbør {{value}}",
|
||||
"live_sea_state_entry": "Sjøgang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vann +{{liters}} L",
|
||||
"live_auto_position": "Auto-posisjon",
|
||||
"live_undo_hint": "Oppføring lagret",
|
||||
"live_undo_btn": "Angre",
|
||||
"live_pressure_placeholder": "f.eks. 1013",
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. lett regn",
|
||||
"live_sea_state_placeholder": "f.eks. 3",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Påfylte liter",
|
||||
"live_water_placeholder": "Påfylte liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "f.eks. 5,2",
|
||||
"live_stw_placeholder": "f.eks. 4,8",
|
||||
"live_sog_hint": "Fart over grunn (kn) — GPS-verdi fylles inn hvis tilgjengelig.",
|
||||
"delete_entry": "Slett tagg",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?",
|
||||
"carry_over_tanks_title": "Overføre data fra dagen før?",
|
||||
@@ -283,7 +344,57 @@
|
||||
"revoke": "Fjern",
|
||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Lenken er gyldig i 48 timer"
|
||||
"invite_expires": "Lenken er gyldig i 48 timer",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||
"nmea_import_btn": "Import NMEA",
|
||||
"nmea_file_label": "NMEA file",
|
||||
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||
"nmea_mode_label": "Generate journal entries",
|
||||
"nmea_mode_interval": "By time interval",
|
||||
"nmea_mode_change": "On significant change",
|
||||
"nmea_mode_both": "Both (merge)",
|
||||
"nmea_interval_label": "Interval (minutes)",
|
||||
"nmea_import_track": "Import GPS track from NMEA",
|
||||
"nmea_preview": "Preview",
|
||||
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||
"nmea_select_all": "Select all",
|
||||
"nmea_select_none": "Select none",
|
||||
"nmea_source_interval": "Interval",
|
||||
"nmea_source_change": "Event",
|
||||
"nmea_apply": "Apply to journal",
|
||||
"nmea_back": "Back",
|
||||
"nmea_cancel": "Cancel",
|
||||
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||
"nmea_archive_keep": "Archive",
|
||||
"nmea_archive_discard": "Discard",
|
||||
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||
"nmea_error_parse": "Could not read NMEA file.",
|
||||
"nmea_error_read": "Could not read file.",
|
||||
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||
"nmea_remark_interval": "NMEA interval",
|
||||
"nmea_remark_uncertain": "uncertain",
|
||||
"nmea_remark_depth": "Depth {{depth}} m",
|
||||
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Loggbøkene dine",
|
||||
@@ -663,7 +774,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}",
|
||||
"account_logbooks": "Oversikt over loggbøker",
|
||||
"col_logbook": "Loggbok"
|
||||
"col_logbook": "Loggbok",
|
||||
"event_series_title": "Hendelsesforløp",
|
||||
"event_series_hint": "Kronologiske verdier fra hendelsesloggen.",
|
||||
"event_series_pressure": "Lufttrykk",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Ingen oppføringer ennå."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Hopp over turen",
|
||||
|
||||
@@ -197,6 +197,67 @@
|
||||
"saving": "Kommer att sparas...",
|
||||
"saved": "Loggbokssidan har sparats framgångsrikt!",
|
||||
"loading": "Journalen laddas...",
|
||||
"view_mode_label": "Vy",
|
||||
"view_list": "Lista",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal laddas...",
|
||||
"live_load_error": "Live-journal kunde inte laddas.",
|
||||
"live_action_error": "Posten kunde inte sparas.",
|
||||
"live_open_editor": "Fullständig editor",
|
||||
"live_actions_label": "Snabbåtgärder",
|
||||
"live_stream_label": "Händelselogg",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Inga poster ännu — tryck på en åtgärd.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stopp",
|
||||
"live_cast_off": "Avgång",
|
||||
"live_moor": "Anlöp",
|
||||
"live_sails_btn": "Segel",
|
||||
"live_sails_pick": "Välj segel",
|
||||
"live_sails_confirm": "Logga",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Ange text…",
|
||||
"live_comment_confirm": "Logga",
|
||||
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
||||
"live_event_generic": "Händelse",
|
||||
"live_weather_btn": "Väder",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttryck",
|
||||
"live_precip_btn": "Nederbörd",
|
||||
"live_sea_state_btn": "Sjögang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vatten",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttryck {{value}} hPa",
|
||||
"live_precip_entry": "Nederbörd {{value}}",
|
||||
"live_sea_state_entry": "Sjögang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vatten +{{liters}} L",
|
||||
"live_auto_position": "Auto-position",
|
||||
"live_undo_hint": "Post sparad",
|
||||
"live_undo_btn": "Ångra",
|
||||
"live_pressure_placeholder": "t.ex. 1013",
|
||||
"live_temp_placeholder": "t.ex. 18",
|
||||
"live_precip_placeholder": "t.ex. lätt regn",
|
||||
"live_sea_state_placeholder": "t.ex. 3",
|
||||
"live_course_placeholder": "t.ex. 245",
|
||||
"live_fuel_placeholder": "Påfyllda liter",
|
||||
"live_water_placeholder": "Påfyllda liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "t.ex. 5,2",
|
||||
"live_stw_placeholder": "t.ex. 4,8",
|
||||
"live_sog_hint": "Fart över grund (kn) — GPS-värde fylls i om tillgängligt.",
|
||||
"delete_entry": "Ta bort tagg",
|
||||
"delete_confirm": "Är du säker på att du vill radera den här resedagen permanent?",
|
||||
"carry_over_tanks_title": "Överföra data från föregående dag?",
|
||||
@@ -283,7 +344,57 @@
|
||||
"revoke": "Ta bort",
|
||||
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
|
||||
"invite_role": "Roll",
|
||||
"invite_expires": "Länken är giltig i 48 timmar"
|
||||
"invite_expires": "Länken är giltig i 48 timmar",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||
"nmea_import_btn": "Import NMEA",
|
||||
"nmea_file_label": "NMEA file",
|
||||
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||
"nmea_mode_label": "Generate journal entries",
|
||||
"nmea_mode_interval": "By time interval",
|
||||
"nmea_mode_change": "On significant change",
|
||||
"nmea_mode_both": "Both (merge)",
|
||||
"nmea_interval_label": "Interval (minutes)",
|
||||
"nmea_import_track": "Import GPS track from NMEA",
|
||||
"nmea_preview": "Preview",
|
||||
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||
"nmea_select_all": "Select all",
|
||||
"nmea_select_none": "Select none",
|
||||
"nmea_source_interval": "Interval",
|
||||
"nmea_source_change": "Event",
|
||||
"nmea_apply": "Apply to journal",
|
||||
"nmea_back": "Back",
|
||||
"nmea_cancel": "Cancel",
|
||||
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||
"nmea_archive_keep": "Archive",
|
||||
"nmea_archive_discard": "Discard",
|
||||
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||
"nmea_error_parse": "Could not read NMEA file.",
|
||||
"nmea_error_read": "Could not read file.",
|
||||
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||
"nmea_remark_interval": "NMEA interval",
|
||||
"nmea_remark_uncertain": "uncertain",
|
||||
"nmea_remark_depth": "Depth {{depth}} m",
|
||||
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dina loggböcker",
|
||||
@@ -663,7 +774,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}__.",
|
||||
"account_logbooks": "Loggböcker i en överblick",
|
||||
"col_logbook": "Loggbok"
|
||||
"col_logbook": "Loggbok",
|
||||
"event_series_title": "Händelseförlopp",
|
||||
"event_series_hint": "Kronologiska värden från händelseloggen.",
|
||||
"event_series_pressure": "Lufttryck",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Inga poster ännu."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Hoppa över turen",
|
||||
|
||||
@@ -34,7 +34,12 @@ export const PlausibleEvents = {
|
||||
LOCAL_PIN_SET: 'Local PIN Set',
|
||||
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||
RECOVERY_ROTATED: 'Recovery Rotated'
|
||||
RECOVERY_ROTATED: 'Recovery Rotated',
|
||||
LANGUAGE_CHANGED: 'Language Changed',
|
||||
NMEA_IMPORTED: 'NMEA Imported',
|
||||
NMEA_UPLOADED: 'NMEA Uploaded',
|
||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged'
|
||||
} as const
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
@@ -64,6 +64,15 @@ export interface LocalGpsTrack {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalNmeaArchive {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalLogbookKey {
|
||||
logbookId: string
|
||||
encryptedKey: string
|
||||
@@ -89,6 +98,7 @@ class DaagboxDatabase extends Dexie {
|
||||
entries!: Table<LocalEntry>
|
||||
photos!: Table<LocalPhoto>
|
||||
gpsTracks!: Table<LocalGpsTrack>
|
||||
nmeaArchives!: Table<LocalNmeaArchive>
|
||||
logbookKeys!: Table<LocalLogbookKey>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
|
||||
@@ -145,6 +155,18 @@ class DaagboxDatabase extends Dexie {
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
this.version(6).stores({
|
||||
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||
yachts: 'logbookId, updatedAt',
|
||||
crews: 'payloadId, logbookId, updatedAt',
|
||||
deviations: 'logbookId, updatedAt',
|
||||
entries: 'payloadId, logbookId, updatedAt',
|
||||
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { LIVE_EVENT_CODES } from '../utils/liveEventCodes.js'
|
||||
|
||||
export interface EventSeriesPoint {
|
||||
entryId: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
time: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface EventSeriesSummary {
|
||||
pressure: EventSeriesPoint[]
|
||||
wind: EventSeriesPoint[]
|
||||
motor: EventSeriesPoint[]
|
||||
}
|
||||
|
||||
function sortPoints(points: EventSeriesPoint[]): EventSeriesPoint[] {
|
||||
return [...points].sort((a, b) => {
|
||||
const dateCompare = a.date.localeCompare(b.date)
|
||||
if (dateCompare !== 0) return dateCompare
|
||||
return a.time.localeCompare(b.time)
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadLogbookEventSeries(logbookId: string): Promise<EventSeriesSummary> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const local = await db.entries.where({ logbookId }).toArray()
|
||||
const decryptedEntries: Array<{
|
||||
entryId: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
events: LogEventPayload[]
|
||||
}> = []
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (!decrypted) continue
|
||||
decryptedEntries.push({
|
||||
entryId: entry.payloadId,
|
||||
date: String(decrypted.date || ''),
|
||||
dayOfTravel: String(decrypted.dayOfTravel || ''),
|
||||
events: (decrypted.events as LogEventPayload[]) || []
|
||||
})
|
||||
}
|
||||
|
||||
decryptedEntries.sort((a, b) =>
|
||||
compareTravelDaysChronological(
|
||||
{ date: a.date, dayOfTravel: a.dayOfTravel },
|
||||
{ date: b.date, dayOfTravel: b.dayOfTravel }
|
||||
)
|
||||
)
|
||||
|
||||
const pressure: EventSeriesPoint[] = []
|
||||
const wind: EventSeriesPoint[] = []
|
||||
const motor: EventSeriesPoint[] = []
|
||||
|
||||
for (const entry of decryptedEntries) {
|
||||
for (const event of entry.events) {
|
||||
const base = {
|
||||
entryId: entry.entryId,
|
||||
date: entry.date,
|
||||
dayOfTravel: entry.dayOfTravel,
|
||||
time: event.time
|
||||
}
|
||||
|
||||
if (event.windPressure?.trim()) {
|
||||
pressure.push({
|
||||
...base,
|
||||
summary: `${event.windPressure} hPa`
|
||||
})
|
||||
}
|
||||
|
||||
if (event.windDirection?.trim() || event.windStrength?.trim()) {
|
||||
wind.push({
|
||||
...base,
|
||||
summary: [event.windDirection, event.windStrength].filter(Boolean).join(' ')
|
||||
})
|
||||
}
|
||||
|
||||
const code = event.remarks?.trim() ?? ''
|
||||
if (
|
||||
code === LIVE_EVENT_CODES.MOTOR_START ||
|
||||
code === LIVE_EVENT_CODES.MOTOR_STOP
|
||||
) {
|
||||
motor.push({
|
||||
...base,
|
||||
summary: code === LIVE_EVENT_CODES.MOTOR_START ? 'start' : 'stop'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pressure: sortPoints(pressure),
|
||||
wind: sortPoints(wind),
|
||||
motor: sortPoints(motor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseNmeaFile } from './nmeaParse.js'
|
||||
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||
import { generateNmeaJournalCandidates } from './nmeaJournalGenerator.js'
|
||||
|
||||
const nmeaPath = resolve(import.meta.dirname, '../../../../testdata/tracks/kieler-foerde-5sm.nmea')
|
||||
|
||||
describe('kieler-foerde testdata', () => {
|
||||
it('parses the sample NMEA log and yields journal candidates', () => {
|
||||
const text = readFileSync(nmeaPath, 'utf8')
|
||||
const result = parseNmeaFile(text, 'kieler-foerde-5sm.nmea')
|
||||
|
||||
expect(result.stats.checksumErrors).toBe(0)
|
||||
expect(result.points.length).toBeGreaterThan(30)
|
||||
expect(result.stats.sentenceTypes).toEqual(expect.arrayContaining(['RMC', 'GGA', 'MWV', 'DPT', 'MDA']))
|
||||
|
||||
const changes = detectNmeaChanges(result.points)
|
||||
expect(changes.length).toBeGreaterThan(0)
|
||||
expect(changes.some((c) => ['wind', 'engine_start', 'departure', 'speed', 'depth'].includes(c.type))).toBe(true)
|
||||
|
||||
const journal = generateNmeaJournalCandidates({
|
||||
points: result.points,
|
||||
mode: 'both',
|
||||
intervalMinutes: 60,
|
||||
t: (key) => key
|
||||
})
|
||||
expect(journal.candidates.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NmeaTimePoint } from './nmeaTypes.js'
|
||||
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||
|
||||
function point(
|
||||
timestamp: number,
|
||||
overrides: Partial<NmeaTimePoint> = {}
|
||||
): NmeaTimePoint {
|
||||
return { timestamp, ...overrides }
|
||||
}
|
||||
|
||||
describe('detectNmeaChanges', () => {
|
||||
it('detects significant course changes while underway', () => {
|
||||
const points = [
|
||||
point(0, { cog: 0, sog: 5 }),
|
||||
point(60_000, { cog: 45, sog: 5 })
|
||||
]
|
||||
|
||||
const events = detectNmeaChanges(points, {
|
||||
courseDeltaDeg: 30,
|
||||
windDirDeltaDeg: 30,
|
||||
windSpeedDeltaKnots: 5,
|
||||
pressureDeltaHpa: 2,
|
||||
depthDeltaM: 1,
|
||||
depthDeltaPercent: 25,
|
||||
rpmIdle: 400,
|
||||
rpmRunning: 800,
|
||||
sogUnderWayKn: 2,
|
||||
sogStoppedKn: 0.5,
|
||||
anchorMinutes: 10,
|
||||
speedDeltaKn: 2,
|
||||
dedupeWindowMs: 60_000
|
||||
})
|
||||
|
||||
expect(events.some((e) => e.type === 'course')).toBe(true)
|
||||
const course = events.find((e) => e.type === 'course')
|
||||
expect(course?.summaryParams).toMatchObject({ from: 0, to: 45 })
|
||||
})
|
||||
|
||||
it('detects engine start when RPM rises above threshold', () => {
|
||||
const points = [
|
||||
point(0, { sog: 0, rpm: 0 }),
|
||||
point(30_000, { sog: 3, rpm: 1200 })
|
||||
]
|
||||
|
||||
const events = detectNmeaChanges(points)
|
||||
expect(events.some((e) => e.type === 'engine_start')).toBe(true)
|
||||
})
|
||||
|
||||
it('dedupes repeated events within the configured window', () => {
|
||||
const points = [
|
||||
point(0, { cog: 0, sog: 5 }),
|
||||
point(10_000, { cog: 50, sog: 5 }),
|
||||
point(20_000, { cog: 100, sog: 5 })
|
||||
]
|
||||
|
||||
const events = detectNmeaChanges(points, {
|
||||
courseDeltaDeg: 30,
|
||||
windDirDeltaDeg: 30,
|
||||
windSpeedDeltaKnots: 5,
|
||||
pressureDeltaHpa: 2,
|
||||
depthDeltaM: 1,
|
||||
depthDeltaPercent: 25,
|
||||
rpmIdle: 400,
|
||||
rpmRunning: 800,
|
||||
sogUnderWayKn: 2,
|
||||
sogStoppedKn: 0.5,
|
||||
anchorMinutes: 10,
|
||||
speedDeltaKn: 2,
|
||||
dedupeWindowMs: 120_000
|
||||
})
|
||||
|
||||
const courseEvents = events.filter((e) => e.type === 'course')
|
||||
expect(courseEvents.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
||||
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
||||
import { angularDelta } from './nmeaTimeSeries.js'
|
||||
|
||||
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
||||
const last = events[events.length - 1]
|
||||
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
export function detectNmeaChanges(
|
||||
points: NmeaTimePoint[],
|
||||
config: NmeaDetectionConfig = DEFAULT_NMEA_DETECTION_CONFIG
|
||||
): NmeaChangeEvent[] {
|
||||
const events: NmeaChangeEvent[] = []
|
||||
if (points.length < 2) return events
|
||||
|
||||
let lastCourse: number | undefined
|
||||
let lastWindDir: number | undefined
|
||||
let lastWindSpeed: number | undefined
|
||||
let lastPressure: number | undefined
|
||||
let lastDepth: number | undefined
|
||||
let lastWaterTemp: number | undefined
|
||||
let lastFix: boolean | undefined
|
||||
let engineRunning = false
|
||||
let autopilot: boolean | undefined
|
||||
let underWay = false
|
||||
let stoppedSince: number | null = null
|
||||
let lastSog: number | undefined
|
||||
|
||||
for (const p of points) {
|
||||
const course = p.cog ?? p.hdt ?? p.hdm
|
||||
if (course != null && lastCourse != null && (p.sog ?? 0) > 1) {
|
||||
if (angularDelta(course, lastCourse) >= config.courseDeltaDeg) {
|
||||
pushUnique(events, {
|
||||
type: 'course',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_course',
|
||||
summaryParams: { from: Math.round(lastCourse), to: Math.round(course) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (course != null) lastCourse = course
|
||||
|
||||
if (p.windDir != null && lastWindDir != null) {
|
||||
if (angularDelta(p.windDir, lastWindDir) >= config.windDirDeltaDeg) {
|
||||
pushUnique(events, {
|
||||
type: 'wind',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_wind',
|
||||
summaryParams: { from: Math.round(lastWindDir), to: Math.round(p.windDir) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
} else if (
|
||||
p.windSpeedKnots != null &&
|
||||
lastWindSpeed != null &&
|
||||
Math.abs(p.windSpeedKnots - lastWindSpeed) >= config.windSpeedDeltaKnots
|
||||
) {
|
||||
pushUnique(events, {
|
||||
type: 'wind',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_wind_speed',
|
||||
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.windDir != null) lastWindDir = p.windDir
|
||||
if (p.windSpeedKnots != null) lastWindSpeed = p.windSpeedKnots
|
||||
|
||||
if (p.pressureHpa != null && lastPressure != null) {
|
||||
if (Math.abs(p.pressureHpa - lastPressure) >= config.pressureDeltaHpa) {
|
||||
pushUnique(events, {
|
||||
type: 'pressure',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_pressure',
|
||||
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.pressureHpa != null) lastPressure = p.pressureHpa
|
||||
|
||||
if (p.depthM != null && lastDepth != null) {
|
||||
const delta = Math.abs(p.depthM - lastDepth)
|
||||
const rel = lastDepth > 0 ? (delta / lastDepth) * 100 : 100
|
||||
if (delta >= config.depthDeltaM || rel >= config.depthDeltaPercent) {
|
||||
pushUnique(events, {
|
||||
type: 'depth',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_depth',
|
||||
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.depthM != null) lastDepth = p.depthM
|
||||
|
||||
if (p.rpm != null) {
|
||||
const running = p.rpm >= config.rpmRunning
|
||||
const idle = p.rpm <= config.rpmIdle
|
||||
if (running && !engineRunning) {
|
||||
pushUnique(events, {
|
||||
type: 'engine_start',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_engine_start',
|
||||
summaryParams: { rpm: Math.round(p.rpm) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
engineRunning = true
|
||||
} else if (idle && engineRunning) {
|
||||
pushUnique(events, {
|
||||
type: 'engine_stop',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_engine_stop',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
engineRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
if (p.autopilotEngaged != null && autopilot != null && p.autopilotEngaged !== autopilot) {
|
||||
pushUnique(events, {
|
||||
type: p.autopilotEngaged ? 'autopilot_on' : 'autopilot_off',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: p.autopilotEngaged ? 'logs.nmea_change_autopilot_on' : 'logs.nmea_change_autopilot_off',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
if (p.autopilotEngaged != null) autopilot = p.autopilotEngaged
|
||||
|
||||
if (p.fixValid != null && lastFix != null && p.fixValid !== lastFix) {
|
||||
pushUnique(events, {
|
||||
type: p.fixValid ? 'gps_fix_regained' : 'gps_fix_lost',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: p.fixValid ? 'logs.nmea_change_gps_regained' : 'logs.nmea_change_gps_lost',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
if (p.fixValid != null) lastFix = p.fixValid
|
||||
|
||||
if (p.waterTempC != null && lastWaterTemp != null) {
|
||||
if (Math.abs(p.waterTempC - lastWaterTemp) >= 2) {
|
||||
pushUnique(events, {
|
||||
type: 'water_temp',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_water_temp',
|
||||
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.waterTempC != null) lastWaterTemp = p.waterTempC
|
||||
|
||||
const sog = p.sog ?? 0
|
||||
if (sog >= config.sogUnderWayKn && !underWay) {
|
||||
if (stoppedSince != null && p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
|
||||
pushUnique(events, {
|
||||
type: 'departure',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_departure',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
underWay = true
|
||||
stoppedSince = null
|
||||
}
|
||||
if (sog <= config.sogStoppedKn && underWay) {
|
||||
underWay = false
|
||||
stoppedSince = p.timestamp
|
||||
}
|
||||
if (sog <= config.sogStoppedKn && stoppedSince != null && !underWay) {
|
||||
if (p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
|
||||
pushUnique(events, {
|
||||
type: 'anchor',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_anchor',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
stoppedSince = null
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSog != null && Math.abs(sog - lastSog) >= config.speedDeltaKn) {
|
||||
pushUnique(events, {
|
||||
type: 'speed',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'low',
|
||||
summaryKey: 'logs.nmea_change_speed',
|
||||
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
lastSog = sog
|
||||
}
|
||||
|
||||
return events.sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
||||
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
||||
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
||||
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
||||
import type {
|
||||
NmeaChangeEvent,
|
||||
NmeaImportMode,
|
||||
NmeaJournalCandidate,
|
||||
NmeaTimePoint
|
||||
} from './nmeaTypes.js'
|
||||
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||
import { intervalTimestamps, sampleAt, timestampToHHMM } from './nmeaTimeSeries.js'
|
||||
|
||||
export interface GeneratedNmeaJournal {
|
||||
candidates: Array<NmeaJournalCandidate & { event: LogEventPayload }>
|
||||
}
|
||||
|
||||
function pointToLogEvent(
|
||||
point: NmeaTimePoint,
|
||||
remarks: string,
|
||||
sailsOrMotor: string
|
||||
): LogEventPayload {
|
||||
const course = point.cog ?? point.hdt ?? point.hdm
|
||||
const mgk = course != null ? formatCourseAngle(course) : ''
|
||||
const windDir =
|
||||
point.windDir != null ? degreesToCardinal(point.windDir) : ''
|
||||
|
||||
return normalizeLogEvent({
|
||||
time: timestampToHHMM(point.timestamp),
|
||||
mgk,
|
||||
rwk: '',
|
||||
windDirection: windDir,
|
||||
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
||||
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
||||
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
|
||||
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
|
||||
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
|
||||
sailsOrMotor,
|
||||
remarks
|
||||
})
|
||||
}
|
||||
|
||||
function changeToSailsOrMotor(type: NmeaChangeEvent['type']): string {
|
||||
if (type === 'engine_start') return 'Motor'
|
||||
if (type === 'engine_stop') return 'Segel'
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
|
||||
const parts: string[] = []
|
||||
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
||||
if (change.data?.depthM != null) {
|
||||
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
|
||||
}
|
||||
if (change.confidence === 'low') {
|
||||
parts.push(t('logs.nmea_remark_uncertain'))
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function dedupeCandidates(
|
||||
items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }>,
|
||||
windowMs: number
|
||||
): Array<NmeaJournalCandidate & { event: LogEventPayload }> {
|
||||
const sorted = [...items].sort((a, b) => a.timestamp - b.timestamp)
|
||||
const kept: typeof sorted = []
|
||||
|
||||
for (const item of sorted) {
|
||||
const near = kept.find((k) => Math.abs(k.timestamp - item.timestamp) <= windowMs)
|
||||
if (!near) {
|
||||
kept.push(item)
|
||||
continue
|
||||
}
|
||||
if (item.source === 'change' && near.source === 'interval') {
|
||||
const idx = kept.indexOf(near)
|
||||
kept[idx] = {
|
||||
...item,
|
||||
event: {
|
||||
...near.event,
|
||||
remarks: [item.event.remarks, near.event.remarks].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
|
||||
export function generateNmeaJournalCandidates(options: {
|
||||
points: NmeaTimePoint[]
|
||||
mode: NmeaImportMode
|
||||
intervalMinutes: number
|
||||
t: TFunction
|
||||
}): GeneratedNmeaJournal {
|
||||
const { points, mode, intervalMinutes, t } = options
|
||||
const items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }> = []
|
||||
|
||||
if (mode === 'interval' || mode === 'both') {
|
||||
for (const ts of intervalTimestamps(points, intervalMinutes)) {
|
||||
const sample = sampleAt(points, ts)
|
||||
if (!sample) continue
|
||||
items.push({
|
||||
id: `interval-${ts}`,
|
||||
timestamp: ts,
|
||||
source: 'interval',
|
||||
selected: true,
|
||||
event: pointToLogEvent(sample, t('logs.nmea_remark_interval'), '')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'change' || mode === 'both') {
|
||||
const changes = detectNmeaChanges(points)
|
||||
for (const change of changes) {
|
||||
const sample = change.data ?? sampleAt(points, change.timestamp)
|
||||
if (!sample) continue
|
||||
items.push({
|
||||
id: `change-${change.type}-${change.timestamp}`,
|
||||
timestamp: change.timestamp,
|
||||
source: 'change',
|
||||
changeType: change.type,
|
||||
confidence: change.confidence,
|
||||
selected: true,
|
||||
event: pointToLogEvent(
|
||||
{ ...sample, timestamp: change.timestamp },
|
||||
buildRemarks(change, t),
|
||||
changeToSailsOrMotor(change.type)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = mode === 'both'
|
||||
? dedupeCandidates(items, 15 * 60 * 1000)
|
||||
: items
|
||||
|
||||
return { candidates: deduped }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nmeaPointsToWaypoints, parseNmeaFile } from './nmeaParse.js'
|
||||
|
||||
describe('parseNmeaFile', () => {
|
||||
it('parses RMC position, course and speed', () => {
|
||||
const text = [
|
||||
'$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W',
|
||||
'$GPRMC,133519,A,4808.038,N,01132.000,E,025.0,090.0,230394,003.1,W'
|
||||
].join('\n')
|
||||
|
||||
const result = parseNmeaFile(text, 'test.nmea')
|
||||
|
||||
expect(result.stats.parsedLines).toBe(2)
|
||||
expect(result.stats.sentenceTypes).toContain('RMC')
|
||||
expect(result.points.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const first = result.points[0]
|
||||
expect(first.lat).toBeCloseTo(48.1173, 3)
|
||||
expect(first.lng).toBeCloseTo(11.516667, 3)
|
||||
expect(first.sog).toBe(22.4)
|
||||
expect(first.cog).toBe(84.4)
|
||||
expect(first.fixValid).toBe(true)
|
||||
})
|
||||
|
||||
it('merges wind and depth sentences onto the same timestamp', () => {
|
||||
const text = [
|
||||
'$GPRMC,100000,A,5400.000,N,01000.000,E,5.0,180.0,010124,003.0,E',
|
||||
'$IIMWV,270.0,R,12.5,N,A',
|
||||
'$SDDPT,4.5,0.0'
|
||||
].join('\n')
|
||||
|
||||
const result = parseNmeaFile(text, 'merged.nmea')
|
||||
const last = result.points[result.points.length - 1]
|
||||
|
||||
expect(last.windDir).toBe(270)
|
||||
expect(last.windSpeedKnots).toBe(12.5)
|
||||
expect(last.depthM).toBe(4.5)
|
||||
})
|
||||
|
||||
it('skips lines with invalid checksum', () => {
|
||||
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*FF'
|
||||
const result = parseNmeaFile(text, 'bad.nmea')
|
||||
|
||||
expect(result.stats.checksumErrors).toBe(1)
|
||||
expect(result.points).toHaveLength(0)
|
||||
expect(result.warnings).toContain('no_samples')
|
||||
})
|
||||
|
||||
it('warns when no position sentences are present', () => {
|
||||
const text = '$IIMWV,090.0,R,8.0,N,A'
|
||||
const result = parseNmeaFile(text, 'wind-only.nmea')
|
||||
|
||||
expect(result.warnings).toContain('no_position')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nmeaPointsToWaypoints', () => {
|
||||
it('maps points with coordinates to track waypoints', () => {
|
||||
const waypoints = nmeaPointsToWaypoints([
|
||||
{ timestamp: 1, lat: 54.0, lng: 10.0, sog: 6, cog: 90 },
|
||||
{ timestamp: 2, windDir: 180 },
|
||||
{ timestamp: 3, lat: 54.01, lng: 10.01, hdt: 95 }
|
||||
])
|
||||
|
||||
expect(waypoints).toHaveLength(2)
|
||||
expect(waypoints[0]).toMatchObject({ lat: 54, lng: 10, speedKnots: 6, heading: 90 })
|
||||
expect(waypoints[1].heading).toBe(95)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { NmeaParseResult, NmeaParseStats, NmeaTimePoint } from './nmeaTypes.js'
|
||||
|
||||
function parseChecksum(line: string): boolean {
|
||||
const star = line.lastIndexOf('*')
|
||||
if (star < 0) return true
|
||||
const expected = line.slice(star + 1, star + 3)
|
||||
if (!/^[0-9A-Fa-f]{2}$/.test(expected)) return false
|
||||
let sum = 0
|
||||
for (let i = 1; i < star; i++) sum ^= line.charCodeAt(i)
|
||||
return sum.toString(16).toUpperCase().padStart(2, '0') === expected.toUpperCase()
|
||||
}
|
||||
|
||||
function sentenceType(field0: string): string {
|
||||
return field0.length >= 3 ? field0.slice(-3) : field0
|
||||
}
|
||||
|
||||
function parseLatLon(latStr: string, latHem: string, lonStr: string, lonHem: string): { lat?: number; lng?: number } {
|
||||
const latVal = parseFloat(latStr)
|
||||
const lonVal = parseFloat(lonStr)
|
||||
if (Number.isNaN(latVal) || Number.isNaN(lonVal)) return {}
|
||||
const latDeg = Math.floor(latVal / 100)
|
||||
const latMin = latVal - latDeg * 100
|
||||
let lat = latDeg + latMin / 60
|
||||
if (latHem === 'S') lat = -lat
|
||||
|
||||
const lonDeg = Math.floor(lonVal / 100)
|
||||
const lonMin = lonVal - lonDeg * 100
|
||||
let lng = lonDeg + lonMin / 60
|
||||
if (lonHem === 'W') lng = -lng
|
||||
|
||||
return { lat: Number(lat.toFixed(6)), lng: Number(lng.toFixed(6)) }
|
||||
}
|
||||
|
||||
function parseRmcDateTime(timeStr: string, dateStr: string, baseYear = new Date().getFullYear()): number | null {
|
||||
if (!timeStr || timeStr.length < 6) return null
|
||||
const hh = parseInt(timeStr.slice(0, 2), 10)
|
||||
const mm = parseInt(timeStr.slice(2, 4), 10)
|
||||
const ss = parseInt(timeStr.slice(4, 6), 10)
|
||||
if ([hh, mm, ss].some((n) => Number.isNaN(n))) return null
|
||||
|
||||
let year = baseYear
|
||||
let month = 0
|
||||
let day = 1
|
||||
if (dateStr && dateStr.length >= 6) {
|
||||
day = parseInt(dateStr.slice(0, 2), 10)
|
||||
month = parseInt(dateStr.slice(2, 4), 10) - 1
|
||||
const yy = parseInt(dateStr.slice(4, 6), 10)
|
||||
year = yy >= 70 ? 1900 + yy : 2000 + yy
|
||||
}
|
||||
|
||||
return Date.UTC(year, month, day, hh, mm, ss)
|
||||
}
|
||||
|
||||
function parseWindSpeed(value: string, unit: string): number | undefined {
|
||||
const speed = parseFloat(value)
|
||||
if (Number.isNaN(speed)) return undefined
|
||||
if (unit === 'N') return speed
|
||||
if (unit === 'M') return speed * 1.94384
|
||||
if (unit === 'K') return speed * 0.539957
|
||||
return speed
|
||||
}
|
||||
|
||||
interface MutableState extends NmeaTimePoint {
|
||||
lastTimestamp: number | null
|
||||
}
|
||||
|
||||
function snapshot(state: MutableState): NmeaTimePoint | null {
|
||||
if (state.lastTimestamp == null) return null
|
||||
const { lastTimestamp, ...rest } = state
|
||||
void lastTimestamp
|
||||
if (
|
||||
rest.lat == null &&
|
||||
rest.lng == null &&
|
||||
rest.cog == null &&
|
||||
rest.sog == null &&
|
||||
rest.hdt == null &&
|
||||
rest.windDir == null &&
|
||||
rest.windSpeedKnots == null &&
|
||||
rest.depthM == null &&
|
||||
rest.rpm == null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return rest as NmeaTimePoint
|
||||
}
|
||||
|
||||
function pushPoint(points: NmeaTimePoint[], state: MutableState) {
|
||||
const snap = snapshot(state)
|
||||
if (!snap) return
|
||||
const last = points[points.length - 1]
|
||||
if (last && last.timestamp === snap.timestamp) {
|
||||
points[points.length - 1] = { ...last, ...snap }
|
||||
return
|
||||
}
|
||||
points.push(snap)
|
||||
}
|
||||
|
||||
function applySentence(state: MutableState, type: string, fields: string[], points: NmeaTimePoint[]) {
|
||||
switch (type) {
|
||||
case 'RMC': {
|
||||
const status = fields[2]
|
||||
const ts = parseRmcDateTime(fields[1], fields[9])
|
||||
if (ts != null) {
|
||||
state.timestamp = ts
|
||||
state.lastTimestamp = ts
|
||||
}
|
||||
if (status === 'A') {
|
||||
Object.assign(state, parseLatLon(fields[3], fields[4], fields[5], fields[6]))
|
||||
state.fixValid = true
|
||||
const sog = parseFloat(fields[7])
|
||||
const cog = parseFloat(fields[8])
|
||||
if (!Number.isNaN(sog)) state.sog = sog
|
||||
if (!Number.isNaN(cog)) state.cog = cog
|
||||
} else {
|
||||
state.fixValid = false
|
||||
}
|
||||
pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'GGA': {
|
||||
const ts = parseRmcDateTime(fields[1], '')
|
||||
if (ts != null) {
|
||||
state.timestamp = ts
|
||||
state.lastTimestamp = ts
|
||||
}
|
||||
Object.assign(state, parseLatLon(fields[2], fields[3], fields[4], fields[5]))
|
||||
const quality = parseInt(fields[6], 10)
|
||||
state.fixValid = !Number.isNaN(quality) && quality > 0
|
||||
pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'GLL': {
|
||||
const ts = parseRmcDateTime(fields[5], fields[6] ?? '')
|
||||
if (ts != null) {
|
||||
state.timestamp = ts
|
||||
state.lastTimestamp = ts
|
||||
}
|
||||
Object.assign(state, parseLatLon(fields[1], fields[2], fields[3], fields[4]))
|
||||
state.fixValid = fields[7] === 'A'
|
||||
pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'VTG': {
|
||||
const cog = parseFloat(fields[1])
|
||||
const sog = parseFloat(fields[5] || fields[7])
|
||||
if (!Number.isNaN(cog)) state.cog = cog
|
||||
if (!Number.isNaN(sog)) state.sog = sog
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'HDT':
|
||||
state.hdt = parseFloat(fields[1])
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
case 'HDM':
|
||||
state.hdm = parseFloat(fields[1])
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
case 'HDG': {
|
||||
const hdg = parseFloat(fields[1])
|
||||
if (!Number.isNaN(hdg)) state.hdm = hdg
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MWV': {
|
||||
if (fields[5] !== 'A') break
|
||||
const dir = parseFloat(fields[1])
|
||||
const speed = parseWindSpeed(fields[3], fields[4])
|
||||
if (!Number.isNaN(dir)) state.windDir = dir
|
||||
if (speed != null) state.windSpeedKnots = Number(speed.toFixed(1))
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MWD': {
|
||||
const dir = parseFloat(fields[1])
|
||||
const speed = parseFloat(fields[5])
|
||||
if (!Number.isNaN(dir)) state.windDir = dir
|
||||
if (!Number.isNaN(speed)) state.windSpeedKnots = Number(speed.toFixed(1))
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'DPT':
|
||||
case 'DBT': {
|
||||
const depth = parseFloat(fields[1])
|
||||
if (!Number.isNaN(depth)) state.depthM = depth
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'RPM': {
|
||||
const rpm = parseFloat(fields[3] ?? fields[2])
|
||||
if (!Number.isNaN(rpm)) state.rpm = rpm
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MDA': {
|
||||
const inchHg = parseFloat(fields[3])
|
||||
const hpaField = parseFloat(fields[15] ?? fields[4])
|
||||
if (!Number.isNaN(hpaField) && hpaField > 800) state.pressureHpa = hpaField
|
||||
else if (!Number.isNaN(inchHg)) state.pressureHpa = inchHg * 33.8639
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MTW': {
|
||||
const temp = parseFloat(fields[1])
|
||||
if (!Number.isNaN(temp)) state.waterTempC = temp
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'VLW': {
|
||||
const nm = parseFloat(fields[1] ?? fields[2])
|
||||
if (!Number.isNaN(nm)) state.logDistanceNm = nm
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'APA': {
|
||||
const mode = fields[1]
|
||||
state.autopilotEngaged = mode === '1' || mode?.toUpperCase() === 'A'
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export function parseNmeaFile(text: string, filename: string): NmeaParseResult {
|
||||
const warnings: string[] = []
|
||||
const points: NmeaTimePoint[] = []
|
||||
const typesSeen = new Set<string>()
|
||||
let totalLines = 0
|
||||
let parsedLines = 0
|
||||
let checksumErrors = 0
|
||||
|
||||
const state: MutableState = { timestamp: 0, lastTimestamp: null }
|
||||
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || (!line.startsWith('$') && !line.startsWith('!'))) continue
|
||||
totalLines++
|
||||
if (!parseChecksum(line)) {
|
||||
checksumErrors++
|
||||
continue
|
||||
}
|
||||
|
||||
const star = line.indexOf('*')
|
||||
const body = star >= 0 ? line.slice(0, star) : line
|
||||
const fields = body.slice(1).split(',')
|
||||
if (fields.length < 2) continue
|
||||
|
||||
const type = sentenceType(fields[0])
|
||||
typesSeen.add(type)
|
||||
applySentence(state, type, fields, points)
|
||||
parsedLines++
|
||||
}
|
||||
|
||||
if (points.length === 0) {
|
||||
warnings.push('no_samples')
|
||||
}
|
||||
if (!typesSeen.has('RMC') && !typesSeen.has('GGA') && !typesSeen.has('GLL')) {
|
||||
warnings.push('no_position')
|
||||
}
|
||||
|
||||
const stats: NmeaParseStats = {
|
||||
totalLines,
|
||||
parsedLines,
|
||||
checksumErrors,
|
||||
sentenceTypes: [...typesSeen].sort()
|
||||
}
|
||||
|
||||
return { points, stats, warnings, rawText: text, filename }
|
||||
}
|
||||
|
||||
export function nmeaPointsToWaypoints(points: NmeaTimePoint[]): import('../trackUpload.js').TrackWaypoint[] {
|
||||
return points
|
||||
.filter((p) => p.lat != null && p.lng != null)
|
||||
.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
lat: p.lat!,
|
||||
lng: p.lng!,
|
||||
speedKnots: p.sog,
|
||||
heading: p.cog ?? p.hdt ?? p.hdm
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { NmeaTimePoint } from './nmeaTypes.js'
|
||||
|
||||
/** Nearest sample at or before timestamp (carry-forward). */
|
||||
export function sampleAt(points: NmeaTimePoint[], timestamp: number): NmeaTimePoint | null {
|
||||
if (points.length === 0) return null
|
||||
let best: NmeaTimePoint | null = null
|
||||
for (const p of points) {
|
||||
if (p.timestamp <= timestamp) best = p
|
||||
else break
|
||||
}
|
||||
return best ?? points[0]
|
||||
}
|
||||
|
||||
export function filterPointsForDate(points: NmeaTimePoint[], dateYmd: string): NmeaTimePoint[] {
|
||||
if (!dateYmd || points.length === 0) return points
|
||||
const [y, m, d] = dateYmd.split('-').map((v) => parseInt(v, 10))
|
||||
if ([y, m, d].some((n) => Number.isNaN(n))) return points
|
||||
|
||||
const start = Date.UTC(y, m - 1, d, 0, 0, 0)
|
||||
const end = Date.UTC(y, m - 1, d, 23, 59, 59)
|
||||
|
||||
const filtered = points.filter((p) => p.timestamp >= start && p.timestamp <= end)
|
||||
return filtered.length > 0 ? filtered : points
|
||||
}
|
||||
|
||||
export function timestampToHHMM(timestamp: number, timeZone?: string): string {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: timeZone ?? undefined
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('en-GB', opts).formatToParts(new Date(timestamp))
|
||||
const hh = parts.find((p) => p.type === 'hour')?.value ?? '00'
|
||||
const mm = parts.find((p) => p.type === 'minute')?.value ?? '00'
|
||||
return `${hh}:${mm}`
|
||||
}
|
||||
|
||||
export function angularDelta(a: number, b: number): number {
|
||||
const diff = Math.abs(a - b) % 360
|
||||
return diff > 180 ? 360 - diff : diff
|
||||
}
|
||||
|
||||
export function intervalTimestamps(
|
||||
points: NmeaTimePoint[],
|
||||
intervalMinutes: number
|
||||
): number[] {
|
||||
if (points.length === 0) return []
|
||||
const start = points[0].timestamp
|
||||
const end = points[points.length - 1].timestamp
|
||||
const stepMs = intervalMinutes * 60 * 1000
|
||||
const stamps: number[] = []
|
||||
for (let t = start; t <= end; t += stepMs) {
|
||||
stamps.push(t)
|
||||
}
|
||||
if (stamps[stamps.length - 1] !== end) stamps.push(end)
|
||||
return stamps
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export type NmeaChangeType =
|
||||
| 'course'
|
||||
| 'wind'
|
||||
| 'pressure'
|
||||
| 'engine_start'
|
||||
| 'engine_stop'
|
||||
| 'autopilot_on'
|
||||
| 'autopilot_off'
|
||||
| 'depth'
|
||||
| 'anchor'
|
||||
| 'departure'
|
||||
| 'speed'
|
||||
| 'gps_fix_lost'
|
||||
| 'gps_fix_regained'
|
||||
| 'water_temp'
|
||||
| 'wind_shift'
|
||||
|
||||
export interface NmeaParseStats {
|
||||
totalLines: number
|
||||
parsedLines: number
|
||||
checksumErrors: number
|
||||
sentenceTypes: string[]
|
||||
}
|
||||
|
||||
export interface NmeaTimePoint {
|
||||
timestamp: number
|
||||
lat?: number
|
||||
lng?: number
|
||||
cog?: number
|
||||
sog?: number
|
||||
hdt?: number
|
||||
hdm?: number
|
||||
windDir?: number
|
||||
windSpeedKnots?: number
|
||||
depthM?: number
|
||||
rpm?: number
|
||||
pressureHpa?: number
|
||||
waterTempC?: number
|
||||
logDistanceNm?: number
|
||||
fixValid?: boolean
|
||||
autopilotEngaged?: boolean
|
||||
}
|
||||
|
||||
export interface NmeaChangeEvent {
|
||||
type: NmeaChangeType
|
||||
timestamp: number
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
summaryKey: string
|
||||
summaryParams?: Record<string, string | number>
|
||||
data?: Partial<NmeaTimePoint>
|
||||
}
|
||||
|
||||
export interface NmeaParseResult {
|
||||
points: NmeaTimePoint[]
|
||||
stats: NmeaParseStats
|
||||
warnings: string[]
|
||||
rawText: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export type NmeaImportMode = 'interval' | 'change' | 'both'
|
||||
|
||||
export interface NmeaJournalCandidate {
|
||||
id: string
|
||||
timestamp: number
|
||||
source: 'interval' | 'change'
|
||||
changeType?: NmeaChangeType
|
||||
confidence?: 'high' | 'medium' | 'low'
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
export interface NmeaDetectionConfig {
|
||||
courseDeltaDeg: number
|
||||
windDirDeltaDeg: number
|
||||
windSpeedDeltaKnots: number
|
||||
pressureDeltaHpa: number
|
||||
depthDeltaM: number
|
||||
depthDeltaPercent: number
|
||||
rpmIdle: number
|
||||
rpmRunning: number
|
||||
sogUnderWayKn: number
|
||||
sogStoppedKn: number
|
||||
anchorMinutes: number
|
||||
speedDeltaKn: number
|
||||
dedupeWindowMs: number
|
||||
}
|
||||
|
||||
export const DEFAULT_NMEA_DETECTION_CONFIG: NmeaDetectionConfig = {
|
||||
courseDeltaDeg: 28,
|
||||
windDirDeltaDeg: 35,
|
||||
windSpeedDeltaKnots: 4,
|
||||
pressureDeltaHpa: 2,
|
||||
depthDeltaM: 2,
|
||||
depthDeltaPercent: 25,
|
||||
rpmIdle: 400,
|
||||
rpmRunning: 800,
|
||||
sogUnderWayKn: 2,
|
||||
sogStoppedKn: 0.5,
|
||||
anchorMinutes: 10,
|
||||
speedDeltaKn: 3,
|
||||
dedupeWindowMs: 5 * 60 * 1000
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isNmeaCrcAlreadyImported, type NmeaArchiveRecord } from './nmeaArchive.js'
|
||||
import { nmeaFileCrc32 } from '../utils/crc32.js'
|
||||
|
||||
describe('nmeaArchive CRC tracking', () => {
|
||||
it('detects duplicate file content by CRC32', () => {
|
||||
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W\n'
|
||||
const record: NmeaArchiveRecord = {
|
||||
filename: 'a.nmea',
|
||||
rawText: '',
|
||||
importedAt: '2026-05-29T10:00:00.000Z',
|
||||
importedFiles: [{
|
||||
crc32: nmeaFileCrc32(text),
|
||||
filename: 'a.nmea',
|
||||
importedAt: '2026-05-29T10:00:00.000Z'
|
||||
}]
|
||||
}
|
||||
|
||||
expect(isNmeaCrcAlreadyImported(record, text)).toBe(true)
|
||||
expect(isNmeaCrcAlreadyImported(record, text.replace('\n', '\r\n'))).toBe(true)
|
||||
expect(isNmeaCrcAlreadyImported(record, '$GPRMC,999999,A\n')).toBe(false)
|
||||
expect(isNmeaCrcAlreadyImported(null, text)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from './crypto.js'
|
||||
import { nmeaFileCrc32 } from '../utils/crc32.js'
|
||||
|
||||
export interface NmeaImportedFile {
|
||||
crc32: string
|
||||
filename: string
|
||||
importedAt: string
|
||||
}
|
||||
|
||||
export interface NmeaArchiveRecord {
|
||||
filename: string
|
||||
rawText: string
|
||||
importedAt: string
|
||||
importedFiles: NmeaImportedFile[]
|
||||
}
|
||||
|
||||
function normalizeArchiveRecord(raw: Partial<NmeaArchiveRecord>): NmeaArchiveRecord {
|
||||
const importedFiles = [...(raw.importedFiles ?? [])]
|
||||
if (importedFiles.length === 0 && raw.rawText) {
|
||||
importedFiles.push({
|
||||
crc32: nmeaFileCrc32(raw.rawText),
|
||||
filename: raw.filename ?? '',
|
||||
importedAt: raw.importedAt ?? ''
|
||||
})
|
||||
}
|
||||
return {
|
||||
filename: raw.filename ?? '',
|
||||
rawText: raw.rawText ?? '',
|
||||
importedAt: raw.importedAt ?? '',
|
||||
importedFiles
|
||||
}
|
||||
}
|
||||
|
||||
async function putNmeaArchiveRecord(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
payload: NmeaArchiveRecord
|
||||
): Promise<void> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const encrypted = await encryptJson(payload, masterKey)
|
||||
await db.nmeaArchives.put({
|
||||
entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: payload.importedAt || new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
export async function getNmeaArchive(entryId: string): Promise<NmeaArchiveRecord | null> {
|
||||
const record = await db.nmeaArchives.get(entryId)
|
||||
if (!record) return null
|
||||
|
||||
const masterKey = await getLogbookKey(record.logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
try {
|
||||
return normalizeArchiveRecord(
|
||||
await decryptJson(record.encryptedData, record.iv, record.tag, masterKey) as Partial<NmeaArchiveRecord>
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isNmeaCrcAlreadyImported(record: NmeaArchiveRecord | null, rawText: string): boolean {
|
||||
if (!record) return false
|
||||
const crc32 = nmeaFileCrc32(rawText)
|
||||
return record.importedFiles.some((file) => file.crc32 === crc32)
|
||||
}
|
||||
|
||||
/** Remember imported file by CRC (even when raw log is discarded). */
|
||||
export async function recordNmeaFileImport(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
filename: string,
|
||||
rawText: string
|
||||
): Promise<string> {
|
||||
const crc32 = nmeaFileCrc32(rawText)
|
||||
const existing = await getNmeaArchive(entryId)
|
||||
const importedFiles = [...(existing?.importedFiles ?? [])]
|
||||
if (!importedFiles.some((file) => file.crc32 === crc32)) {
|
||||
importedFiles.push({
|
||||
crc32,
|
||||
filename,
|
||||
importedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const payload: NmeaArchiveRecord = {
|
||||
filename: existing?.filename ?? '',
|
||||
rawText: existing?.rawText ?? '',
|
||||
importedAt: new Date().toISOString(),
|
||||
importedFiles
|
||||
}
|
||||
await putNmeaArchiveRecord(logbookId, entryId, payload)
|
||||
return crc32
|
||||
}
|
||||
|
||||
export async function saveNmeaArchive(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
filename: string,
|
||||
rawText: string
|
||||
): Promise<void> {
|
||||
const crc32 = nmeaFileCrc32(rawText)
|
||||
const existing = await getNmeaArchive(entryId)
|
||||
const importedFiles = [...(existing?.importedFiles ?? [])]
|
||||
if (!importedFiles.some((file) => file.crc32 === crc32)) {
|
||||
importedFiles.push({
|
||||
crc32,
|
||||
filename,
|
||||
importedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const payload: NmeaArchiveRecord = {
|
||||
filename,
|
||||
rawText,
|
||||
importedAt: new Date().toISOString(),
|
||||
importedFiles
|
||||
}
|
||||
await putNmeaArchiveRecord(logbookId, entryId, payload)
|
||||
}
|
||||
|
||||
export async function deleteNmeaArchive(entryId: string): Promise<void> {
|
||||
await db.nmeaArchives.delete(entryId)
|
||||
}
|
||||
|
||||
export function downloadNmeaArchive(record: NmeaArchiveRecord): void {
|
||||
const blob = new Blob([record.rawText], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = record.filename || 'track.nmea'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import {
|
||||
buildLogEntryPayload,
|
||||
normalizeLogEvent,
|
||||
sortLogEventsByTime,
|
||||
currentLocalTimeHHMM,
|
||||
type LogEventPayload
|
||||
} from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
getNextTravelDayNumber,
|
||||
type LogEntryTankSource,
|
||||
type TravelDaySortable
|
||||
} from '../utils/logEntryTankLevels.js'
|
||||
|
||||
export interface LoadedEntry {
|
||||
payloadId: string
|
||||
updatedAt: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function getMasterKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
return masterKey
|
||||
}
|
||||
|
||||
function tankLevelsFromData(data: Record<string, unknown>) {
|
||||
const fw = (data.freshwater as Record<string, number> | undefined) ?? {
|
||||
morning: 0, refilled: 0, evening: 0, consumption: 0
|
||||
}
|
||||
const fuel = (data.fuel as Record<string, number> | undefined) ?? {
|
||||
morning: 0, refilled: 0, evening: 0, consumption: 0
|
||||
}
|
||||
const gw = data.greywater as { level?: number } | undefined
|
||||
return { fw, fuel, gw }
|
||||
}
|
||||
|
||||
function buildEncryptedPayload(
|
||||
data: Record<string, unknown>,
|
||||
options: {
|
||||
events: LogEventPayload[]
|
||||
departure?: string
|
||||
destination?: string
|
||||
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
clearSignatures?: boolean
|
||||
}
|
||||
): Record<string, unknown> {
|
||||
const { fw, fuel, gw } = tankLevelsFromData(data)
|
||||
const trackDistance = data.trackDistanceNm
|
||||
const trackSpeedMax = data.trackSpeedMaxKn
|
||||
const trackSpeedAvg = data.trackSpeedAvgKn
|
||||
const motorHoursRaw = data.motorHours
|
||||
|
||||
const freshwater = options.freshwater ?? {
|
||||
morning: fw.morning || 0,
|
||||
refilled: fw.refilled || 0,
|
||||
evening: fw.evening || 0,
|
||||
consumption: fw.consumption ?? 0
|
||||
}
|
||||
const fuelLevels = options.fuel ?? {
|
||||
morning: fuel.morning || 0,
|
||||
refilled: fuel.refilled || 0,
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
}
|
||||
|
||||
const payload = buildLogEntryPayload({
|
||||
date: String(data.date || ''),
|
||||
dayOfTravel: String(data.dayOfTravel || ''),
|
||||
departure: options.departure ?? String(data.departure || ''),
|
||||
destination: options.destination ?? String(data.destination || ''),
|
||||
freshwater,
|
||||
fuel: fuelLevels,
|
||||
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||
trackDistanceNm:
|
||||
trackDistance != null && trackDistance !== ''
|
||||
? parseFloat(String(trackDistance))
|
||||
: undefined,
|
||||
trackSpeedMaxKn:
|
||||
trackSpeedMax != null && trackSpeedMax !== ''
|
||||
? parseFloat(String(trackSpeedMax))
|
||||
: undefined,
|
||||
trackSpeedAvgKn:
|
||||
trackSpeedAvg != null && trackSpeedAvg !== ''
|
||||
? parseFloat(String(trackSpeedAvg))
|
||||
: undefined,
|
||||
motorHours:
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? parseFloat(String(motorHoursRaw))
|
||||
: undefined,
|
||||
events: options.events
|
||||
})
|
||||
|
||||
const clear = options.clearSignatures
|
||||
return {
|
||||
...payload,
|
||||
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
||||
signCrew: clear ? '' : (data.signCrew ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const record = await db.entries.get(entryId)
|
||||
if (!record) return null
|
||||
const data = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
|
||||
if (!data) return null
|
||||
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
|
||||
}
|
||||
|
||||
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
|
||||
const todayStr = new Date().toISOString().substring(0, 10)
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const local = await db.entries.where({ logbookId }).toArray()
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (decrypted && String(decrypted.date) === todayStr) {
|
||||
return entry.payloadId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||
|
||||
for (const entry of localEntries) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
||||
}
|
||||
|
||||
decryptedEntries.sort(compareTravelDaysChronological)
|
||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||
const { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||
|
||||
const localId = window.crypto.randomUUID()
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
departure,
|
||||
destination: '',
|
||||
freshwater,
|
||||
fuel,
|
||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: []
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'entry',
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: nowStr
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
return localId
|
||||
}
|
||||
|
||||
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
|
||||
const existing = await findTodayEntryId(logbookId)
|
||||
if (existing) return existing
|
||||
return createTodayEntry(logbookId)
|
||||
}
|
||||
|
||||
export interface AppendQuickEventResult {
|
||||
events: LogEventPayload[]
|
||||
hadSignature: boolean
|
||||
}
|
||||
|
||||
export async function appendQuickEvent(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
partialEvent: Partial<LogEventPayload>,
|
||||
headerPatch?: { departure?: string; destination?: string }
|
||||
): Promise<AppendQuickEventResult> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
const newEvent = normalizeLogEvent({
|
||||
time: currentLocalTimeHHMM(),
|
||||
...partialEvent
|
||||
})
|
||||
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
|
||||
|
||||
await persistEntry(logbookId, entryId, loaded.data, {
|
||||
events: nextEvents,
|
||||
departure: headerPatch?.departure,
|
||||
destination: headerPatch?.destination,
|
||||
clearSignatures: hadSignature
|
||||
})
|
||||
|
||||
return { events: nextEvents, hadSignature }
|
||||
}
|
||||
|
||||
async function persistEntry(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
data: Record<string, unknown>,
|
||||
options: Parameters<typeof buildEncryptedPayload>[1]
|
||||
): Promise<void> {
|
||||
const hadSignature = !!(data.signSkipper || data.signCrew)
|
||||
const entryData = buildEncryptedPayload(data, {
|
||||
...options,
|
||||
clearSignatures: options.clearSignatures ?? hadSignature
|
||||
})
|
||||
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'entry',
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
export async function removeLastEvent(
|
||||
logbookId: string,
|
||||
entryId: string
|
||||
): Promise<LogEventPayload[]> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
if (currentEvents.length === 0) return []
|
||||
|
||||
const nextEvents = sortLogEventsByTime(currentEvents.slice(0, -1))
|
||||
await persistEntry(logbookId, entryId, loaded.data, { events: nextEvents })
|
||||
return nextEvents
|
||||
}
|
||||
|
||||
export async function appendTankRefill(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
tank: 'fuel' | 'freshwater',
|
||||
addLiters: number,
|
||||
event: Partial<LogEventPayload>
|
||||
): Promise<AppendQuickEventResult> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const { fw, fuel } = tankLevelsFromData(loaded.data)
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
const newEvent = normalizeLogEvent({
|
||||
time: currentLocalTimeHHMM(),
|
||||
...event
|
||||
})
|
||||
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
|
||||
|
||||
const tankPatch = tank === 'fuel'
|
||||
? {
|
||||
fuel: {
|
||||
morning: fuel.morning || 0,
|
||||
refilled: (fuel.refilled || 0) + addLiters,
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
}
|
||||
}
|
||||
: {
|
||||
freshwater: {
|
||||
morning: fw.morning || 0,
|
||||
refilled: (fw.refilled || 0) + addLiters,
|
||||
evening: fw.evening || 0,
|
||||
consumption: fw.consumption ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
||||
await persistEntry(logbookId, entryId, loaded.data, {
|
||||
events: nextEvents,
|
||||
...tankPatch,
|
||||
clearSignatures: hadSignature
|
||||
})
|
||||
|
||||
return { events: nextEvents, hadSignature }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { crc32Hex, nmeaFileCrc32, normalizeNmeaTextForCrc } from './crc32.js'
|
||||
|
||||
describe('crc32', () => {
|
||||
it('hashes known test vectors', () => {
|
||||
expect(crc32Hex('')).toBe('00000000')
|
||||
expect(crc32Hex('123456789')).toBe('CBF43926')
|
||||
})
|
||||
|
||||
it('normalizes line endings before hashing NMEA content', () => {
|
||||
const a = nmeaFileCrc32('$GPRMC,123519,A\r\n$GPGGA,123519\r\n')
|
||||
const b = nmeaFileCrc32('$GPRMC,123519,A\n$GPGGA,123519\n')
|
||||
expect(a).toBe(b)
|
||||
expect(normalizeNmeaTextForCrc('a\r\nb\r')).toBe('a\nb')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Normalize NMEA text so identical content hashes the same across platforms. */
|
||||
export function normalizeNmeaTextForCrc(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd()
|
||||
}
|
||||
|
||||
const CRC32_TABLE = (() => {
|
||||
const table = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
}
|
||||
table[i] = c >>> 0
|
||||
}
|
||||
return table
|
||||
})()
|
||||
|
||||
/** CRC-32 (IEEE / Ethernet polynomial), uppercase 8-char hex. */
|
||||
export function crc32Hex(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text)
|
||||
let crc = 0xffffffff
|
||||
for (const byte of bytes) {
|
||||
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
return ((crc ^ 0xffffffff) >>> 0).toString(16).toUpperCase().padStart(8, '0')
|
||||
}
|
||||
|
||||
export function nmeaFileCrc32(text: string): string {
|
||||
return crc32Hex(normalizeNmeaTextForCrc(text))
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isMotorRunningFromEvents,
|
||||
LIVE_EVENT_CODES,
|
||||
liveCommentRemark,
|
||||
liveSailsRemark,
|
||||
liveSogRemark,
|
||||
parseLiveCommentRemark,
|
||||
parseLiveSailsRemark
|
||||
} from './liveEventCodes.js'
|
||||
import { formatEventSummary } from './formatEventSummary.js'
|
||||
import { normalizeLogEvent } from './logEntryPayload.js'
|
||||
|
||||
const t = (key: string, opts?: Record<string, unknown>) => {
|
||||
const map: Record<string, string> = {
|
||||
'logs.live_motor_start': 'Motor Start',
|
||||
'logs.live_motor_stop': 'Motor Stop',
|
||||
'logs.live_cast_off': 'Cast off',
|
||||
'logs.live_moor': 'Moor',
|
||||
'logs.live_sails': `Sails: ${opts?.sails ?? ''}`,
|
||||
'logs.live_fix': 'Fix',
|
||||
'logs.live_fix_coords': `Fix ${opts?.lat}, ${opts?.lng}`,
|
||||
'logs.live_event_generic': 'Event',
|
||||
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
|
||||
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
|
||||
'logs.live_wind_entry': `Wind ${opts?.value}`,
|
||||
'logs.live_course_entry': `Course ${opts?.course}`,
|
||||
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
|
||||
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
|
||||
'logs.event_mgk': 'Course',
|
||||
'logs.event_wind_pressure': 'Pressure'
|
||||
}
|
||||
return map[key] ?? key
|
||||
}
|
||||
|
||||
describe('liveEventCodes', () => {
|
||||
it('derives motor running from last motor event', () => {
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.MOTOR_START },
|
||||
{ remarks: LIVE_EVENT_CODES.MOTOR_STOP },
|
||||
{ remarks: LIVE_EVENT_CODES.MOTOR_START }
|
||||
]
|
||||
expect(isMotorRunningFromEvents(events)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when last motor event is stop', () => {
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.MOTOR_START },
|
||||
{ remarks: LIVE_EVENT_CODES.MOTOR_STOP }
|
||||
]
|
||||
expect(isMotorRunningFromEvents(events)).toBe(false)
|
||||
})
|
||||
|
||||
it('parses sail and comment remarks', () => {
|
||||
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
|
||||
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatEventSummary', () => {
|
||||
it('formats live motor start', () => {
|
||||
const event = normalizeLogEvent({ time: '08:10', remarks: LIVE_EVENT_CODES.MOTOR_START })
|
||||
expect(formatEventSummary(event, t)).toBe('Motor Start')
|
||||
})
|
||||
|
||||
it('formats sails remark', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '08:20',
|
||||
remarks: liveSailsRemark('Main + Genoa'),
|
||||
sailsOrMotor: 'Main + Genoa'
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
|
||||
})
|
||||
|
||||
it('formats fix with coordinates', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '09:00',
|
||||
remarks: LIVE_EVENT_CODES.FIX,
|
||||
gpsLat: '54.323000',
|
||||
gpsLng: '10.145000'
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000')
|
||||
})
|
||||
|
||||
it('formats pressure entry', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '09:00',
|
||||
remarks: LIVE_EVENT_CODES.PRESSURE,
|
||||
windPressure: '1013'
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa')
|
||||
})
|
||||
|
||||
it('formats SOG entry', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '10:15',
|
||||
remarks: liveSogRemark('5.2')
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('SOG 5.2 kn')
|
||||
})
|
||||
|
||||
it('formats STW entry', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '10:20',
|
||||
remarks: '__live:stw:4.8'
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('STW 4.8 kn')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { LogEventPayload } from './logEntryPayload.js'
|
||||
import {
|
||||
LIVE_EVENT_CODES,
|
||||
parseLiveCommentRemark,
|
||||
parseLiveFuelRemark,
|
||||
parseLivePrecipRemark,
|
||||
parseLiveSailsRemark,
|
||||
parseLiveSogRemark,
|
||||
parseLiveStwRemark,
|
||||
parseLiveTempRemark,
|
||||
parseLiveWaterRemark
|
||||
} from './liveEventCodes.js'
|
||||
|
||||
export function formatEventSummary(event: LogEventPayload, t: TFunction): string {
|
||||
const code = event.remarks.trim()
|
||||
|
||||
if (code === LIVE_EVENT_CODES.MOTOR_START) return t('logs.live_motor_start')
|
||||
if (code === LIVE_EVENT_CODES.MOTOR_STOP) return t('logs.live_motor_stop')
|
||||
if (code === LIVE_EVENT_CODES.CAST_OFF) return t('logs.live_cast_off')
|
||||
if (code === LIVE_EVENT_CODES.MOOR) return t('logs.live_moor')
|
||||
|
||||
const sails = parseLiveSailsRemark(code)
|
||||
if (sails) return t('logs.live_sails', { sails })
|
||||
|
||||
const comment = parseLiveCommentRemark(code)
|
||||
if (comment) return comment
|
||||
|
||||
const temp = parseLiveTempRemark(code)
|
||||
if (temp) return t('logs.live_temp_entry', { temp })
|
||||
|
||||
const precip = parseLivePrecipRemark(code)
|
||||
if (precip) return t('logs.live_precip_entry', { value: precip })
|
||||
|
||||
const fuel = parseLiveFuelRemark(code)
|
||||
if (fuel) return t('logs.live_fuel_entry', { liters: fuel })
|
||||
|
||||
const water = parseLiveWaterRemark(code)
|
||||
if (water) return t('logs.live_water_entry', { liters: water })
|
||||
|
||||
const sog = parseLiveSogRemark(code)
|
||||
if (sog) return t('logs.live_sog_entry', { speed: sog })
|
||||
|
||||
const stw = parseLiveStwRemark(code)
|
||||
if (stw) return t('logs.live_stw_entry', { speed: stw })
|
||||
|
||||
if (code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION) {
|
||||
if (event.gpsLat && event.gpsLng) {
|
||||
const label = code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||
? t('logs.live_auto_position')
|
||||
: t('logs.live_fix')
|
||||
return `${label} ${event.gpsLat}, ${event.gpsLng}`
|
||||
}
|
||||
return code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||
? t('logs.live_auto_position')
|
||||
: t('logs.live_fix')
|
||||
}
|
||||
|
||||
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
|
||||
return t('logs.live_course_entry', { course: event.mgk })
|
||||
}
|
||||
|
||||
if (code === LIVE_EVENT_CODES.WIND) {
|
||||
const wind = [event.windDirection, event.windStrength].filter(Boolean).join(' ')
|
||||
return wind ? t('logs.live_wind_entry', { value: wind }) : t('logs.live_wind_btn')
|
||||
}
|
||||
|
||||
if (code === LIVE_EVENT_CODES.PRESSURE && event.windPressure) {
|
||||
return t('logs.live_pressure_entry', { value: event.windPressure })
|
||||
}
|
||||
|
||||
if (code === LIVE_EVENT_CODES.SEA_STATE && event.seaState) {
|
||||
return t('logs.live_sea_state_entry', { value: event.seaState })
|
||||
}
|
||||
|
||||
if (code && !code.startsWith('__live:')) {
|
||||
return code
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
if (event.sailsOrMotor) parts.push(event.sailsOrMotor)
|
||||
if (event.mgk) parts.push(`${t('logs.event_mgk')} ${event.mgk}`)
|
||||
if (event.windDirection || event.windStrength) {
|
||||
parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' '))
|
||||
}
|
||||
if (event.windPressure) parts.push(`${t('logs.event_wind_pressure')}: ${event.windPressure}`)
|
||||
if (event.gpsLat && event.gpsLng) {
|
||||
parts.push(`${event.gpsLat}, ${event.gpsLng}`)
|
||||
}
|
||||
|
||||
return parts.join(' · ') || t('logs.live_event_generic')
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const MPS_TO_KNOTS = 1.9438444924406
|
||||
|
||||
export interface GeoCoordinates {
|
||||
lat: string
|
||||
lng: string
|
||||
/** SOG from GPS when available (kn), otherwise null. */
|
||||
speedKn: number | null
|
||||
}
|
||||
|
||||
export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('geolocation_unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||
: null
|
||||
resolve({
|
||||
lat: pos.coords.latitude.toFixed(6),
|
||||
lng: pos.coords.longitude.toFixed(6),
|
||||
speedKn
|
||||
})
|
||||
},
|
||||
(err) => reject(err),
|
||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getNextLanguage, normalizeAppLanguage, SUPPORTED_LANGUAGES } from './i18nLanguages.js'
|
||||
import type { i18n as I18nInstance } from 'i18next'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PlausibleEvents } from '../services/analytics.js'
|
||||
import {
|
||||
changeAppLanguage,
|
||||
cycleAppLanguage,
|
||||
getNextLanguage,
|
||||
normalizeAppLanguage,
|
||||
SUPPORTED_LANGUAGES
|
||||
} from './i18nLanguages.js'
|
||||
|
||||
const trackPlausibleEvent = vi.fn()
|
||||
|
||||
vi.mock('../services/analytics.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../services/analytics.js')>()
|
||||
return {
|
||||
...actual,
|
||||
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
|
||||
}
|
||||
})
|
||||
|
||||
function createMockI18n(language: string): I18nInstance {
|
||||
let current = language
|
||||
return {
|
||||
language: current,
|
||||
changeLanguage: vi.fn(async (lng: string) => {
|
||||
current = lng
|
||||
;(this as { language: string }).language = lng
|
||||
})
|
||||
} as unknown as I18nInstance
|
||||
}
|
||||
|
||||
describe('i18nLanguages', () => {
|
||||
beforeEach(() => {
|
||||
trackPlausibleEvent.mockReset()
|
||||
})
|
||||
|
||||
it('normalizes regional tags to supported base codes', () => {
|
||||
expect(normalizeAppLanguage('de-DE')).toBe('de')
|
||||
expect(normalizeAppLanguage('nb-NO')).toBe('nb')
|
||||
@@ -18,4 +51,33 @@ describe('i18nLanguages', () => {
|
||||
expect(seen.size).toBe(SUPPORTED_LANGUAGES.length)
|
||||
expect(current).toBe('de')
|
||||
})
|
||||
|
||||
it('tracks explicit language changes', () => {
|
||||
const i18n = createMockI18n('de')
|
||||
changeAppLanguage(i18n, 'sv')
|
||||
|
||||
expect(i18n.changeLanguage).toHaveBeenCalledWith('sv')
|
||||
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
|
||||
from: 'de',
|
||||
to: 'sv'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not track when language stays the same', () => {
|
||||
const i18n = createMockI18n('en')
|
||||
changeAppLanguage(i18n, 'en')
|
||||
|
||||
expect(i18n.changeLanguage).not.toHaveBeenCalled()
|
||||
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cycleAppLanguage tracks the next language', () => {
|
||||
const i18n = createMockI18n('nb')
|
||||
cycleAppLanguage(i18n)
|
||||
|
||||
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
|
||||
from: 'nb',
|
||||
to: 'de'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { i18n as I18nInstance } from 'i18next'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
/** Supported UI languages (ISO 639-1, language-only). */
|
||||
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const
|
||||
|
||||
@@ -20,3 +23,17 @@ export function getNextLanguage(current?: string): AppLanguage {
|
||||
export function isGermanLocale(language?: string): boolean {
|
||||
return normalizeAppLanguage(language) === 'de'
|
||||
}
|
||||
|
||||
/** Switch UI language and track explicit user choice (not auto-detection). */
|
||||
export function changeAppLanguage(i18n: I18nInstance, language: AppLanguage): void {
|
||||
const from = normalizeAppLanguage(i18n.language)
|
||||
const to = normalizeAppLanguage(language)
|
||||
if (from === to) return
|
||||
|
||||
void i18n.changeLanguage(to)
|
||||
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from, to })
|
||||
}
|
||||
|
||||
export function cycleAppLanguage(i18n: I18nInstance): void {
|
||||
changeAppLanguage(i18n, getNextLanguage(i18n.language))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/** Machine-readable live-log markers stored in event.remarks (locale-independent). */
|
||||
export const LIVE_EVENT_CODES = {
|
||||
MOTOR_START: '__live:motor_start',
|
||||
MOTOR_STOP: '__live:motor_stop',
|
||||
CAST_OFF: '__live:cast_off',
|
||||
MOOR: '__live:moor',
|
||||
FIX: '__live:fix',
|
||||
AUTO_POSITION: '__live:auto_position',
|
||||
COURSE: '__live:course',
|
||||
WIND: '__live:wind',
|
||||
PRESSURE: '__live:pressure',
|
||||
SEA_STATE: '__live:sea_state'
|
||||
} as const
|
||||
|
||||
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
|
||||
|
||||
export function liveSailsRemark(sails: string): string {
|
||||
return `__live:sails:${sails}`
|
||||
}
|
||||
|
||||
export function liveCommentRemark(text: string): string {
|
||||
return `__live:comment:${text}`
|
||||
}
|
||||
|
||||
export function liveTempRemark(tempC: string): string {
|
||||
return `__live:temp:${tempC}`
|
||||
}
|
||||
|
||||
export function livePrecipRemark(text: string): string {
|
||||
return `__live:precip:${text}`
|
||||
}
|
||||
|
||||
export function liveFuelRemark(liters: string): string {
|
||||
return `__live:fuel:${liters}`
|
||||
}
|
||||
|
||||
export function liveWaterRemark(liters: string): string {
|
||||
return `__live:water:${liters}`
|
||||
}
|
||||
|
||||
export function liveSogRemark(speedKn: string): string {
|
||||
return `__live:sog:${speedKn}`
|
||||
}
|
||||
|
||||
export function liveStwRemark(speedKn: string): string {
|
||||
return `__live:stw:${speedKn}`
|
||||
}
|
||||
|
||||
export function parseLiveSailsRemark(remarks: string): string | null {
|
||||
const prefix = '__live:sails:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveCommentRemark(remarks: string): string | null {
|
||||
const prefix = '__live:comment:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveTempRemark(remarks: string): string | null {
|
||||
const prefix = '__live:temp:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLivePrecipRemark(remarks: string): string | null {
|
||||
const prefix = '__live:precip:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveFuelRemark(remarks: string): string | null {
|
||||
const prefix = '__live:fuel:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveWaterRemark(remarks: string): string | null {
|
||||
const prefix = '__live:water:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveSogRemark(remarks: string): string | null {
|
||||
const prefix = '__live:sog:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveStwRemark(remarks: string): string | null {
|
||||
const prefix = '__live:stw:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
/** Derive motor running state from event history (survives reload). */
|
||||
export function isMotorRunningFromEvents(
|
||||
events: Array<{ remarks: string }>,
|
||||
motorStartCode: string = LIVE_EVENT_CODES.MOTOR_START,
|
||||
motorStopCode: string = LIVE_EVENT_CODES.MOTOR_STOP
|
||||
): boolean {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const code = events[i].remarks.trim()
|
||||
if (code === motorStartCode) return true
|
||||
if (code === motorStopCode) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function eventTimestampMs(date: string, time: string): number | null {
|
||||
const normalized = time.trim().match(/^(\d{1,2}):(\d{2})/)
|
||||
if (!normalized || !date) return null
|
||||
const hours = parseInt(normalized[1], 10)
|
||||
const minutes = parseInt(normalized[2], 10)
|
||||
if (hours > 23 || minutes > 59) return null
|
||||
const parsed = new Date(`${date}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime()
|
||||
}
|
||||
|
||||
export function getLastAutoPositionMs(
|
||||
events: Array<{ remarks: string; time: string }>,
|
||||
entryDate: string
|
||||
): number | null {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
if (events[i].remarks.trim() !== LIVE_EVENT_CODES.AUTO_POSITION) continue
|
||||
return eventTimestampMs(entryDate, events[i].time)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
# Code-Statistik — Kapteins Daagbok
|
||||
|
||||
Erstellt am **31. Mai 2026** mit [cloc](https://github.com/AlDanial/cloc) v1.98.
|
||||
|
||||
## Methode
|
||||
|
||||
```bash
|
||||
cloc . \
|
||||
--exclude-dir=node_modules,dist,.git,userfeedback,.cursor,.planning \
|
||||
--md
|
||||
```
|
||||
|
||||
Ausgeschlossen: Build-Artefakte (`dist/`), Abhängigkeiten (`node_modules/`), lokales Feedback, Cursor-/Planungs-Artefakte.
|
||||
|
||||
## Gesamtübersicht
|
||||
|
||||
| Language | files | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| TypeScript | 145 | 3012 | 540 | 23599 |
|
||||
| JSON | 14 | 4 | 0 | 15005 |
|
||||
| CSS | 3 | 743 | 45 | 4837 |
|
||||
| XML | 3 | 0 | 0 | 4302 |
|
||||
| HTML | 5 | 160 | 0 | 1411 |
|
||||
| Markdown | 8 | 390 | 12 | 1077 |
|
||||
| JavaScript | 8 | 117 | 43 | 709 |
|
||||
| Bourne Shell | 3 | 81 | 21 | 412 |
|
||||
| YAML | 1 | 3 | 0 | 55 |
|
||||
| Dockerfile | 2 | 20 | 21 | 39 |
|
||||
| SVG | 4 | 0 | 0 | 27 |
|
||||
| **SUM** | **196** | **4530** | **682** | **51473** |
|
||||
|
||||
### Anwendungscode (TypeScript, JavaScript, CSS)
|
||||
|
||||
Ohne JSON, GPX/XML, HTML, Docs und Assets — näher an der eigentlichen Implementierung:
|
||||
|
||||
| Language | files | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| TypeScript | 145 | 3012 | 540 | 23599 |
|
||||
| CSS | 3 | 743 | 45 | 4837 |
|
||||
| JavaScript | 8 | 117 | 43 | 709 |
|
||||
| **SUM** | **156** | **3872** | **628** | **29145** |
|
||||
|
||||
> **Hinweis:** Der hohe JSON-Anteil (~15k Zeilen) stammt überwiegend aus i18n-Locale-Dateien (`client/src/i18n/locales/*.json`). XML (~4,3k Zeilen) sind Demo-GPX-Tracks unter `client/src/assets/demo/`.
|
||||
|
||||
## Aufteilung nach Bereich
|
||||
|
||||
| Bereich | Dateien | Leer | Kommentar | Code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| `client/` | 154 | 3398 | 557 | 43534 |
|
||||
| `server/` | 20 | 399 | 54 | 4426 |
|
||||
| `scripts/` | 9 | 193 | 59 | 1065 |
|
||||
| `docs/` | 8 | 418 | 0 | 2079 |
|
||||
|
||||
### `client/`
|
||||
|
||||
| Language | files | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| TypeScript | 129 | 2625 | 499 | 21291 |
|
||||
| JSON | 10 | 4 | 0 | 12898 |
|
||||
| CSS | 3 | 743 | 45 | 4837 |
|
||||
| XML | 3 | 0 | 0 | 4302 |
|
||||
| Markdown | 1 | 13 | 0 | 60 |
|
||||
| JavaScript | 2 | 5 | 5 | 56 |
|
||||
| HTML | 1 | 0 | 0 | 47 |
|
||||
| SVG | 4 | 0 | 0 | 27 |
|
||||
| Dockerfile | 1 | 8 | 8 | 16 |
|
||||
| **SUM** | **154** | **3398** | **557** | **43534** |
|
||||
|
||||
### `server/`
|
||||
|
||||
| Language | files | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| TypeScript | 16 | 387 | 41 | 2308 |
|
||||
| JSON | 3 | 0 | 0 | 2095 |
|
||||
| Dockerfile | 1 | 12 | 13 | 23 |
|
||||
| **SUM** | **20** | **399** | **54** | **4426** |
|
||||
|
||||
### `scripts/`
|
||||
|
||||
| Language | files | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| JavaScript | 6 | 112 | 38 | 653 |
|
||||
| Bourne Shell | 3 | 81 | 21 | 412 |
|
||||
| **SUM** | **9** | **193** | **59** | **1065** |
|
||||
|
||||
## Größte Quelldateien (TypeScript & CSS)
|
||||
|
||||
| Datei | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: |
|
||||
| `client/src/App.css` | 730 | 31 | 4430 |
|
||||
| `client/src/components/LogEntryEditor.tsx` | 176 | 17 | 1929 |
|
||||
| `client/src/components/UserProfilePage.tsx` | 52 | 0 | 746 |
|
||||
| `client/src/components/LiveLogView.tsx` | 50 | 2 | 711 |
|
||||
| `client/src/App.tsx` | 85 | 21 | 656 |
|
||||
| `client/src/components/CrewForm.tsx` | 82 | 117 | 644 |
|
||||
| `client/src/components/VesselForm.tsx` | 55 | 8 | 558 |
|
||||
| `client/src/services/auth.ts` | 80 | 66 | 556 |
|
||||
| `client/src/services/logbookBackup.ts` | 56 | 0 | 545 |
|
||||
| `client/src/components/AuthOnboarding.tsx` | 49 | 25 | 542 |
|
||||
| `client/src/components/StatsDashboard.tsx` | 43 | 0 | 521 |
|
||||
| `client/src/components/LogbookDashboard.tsx` | 46 | 2 | 508 |
|
||||
| `client/src/components/InvitationAcceptance.tsx` | 59 | 0 | 461 |
|
||||
| `client/src/components/LogEntriesList.tsx` | 50 | 4 | 447 |
|
||||
| `client/src/services/sync.ts` | 70 | 29 | 428 |
|
||||
|
||||
## Kurzfassung
|
||||
|
||||
- **~51k** physische Codezeilen gesamt (inkl. Locales, Demo-GPX, Docs).
|
||||
- **~29k** Zeilen reiner Anwendungscode (TS/JS/CSS).
|
||||
- **~21k** TypeScript im Client, **~2,3k** im Server.
|
||||
- Größte Einzeldatei: `App.css` (~4,4k Zeilen), größte Komponente: `LogEntryEditor.tsx` (~1,9k Zeilen).
|
||||
|
||||
## Report aktualisieren
|
||||
|
||||
```bash
|
||||
cloc . \
|
||||
--exclude-dir=node_modules,dist,.git,userfeedback,.cursor,.planning \
|
||||
--md > docs/cloc-report-raw.md
|
||||
```
|
||||
|
||||
Für eine reine Markdown-Tabelle reicht `--md`; dieser Report fasst mehrere cloc-Läufe manuell zusammen.
|
||||
@@ -0,0 +1,165 @@
|
||||
# NMEA-Import — Recherche & Entscheidungsnotizen
|
||||
|
||||
Stand: 2026-05-31 · Status: **In Umsetzung** (`feature/nmea-journal-import`)
|
||||
|
||||
Anlass: Nutzeranfrage, ob Kapteins Daagbok um NMEA-Empfang erweiterbar sei.
|
||||
|
||||
## Kurzfassung
|
||||
|
||||
| Ansatz | Machbarkeit (PWA) | Empfehlung |
|
||||
|--------|-------------------|------------|
|
||||
| **Live-NMEA** (Serial/TCP/Bluetooth vom Plotter) | Praktisch nein (Browser-Sandbox, iOS) | Nicht als reine PWA versprechen |
|
||||
| **NMEA-Dateiimport** | Ja (Parsing im Client) | Sinnvoller nächster Schritt, wenn überhaupt |
|
||||
| **GPX-Import** (bereits vorhanden) | Ja | Für die meisten Freizeit-Skipper der praktischere Weg |
|
||||
|
||||
---
|
||||
|
||||
## Aktueller Stand in Kapteins Daagbok
|
||||
|
||||
- **PWA** (installierbar, offline-fähig), kein nativer App-Store-Wrapper
|
||||
- **Position:** `navigator.geolocation` (Geräte-GPS) in `LogEntryEditor.tsx`
|
||||
- **Tracks:** Upload von **GPX/KML/GeoJSON** → Karte, Streckenstatistik (`trackUpload.ts`, `LogEntryEditor.tsx`)
|
||||
- **Log-Ereignisse** u. a.: Zeit, MgK/rwk, Wind (Richtung/Stärke/Druck), Seegang, Wetter, Strom, Krängung, Segel/Motor, Log, Distanz, GPS-Koordinaten, Bemerkungen (`logEntryPayload.ts`)
|
||||
|
||||
Es gibt **keinen** NMEA-Parser und **keinen** Live-Datenstrom.
|
||||
|
||||
---
|
||||
|
||||
## Warum Live-NMEA in einer PWA schwierig ist
|
||||
|
||||
Typische NMEA-Quellen an Bord und Browser-Fähigkeiten:
|
||||
|
||||
| Quelle | PWA-tauglich? |
|
||||
|--------|----------------|
|
||||
| USB/Serial (RS422/232) | Kaum — Web Serial API nur Chrome/Edge, **nicht iOS/Safari**, am Tablet/Phone selten praktikabel |
|
||||
| TCP/UDP (z. B. Port 10110) | **Nein** — Browser haben keine Raw-Sockets |
|
||||
| Bluetooth-NMEA | Sehr eingeschränkt (Web Bluetooth), iOS praktisch unbrauchbar |
|
||||
| Handy-GPS | **Ja** — Geolocation API (bereits implementiert), aber **kein NMEA vom Plotter** |
|
||||
|
||||
Weitere PWA-Limits:
|
||||
|
||||
- Kein zuverlässiger **Hintergrundbetrieb** für kontinuierlichen Empfang
|
||||
- **HTTPS-App → lokales Boot-Netz** (`192.168.x.x`): Mixed Content, CORS, ggf. Local-Network-Permissions
|
||||
- iPad/iPhone als installierte PWA besonders restriktiv
|
||||
|
||||
**Umweg (später optional):** Gateway im Boot (z. B. SignalK) mit WebSocket/HTTP — PWA verbindet sich dann zu einem **Server**, nicht direkt zu NMEA. Setup-Aufwand, eher für Technikaffine.
|
||||
|
||||
**Native Hülle** (Capacitor, Electron, …) würde Serial/TCP/Bluetooth erweitern — wäre keine reine PWA mehr.
|
||||
|
||||
---
|
||||
|
||||
## Was ist eine NMEA-Datei?
|
||||
|
||||
**NMEA 0183** = textbasiertes Protokoll aus **Einzelzeilen-Sätzen**, z. B.:
|
||||
|
||||
```
|
||||
$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
|
||||
$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47
|
||||
$HDT,274.3,T*2F
|
||||
$MWV,274.5,R,15.2,N,A*2B
|
||||
$DPT,12.4,0.5*42
|
||||
```
|
||||
|
||||
Eine `.nmea`- oder `.log`-Datei ist ein **Zeitstempel-Stream** — alles, was der Logger in diesem Zeitraum mitgeschrieben hat.
|
||||
|
||||
**Nicht alle Telemetriedaten sind garantiert enthalten.** Es hängt ab von:
|
||||
|
||||
1. Sensoren an Bord (GPS ja, Wind nur mit Windgeber, …)
|
||||
2. Logger-/Multiplexer-Konfiguration
|
||||
3. Empfang während der Aufzeichnung
|
||||
|
||||
Ein reiner GPS-Logger liefert praktisch nur Position/Kurs/Fahrt.
|
||||
|
||||
---
|
||||
|
||||
## Was könnte ein NMEA-Dateiimport in der App bewirken?
|
||||
|
||||
Mapping zu bestehenden Logbuch-Feldern (Auszug):
|
||||
|
||||
| NMEA-Satz (Beispiel) | Inhalt | Nutzen |
|
||||
|----------------------|--------|--------|
|
||||
| RMC / GGA / GLL | Position, Zeit, oft SOG/COG | GPS-Koordinaten, **Track** (analog GPX), Kurs |
|
||||
| VTG / VHW | Fahrt über Grund/Wasser, Kurs | Streckenstatistik, Kursfelder |
|
||||
| HDT / HDG / HDM | Peilung/Kompass | MgK/rwk-Vorschläge |
|
||||
| MWV / MWD | Wind | Windfelder im Reisetag |
|
||||
| DPT / DBT | Tiefe | aktuell kein eigenes Feld |
|
||||
| MTW | Wassertemperatur | ggf. Bemerkungen |
|
||||
| XDR | diverse Transducer | abhängig vom Gerät |
|
||||
|
||||
**Mehrwert gegenüber GPX:**
|
||||
|
||||
- Track **plus** zeitlich zugeordnete Wind-/Kursdaten (wenn in der Datei vorhanden)
|
||||
- Automatisches Vorschlagen von Log-Ereignissen aus Bord-Sensoren
|
||||
- Eine Quelle (Bordanlage) statt nur Handy-GPS
|
||||
|
||||
**Was NMEA typischerweise nicht liefert** (bleibt manuell / Wetter-API):
|
||||
|
||||
- Seegang, Wetter-Symbolik, Strom, Krängung
|
||||
- Crew, Hafen, Bemerkungen, Tankstände
|
||||
- Segel/Motor-Konfiguration im nautischen Sinne
|
||||
|
||||
NMEA = **Sensor-Telemetrie**, kein **Skipper-Logbuch**.
|
||||
|
||||
---
|
||||
|
||||
## Wird NMEA an Bord üblicherweise aufgezeichnet & exportiert?
|
||||
|
||||
**Teilweise — selten so einfach wie GPX für Endnutzer.**
|
||||
|
||||
| Quelle | Typischer Export | Einfach für Freizeit-Skipper? |
|
||||
|--------|------------------|-------------------------------|
|
||||
| Chartplotter (Garmin, Raymarine, B&G, …) | **GPX** (Track/Route) | ✅ oft (SD/USB/App) |
|
||||
| Chartplotter | Roh-NMEA | ⚠️ selten direkt |
|
||||
| WiFi-Multiplexer, SignalK, Raspi | NMEA-Datei oder Stream | ⚠️ Technikaffine |
|
||||
| PC-Software (OpenCPN, …) | NMEA-Log | ⚠️ |
|
||||
|
||||
**GPX ist der de-facto-Standard** für „Track mit nach Hause nehmen“. NMEA-Rohlogs sind Nischen- oder Profi-/Tüftler-Setup.
|
||||
|
||||
---
|
||||
|
||||
## Mögliche Roadmap (wenn wir es angehen)
|
||||
|
||||
### Phase 1 — NMEA-Dateiimport (PWA-kompatibel)
|
||||
|
||||
- Parser für gängige Sätze: RMC, GGA, GLL, VTG, optional MWV/MWD, HDT
|
||||
- Track aus Positions-Sätzen (wie GPX-Pipeline)
|
||||
- UI: Upload neben GPX/KML in `LogEntryEditor`
|
||||
- Checksummen-Validierung (`*XX`), Encoding, gemischte Talker-IDs (GP, GN, …)
|
||||
|
||||
### Phase 2 — Anreicherung Log-Ereignisse
|
||||
|
||||
- Aus NMEA-Stream pro Zeitpunkt Wind/Kurs/Position in Log-Events vorschlagen
|
||||
- Nutzer bestätigt/korrigiert (kein blindes Überschreiben)
|
||||
|
||||
### Phase 3 — optional, nicht PWA-pur
|
||||
|
||||
- SignalK-WebSocket (Nutzer konfiguriert Boot-URL)
|
||||
- Oder native Wrapper / Companion-Bridge
|
||||
|
||||
**Nicht empfohlen als Phase 1:** Live-NMEA direkt aus der PWA.
|
||||
|
||||
---
|
||||
|
||||
## Antwortvorlage für Nutzer
|
||||
|
||||
> Als reine Browser-App können wir keinen direkten NMEA-Anschluss (Serial/TCP vom Plotter) zuverlässig anbieten — mobile Browser erlauben das nicht, besonders auf iPhone/iPad.
|
||||
> Position über Handy-GPS und GPX-Tracks (Export vom Plotter oder Nav-App) funktionieren bereits.
|
||||
> Ein **Import von NMEA-Dateien** (vom Gateway oder Logger) ist grundsätzlich denkbar und könnte Track plus ggf. Wind/Kurs ins Logbuch übernehmen — das prüfen wir für eine spätere Version.
|
||||
> Für die meisten Skipper ist **GPX vom Plotter** der einfachere Weg.
|
||||
|
||||
---
|
||||
|
||||
## Offene Fragen für spätere Planung
|
||||
|
||||
- Welche NMEA-Varianten melden Nutzer realistisch (0183 vs. NMEA 2000 nur über Gateway)?
|
||||
- Reicht Parser-Abdeckung für 95 % der Dateien mit RMC+GGA+MWV?
|
||||
- Sollen importierte Rohdaten gespeichert werden oder nur abgeleitete GPX/Events?
|
||||
- Datenschutz: NMEA-Datei lokal parsen, nichts an Server senden (passt zu E2E-Modell)
|
||||
- Plausible-Event analog `GPS Track Uploaded` → z. B. `NMEA File Imported`?
|
||||
|
||||
## Referenzen
|
||||
|
||||
- [NMEA 0183](https://www.nmea.org/) — Protokollstandard
|
||||
- [SignalK](https://signalk.org/) — moderne Boot-API, WebSocket
|
||||
- [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) — Browser, eingeschränkt
|
||||
- Bestehender Code: `client/src/services/trackUpload.ts`, `client/src/components/LogEntryEditor.tsx`
|
||||
@@ -21,6 +21,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
|
||||
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
|
||||
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
|
||||
| NMEA Uploaded | NMEA-Datei erfolgreich gelesen und geparst (`NmeaImportWizard.tsx`) | `lines` (Anzahl Sätze), `candidates` (Vorschläge für Reisetag), `duplicate` (Datei schon importiert), `has_position` |
|
||||
| NMEA Imported | NMEA-Vorschläge in Journal übernommen (`NmeaImportWizard.tsx`) | `mode`: `interval` \| `change` \| `both`, `events` (übernommene Einträge), `track` (GPS-Track mit importiert) |
|
||||
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
|
||||
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
|
||||
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
|
||||
@@ -49,6 +51,34 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
|
||||
| Language Changed | Sprache über UI-Wechsler gewählt (`i18nLanguages.ts` via Sprach-Button in App, Dashboard, Auth, Demo, Einladung, Share-Viewer) | `from`, `to`: ISO 639-1 (`de`, `en`, `da`, `sv`, `nb`) |
|
||||
| Live Log Opened | Live-Journal-Ansicht geladen (`LiveLogView.tsx`, einmal pro Mount nach erfolgreichem Init) | — |
|
||||
| Live Log Event Logged | Quick-Action erfolgreich ins heutige Journal geschrieben (`LiveLogView.tsx`) | `action`: siehe [Live-Log-Aktionen](#live-log-aktionen) |
|
||||
|
||||
### Live-Log-Aktionen
|
||||
|
||||
Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel, keine Inhalte (kein Kurs, kein Kommentartext, keine Koordinaten):
|
||||
|
||||
| `action` | Button / Auslöser |
|
||||
|----------|-------------------|
|
||||
| `motor_start` | Motor Start |
|
||||
| `motor_stop` | Motor Stop |
|
||||
| `cast_off` | Ablegen |
|
||||
| `moor` | Anlegen |
|
||||
| `sails` | Segel (Modal bestätigt) |
|
||||
| `course` | Kurs (Dial/Modal bestätigt) |
|
||||
| `sog` | SOG |
|
||||
| `stw` | STW |
|
||||
| `fuel` | Diesel-Tank |
|
||||
| `water` | Wasser-Tank |
|
||||
| `wind` | Wind (Richtung/Stärke) |
|
||||
| `pressure` | Luftdruck |
|
||||
| `temp` | Temperatur |
|
||||
| `precip` | Niederschlag |
|
||||
| `sea_state` | Seegang |
|
||||
| `fix` | GPS-Fix (manuell) |
|
||||
| `comment` | Kommentar |
|
||||
| `undo` | Letztes Ereignis rückgängig |
|
||||
|
||||
## Bewusst nicht getrackt
|
||||
|
||||
@@ -56,11 +86,16 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
|
||||
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
||||
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
|
||||
- **Sprache bei Erstbesuch:** Automatische Browser-/URL-Erkennung (`i18next-browser-languagedetector`, `?lng=`) löst kein `Language Changed` aus — nur explizite Klicks auf den Sprach-Button.
|
||||
- **Live-Log Auto-Position:** Hintergrund-GPS alle 3 h (`LIVE_EVENT_CODES.AUTO_POSITION`) — automatisch, best-effort, kein Nutzer-Tap.
|
||||
- **Live-Log Modals:** Öffnen/Abbrechen von Dialogen ohne Speichern; Wechsel Liste ↔ Live (nur `Live Log Opened` beim erneuten Mount).
|
||||
- **Live-Log Editor-Link:** Öffnen des vollständigen Editors aus der Live-Ansicht.
|
||||
- **NMEA-Import:** Abbrechen, Vorschau ohne Übernahme, Archiv-Entscheid (Archivieren/Verwerfen); fehlgeschlagene Datei-Lesevorgänge.
|
||||
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
|
||||
|
||||
## Typische Funnels (Plausible Goals)
|
||||
|
||||
Empfohlene Goal-Ketten für Auswertung:
|
||||
Empfohlene Goal-Ketten für Auswertung (nur Business!):
|
||||
|
||||
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
|
||||
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
|
||||
@@ -69,6 +104,9 @@ Empfohlene Goal-Ketten für Auswertung:
|
||||
5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
|
||||
6. **Datensicherung:** Backup Exported → Backup Restored
|
||||
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
||||
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
|
||||
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
|
||||
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`)
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -77,6 +115,10 @@ import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
||||
```
|
||||
|
||||
Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.
|
||||
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
; Kapteins Daagbok Test-NMEA — Kieler Förde Kiellinie → Laboe, 5 sm
|
||||
; Datum: 2026-05-29, passend zu testdata/tracks/kieler-foerde-5sm.gpx
|
||||
; Import-Tipp: Reisetag-Datum auf 2026-05-29 setzen
|
||||
; Sätze: RMC, GGA, VTG, HDT, MWV, DPT, MDA, RPM (Motorphase), MTW, VLW
|
||||
|
||||
$GPRMC,101500.00,A,5419.7280,N,01008.7360,E,2.5,42.2,290526,,*00
|
||||
$GPGGA,101500.00,5419.7280,N,01008.7360,E,1,08,1.0,12.5,M,46.0,M,,*5B
|
||||
$GPVTG,42.2,T,,M,2.5,N,4.6,K*51
|
||||
$HEHDT,42.2,T*1B
|
||||
$IIMWV,240.0,R,12.0,N,A*08
|
||||
$SDDPT,12.5,0.0*61
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.00,N,0.00,N,,*4D
|
||||
|
||||
$GPRMC,101637.00,A,5419.7815,N,01008.8193,E,2.9,42.2,290526,,*0C
|
||||
$GPGGA,101637.00,5419.7815,N,01008.8193,E,1,08,1.0,12.4,M,46.0,M,,*5A
|
||||
$GPVTG,42.2,T,,M,2.9,N,5.3,K*59
|
||||
$HEHDT,42.2,T*1B
|
||||
$IIMWV,240.0,R,12.0,N,A*08
|
||||
$SDDPT,12.4,0.0*60
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.00,N,0.00,N,,*4D
|
||||
|
||||
$GPRMC,101829.00,A,5419.8529,N,01008.9305,E,3.3,42.2,290526,,*07
|
||||
$GPGGA,101829.00,5419.8529,N,01008.9305,E,1,08,1.0,12.3,M,46.0,M,,*5D
|
||||
$GPVTG,42.2,T,,M,3.3,N,6.2,K*50
|
||||
$HEHDT,42.2,T*1B
|
||||
$IIMWV,240.0,R,12.0,N,A*08
|
||||
$SDDPT,12.3,0.0*67
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.00,N,0.00,N,,*4D
|
||||
|
||||
$GPRMC,102006.00,A,5419.9242,N,01009.0416,E,3.8,42.2,290526,,*0C
|
||||
$GPGGA,102006.00,5419.9242,N,01009.0416,E,1,08,1.0,12.2,M,46.0,M,,*5C
|
||||
$GPVTG,42.2,T,,M,3.8,N,7.1,K*59
|
||||
$HEHDT,42.2,T*1B
|
||||
$IIMWV,240.0,R,12.0,N,A*08
|
||||
$SDDPT,12.2,0.0*66
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.00,N,0.00,N,,*4D
|
||||
|
||||
$GPRMC,102152.00,A,5420.0134,N,01009.1806,E,4.4,42.2,290526,,*0A
|
||||
$GPGGA,102152.00,5420.0134,N,01009.1806,E,1,08,1.0,12.1,M,46.0,M,,*52
|
||||
$GPVTG,42.2,T,,M,4.4,N,8.2,K*5E
|
||||
$HEHDT,42.2,T*1B
|
||||
$IIMWV,240.0,R,12.0,N,A*08
|
||||
$SDDPT,12.1,0.0*65
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.01,N,0.01,N,,*4D
|
||||
|
||||
$GPRMC,102329.00,A,5420.1204,N,01009.3473,E,5.9,42.2,290526,,*05
|
||||
$GPGGA,102329.00,5420.1204,N,01009.3473,E,1,08,1.0,12.0,M,46.0,M,,*50
|
||||
$GPVTG,42.2,T,,M,5.9,N,10.9,K*60
|
||||
$HEHDT,42.2,T*1B
|
||||
$IIMWV,240.0,R,12.0,N,A*08
|
||||
$SDDPT,12.0,0.0*64
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.01,N,0.01,N,,*4D
|
||||
|
||||
$GPRMC,102503.00,A,5420.2274,N,01009.5141,E,4.9,42.2,290526,,*0C
|
||||
$GPGGA,102503.00,5420.2274,N,01009.5141,E,1,08,1.0,11.8,M,46.0,M,,*53
|
||||
$GPVTG,42.2,T,,M,4.9,N,9.2,K*52
|
||||
$HEHDT,42.2,T*1B
|
||||
$IIMWV,240.0,R,12.0,N,A*08
|
||||
$SDDPT,11.8,0.0*6F
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.01,N,0.01,N,,*4D
|
||||
|
||||
$GPRMC,102637.00,A,5420.3167,N,01009.6530,E,4.6,42.2,290526,,*06
|
||||
$GPGGA,102637.00,5420.3167,N,01009.6530,E,1,08,1.0,11.7,M,46.0,M,,*59
|
||||
$GPVTG,42.2,T,,M,4.6,N,8.5,K*5B
|
||||
$HEHDT,42.2,T*1B
|
||||
$IIMWV,240.0,R,12.0,N,A*08
|
||||
$SDDPT,11.7,0.0*60
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.01,N,0.01,N,,*4D
|
||||
|
||||
$GPRMC,102818.00,A,5420.4236,N,01009.8197,E,5.8,42.2,290526,,*0D
|
||||
$GPGGA,102818.00,5420.4236,N,01009.8197,E,1,08,1.0,11.6,M,46.0,M,,*5C
|
||||
$GPVTG,42.2,T,,M,5.8,N,10.8,K*60
|
||||
$HEHDT,42.2,T*1B
|
||||
$IIMWV,240.0,R,12.0,N,A*08
|
||||
$SDDPT,11.6,0.0*61
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.02,N,0.02,N,,*4D
|
||||
|
||||
$GPRMC,102949.00,A,5420.5307,N,01009.9865,E,5.2,42.2,290526,,*05
|
||||
$GPGGA,102949.00,5420.5307,N,01009.9865,E,1,08,1.0,11.4,M,46.0,M,,*5C
|
||||
$GPVTG,42.2,T,,M,5.2,N,9.6,K*5C
|
||||
$HEHDT,42.2,T*1B
|
||||
$IIMWV,240.0,R,12.0,N,A*08
|
||||
$SDDPT,11.4,0.0*63
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.02,N,0.02,N,,*4D
|
||||
|
||||
$GPRMC,103111.00,A,5420.6100,N,01010.1100,E,4.5,32.8,290526,,*06
|
||||
$GPGGA,103111.00,5420.6100,N,01010.1100,E,1,08,1.0,11.3,M,46.0,M,,*53
|
||||
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
|
||||
$HEHDT,32.8,T*16
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,11.3,0.0*64
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.02,N,0.02,N,,*4D
|
||||
|
||||
$GPRMC,103255.00,A,5420.7314,N,01010.2446,E,5.7,32.8,290526,,*04
|
||||
$GPGGA,103255.00,5420.7314,N,01010.2446,E,1,08,1.0,11.2,M,46.0,M,,*53
|
||||
$GPVTG,32.8,T,,M,5.7,N,10.5,K*6F
|
||||
$HEHDT,32.8,T*16
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,11.2,0.0*65
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.02,N,0.02,N,,*4D
|
||||
|
||||
$GPRMC,103425.00,A,5420.8529,N,01010.3792,E,5.4,32.8,290526,,*0A
|
||||
$GPGGA,103425.00,5420.8529,N,01010.3792,E,1,08,1.0,11.0,M,46.0,M,,*5C
|
||||
$GPVTG,32.8,T,,M,5.4,N,10.0,K*69
|
||||
$HEHDT,32.8,T*16
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,11.0,0.0*67
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.02,N,0.02,N,,*4D
|
||||
|
||||
$GPRMC,103614.00,A,5420.9744,N,01010.5137,E,4.5,32.8,290526,,*0D
|
||||
$GPGGA,103614.00,5420.9744,N,01010.5137,E,1,08,1.0,10.8,M,46.0,M,,*52
|
||||
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
|
||||
$HEHDT,32.8,T*16
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,10.8,0.0*6E
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.03,N,0.03,N,,*4D
|
||||
|
||||
$GPRMC,103758.00,A,5421.0958,N,01010.6483,E,5.7,32.8,290526,,*05
|
||||
$GPGGA,103758.00,5421.0958,N,01010.6483,E,1,08,1.0,10.7,M,46.0,M,,*56
|
||||
$GPVTG,32.8,T,,M,5.7,N,10.5,K*6F
|
||||
$HEHDT,32.8,T*16
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,10.7,0.0*61
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.03,N,0.03,N,,*4D
|
||||
|
||||
$GPRMC,103929.00,A,5421.2173,N,01010.7829,E,5.4,32.8,290526,,*00
|
||||
$GPGGA,103929.00,5421.2173,N,01010.7829,E,1,08,1.0,10.5,M,46.0,M,,*52
|
||||
$GPVTG,32.8,T,,M,5.4,N,10.0,K*69
|
||||
$HEHDT,32.8,T*16
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,10.5,0.0*63
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.03,N,0.03,N,,*4D
|
||||
|
||||
$GPRMC,104117.00,A,5421.3387,N,01010.9175,E,4.5,32.8,290526,,*04
|
||||
$GPGGA,104117.00,5421.3387,N,01010.9175,E,1,08,1.0,10.4,M,46.0,M,,*57
|
||||
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
|
||||
$HEHDT,32.8,T*16
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,10.4,0.0*62
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.03,N,0.03,N,,*4D
|
||||
$IERPM,E,0,1850,37.0*20
|
||||
|
||||
$GPRMC,104257.00,A,5421.5006,N,01011.0969,E,7.8,32.8,290526,,*0C
|
||||
$GPGGA,104257.00,5421.5006,N,01011.0969,E,1,08,1.0,10.1,M,46.0,M,,*54
|
||||
$GPVTG,32.8,T,,M,7.8,N,14.4,K*67
|
||||
$HEHDT,32.8,T*16
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,10.1,0.0*67
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.04,N,0.04,N,,*4D
|
||||
$IERPM,E,0,1850,37.0*20
|
||||
|
||||
$GPRMC,104437.00,A,5421.6626,N,01011.2764,E,4.6,32.8,290526,,*07
|
||||
$GPGGA,104437.00,5421.6626,N,01011.2764,E,1,08,1.0,9.9,M,46.0,M,,*62
|
||||
$GPVTG,32.8,T,,M,4.6,N,8.5,K*56
|
||||
$HEHDT,32.8,T*16
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,9.9,0.0*57
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.04,N,0.04,N,,*4D
|
||||
$IERPM,E,0,1850,37.0*20
|
||||
|
||||
$GPRMC,104607.00,A,5421.7616,N,01011.3816,E,5.0,30.2,290526,,*00
|
||||
$GPGGA,104607.00,5421.7616,N,01011.3816,E,1,08,1.0,9.8,M,46.0,M,,*6B
|
||||
$GPVTG,30.2,T,,M,5.0,N,9.3,K*5E
|
||||
$HEHDT,30.2,T*1E
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,9.8,0.0*56
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.04,N,0.04,N,,*4D
|
||||
|
||||
$GPRMC,104740.00,A,5421.8866,N,01011.5066,E,5.9,30.2,290526,,*04
|
||||
$GPGGA,104740.00,5421.8866,N,01011.5066,E,1,08,1.0,9.6,M,46.0,M,,*68
|
||||
$GPVTG,30.2,T,,M,5.9,N,10.9,K*65
|
||||
$HEHDT,30.2,T*1E
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,9.6,0.0*58
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.05,N,0.05,N,,*4D
|
||||
|
||||
$GPRMC,104918.00,A,5422.0115,N,01011.6315,E,4.7,30.2,290526,,*0A
|
||||
$GPGGA,104918.00,5422.0115,N,01011.6315,E,1,08,1.0,9.5,M,46.0,M,,*6A
|
||||
$GPVTG,30.2,T,,M,4.7,N,8.7,K*5D
|
||||
$HEHDT,30.2,T*1E
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,9.5,0.0*5B
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.05,N,0.05,N,,*4D
|
||||
|
||||
$GPRMC,105049.00,A,5422.1364,N,01011.7564,E,6.9,30.2,290526,,*0E
|
||||
$GPGGA,105049.00,5422.1364,N,01011.7564,E,1,08,1.0,9.3,M,46.0,M,,*64
|
||||
$GPVTG,30.2,T,,M,6.9,N,12.8,K*65
|
||||
$HEHDT,30.2,T*1E
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,9.3,0.0*5D
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.05,N,0.05,N,,*4D
|
||||
|
||||
$GPRMC,105233.00,A,5422.3030,N,01011.9230,E,5.6,30.2,290526,,*05
|
||||
$GPGGA,105233.00,5422.3030,N,01011.9230,E,1,08,1.0,9.1,M,46.0,M,,*61
|
||||
$GPVTG,30.2,T,,M,5.6,N,10.3,K*60
|
||||
$HEHDT,30.2,T*1E
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,9.1,0.0*5F
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.05,N,0.05,N,,*4D
|
||||
|
||||
$GPRMC,105419.00,A,5422.4279,N,01012.0479,E,4.5,30.2,290526,,*00
|
||||
$GPGGA,105419.00,5422.4279,N,01012.0479,E,1,08,1.0,8.9,M,46.0,M,,*6F
|
||||
$GPVTG,30.2,T,,M,4.5,N,8.3,K*5B
|
||||
$HEHDT,30.2,T*1E
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,8.9,0.0*56
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.06,N,0.06,N,,*4D
|
||||
|
||||
$GPRMC,105550.00,A,5422.5320,N,01012.1520,E,5.3,30.2,290526,,*0B
|
||||
$GPGGA,105550.00,5422.5320,N,01012.1520,E,1,08,1.0,8.8,M,46.0,M,,*62
|
||||
$GPVTG,30.2,T,,M,5.3,N,9.8,K*56
|
||||
$HEHDT,30.2,T*1E
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,8.8,0.0*57
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.06,N,0.06,N,,*4D
|
||||
|
||||
$GPRMC,105721.00,A,5422.6570,N,01012.2770,E,5.8,30.2,290526,,*00
|
||||
$GPGGA,105721.00,5422.6570,N,01012.2770,E,1,08,1.0,8.6,M,46.0,M,,*6C
|
||||
$GPVTG,30.2,T,,M,5.8,N,10.7,K*6A
|
||||
$HEHDT,30.2,T*1E
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,8.6,0.0*59
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.06,N,0.06,N,,*4D
|
||||
|
||||
$GPRMC,105856.00,A,5422.7731,N,01012.3907,E,4.5,29.3,290526,,*03
|
||||
$GPGGA,105856.00,5422.7731,N,01012.3907,E,1,08,1.0,8.4,M,46.0,M,,*68
|
||||
$GPVTG,29.3,T,,M,4.5,N,8.4,K*55
|
||||
$HEHDT,29.3,T*17
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,8.4,0.0*5B
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.06,N,0.06,N,,*4D
|
||||
|
||||
$GPRMC,110035.00,A,5422.8992,N,01012.5122,E,5.1,29.3,290526,,*0E
|
||||
$GPGGA,110035.00,5422.8992,N,01012.5122,E,1,08,1.0,8.3,M,46.0,M,,*67
|
||||
$GPVTG,29.3,T,,M,5.1,N,9.5,K*50
|
||||
$HEHDT,29.3,T*17
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,8.3,0.0*5C
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.06,N,0.06,N,,*4D
|
||||
|
||||
$GPRMC,110220.00,A,5423.0252,N,01012.6336,E,4.7,29.3,290526,,*05
|
||||
$GPGGA,110220.00,5423.0252,N,01012.6336,E,1,08,1.0,8.1,M,46.0,M,,*69
|
||||
$GPVTG,29.3,T,,M,4.7,N,8.8,K*5B
|
||||
$HEHDT,29.3,T*17
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,8.1,0.0*5E
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.07,N,0.07,N,,*4D
|
||||
|
||||
$GPRMC,110355.00,A,5423.1304,N,01012.7348,E,4.4,29.3,290526,,*0E
|
||||
$GPGGA,110355.00,5423.1304,N,01012.7348,E,1,08,1.0,8.0,M,46.0,M,,*60
|
||||
$GPVTG,29.3,T,,M,4.4,N,8.2,K*52
|
||||
$HEHDT,29.3,T*17
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,8.0,0.0*5F
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.07,N,0.07,N,,*4D
|
||||
|
||||
$GPRMC,110537.00,A,5423.2354,N,01012.8360,E,4.1,29.3,290526,,*0A
|
||||
$GPGGA,110537.00,5423.2354,N,01012.8360,E,1,08,1.0,7.8,M,46.0,M,,*66
|
||||
$GPVTG,29.3,T,,M,4.1,N,7.5,K*5F
|
||||
$HEHDT,29.3,T*17
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,7.8,0.0*58
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.07,N,0.07,N,,*4D
|
||||
|
||||
$GPRMC,110728.00,A,5423.3405,N,01012.9372,E,3.7,29.3,290526,,*07
|
||||
$GPGGA,110728.00,5423.3405,N,01012.9372,E,1,08,1.0,7.7,M,46.0,M,,*65
|
||||
$GPVTG,29.3,T,,M,3.7,N,6.9,K*53
|
||||
$HEHDT,29.3,T*17
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,7.7,0.0*57
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.07,N,0.07,N,,*4D
|
||||
|
||||
$GPRMC,110905.00,A,5423.4246,N,01013.0181,E,3.5,29.3,290526,,*04
|
||||
$GPGGA,110905.00,5423.4246,N,01013.0181,E,1,08,1.0,7.6,M,46.0,M,,*65
|
||||
$GPVTG,29.3,T,,M,3.5,N,6.4,K*5C
|
||||
$HEHDT,29.3,T*17
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,7.6,0.0*56
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.07,N,0.07,N,,*4D
|
||||
|
||||
$GPRMC,111049.00,A,5423.5087,N,01013.0991,E,3.2,29.3,290526,,*04
|
||||
$GPGGA,111049.00,5423.5087,N,01013.0991,E,1,08,1.0,7.5,M,46.0,M,,*61
|
||||
$GPVTG,29.3,T,,M,3.2,N,5.9,K*55
|
||||
$HEHDT,29.3,T*17
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,7.5,0.0*55
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.08,N,0.08,N,,*4D
|
||||
|
||||
$GPRMC,111229.00,A,5423.5828,N,01013.1716,E,2.9,29.7,290526,,*03
|
||||
$GPGGA,111229.00,5423.5828,N,01013.1716,E,1,08,1.0,7.4,M,46.0,M,,*69
|
||||
$GPVTG,29.7,T,,M,2.9,N,5.4,K*56
|
||||
$HEHDT,29.7,T*13
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,7.4,0.0*54
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.08,N,0.08,N,,*4D
|
||||
|
||||
$GPRMC,111401.00,A,5423.6455,N,01013.2332,E,2.7,29.7,290526,,*05
|
||||
$GPGGA,111401.00,5423.6455,N,01013.2332,E,1,08,1.0,7.3,M,46.0,M,,*66
|
||||
$GPVTG,29.7,T,,M,2.7,N,5.1,K*5D
|
||||
$HEHDT,29.7,T*13
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,7.3,0.0*53
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.08,N,0.08,N,,*4D
|
||||
|
||||
$GPRMC,111540.00,A,5423.7083,N,01013.2947,E,2.5,29.7,290526,,*05
|
||||
$GPGGA,111540.00,5423.7083,N,01013.2947,E,1,08,1.0,7.2,M,46.0,M,,*65
|
||||
$GPVTG,29.7,T,,M,2.5,N,4.7,K*58
|
||||
$HEHDT,29.7,T*13
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,7.2,0.0*52
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.08,N,0.08,N,,*4D
|
||||
|
||||
$GPRMC,111727.00,A,5423.7711,N,01013.3563,E,2.3,29.7,290526,,*07
|
||||
$GPGGA,111727.00,5423.7711,N,01013.3563,E,1,08,1.0,7.1,M,46.0,M,,*62
|
||||
$GPVTG,29.7,T,,M,2.3,N,4.3,K*5A
|
||||
$HEHDT,29.7,T*13
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,7.1,0.0*51
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.08,N,0.08,N,,*4D
|
||||
|
||||
$GPRMC,111924.00,A,5423.8339,N,01013.4179,E,2.1,29.7,290526,,*01
|
||||
$GPGGA,111924.00,5423.8339,N,01013.4179,E,1,08,1.0,7.0,M,46.0,M,,*67
|
||||
$GPVTG,29.7,T,,M,2.1,N,4.0,K*5B
|
||||
$HEHDT,29.7,T*13
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,7.0,0.0*50
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.08,N,0.08,N,,*4D
|
||||
|
||||
$GPRMC,112048.00,A,5423.8757,N,01013.4590,E,2.0,29.7,290526,,*0F
|
||||
$GPGGA,112048.00,5423.8757,N,01013.4590,E,1,08,1.0,7.0,M,46.0,M,,*68
|
||||
$GPVTG,29.7,T,,M,2.0,N,3.7,K*5A
|
||||
$HEHDT,29.7,T*13
|
||||
$IIMWV,280.0,R,12.0,N,A*04
|
||||
$SDDPT,7.0,0.0*50
|
||||
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
|
||||
$YXMTW,14.2,C*15
|
||||
$IIVLW,0.08,N,0.08,N,,*4D
|
||||
|
||||
Reference in New Issue
Block a user