Compare commits

...

16 Commits

Author SHA1 Message Date
elpatron d94502097e chore: release v0.1.0.73 2026-05-31 21:46:44 +02:00
elpatron a36ca2facb Add Plausible analytics for live journal and NMEA upload.
Track Live Log Opened/Event Logged with action types, NMEA Uploaded on parse success, and align NMEA Imported properties with docs.

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:38:10 +02:00
45 changed files with 5216 additions and 32 deletions
+2
View File
@@ -11,3 +11,5 @@ server/dist/
.env.local
.env.*.local
*.log
userfeedback/
+1 -1
View File
@@ -1 +1 @@
0.1.0.71
0.1.0.74
+475
View File
@@ -611,6 +611,7 @@ html.scheme-dark .themed-select-option.is-selected {
width: 100%;
max-width: 560px;
max-height: min(90vh, 820px);
overflow-y: auto;
}
.feedback-modal {
@@ -662,6 +663,182 @@ html.scheme-dark .themed-select-option.is-selected {
margin-top: 0;
}
.registration-disclaimer.feedback-modal {
align-items: stretch;
width: 100%;
}
.registration-disclaimer.feedback-modal .auth-header,
.registration-disclaimer.feedback-modal > p,
.registration-disclaimer.feedback-modal .nmea-import-summary,
.registration-disclaimer.feedback-modal .nmea-import-warning,
.registration-disclaimer.feedback-modal .nmea-import-mode,
.registration-disclaimer.feedback-modal .feedback-form__field,
.registration-disclaimer.feedback-modal .nmea-import-checkbox,
.registration-disclaimer.feedback-modal .nmea-preview-actions,
.registration-disclaimer.feedback-modal .nmea-preview-list,
.registration-disclaimer.feedback-modal .auth-actions {
width: 100%;
box-sizing: border-box;
}
.nmea-import-warning {
width: 100%;
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
text-align: left;
color: var(--app-warning-text, #fcd34d);
background: var(--app-warning-bg, rgba(251, 191, 36, 0.1));
border: 1px solid var(--app-warning-border, rgba(251, 191, 36, 0.35));
box-sizing: border-box;
}
.nmea-import-summary {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
background: var(--app-surface-inset);
border: 1px solid var(--app-border-muted);
text-align: left;
font-size: 13px;
line-height: 1.5;
}
.nmea-import-summary p {
margin: 0;
}
.nmea-import-summary p + p {
margin-top: 6px;
}
.nmea-import-mode {
border: 1px solid var(--app-border-muted);
border-radius: 8px;
padding: 12px 16px;
margin: 0 0 16px;
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
}
.nmea-import-mode legend {
padding: 0 4px;
font-size: 13px;
font-weight: 600;
color: var(--app-text-heading, #f1f5f9);
}
.nmea-import-mode label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 14px;
line-height: 1.4;
}
.nmea-import-checkbox {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
cursor: pointer;
text-align: left;
font-size: 14px;
}
.nmea-preview-actions {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.nmea-preview-actions .btn {
flex: 1;
margin: 0;
}
.nmea-preview-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(45vh, 360px);
overflow-y: auto;
margin-bottom: 16px;
padding: 2px;
}
.nmea-preview-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--app-border-muted);
border-radius: 8px;
background: var(--app-surface-inset);
cursor: pointer;
text-align: left;
transition: border-color 0.15s ease;
}
.nmea-preview-row:hover {
border-color: var(--app-accent-border, rgba(212, 175, 55, 0.35));
}
.nmea-preview-row__check {
flex-shrink: 0;
margin: 2px 0 0;
width: 18px;
height: 18px;
accent-color: var(--app-accent-light, #d4af37);
}
.nmea-preview-row__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.nmea-preview-row__meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.nmea-preview-time {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 14px;
color: var(--app-accent-light, #d4af37);
min-width: 3.25rem;
}
.nmea-preview-source {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.15);
color: var(--app-text-muted, #94a3b8);
}
.nmea-preview-remarks {
font-size: 13px;
line-height: 1.45;
color: var(--app-text, #e2e8f0);
word-break: break-word;
}
.feedback-form {
display: flex;
flex-direction: column;
@@ -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
View File
@@ -45,7 +45,7 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from './utils/i18nLanguages.js'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import {
resolveTourLogbookContext,
seedDemoLogbookIfNeeded
@@ -497,7 +497,7 @@ function App() {
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const handleExitDemo = () => {
+2 -2
View File
@@ -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 = () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogEntriesList from './LogEntriesList.tsx'
@@ -49,7 +49,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
@@ -1,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) {
+771
View File
@@ -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
)}
</>
)
}
+49 -2
View File
@@ -9,10 +9,11 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
import {
carryOverFromPreviousDay,
compareTravelDaysChronological,
@@ -36,6 +37,8 @@ interface LogEntriesListProps {
highlightEntryId?: string | null
}
type LogsViewMode = 'list' | 'live'
interface DecryptedEntryItem {
id: string
date: string
@@ -75,6 +78,8 @@ export default function LogEntriesList({
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
const loadEntries = useCallback(async () => {
@@ -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>
+88 -1
View File
@@ -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>
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
@@ -194,7 +194,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
+333
View File
@@ -0,0 +1,333 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { FileText, X } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js'
import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js'
import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js'
import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js'
import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import type { TrackWaypoint } from '../services/trackUpload.js'
interface NmeaImportWizardProps {
open: boolean
onClose: () => void
logbookId: string
entryId: string
entryDate: string
nmeaArchive: NmeaArchiveRecord | null
onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void
}
type WizardStep = 'config' | 'preview' | 'archive'
export default function NmeaImportWizard({
open,
onClose,
logbookId,
entryId,
entryDate,
nmeaArchive,
onImport
}: NmeaImportWizardProps) {
const { t } = useTranslation()
const [step, setStep] = useState<WizardStep>('config')
const [parseResult, setParseResult] = useState<NmeaParseResult | null>(null)
const [mode, setMode] = useState<NmeaImportMode>('both')
const [intervalMinutes, setIntervalMinutes] = useState(60)
const [importTrack, setImportTrack] = useState(true)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null)
const [duplicateFile, setDuplicateFile] = useState(false)
const filteredPoints = useMemo(() => {
if (!parseResult) return []
return filterPointsForDate(parseResult.points, entryDate)
}, [parseResult, entryDate])
const candidates = useMemo(() => {
if (!parseResult || filteredPoints.length === 0) return []
return generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
}, [parseResult, filteredPoints, mode, intervalMinutes, t])
const reset = () => {
setStep('config')
setParseResult(null)
setMode('both')
setIntervalMinutes(60)
setImportTrack(true)
setSelectedIds(new Set())
setError(null)
setDuplicateFile(false)
setPendingRaw(null)
}
const handleClose = () => {
reset()
onClose()
}
const handleFile = (file: File) => {
setError(null)
setDuplicateFile(false)
const reader = new FileReader()
reader.onload = () => {
try {
const text = String(reader.result ?? '')
const crc32 = nmeaFileCrc32(text)
const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false
setDuplicateFile(alreadyImported)
const result = parseNmeaFile(text, file.name)
if (result.points.length === 0) {
setError(t('logs.nmea_error_no_samples'))
return
}
setParseResult(result)
setPendingRaw({ filename: file.name, text })
const generated = generateNmeaJournalCandidates({
points: filterPointsForDate(result.points, entryDate),
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, {
duplicate: alreadyImported,
lines: result.stats.parsedLines,
candidates: generated.length,
has_position: !result.warnings.includes('no_position')
})
} catch (err) {
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
}
}
reader.onerror = () => setError(t('logs.nmea_error_read'))
reader.readAsText(file)
}
const toggleAll = (checked: boolean) => {
setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set())
}
const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const goPreview = () => {
if (!parseResult) {
setError(t('logs.nmea_error_no_file'))
return
}
const generated = generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
setStep('preview')
}
const applyImport = async () => {
const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event)
if (picked.length === 0) {
setError(t('logs.nmea_error_no_selection'))
return
}
const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined
onImport(sortLogEventsByTime(picked), waypoints)
if (pendingRaw) {
try {
await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
} catch (err) {
console.warn('NMEA import CRC record failed:', err)
}
}
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
mode,
events: picked.length,
track: importTrack && (waypoints?.length ?? 0) > 0
})
setStep('archive')
}
const finishArchive = async (archive: boolean) => {
try {
if (archive && pendingRaw) {
await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
}
} catch (err) {
console.warn('NMEA archive save failed:', err)
}
handleClose()
}
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') handleClose()
}
window.addEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
}
}, [open])
if (!open) return null
return createPortal(
<div className="disclaimer-modal-overlay" onClick={handleClose}>
<div className="disclaimer-modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
<button
type="button"
className="registration-disclaimer__close feedback-modal__close"
onClick={handleClose}
aria-label={t('logs.nmea_cancel')}
>
<X size={18} />
</button>
<div className="auth-header">
<FileText className="auth-icon accent" size={40} />
<h2>{t('logs.nmea_import_title')}</h2>
</div>
{error && <div className="track-error-msg">{error}</div>}
{duplicateFile && (
<div className="nmea-import-warning" role="status">
{t('logs.nmea_warn_duplicate_file')}
</div>
)}
{step === 'config' && (
<>
<p className="registration-disclaimer__intro">{t('logs.nmea_import_intro')}</p>
<label className="feedback-form__field">
<span>{t('logs.nmea_file_label')}</span>
<input
type="file"
accept=".nmea,.log,.txt"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
</label>
{parseResult && (
<div className="nmea-import-summary">
<p>{t('logs.nmea_stats', {
lines: parseResult.stats.parsedLines,
types: parseResult.stats.sentenceTypes.join(', ')
})}</p>
{parseResult.warnings.includes('no_position') && (
<p>{t('logs.nmea_warn_no_position')}</p>
)}
</div>
)}
<fieldset className="nmea-import-mode">
<legend>{t('logs.nmea_mode_label')}</legend>
<label><input type="radio" name="nmea-mode" checked={mode === 'interval'} onChange={() => setMode('interval')} /> {t('logs.nmea_mode_interval')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'change'} onChange={() => setMode('change')} /> {t('logs.nmea_mode_change')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'both'} onChange={() => setMode('both')} /> {t('logs.nmea_mode_both')}</label>
</fieldset>
{(mode === 'interval' || mode === 'both') && (
<label className="feedback-form__field">
<span>{t('logs.nmea_interval_label')}</span>
<select value={intervalMinutes} onChange={(e) => setIntervalMinutes(Number(e.target.value))}>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
<option value={90}>90 min</option>
<option value={120}>120 min</option>
</select>
</label>
)}
<label className="nmea-import-checkbox">
<input type="checkbox" checked={importTrack} onChange={(e) => setImportTrack(e.target.checked)} />
{t('logs.nmea_import_track')}
</label>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={handleClose}>{t('logs.nmea_cancel')}</button>
<button type="button" className="btn primary" onClick={goPreview} disabled={!parseResult}>
{t('logs.nmea_preview')}
</button>
</div>
</>
)}
{step === 'preview' && (
<>
<p>{t('logs.nmea_preview_hint', { count: candidates.length })}</p>
<div className="nmea-preview-actions">
<button type="button" className="btn secondary" onClick={() => toggleAll(true)}>{t('logs.nmea_select_all')}</button>
<button type="button" className="btn secondary" onClick={() => toggleAll(false)}>{t('logs.nmea_select_none')}</button>
</div>
<div className="nmea-preview-list">
{candidates.map((c) => (
<label key={c.id} className="nmea-preview-row">
<input
type="checkbox"
className="nmea-preview-row__check"
checked={selectedIds.has(c.id)}
onChange={() => toggleOne(c.id)}
/>
<div className="nmea-preview-row__body">
<div className="nmea-preview-row__meta">
<span className="nmea-preview-time">{c.event.time}</span>
<span className="nmea-preview-source">{t(`logs.nmea_source_${c.source}`)}</span>
</div>
<span className="nmea-preview-remarks">{c.event.remarks || c.event.mgk || '—'}</span>
</div>
</label>
))}
</div>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => setStep('config')}>{t('logs.nmea_back')}</button>
<button type="button" className="btn primary" onClick={applyImport}>{t('logs.nmea_apply')}</button>
</div>
</>
)}
{step === 'archive' && (
<>
<p>{t('logs.nmea_archive_question')}</p>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => finishArchive(false)}>
{t('logs.nmea_archive_discard')}
</button>
<button type="button" className="btn primary" onClick={() => finishArchive(true)}>
{t('logs.nmea_archive_keep')}
</button>
</div>
</>
)}
</div>
</div>
</div>,
document.body
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
@@ -137,7 +137,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
if (loading) {
+69 -4
View File
@@ -14,6 +14,11 @@ import {
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import {
loadLogbookEventSeries,
type EventSeriesPoint,
type EventSeriesSummary
} from '../services/eventSeriesAggregation.js'
interface StatsDashboardProps {
logbookId: string
@@ -217,7 +222,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)
}
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
if (points.length === 0) {
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<p className="stats-section-sub">{emptyLabel}</p>
</div>
)
}
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<ul className="stats-event-series-list">
{points.map((point, idx) => (
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
<span className="stats-event-series-when">
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
{' · '}
{point.time}
</span>
<span className="stats-event-series-value">{point.summary}</span>
</li>
))}
</ul>
</div>
)
}
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
const { t } = useTranslation()
const motorPoints = series.motor.map((point) => ({
...point,
summary: point.summary === 'start'
? t('logs.live_motor_start')
: t('logs.live_motor_stop')
}))
return (
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
</div>
)
}
function LogbookScopeView({
summary,
eventSeries
}: {
summary: LogbookStatsSummary
eventSeries: EventSeriesSummary | null
}) {
const { t } = useTranslation()
const { travelDays, routePorts, trackSegments, totals } = summary
@@ -313,6 +373,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
<PropulsionBreakdown totals={totals} />
</div>
{eventSeries && <EventSeriesPanel series={eventSeries} />}
</>
)
}
@@ -323,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [lb, acc] = await Promise.all([
const [lb, acc, series] = await Promise.all([
loadLogbookStats(logbookId, logbookTitle, true),
loadAccountStats(false)
loadAccountStats(false),
loadLogbookEventSeries(logbookId)
])
setLogbookStats(lb)
setAccountStats(acc)
setEventSeries(series)
} catch (err: unknown) {
console.error('Failed to load statistics:', err)
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
@@ -397,7 +462,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<p>{t('stats.loading')}</p>
</div>
) : scope === 'logbook' && logbookStats ? (
<LogbookScopeView summary={logbookStats} />
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
) : scope === 'account' && accountStats ? (
<>
<TotalsGrid totals={accountStats.totals} />
+119 -2
View File
@@ -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",
+118 -1
View File
@@ -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",
+118 -1
View File
@@ -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",
+119 -2
View File
@@ -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",
+119 -2
View File
@@ -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",
+6 -1
View File
@@ -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]
+22
View File
@@ -64,6 +64,15 @@ export interface LocalGpsTrack {
updatedAt: string
}
export interface LocalNmeaArchive {
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookKey {
logbookId: string
encryptedKey: string
@@ -89,6 +98,7 @@ class DaagboxDatabase extends Dexie {
entries!: Table<LocalEntry>
photos!: Table<LocalPhoto>
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
syncQueue!: Table<SyncQueueItem>
@@ -145,6 +155,18 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(6).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
}
}
@@ -0,0 +1,106 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { LIVE_EVENT_CODES } from '../utils/liveEventCodes.js'
export interface EventSeriesPoint {
entryId: string
date: string
dayOfTravel: string
time: string
summary: string
}
export interface EventSeriesSummary {
pressure: EventSeriesPoint[]
wind: EventSeriesPoint[]
motor: EventSeriesPoint[]
}
function sortPoints(points: EventSeriesPoint[]): EventSeriesPoint[] {
return [...points].sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.time.localeCompare(b.time)
})
}
export async function loadLogbookEventSeries(logbookId: string): Promise<EventSeriesSummary> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<{
entryId: string
date: string
dayOfTravel: string
events: LogEventPayload[]
}> = []
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) continue
decryptedEntries.push({
entryId: entry.payloadId,
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
events: (decrypted.events as LogEventPayload[]) || []
})
}
decryptedEntries.sort((a, b) =>
compareTravelDaysChronological(
{ date: a.date, dayOfTravel: a.dayOfTravel },
{ date: b.date, dayOfTravel: b.dayOfTravel }
)
)
const pressure: EventSeriesPoint[] = []
const wind: EventSeriesPoint[] = []
const motor: EventSeriesPoint[] = []
for (const entry of decryptedEntries) {
for (const event of entry.events) {
const base = {
entryId: entry.entryId,
date: entry.date,
dayOfTravel: entry.dayOfTravel,
time: event.time
}
if (event.windPressure?.trim()) {
pressure.push({
...base,
summary: `${event.windPressure} hPa`
})
}
if (event.windDirection?.trim() || event.windStrength?.trim()) {
wind.push({
...base,
summary: [event.windDirection, event.windStrength].filter(Boolean).join(' ')
})
}
const code = event.remarks?.trim() ?? ''
if (
code === LIVE_EVENT_CODES.MOTOR_START ||
code === LIVE_EVENT_CODES.MOTOR_STOP
) {
motor.push({
...base,
summary: code === LIVE_EVENT_CODES.MOTOR_START ? 'start' : 'stop'
})
}
}
}
return {
pressure: sortPoints(pressure),
wind: sortPoints(wind),
motor: sortPoints(motor)
}
}
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseNmeaFile } from './nmeaParse.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
import { generateNmeaJournalCandidates } from './nmeaJournalGenerator.js'
const nmeaPath = resolve(import.meta.dirname, '../../../../testdata/tracks/kieler-foerde-5sm.nmea')
describe('kieler-foerde testdata', () => {
it('parses the sample NMEA log and yields journal candidates', () => {
const text = readFileSync(nmeaPath, 'utf8')
const result = parseNmeaFile(text, 'kieler-foerde-5sm.nmea')
expect(result.stats.checksumErrors).toBe(0)
expect(result.points.length).toBeGreaterThan(30)
expect(result.stats.sentenceTypes).toEqual(expect.arrayContaining(['RMC', 'GGA', 'MWV', 'DPT', 'MDA']))
const changes = detectNmeaChanges(result.points)
expect(changes.length).toBeGreaterThan(0)
expect(changes.some((c) => ['wind', 'engine_start', 'departure', 'speed', 'depth'].includes(c.type))).toBe(true)
const journal = generateNmeaJournalCandidates({
points: result.points,
mode: 'both',
intervalMinutes: 60,
t: (key) => key
})
expect(journal.candidates.length).toBeGreaterThanOrEqual(3)
})
})
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { NmeaTimePoint } from './nmeaTypes.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
function point(
timestamp: number,
overrides: Partial<NmeaTimePoint> = {}
): NmeaTimePoint {
return { timestamp, ...overrides }
}
describe('detectNmeaChanges', () => {
it('detects significant course changes while underway', () => {
const points = [
point(0, { cog: 0, sog: 5 }),
point(60_000, { cog: 45, sog: 5 })
]
const events = detectNmeaChanges(points, {
courseDeltaDeg: 30,
windDirDeltaDeg: 30,
windSpeedDeltaKnots: 5,
pressureDeltaHpa: 2,
depthDeltaM: 1,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 2,
dedupeWindowMs: 60_000
})
expect(events.some((e) => e.type === 'course')).toBe(true)
const course = events.find((e) => e.type === 'course')
expect(course?.summaryParams).toMatchObject({ from: 0, to: 45 })
})
it('detects engine start when RPM rises above threshold', () => {
const points = [
point(0, { sog: 0, rpm: 0 }),
point(30_000, { sog: 3, rpm: 1200 })
]
const events = detectNmeaChanges(points)
expect(events.some((e) => e.type === 'engine_start')).toBe(true)
})
it('dedupes repeated events within the configured window', () => {
const points = [
point(0, { cog: 0, sog: 5 }),
point(10_000, { cog: 50, sog: 5 }),
point(20_000, { cog: 100, sog: 5 })
]
const events = detectNmeaChanges(points, {
courseDeltaDeg: 30,
windDirDeltaDeg: 30,
windSpeedDeltaKnots: 5,
pressureDeltaHpa: 2,
depthDeltaM: 1,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 2,
dedupeWindowMs: 120_000
})
const courseEvents = events.filter((e) => e.type === 'course')
expect(courseEvents.length).toBe(1)
})
})
@@ -0,0 +1,211 @@
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
import { angularDelta } from './nmeaTimeSeries.js'
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
const last = events[events.length - 1]
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
events.push(event)
}
export function detectNmeaChanges(
points: NmeaTimePoint[],
config: NmeaDetectionConfig = DEFAULT_NMEA_DETECTION_CONFIG
): NmeaChangeEvent[] {
const events: NmeaChangeEvent[] = []
if (points.length < 2) return events
let lastCourse: number | undefined
let lastWindDir: number | undefined
let lastWindSpeed: number | undefined
let lastPressure: number | undefined
let lastDepth: number | undefined
let lastWaterTemp: number | undefined
let lastFix: boolean | undefined
let engineRunning = false
let autopilot: boolean | undefined
let underWay = false
let stoppedSince: number | null = null
let lastSog: number | undefined
for (const p of points) {
const course = p.cog ?? p.hdt ?? p.hdm
if (course != null && lastCourse != null && (p.sog ?? 0) > 1) {
if (angularDelta(course, lastCourse) >= config.courseDeltaDeg) {
pushUnique(events, {
type: 'course',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_course',
summaryParams: { from: Math.round(lastCourse), to: Math.round(course) },
data: p
}, config.dedupeWindowMs)
}
}
if (course != null) lastCourse = course
if (p.windDir != null && lastWindDir != null) {
if (angularDelta(p.windDir, lastWindDir) >= config.windDirDeltaDeg) {
pushUnique(events, {
type: 'wind',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_wind',
summaryParams: { from: Math.round(lastWindDir), to: Math.round(p.windDir) },
data: p
}, config.dedupeWindowMs)
} else if (
p.windSpeedKnots != null &&
lastWindSpeed != null &&
Math.abs(p.windSpeedKnots - lastWindSpeed) >= config.windSpeedDeltaKnots
) {
pushUnique(events, {
type: 'wind',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_wind_speed',
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.windDir != null) lastWindDir = p.windDir
if (p.windSpeedKnots != null) lastWindSpeed = p.windSpeedKnots
if (p.pressureHpa != null && lastPressure != null) {
if (Math.abs(p.pressureHpa - lastPressure) >= config.pressureDeltaHpa) {
pushUnique(events, {
type: 'pressure',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_pressure',
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.pressureHpa != null) lastPressure = p.pressureHpa
if (p.depthM != null && lastDepth != null) {
const delta = Math.abs(p.depthM - lastDepth)
const rel = lastDepth > 0 ? (delta / lastDepth) * 100 : 100
if (delta >= config.depthDeltaM || rel >= config.depthDeltaPercent) {
pushUnique(events, {
type: 'depth',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_depth',
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.depthM != null) lastDepth = p.depthM
if (p.rpm != null) {
const running = p.rpm >= config.rpmRunning
const idle = p.rpm <= config.rpmIdle
if (running && !engineRunning) {
pushUnique(events, {
type: 'engine_start',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_engine_start',
summaryParams: { rpm: Math.round(p.rpm) },
data: p
}, config.dedupeWindowMs)
engineRunning = true
} else if (idle && engineRunning) {
pushUnique(events, {
type: 'engine_stop',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_engine_stop',
data: p
}, config.dedupeWindowMs)
engineRunning = false
}
}
if (p.autopilotEngaged != null && autopilot != null && p.autopilotEngaged !== autopilot) {
pushUnique(events, {
type: p.autopilotEngaged ? 'autopilot_on' : 'autopilot_off',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: p.autopilotEngaged ? 'logs.nmea_change_autopilot_on' : 'logs.nmea_change_autopilot_off',
data: p
}, config.dedupeWindowMs)
}
if (p.autopilotEngaged != null) autopilot = p.autopilotEngaged
if (p.fixValid != null && lastFix != null && p.fixValid !== lastFix) {
pushUnique(events, {
type: p.fixValid ? 'gps_fix_regained' : 'gps_fix_lost',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: p.fixValid ? 'logs.nmea_change_gps_regained' : 'logs.nmea_change_gps_lost',
data: p
}, config.dedupeWindowMs)
}
if (p.fixValid != null) lastFix = p.fixValid
if (p.waterTempC != null && lastWaterTemp != null) {
if (Math.abs(p.waterTempC - lastWaterTemp) >= 2) {
pushUnique(events, {
type: 'water_temp',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_water_temp',
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.waterTempC != null) lastWaterTemp = p.waterTempC
const sog = p.sog ?? 0
if (sog >= config.sogUnderWayKn && !underWay) {
if (stoppedSince != null && p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
pushUnique(events, {
type: 'departure',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_departure',
data: p
}, config.dedupeWindowMs)
}
underWay = true
stoppedSince = null
}
if (sog <= config.sogStoppedKn && underWay) {
underWay = false
stoppedSince = p.timestamp
}
if (sog <= config.sogStoppedKn && stoppedSince != null && !underWay) {
if (p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
pushUnique(events, {
type: 'anchor',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_anchor',
data: p
}, config.dedupeWindowMs)
stoppedSince = null
}
}
if (lastSog != null && Math.abs(sog - lastSog) >= config.speedDeltaKn) {
pushUnique(events, {
type: 'speed',
timestamp: p.timestamp,
confidence: 'low',
summaryKey: 'logs.nmea_change_speed',
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
lastSog = sog
}
return events.sort((a, b) => a.timestamp - b.timestamp)
}
@@ -0,0 +1,139 @@
import type { TFunction } from 'i18next'
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
import { formatCourseAngle } from '../../utils/courseAngle.js'
import { degreesToCardinal } from '../../utils/courseAngle.js'
import type {
NmeaChangeEvent,
NmeaImportMode,
NmeaJournalCandidate,
NmeaTimePoint
} from './nmeaTypes.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
import { intervalTimestamps, sampleAt, timestampToHHMM } from './nmeaTimeSeries.js'
export interface GeneratedNmeaJournal {
candidates: Array<NmeaJournalCandidate & { event: LogEventPayload }>
}
function pointToLogEvent(
point: NmeaTimePoint,
remarks: string,
sailsOrMotor: string
): LogEventPayload {
const course = point.cog ?? point.hdt ?? point.hdm
const mgk = course != null ? formatCourseAngle(course) : ''
const windDir =
point.windDir != null ? degreesToCardinal(point.windDir) : ''
return normalizeLogEvent({
time: timestampToHHMM(point.timestamp),
mgk,
rwk: '',
windDirection: windDir,
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
sailsOrMotor,
remarks
})
}
function changeToSailsOrMotor(type: NmeaChangeEvent['type']): string {
if (type === 'engine_start') return 'Motor'
if (type === 'engine_stop') return 'Segel'
return ''
}
function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
const parts: string[] = []
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
if (change.data?.depthM != null) {
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
}
if (change.confidence === 'low') {
parts.push(t('logs.nmea_remark_uncertain'))
}
return parts.join(' · ')
}
function dedupeCandidates(
items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }>,
windowMs: number
): Array<NmeaJournalCandidate & { event: LogEventPayload }> {
const sorted = [...items].sort((a, b) => a.timestamp - b.timestamp)
const kept: typeof sorted = []
for (const item of sorted) {
const near = kept.find((k) => Math.abs(k.timestamp - item.timestamp) <= windowMs)
if (!near) {
kept.push(item)
continue
}
if (item.source === 'change' && near.source === 'interval') {
const idx = kept.indexOf(near)
kept[idx] = {
...item,
event: {
...near.event,
remarks: [item.event.remarks, near.event.remarks].filter(Boolean).join(' · ')
}
}
}
}
return kept
}
export function generateNmeaJournalCandidates(options: {
points: NmeaTimePoint[]
mode: NmeaImportMode
intervalMinutes: number
t: TFunction
}): GeneratedNmeaJournal {
const { points, mode, intervalMinutes, t } = options
const items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }> = []
if (mode === 'interval' || mode === 'both') {
for (const ts of intervalTimestamps(points, intervalMinutes)) {
const sample = sampleAt(points, ts)
if (!sample) continue
items.push({
id: `interval-${ts}`,
timestamp: ts,
source: 'interval',
selected: true,
event: pointToLogEvent(sample, t('logs.nmea_remark_interval'), '')
})
}
}
if (mode === 'change' || mode === 'both') {
const changes = detectNmeaChanges(points)
for (const change of changes) {
const sample = change.data ?? sampleAt(points, change.timestamp)
if (!sample) continue
items.push({
id: `change-${change.type}-${change.timestamp}`,
timestamp: change.timestamp,
source: 'change',
changeType: change.type,
confidence: change.confidence,
selected: true,
event: pointToLogEvent(
{ ...sample, timestamp: change.timestamp },
buildRemarks(change, t),
changeToSailsOrMotor(change.type)
)
})
}
}
const deduped = mode === 'both'
? dedupeCandidates(items, 15 * 60 * 1000)
: items
return { candidates: deduped }
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { nmeaPointsToWaypoints, parseNmeaFile } from './nmeaParse.js'
describe('parseNmeaFile', () => {
it('parses RMC position, course and speed', () => {
const text = [
'$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W',
'$GPRMC,133519,A,4808.038,N,01132.000,E,025.0,090.0,230394,003.1,W'
].join('\n')
const result = parseNmeaFile(text, 'test.nmea')
expect(result.stats.parsedLines).toBe(2)
expect(result.stats.sentenceTypes).toContain('RMC')
expect(result.points.length).toBeGreaterThanOrEqual(2)
const first = result.points[0]
expect(first.lat).toBeCloseTo(48.1173, 3)
expect(first.lng).toBeCloseTo(11.516667, 3)
expect(first.sog).toBe(22.4)
expect(first.cog).toBe(84.4)
expect(first.fixValid).toBe(true)
})
it('merges wind and depth sentences onto the same timestamp', () => {
const text = [
'$GPRMC,100000,A,5400.000,N,01000.000,E,5.0,180.0,010124,003.0,E',
'$IIMWV,270.0,R,12.5,N,A',
'$SDDPT,4.5,0.0'
].join('\n')
const result = parseNmeaFile(text, 'merged.nmea')
const last = result.points[result.points.length - 1]
expect(last.windDir).toBe(270)
expect(last.windSpeedKnots).toBe(12.5)
expect(last.depthM).toBe(4.5)
})
it('skips lines with invalid checksum', () => {
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*FF'
const result = parseNmeaFile(text, 'bad.nmea')
expect(result.stats.checksumErrors).toBe(1)
expect(result.points).toHaveLength(0)
expect(result.warnings).toContain('no_samples')
})
it('warns when no position sentences are present', () => {
const text = '$IIMWV,090.0,R,8.0,N,A'
const result = parseNmeaFile(text, 'wind-only.nmea')
expect(result.warnings).toContain('no_position')
})
})
describe('nmeaPointsToWaypoints', () => {
it('maps points with coordinates to track waypoints', () => {
const waypoints = nmeaPointsToWaypoints([
{ timestamp: 1, lat: 54.0, lng: 10.0, sog: 6, cog: 90 },
{ timestamp: 2, windDir: 180 },
{ timestamp: 3, lat: 54.01, lng: 10.01, hdt: 95 }
])
expect(waypoints).toHaveLength(2)
expect(waypoints[0]).toMatchObject({ lat: 54, lng: 10, speedKnots: 6, heading: 90 })
expect(waypoints[1].heading).toBe(95)
})
})
+283
View File
@@ -0,0 +1,283 @@
import type { NmeaParseResult, NmeaParseStats, NmeaTimePoint } from './nmeaTypes.js'
function parseChecksum(line: string): boolean {
const star = line.lastIndexOf('*')
if (star < 0) return true
const expected = line.slice(star + 1, star + 3)
if (!/^[0-9A-Fa-f]{2}$/.test(expected)) return false
let sum = 0
for (let i = 1; i < star; i++) sum ^= line.charCodeAt(i)
return sum.toString(16).toUpperCase().padStart(2, '0') === expected.toUpperCase()
}
function sentenceType(field0: string): string {
return field0.length >= 3 ? field0.slice(-3) : field0
}
function parseLatLon(latStr: string, latHem: string, lonStr: string, lonHem: string): { lat?: number; lng?: number } {
const latVal = parseFloat(latStr)
const lonVal = parseFloat(lonStr)
if (Number.isNaN(latVal) || Number.isNaN(lonVal)) return {}
const latDeg = Math.floor(latVal / 100)
const latMin = latVal - latDeg * 100
let lat = latDeg + latMin / 60
if (latHem === 'S') lat = -lat
const lonDeg = Math.floor(lonVal / 100)
const lonMin = lonVal - lonDeg * 100
let lng = lonDeg + lonMin / 60
if (lonHem === 'W') lng = -lng
return { lat: Number(lat.toFixed(6)), lng: Number(lng.toFixed(6)) }
}
function parseRmcDateTime(timeStr: string, dateStr: string, baseYear = new Date().getFullYear()): number | null {
if (!timeStr || timeStr.length < 6) return null
const hh = parseInt(timeStr.slice(0, 2), 10)
const mm = parseInt(timeStr.slice(2, 4), 10)
const ss = parseInt(timeStr.slice(4, 6), 10)
if ([hh, mm, ss].some((n) => Number.isNaN(n))) return null
let year = baseYear
let month = 0
let day = 1
if (dateStr && dateStr.length >= 6) {
day = parseInt(dateStr.slice(0, 2), 10)
month = parseInt(dateStr.slice(2, 4), 10) - 1
const yy = parseInt(dateStr.slice(4, 6), 10)
year = yy >= 70 ? 1900 + yy : 2000 + yy
}
return Date.UTC(year, month, day, hh, mm, ss)
}
function parseWindSpeed(value: string, unit: string): number | undefined {
const speed = parseFloat(value)
if (Number.isNaN(speed)) return undefined
if (unit === 'N') return speed
if (unit === 'M') return speed * 1.94384
if (unit === 'K') return speed * 0.539957
return speed
}
interface MutableState extends NmeaTimePoint {
lastTimestamp: number | null
}
function snapshot(state: MutableState): NmeaTimePoint | null {
if (state.lastTimestamp == null) return null
const { lastTimestamp, ...rest } = state
void lastTimestamp
if (
rest.lat == null &&
rest.lng == null &&
rest.cog == null &&
rest.sog == null &&
rest.hdt == null &&
rest.windDir == null &&
rest.windSpeedKnots == null &&
rest.depthM == null &&
rest.rpm == null
) {
return null
}
return rest as NmeaTimePoint
}
function pushPoint(points: NmeaTimePoint[], state: MutableState) {
const snap = snapshot(state)
if (!snap) return
const last = points[points.length - 1]
if (last && last.timestamp === snap.timestamp) {
points[points.length - 1] = { ...last, ...snap }
return
}
points.push(snap)
}
function applySentence(state: MutableState, type: string, fields: string[], points: NmeaTimePoint[]) {
switch (type) {
case 'RMC': {
const status = fields[2]
const ts = parseRmcDateTime(fields[1], fields[9])
if (ts != null) {
state.timestamp = ts
state.lastTimestamp = ts
}
if (status === 'A') {
Object.assign(state, parseLatLon(fields[3], fields[4], fields[5], fields[6]))
state.fixValid = true
const sog = parseFloat(fields[7])
const cog = parseFloat(fields[8])
if (!Number.isNaN(sog)) state.sog = sog
if (!Number.isNaN(cog)) state.cog = cog
} else {
state.fixValid = false
}
pushPoint(points, state)
break
}
case 'GGA': {
const ts = parseRmcDateTime(fields[1], '')
if (ts != null) {
state.timestamp = ts
state.lastTimestamp = ts
}
Object.assign(state, parseLatLon(fields[2], fields[3], fields[4], fields[5]))
const quality = parseInt(fields[6], 10)
state.fixValid = !Number.isNaN(quality) && quality > 0
pushPoint(points, state)
break
}
case 'GLL': {
const ts = parseRmcDateTime(fields[5], fields[6] ?? '')
if (ts != null) {
state.timestamp = ts
state.lastTimestamp = ts
}
Object.assign(state, parseLatLon(fields[1], fields[2], fields[3], fields[4]))
state.fixValid = fields[7] === 'A'
pushPoint(points, state)
break
}
case 'VTG': {
const cog = parseFloat(fields[1])
const sog = parseFloat(fields[5] || fields[7])
if (!Number.isNaN(cog)) state.cog = cog
if (!Number.isNaN(sog)) state.sog = sog
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'HDT':
state.hdt = parseFloat(fields[1])
if (state.lastTimestamp != null) pushPoint(points, state)
break
case 'HDM':
state.hdm = parseFloat(fields[1])
if (state.lastTimestamp != null) pushPoint(points, state)
break
case 'HDG': {
const hdg = parseFloat(fields[1])
if (!Number.isNaN(hdg)) state.hdm = hdg
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MWV': {
if (fields[5] !== 'A') break
const dir = parseFloat(fields[1])
const speed = parseWindSpeed(fields[3], fields[4])
if (!Number.isNaN(dir)) state.windDir = dir
if (speed != null) state.windSpeedKnots = Number(speed.toFixed(1))
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MWD': {
const dir = parseFloat(fields[1])
const speed = parseFloat(fields[5])
if (!Number.isNaN(dir)) state.windDir = dir
if (!Number.isNaN(speed)) state.windSpeedKnots = Number(speed.toFixed(1))
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'DPT':
case 'DBT': {
const depth = parseFloat(fields[1])
if (!Number.isNaN(depth)) state.depthM = depth
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'RPM': {
const rpm = parseFloat(fields[3] ?? fields[2])
if (!Number.isNaN(rpm)) state.rpm = rpm
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MDA': {
const inchHg = parseFloat(fields[3])
const hpaField = parseFloat(fields[15] ?? fields[4])
if (!Number.isNaN(hpaField) && hpaField > 800) state.pressureHpa = hpaField
else if (!Number.isNaN(inchHg)) state.pressureHpa = inchHg * 33.8639
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MTW': {
const temp = parseFloat(fields[1])
if (!Number.isNaN(temp)) state.waterTempC = temp
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'VLW': {
const nm = parseFloat(fields[1] ?? fields[2])
if (!Number.isNaN(nm)) state.logDistanceNm = nm
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'APA': {
const mode = fields[1]
state.autopilotEngaged = mode === '1' || mode?.toUpperCase() === 'A'
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
default:
break
}
}
export function parseNmeaFile(text: string, filename: string): NmeaParseResult {
const warnings: string[] = []
const points: NmeaTimePoint[] = []
const typesSeen = new Set<string>()
let totalLines = 0
let parsedLines = 0
let checksumErrors = 0
const state: MutableState = { timestamp: 0, lastTimestamp: null }
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || (!line.startsWith('$') && !line.startsWith('!'))) continue
totalLines++
if (!parseChecksum(line)) {
checksumErrors++
continue
}
const star = line.indexOf('*')
const body = star >= 0 ? line.slice(0, star) : line
const fields = body.slice(1).split(',')
if (fields.length < 2) continue
const type = sentenceType(fields[0])
typesSeen.add(type)
applySentence(state, type, fields, points)
parsedLines++
}
if (points.length === 0) {
warnings.push('no_samples')
}
if (!typesSeen.has('RMC') && !typesSeen.has('GGA') && !typesSeen.has('GLL')) {
warnings.push('no_position')
}
const stats: NmeaParseStats = {
totalLines,
parsedLines,
checksumErrors,
sentenceTypes: [...typesSeen].sort()
}
return { points, stats, warnings, rawText: text, filename }
}
export function nmeaPointsToWaypoints(points: NmeaTimePoint[]): import('../trackUpload.js').TrackWaypoint[] {
return points
.filter((p) => p.lat != null && p.lng != null)
.map((p) => ({
timestamp: p.timestamp,
lat: p.lat!,
lng: p.lng!,
speedKnots: p.sog,
heading: p.cog ?? p.hdt ?? p.hdm
}))
}
@@ -0,0 +1,58 @@
import type { NmeaTimePoint } from './nmeaTypes.js'
/** Nearest sample at or before timestamp (carry-forward). */
export function sampleAt(points: NmeaTimePoint[], timestamp: number): NmeaTimePoint | null {
if (points.length === 0) return null
let best: NmeaTimePoint | null = null
for (const p of points) {
if (p.timestamp <= timestamp) best = p
else break
}
return best ?? points[0]
}
export function filterPointsForDate(points: NmeaTimePoint[], dateYmd: string): NmeaTimePoint[] {
if (!dateYmd || points.length === 0) return points
const [y, m, d] = dateYmd.split('-').map((v) => parseInt(v, 10))
if ([y, m, d].some((n) => Number.isNaN(n))) return points
const start = Date.UTC(y, m - 1, d, 0, 0, 0)
const end = Date.UTC(y, m - 1, d, 23, 59, 59)
const filtered = points.filter((p) => p.timestamp >= start && p.timestamp <= end)
return filtered.length > 0 ? filtered : points
}
export function timestampToHHMM(timestamp: number, timeZone?: string): string {
const opts: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timeZone ?? undefined
}
const parts = new Intl.DateTimeFormat('en-GB', opts).formatToParts(new Date(timestamp))
const hh = parts.find((p) => p.type === 'hour')?.value ?? '00'
const mm = parts.find((p) => p.type === 'minute')?.value ?? '00'
return `${hh}:${mm}`
}
export function angularDelta(a: number, b: number): number {
const diff = Math.abs(a - b) % 360
return diff > 180 ? 360 - diff : diff
}
export function intervalTimestamps(
points: NmeaTimePoint[],
intervalMinutes: number
): number[] {
if (points.length === 0) return []
const start = points[0].timestamp
const end = points[points.length - 1].timestamp
const stepMs = intervalMinutes * 60 * 1000
const stamps: number[] = []
for (let t = start; t <= end; t += stepMs) {
stamps.push(t)
}
if (stamps[stamps.length - 1] !== end) stamps.push(end)
return stamps
}
+102
View File
@@ -0,0 +1,102 @@
export type NmeaChangeType =
| 'course'
| 'wind'
| 'pressure'
| 'engine_start'
| 'engine_stop'
| 'autopilot_on'
| 'autopilot_off'
| 'depth'
| 'anchor'
| 'departure'
| 'speed'
| 'gps_fix_lost'
| 'gps_fix_regained'
| 'water_temp'
| 'wind_shift'
export interface NmeaParseStats {
totalLines: number
parsedLines: number
checksumErrors: number
sentenceTypes: string[]
}
export interface NmeaTimePoint {
timestamp: number
lat?: number
lng?: number
cog?: number
sog?: number
hdt?: number
hdm?: number
windDir?: number
windSpeedKnots?: number
depthM?: number
rpm?: number
pressureHpa?: number
waterTempC?: number
logDistanceNm?: number
fixValid?: boolean
autopilotEngaged?: boolean
}
export interface NmeaChangeEvent {
type: NmeaChangeType
timestamp: number
confidence: 'high' | 'medium' | 'low'
summaryKey: string
summaryParams?: Record<string, string | number>
data?: Partial<NmeaTimePoint>
}
export interface NmeaParseResult {
points: NmeaTimePoint[]
stats: NmeaParseStats
warnings: string[]
rawText: string
filename: string
}
export type NmeaImportMode = 'interval' | 'change' | 'both'
export interface NmeaJournalCandidate {
id: string
timestamp: number
source: 'interval' | 'change'
changeType?: NmeaChangeType
confidence?: 'high' | 'medium' | 'low'
selected: boolean
}
export interface NmeaDetectionConfig {
courseDeltaDeg: number
windDirDeltaDeg: number
windSpeedDeltaKnots: number
pressureDeltaHpa: number
depthDeltaM: number
depthDeltaPercent: number
rpmIdle: number
rpmRunning: number
sogUnderWayKn: number
sogStoppedKn: number
anchorMinutes: number
speedDeltaKn: number
dedupeWindowMs: number
}
export const DEFAULT_NMEA_DETECTION_CONFIG: NmeaDetectionConfig = {
courseDeltaDeg: 28,
windDirDeltaDeg: 35,
windSpeedDeltaKnots: 4,
pressureDeltaHpa: 2,
depthDeltaM: 2,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 3,
dedupeWindowMs: 5 * 60 * 1000
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { isNmeaCrcAlreadyImported, type NmeaArchiveRecord } from './nmeaArchive.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
describe('nmeaArchive CRC tracking', () => {
it('detects duplicate file content by CRC32', () => {
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W\n'
const record: NmeaArchiveRecord = {
filename: 'a.nmea',
rawText: '',
importedAt: '2026-05-29T10:00:00.000Z',
importedFiles: [{
crc32: nmeaFileCrc32(text),
filename: 'a.nmea',
importedAt: '2026-05-29T10:00:00.000Z'
}]
}
expect(isNmeaCrcAlreadyImported(record, text)).toBe(true)
expect(isNmeaCrcAlreadyImported(record, text.replace('\n', '\r\n'))).toBe(true)
expect(isNmeaCrcAlreadyImported(record, '$GPRMC,999999,A\n')).toBe(false)
expect(isNmeaCrcAlreadyImported(null, text)).toBe(false)
})
})
+146
View File
@@ -0,0 +1,146 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson, decryptJson } from './crypto.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
export interface NmeaImportedFile {
crc32: string
filename: string
importedAt: string
}
export interface NmeaArchiveRecord {
filename: string
rawText: string
importedAt: string
importedFiles: NmeaImportedFile[]
}
function normalizeArchiveRecord(raw: Partial<NmeaArchiveRecord>): NmeaArchiveRecord {
const importedFiles = [...(raw.importedFiles ?? [])]
if (importedFiles.length === 0 && raw.rawText) {
importedFiles.push({
crc32: nmeaFileCrc32(raw.rawText),
filename: raw.filename ?? '',
importedAt: raw.importedAt ?? ''
})
}
return {
filename: raw.filename ?? '',
rawText: raw.rawText ?? '',
importedAt: raw.importedAt ?? '',
importedFiles
}
}
async function putNmeaArchiveRecord(
logbookId: string,
entryId: string,
payload: NmeaArchiveRecord
): Promise<void> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const encrypted = await encryptJson(payload, masterKey)
await db.nmeaArchives.put({
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: payload.importedAt || new Date().toISOString()
})
}
export async function getNmeaArchive(entryId: string): Promise<NmeaArchiveRecord | null> {
const record = await db.nmeaArchives.get(entryId)
if (!record) return null
const masterKey = await getLogbookKey(record.logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
try {
return normalizeArchiveRecord(
await decryptJson(record.encryptedData, record.iv, record.tag, masterKey) as Partial<NmeaArchiveRecord>
)
} catch {
return null
}
}
export function isNmeaCrcAlreadyImported(record: NmeaArchiveRecord | null, rawText: string): boolean {
if (!record) return false
const crc32 = nmeaFileCrc32(rawText)
return record.importedFiles.some((file) => file.crc32 === crc32)
}
/** Remember imported file by CRC (even when raw log is discarded). */
export async function recordNmeaFileImport(
logbookId: string,
entryId: string,
filename: string,
rawText: string
): Promise<string> {
const crc32 = nmeaFileCrc32(rawText)
const existing = await getNmeaArchive(entryId)
const importedFiles = [...(existing?.importedFiles ?? [])]
if (!importedFiles.some((file) => file.crc32 === crc32)) {
importedFiles.push({
crc32,
filename,
importedAt: new Date().toISOString()
})
}
const payload: NmeaArchiveRecord = {
filename: existing?.filename ?? '',
rawText: existing?.rawText ?? '',
importedAt: new Date().toISOString(),
importedFiles
}
await putNmeaArchiveRecord(logbookId, entryId, payload)
return crc32
}
export async function saveNmeaArchive(
logbookId: string,
entryId: string,
filename: string,
rawText: string
): Promise<void> {
const crc32 = nmeaFileCrc32(rawText)
const existing = await getNmeaArchive(entryId)
const importedFiles = [...(existing?.importedFiles ?? [])]
if (!importedFiles.some((file) => file.crc32 === crc32)) {
importedFiles.push({
crc32,
filename,
importedAt: new Date().toISOString()
})
}
const payload: NmeaArchiveRecord = {
filename,
rawText,
importedAt: new Date().toISOString(),
importedFiles
}
await putNmeaArchiveRecord(logbookId, entryId, payload)
}
export async function deleteNmeaArchive(entryId: string): Promise<void> {
await db.nmeaArchives.delete(entryId)
}
export function downloadNmeaArchive(record: NmeaArchiveRecord): void {
const blob = new Blob([record.rawText], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = record.filename || 'track.nmea'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
+321
View File
@@ -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 }
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { crc32Hex, nmeaFileCrc32, normalizeNmeaTextForCrc } from './crc32.js'
describe('crc32', () => {
it('hashes known test vectors', () => {
expect(crc32Hex('')).toBe('00000000')
expect(crc32Hex('123456789')).toBe('CBF43926')
})
it('normalizes line endings before hashing NMEA content', () => {
const a = nmeaFileCrc32('$GPRMC,123519,A\r\n$GPGGA,123519\r\n')
const b = nmeaFileCrc32('$GPRMC,123519,A\n$GPGGA,123519\n')
expect(a).toBe(b)
expect(normalizeNmeaTextForCrc('a\r\nb\r')).toBe('a\nb')
})
})
+30
View File
@@ -0,0 +1,30 @@
/** Normalize NMEA text so identical content hashes the same across platforms. */
export function normalizeNmeaTextForCrc(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd()
}
const CRC32_TABLE = (() => {
const table = new Uint32Array(256)
for (let i = 0; i < 256; i++) {
let c = i
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
}
table[i] = c >>> 0
}
return table
})()
/** CRC-32 (IEEE / Ethernet polynomial), uppercase 8-char hex. */
export function crc32Hex(text: string): string {
const bytes = new TextEncoder().encode(text)
let crc = 0xffffffff
for (const byte of bytes) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
}
return ((crc ^ 0xffffffff) >>> 0).toString(16).toUpperCase().padStart(8, '0')
}
export function nmeaFileCrc32(text: string): string {
return crc32Hex(normalizeNmeaTextForCrc(text))
}
+109
View File
@@ -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')
})
})
+92
View File
@@ -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')
}
+32
View File
@@ -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 }
)
})
}
+64 -2
View File
@@ -1,7 +1,40 @@
import { describe, expect, it } from 'vitest'
import { getNextLanguage, normalizeAppLanguage, SUPPORTED_LANGUAGES } from './i18nLanguages.js'
import type { i18n as I18nInstance } from 'i18next'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PlausibleEvents } from '../services/analytics.js'
import {
changeAppLanguage,
cycleAppLanguage,
getNextLanguage,
normalizeAppLanguage,
SUPPORTED_LANGUAGES
} from './i18nLanguages.js'
const trackPlausibleEvent = vi.fn()
vi.mock('../services/analytics.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../services/analytics.js')>()
return {
...actual,
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
}
})
function createMockI18n(language: string): I18nInstance {
let current = language
return {
language: current,
changeLanguage: vi.fn(async (lng: string) => {
current = lng
;(this as { language: string }).language = lng
})
} as unknown as I18nInstance
}
describe('i18nLanguages', () => {
beforeEach(() => {
trackPlausibleEvent.mockReset()
})
it('normalizes regional tags to supported base codes', () => {
expect(normalizeAppLanguage('de-DE')).toBe('de')
expect(normalizeAppLanguage('nb-NO')).toBe('nb')
@@ -18,4 +51,33 @@ describe('i18nLanguages', () => {
expect(seen.size).toBe(SUPPORTED_LANGUAGES.length)
expect(current).toBe('de')
})
it('tracks explicit language changes', () => {
const i18n = createMockI18n('de')
changeAppLanguage(i18n, 'sv')
expect(i18n.changeLanguage).toHaveBeenCalledWith('sv')
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
from: 'de',
to: 'sv'
})
})
it('does not track when language stays the same', () => {
const i18n = createMockI18n('en')
changeAppLanguage(i18n, 'en')
expect(i18n.changeLanguage).not.toHaveBeenCalled()
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
it('cycleAppLanguage tracks the next language', () => {
const i18n = createMockI18n('nb')
cycleAppLanguage(i18n)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
from: 'nb',
to: 'de'
})
})
})
+17
View File
@@ -1,3 +1,6 @@
import type { i18n as I18nInstance } from 'i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
/** Supported UI languages (ISO 639-1, language-only). */
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const
@@ -20,3 +23,17 @@ export function getNextLanguage(current?: string): AppLanguage {
export function isGermanLocale(language?: string): boolean {
return normalizeAppLanguage(language) === 'de'
}
/** Switch UI language and track explicit user choice (not auto-detection). */
export function changeAppLanguage(i18n: I18nInstance, language: AppLanguage): void {
const from = normalizeAppLanguage(i18n.language)
const to = normalizeAppLanguage(language)
if (from === to) return
void i18n.changeLanguage(to)
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from, to })
}
export function cycleAppLanguage(i18n: I18nInstance): void {
changeAppLanguage(i18n, getNextLanguage(i18n.language))
}
+122
View File
@@ -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
}
+121
View File
@@ -0,0 +1,121 @@
# Code-Statistik — Kapteins Daagbok
Erstellt am **31. Mai 2026** mit [cloc](https://github.com/AlDanial/cloc) v1.98.
## Methode
```bash
cloc . \
--exclude-dir=node_modules,dist,.git,userfeedback,.cursor,.planning \
--md
```
Ausgeschlossen: Build-Artefakte (`dist/`), Abhängigkeiten (`node_modules/`), lokales Feedback, Cursor-/Planungs-Artefakte.
## Gesamtübersicht
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 145 | 3012 | 540 | 23599 |
| JSON | 14 | 4 | 0 | 15005 |
| CSS | 3 | 743 | 45 | 4837 |
| XML | 3 | 0 | 0 | 4302 |
| HTML | 5 | 160 | 0 | 1411 |
| Markdown | 8 | 390 | 12 | 1077 |
| JavaScript | 8 | 117 | 43 | 709 |
| Bourne Shell | 3 | 81 | 21 | 412 |
| YAML | 1 | 3 | 0 | 55 |
| Dockerfile | 2 | 20 | 21 | 39 |
| SVG | 4 | 0 | 0 | 27 |
| **SUM** | **196** | **4530** | **682** | **51473** |
### Anwendungscode (TypeScript, JavaScript, CSS)
Ohne JSON, GPX/XML, HTML, Docs und Assets — näher an der eigentlichen Implementierung:
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 145 | 3012 | 540 | 23599 |
| CSS | 3 | 743 | 45 | 4837 |
| JavaScript | 8 | 117 | 43 | 709 |
| **SUM** | **156** | **3872** | **628** | **29145** |
> **Hinweis:** Der hohe JSON-Anteil (~15k Zeilen) stammt überwiegend aus i18n-Locale-Dateien (`client/src/i18n/locales/*.json`). XML (~4,3k Zeilen) sind Demo-GPX-Tracks unter `client/src/assets/demo/`.
## Aufteilung nach Bereich
| Bereich | Dateien | Leer | Kommentar | Code |
| :--- | ---: | ---: | ---: | ---: |
| `client/` | 154 | 3398 | 557 | 43534 |
| `server/` | 20 | 399 | 54 | 4426 |
| `scripts/` | 9 | 193 | 59 | 1065 |
| `docs/` | 8 | 418 | 0 | 2079 |
### `client/`
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 129 | 2625 | 499 | 21291 |
| JSON | 10 | 4 | 0 | 12898 |
| CSS | 3 | 743 | 45 | 4837 |
| XML | 3 | 0 | 0 | 4302 |
| Markdown | 1 | 13 | 0 | 60 |
| JavaScript | 2 | 5 | 5 | 56 |
| HTML | 1 | 0 | 0 | 47 |
| SVG | 4 | 0 | 0 | 27 |
| Dockerfile | 1 | 8 | 8 | 16 |
| **SUM** | **154** | **3398** | **557** | **43534** |
### `server/`
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 16 | 387 | 41 | 2308 |
| JSON | 3 | 0 | 0 | 2095 |
| Dockerfile | 1 | 12 | 13 | 23 |
| **SUM** | **20** | **399** | **54** | **4426** |
### `scripts/`
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| JavaScript | 6 | 112 | 38 | 653 |
| Bourne Shell | 3 | 81 | 21 | 412 |
| **SUM** | **9** | **193** | **59** | **1065** |
## Größte Quelldateien (TypeScript & CSS)
| Datei | blank | comment | code |
| :--- | ---: | ---: | ---: |
| `client/src/App.css` | 730 | 31 | 4430 |
| `client/src/components/LogEntryEditor.tsx` | 176 | 17 | 1929 |
| `client/src/components/UserProfilePage.tsx` | 52 | 0 | 746 |
| `client/src/components/LiveLogView.tsx` | 50 | 2 | 711 |
| `client/src/App.tsx` | 85 | 21 | 656 |
| `client/src/components/CrewForm.tsx` | 82 | 117 | 644 |
| `client/src/components/VesselForm.tsx` | 55 | 8 | 558 |
| `client/src/services/auth.ts` | 80 | 66 | 556 |
| `client/src/services/logbookBackup.ts` | 56 | 0 | 545 |
| `client/src/components/AuthOnboarding.tsx` | 49 | 25 | 542 |
| `client/src/components/StatsDashboard.tsx` | 43 | 0 | 521 |
| `client/src/components/LogbookDashboard.tsx` | 46 | 2 | 508 |
| `client/src/components/InvitationAcceptance.tsx` | 59 | 0 | 461 |
| `client/src/components/LogEntriesList.tsx` | 50 | 4 | 447 |
| `client/src/services/sync.ts` | 70 | 29 | 428 |
## Kurzfassung
- **~51k** physische Codezeilen gesamt (inkl. Locales, Demo-GPX, Docs).
- **~29k** Zeilen reiner Anwendungscode (TS/JS/CSS).
- **~21k** TypeScript im Client, **~2,3k** im Server.
- Größte Einzeldatei: `App.css` (~4,4k Zeilen), größte Komponente: `LogEntryEditor.tsx` (~1,9k Zeilen).
## Report aktualisieren
```bash
cloc . \
--exclude-dir=node_modules,dist,.git,userfeedback,.cursor,.planning \
--md > docs/cloc-report-raw.md
```
Für eine reine Markdown-Tabelle reicht `--md`; dieser Report fasst mehrere cloc-Läufe manuell zusammen.
+165
View File
@@ -0,0 +1,165 @@
# NMEA-Import — Recherche & Entscheidungsnotizen
Stand: 2026-05-31 · Status: **In Umsetzung** (`feature/nmea-journal-import`)
Anlass: Nutzeranfrage, ob Kapteins Daagbok um NMEA-Empfang erweiterbar sei.
## Kurzfassung
| Ansatz | Machbarkeit (PWA) | Empfehlung |
|--------|-------------------|------------|
| **Live-NMEA** (Serial/TCP/Bluetooth vom Plotter) | Praktisch nein (Browser-Sandbox, iOS) | Nicht als reine PWA versprechen |
| **NMEA-Dateiimport** | Ja (Parsing im Client) | Sinnvoller nächster Schritt, wenn überhaupt |
| **GPX-Import** (bereits vorhanden) | Ja | Für die meisten Freizeit-Skipper der praktischere Weg |
---
## Aktueller Stand in Kapteins Daagbok
- **PWA** (installierbar, offline-fähig), kein nativer App-Store-Wrapper
- **Position:** `navigator.geolocation` (Geräte-GPS) in `LogEntryEditor.tsx`
- **Tracks:** Upload von **GPX/KML/GeoJSON** → Karte, Streckenstatistik (`trackUpload.ts`, `LogEntryEditor.tsx`)
- **Log-Ereignisse** u. a.: Zeit, MgK/rwk, Wind (Richtung/Stärke/Druck), Seegang, Wetter, Strom, Krängung, Segel/Motor, Log, Distanz, GPS-Koordinaten, Bemerkungen (`logEntryPayload.ts`)
Es gibt **keinen** NMEA-Parser und **keinen** Live-Datenstrom.
---
## Warum Live-NMEA in einer PWA schwierig ist
Typische NMEA-Quellen an Bord und Browser-Fähigkeiten:
| Quelle | PWA-tauglich? |
|--------|----------------|
| USB/Serial (RS422/232) | Kaum — Web Serial API nur Chrome/Edge, **nicht iOS/Safari**, am Tablet/Phone selten praktikabel |
| TCP/UDP (z. B. Port 10110) | **Nein** — Browser haben keine Raw-Sockets |
| Bluetooth-NMEA | Sehr eingeschränkt (Web Bluetooth), iOS praktisch unbrauchbar |
| Handy-GPS | **Ja** — Geolocation API (bereits implementiert), aber **kein NMEA vom Plotter** |
Weitere PWA-Limits:
- Kein zuverlässiger **Hintergrundbetrieb** für kontinuierlichen Empfang
- **HTTPS-App → lokales Boot-Netz** (`192.168.x.x`): Mixed Content, CORS, ggf. Local-Network-Permissions
- iPad/iPhone als installierte PWA besonders restriktiv
**Umweg (später optional):** Gateway im Boot (z. B. SignalK) mit WebSocket/HTTP — PWA verbindet sich dann zu einem **Server**, nicht direkt zu NMEA. Setup-Aufwand, eher für Technikaffine.
**Native Hülle** (Capacitor, Electron, …) würde Serial/TCP/Bluetooth erweitern — wäre keine reine PWA mehr.
---
## Was ist eine NMEA-Datei?
**NMEA 0183** = textbasiertes Protokoll aus **Einzelzeilen-Sätzen**, z. B.:
```
$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47
$HDT,274.3,T*2F
$MWV,274.5,R,15.2,N,A*2B
$DPT,12.4,0.5*42
```
Eine `.nmea`- oder `.log`-Datei ist ein **Zeitstempel-Stream** — alles, was der Logger in diesem Zeitraum mitgeschrieben hat.
**Nicht alle Telemetriedaten sind garantiert enthalten.** Es hängt ab von:
1. Sensoren an Bord (GPS ja, Wind nur mit Windgeber, …)
2. Logger-/Multiplexer-Konfiguration
3. Empfang während der Aufzeichnung
Ein reiner GPS-Logger liefert praktisch nur Position/Kurs/Fahrt.
---
## Was könnte ein NMEA-Dateiimport in der App bewirken?
Mapping zu bestehenden Logbuch-Feldern (Auszug):
| NMEA-Satz (Beispiel) | Inhalt | Nutzen |
|----------------------|--------|--------|
| RMC / GGA / GLL | Position, Zeit, oft SOG/COG | GPS-Koordinaten, **Track** (analog GPX), Kurs |
| VTG / VHW | Fahrt über Grund/Wasser, Kurs | Streckenstatistik, Kursfelder |
| HDT / HDG / HDM | Peilung/Kompass | MgK/rwk-Vorschläge |
| MWV / MWD | Wind | Windfelder im Reisetag |
| DPT / DBT | Tiefe | aktuell kein eigenes Feld |
| MTW | Wassertemperatur | ggf. Bemerkungen |
| XDR | diverse Transducer | abhängig vom Gerät |
**Mehrwert gegenüber GPX:**
- Track **plus** zeitlich zugeordnete Wind-/Kursdaten (wenn in der Datei vorhanden)
- Automatisches Vorschlagen von Log-Ereignissen aus Bord-Sensoren
- Eine Quelle (Bordanlage) statt nur Handy-GPS
**Was NMEA typischerweise nicht liefert** (bleibt manuell / Wetter-API):
- Seegang, Wetter-Symbolik, Strom, Krängung
- Crew, Hafen, Bemerkungen, Tankstände
- Segel/Motor-Konfiguration im nautischen Sinne
NMEA = **Sensor-Telemetrie**, kein **Skipper-Logbuch**.
---
## Wird NMEA an Bord üblicherweise aufgezeichnet & exportiert?
**Teilweise — selten so einfach wie GPX für Endnutzer.**
| Quelle | Typischer Export | Einfach für Freizeit-Skipper? |
|--------|------------------|-------------------------------|
| Chartplotter (Garmin, Raymarine, B&G, …) | **GPX** (Track/Route) | ✅ oft (SD/USB/App) |
| Chartplotter | Roh-NMEA | ⚠️ selten direkt |
| WiFi-Multiplexer, SignalK, Raspi | NMEA-Datei oder Stream | ⚠️ Technikaffine |
| PC-Software (OpenCPN, …) | NMEA-Log | ⚠️ |
**GPX ist der de-facto-Standard** für „Track mit nach Hause nehmen“. NMEA-Rohlogs sind Nischen- oder Profi-/Tüftler-Setup.
---
## Mögliche Roadmap (wenn wir es angehen)
### Phase 1 — NMEA-Dateiimport (PWA-kompatibel)
- Parser für gängige Sätze: RMC, GGA, GLL, VTG, optional MWV/MWD, HDT
- Track aus Positions-Sätzen (wie GPX-Pipeline)
- UI: Upload neben GPX/KML in `LogEntryEditor`
- Checksummen-Validierung (`*XX`), Encoding, gemischte Talker-IDs (GP, GN, …)
### Phase 2 — Anreicherung Log-Ereignisse
- Aus NMEA-Stream pro Zeitpunkt Wind/Kurs/Position in Log-Events vorschlagen
- Nutzer bestätigt/korrigiert (kein blindes Überschreiben)
### Phase 3 — optional, nicht PWA-pur
- SignalK-WebSocket (Nutzer konfiguriert Boot-URL)
- Oder native Wrapper / Companion-Bridge
**Nicht empfohlen als Phase 1:** Live-NMEA direkt aus der PWA.
---
## Antwortvorlage für Nutzer
> Als reine Browser-App können wir keinen direkten NMEA-Anschluss (Serial/TCP vom Plotter) zuverlässig anbieten — mobile Browser erlauben das nicht, besonders auf iPhone/iPad.
> Position über Handy-GPS und GPX-Tracks (Export vom Plotter oder Nav-App) funktionieren bereits.
> Ein **Import von NMEA-Dateien** (vom Gateway oder Logger) ist grundsätzlich denkbar und könnte Track plus ggf. Wind/Kurs ins Logbuch übernehmen — das prüfen wir für eine spätere Version.
> Für die meisten Skipper ist **GPX vom Plotter** der einfachere Weg.
---
## Offene Fragen für spätere Planung
- Welche NMEA-Varianten melden Nutzer realistisch (0183 vs. NMEA 2000 nur über Gateway)?
- Reicht Parser-Abdeckung für 95 % der Dateien mit RMC+GGA+MWV?
- Sollen importierte Rohdaten gespeichert werden oder nur abgeleitete GPX/Events?
- Datenschutz: NMEA-Datei lokal parsen, nichts an Server senden (passt zu E2E-Modell)
- Plausible-Event analog `GPS Track Uploaded` → z. B. `NMEA File Imported`?
## Referenzen
- [NMEA 0183](https://www.nmea.org/) — Protokollstandard
- [SignalK](https://signalk.org/) — moderne Boot-API, WebSocket
- [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) — Browser, eingeschränkt
- Bestehender Code: `client/src/services/trackUpload.ts`, `client/src/components/LogEntryEditor.tsx`
+43 -1
View File
@@ -21,6 +21,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
| NMEA Uploaded | NMEA-Datei erfolgreich gelesen und geparst (`NmeaImportWizard.tsx`) | `lines` (Anzahl Sätze), `candidates` (Vorschläge für Reisetag), `duplicate` (Datei schon importiert), `has_position` |
| NMEA Imported | NMEA-Vorschläge in Journal übernommen (`NmeaImportWizard.tsx`) | `mode`: `interval` \| `change` \| `both`, `events` (übernommene Einträge), `track` (GPS-Track mit importiert) |
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
@@ -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
View File
@@ -0,0 +1,418 @@
; Kapteins Daagbok Test-NMEA — Kieler Förde Kiellinie → Laboe, 5 sm
; Datum: 2026-05-29, passend zu testdata/tracks/kieler-foerde-5sm.gpx
; Import-Tipp: Reisetag-Datum auf 2026-05-29 setzen
; Sätze: RMC, GGA, VTG, HDT, MWV, DPT, MDA, RPM (Motorphase), MTW, VLW
$GPRMC,101500.00,A,5419.7280,N,01008.7360,E,2.5,42.2,290526,,*00
$GPGGA,101500.00,5419.7280,N,01008.7360,E,1,08,1.0,12.5,M,46.0,M,,*5B
$GPVTG,42.2,T,,M,2.5,N,4.6,K*51
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.5,0.0*61
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,101637.00,A,5419.7815,N,01008.8193,E,2.9,42.2,290526,,*0C
$GPGGA,101637.00,5419.7815,N,01008.8193,E,1,08,1.0,12.4,M,46.0,M,,*5A
$GPVTG,42.2,T,,M,2.9,N,5.3,K*59
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.4,0.0*60
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,101829.00,A,5419.8529,N,01008.9305,E,3.3,42.2,290526,,*07
$GPGGA,101829.00,5419.8529,N,01008.9305,E,1,08,1.0,12.3,M,46.0,M,,*5D
$GPVTG,42.2,T,,M,3.3,N,6.2,K*50
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.3,0.0*67
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,102006.00,A,5419.9242,N,01009.0416,E,3.8,42.2,290526,,*0C
$GPGGA,102006.00,5419.9242,N,01009.0416,E,1,08,1.0,12.2,M,46.0,M,,*5C
$GPVTG,42.2,T,,M,3.8,N,7.1,K*59
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.2,0.0*66
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,102152.00,A,5420.0134,N,01009.1806,E,4.4,42.2,290526,,*0A
$GPGGA,102152.00,5420.0134,N,01009.1806,E,1,08,1.0,12.1,M,46.0,M,,*52
$GPVTG,42.2,T,,M,4.4,N,8.2,K*5E
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.1,0.0*65
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102329.00,A,5420.1204,N,01009.3473,E,5.9,42.2,290526,,*05
$GPGGA,102329.00,5420.1204,N,01009.3473,E,1,08,1.0,12.0,M,46.0,M,,*50
$GPVTG,42.2,T,,M,5.9,N,10.9,K*60
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.0,0.0*64
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102503.00,A,5420.2274,N,01009.5141,E,4.9,42.2,290526,,*0C
$GPGGA,102503.00,5420.2274,N,01009.5141,E,1,08,1.0,11.8,M,46.0,M,,*53
$GPVTG,42.2,T,,M,4.9,N,9.2,K*52
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.8,0.0*6F
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102637.00,A,5420.3167,N,01009.6530,E,4.6,42.2,290526,,*06
$GPGGA,102637.00,5420.3167,N,01009.6530,E,1,08,1.0,11.7,M,46.0,M,,*59
$GPVTG,42.2,T,,M,4.6,N,8.5,K*5B
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.7,0.0*60
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102818.00,A,5420.4236,N,01009.8197,E,5.8,42.2,290526,,*0D
$GPGGA,102818.00,5420.4236,N,01009.8197,E,1,08,1.0,11.6,M,46.0,M,,*5C
$GPVTG,42.2,T,,M,5.8,N,10.8,K*60
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.6,0.0*61
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,102949.00,A,5420.5307,N,01009.9865,E,5.2,42.2,290526,,*05
$GPGGA,102949.00,5420.5307,N,01009.9865,E,1,08,1.0,11.4,M,46.0,M,,*5C
$GPVTG,42.2,T,,M,5.2,N,9.6,K*5C
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.4,0.0*63
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103111.00,A,5420.6100,N,01010.1100,E,4.5,32.8,290526,,*06
$GPGGA,103111.00,5420.6100,N,01010.1100,E,1,08,1.0,11.3,M,46.0,M,,*53
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,11.3,0.0*64
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103255.00,A,5420.7314,N,01010.2446,E,5.7,32.8,290526,,*04
$GPGGA,103255.00,5420.7314,N,01010.2446,E,1,08,1.0,11.2,M,46.0,M,,*53
$GPVTG,32.8,T,,M,5.7,N,10.5,K*6F
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,11.2,0.0*65
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103425.00,A,5420.8529,N,01010.3792,E,5.4,32.8,290526,,*0A
$GPGGA,103425.00,5420.8529,N,01010.3792,E,1,08,1.0,11.0,M,46.0,M,,*5C
$GPVTG,32.8,T,,M,5.4,N,10.0,K*69
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,11.0,0.0*67
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103614.00,A,5420.9744,N,01010.5137,E,4.5,32.8,290526,,*0D
$GPGGA,103614.00,5420.9744,N,01010.5137,E,1,08,1.0,10.8,M,46.0,M,,*52
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.8,0.0*6E
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$GPRMC,103758.00,A,5421.0958,N,01010.6483,E,5.7,32.8,290526,,*05
$GPGGA,103758.00,5421.0958,N,01010.6483,E,1,08,1.0,10.7,M,46.0,M,,*56
$GPVTG,32.8,T,,M,5.7,N,10.5,K*6F
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.7,0.0*61
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$GPRMC,103929.00,A,5421.2173,N,01010.7829,E,5.4,32.8,290526,,*00
$GPGGA,103929.00,5421.2173,N,01010.7829,E,1,08,1.0,10.5,M,46.0,M,,*52
$GPVTG,32.8,T,,M,5.4,N,10.0,K*69
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.5,0.0*63
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$GPRMC,104117.00,A,5421.3387,N,01010.9175,E,4.5,32.8,290526,,*04
$GPGGA,104117.00,5421.3387,N,01010.9175,E,1,08,1.0,10.4,M,46.0,M,,*57
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.4,0.0*62
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$IERPM,E,0,1850,37.0*20
$GPRMC,104257.00,A,5421.5006,N,01011.0969,E,7.8,32.8,290526,,*0C
$GPGGA,104257.00,5421.5006,N,01011.0969,E,1,08,1.0,10.1,M,46.0,M,,*54
$GPVTG,32.8,T,,M,7.8,N,14.4,K*67
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.1,0.0*67
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.04,N,0.04,N,,*4D
$IERPM,E,0,1850,37.0*20
$GPRMC,104437.00,A,5421.6626,N,01011.2764,E,4.6,32.8,290526,,*07
$GPGGA,104437.00,5421.6626,N,01011.2764,E,1,08,1.0,9.9,M,46.0,M,,*62
$GPVTG,32.8,T,,M,4.6,N,8.5,K*56
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.9,0.0*57
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.04,N,0.04,N,,*4D
$IERPM,E,0,1850,37.0*20
$GPRMC,104607.00,A,5421.7616,N,01011.3816,E,5.0,30.2,290526,,*00
$GPGGA,104607.00,5421.7616,N,01011.3816,E,1,08,1.0,9.8,M,46.0,M,,*6B
$GPVTG,30.2,T,,M,5.0,N,9.3,K*5E
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.8,0.0*56
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.04,N,0.04,N,,*4D
$GPRMC,104740.00,A,5421.8866,N,01011.5066,E,5.9,30.2,290526,,*04
$GPGGA,104740.00,5421.8866,N,01011.5066,E,1,08,1.0,9.6,M,46.0,M,,*68
$GPVTG,30.2,T,,M,5.9,N,10.9,K*65
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.6,0.0*58
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,104918.00,A,5422.0115,N,01011.6315,E,4.7,30.2,290526,,*0A
$GPGGA,104918.00,5422.0115,N,01011.6315,E,1,08,1.0,9.5,M,46.0,M,,*6A
$GPVTG,30.2,T,,M,4.7,N,8.7,K*5D
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.5,0.0*5B
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,105049.00,A,5422.1364,N,01011.7564,E,6.9,30.2,290526,,*0E
$GPGGA,105049.00,5422.1364,N,01011.7564,E,1,08,1.0,9.3,M,46.0,M,,*64
$GPVTG,30.2,T,,M,6.9,N,12.8,K*65
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.3,0.0*5D
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,105233.00,A,5422.3030,N,01011.9230,E,5.6,30.2,290526,,*05
$GPGGA,105233.00,5422.3030,N,01011.9230,E,1,08,1.0,9.1,M,46.0,M,,*61
$GPVTG,30.2,T,,M,5.6,N,10.3,K*60
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.1,0.0*5F
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,105419.00,A,5422.4279,N,01012.0479,E,4.5,30.2,290526,,*00
$GPGGA,105419.00,5422.4279,N,01012.0479,E,1,08,1.0,8.9,M,46.0,M,,*6F
$GPVTG,30.2,T,,M,4.5,N,8.3,K*5B
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.9,0.0*56
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,105550.00,A,5422.5320,N,01012.1520,E,5.3,30.2,290526,,*0B
$GPGGA,105550.00,5422.5320,N,01012.1520,E,1,08,1.0,8.8,M,46.0,M,,*62
$GPVTG,30.2,T,,M,5.3,N,9.8,K*56
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.8,0.0*57
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,105721.00,A,5422.6570,N,01012.2770,E,5.8,30.2,290526,,*00
$GPGGA,105721.00,5422.6570,N,01012.2770,E,1,08,1.0,8.6,M,46.0,M,,*6C
$GPVTG,30.2,T,,M,5.8,N,10.7,K*6A
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.6,0.0*59
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,105856.00,A,5422.7731,N,01012.3907,E,4.5,29.3,290526,,*03
$GPGGA,105856.00,5422.7731,N,01012.3907,E,1,08,1.0,8.4,M,46.0,M,,*68
$GPVTG,29.3,T,,M,4.5,N,8.4,K*55
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.4,0.0*5B
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,110035.00,A,5422.8992,N,01012.5122,E,5.1,29.3,290526,,*0E
$GPGGA,110035.00,5422.8992,N,01012.5122,E,1,08,1.0,8.3,M,46.0,M,,*67
$GPVTG,29.3,T,,M,5.1,N,9.5,K*50
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.3,0.0*5C
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,110220.00,A,5423.0252,N,01012.6336,E,4.7,29.3,290526,,*05
$GPGGA,110220.00,5423.0252,N,01012.6336,E,1,08,1.0,8.1,M,46.0,M,,*69
$GPVTG,29.3,T,,M,4.7,N,8.8,K*5B
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.1,0.0*5E
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110355.00,A,5423.1304,N,01012.7348,E,4.4,29.3,290526,,*0E
$GPGGA,110355.00,5423.1304,N,01012.7348,E,1,08,1.0,8.0,M,46.0,M,,*60
$GPVTG,29.3,T,,M,4.4,N,8.2,K*52
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.0,0.0*5F
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110537.00,A,5423.2354,N,01012.8360,E,4.1,29.3,290526,,*0A
$GPGGA,110537.00,5423.2354,N,01012.8360,E,1,08,1.0,7.8,M,46.0,M,,*66
$GPVTG,29.3,T,,M,4.1,N,7.5,K*5F
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.8,0.0*58
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110728.00,A,5423.3405,N,01012.9372,E,3.7,29.3,290526,,*07
$GPGGA,110728.00,5423.3405,N,01012.9372,E,1,08,1.0,7.7,M,46.0,M,,*65
$GPVTG,29.3,T,,M,3.7,N,6.9,K*53
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.7,0.0*57
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110905.00,A,5423.4246,N,01013.0181,E,3.5,29.3,290526,,*04
$GPGGA,110905.00,5423.4246,N,01013.0181,E,1,08,1.0,7.6,M,46.0,M,,*65
$GPVTG,29.3,T,,M,3.5,N,6.4,K*5C
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.6,0.0*56
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,111049.00,A,5423.5087,N,01013.0991,E,3.2,29.3,290526,,*04
$GPGGA,111049.00,5423.5087,N,01013.0991,E,1,08,1.0,7.5,M,46.0,M,,*61
$GPVTG,29.3,T,,M,3.2,N,5.9,K*55
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.5,0.0*55
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111229.00,A,5423.5828,N,01013.1716,E,2.9,29.7,290526,,*03
$GPGGA,111229.00,5423.5828,N,01013.1716,E,1,08,1.0,7.4,M,46.0,M,,*69
$GPVTG,29.7,T,,M,2.9,N,5.4,K*56
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.4,0.0*54
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111401.00,A,5423.6455,N,01013.2332,E,2.7,29.7,290526,,*05
$GPGGA,111401.00,5423.6455,N,01013.2332,E,1,08,1.0,7.3,M,46.0,M,,*66
$GPVTG,29.7,T,,M,2.7,N,5.1,K*5D
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.3,0.0*53
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111540.00,A,5423.7083,N,01013.2947,E,2.5,29.7,290526,,*05
$GPGGA,111540.00,5423.7083,N,01013.2947,E,1,08,1.0,7.2,M,46.0,M,,*65
$GPVTG,29.7,T,,M,2.5,N,4.7,K*58
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.2,0.0*52
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111727.00,A,5423.7711,N,01013.3563,E,2.3,29.7,290526,,*07
$GPGGA,111727.00,5423.7711,N,01013.3563,E,1,08,1.0,7.1,M,46.0,M,,*62
$GPVTG,29.7,T,,M,2.3,N,4.3,K*5A
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.1,0.0*51
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111924.00,A,5423.8339,N,01013.4179,E,2.1,29.7,290526,,*01
$GPGGA,111924.00,5423.8339,N,01013.4179,E,1,08,1.0,7.0,M,46.0,M,,*67
$GPVTG,29.7,T,,M,2.1,N,4.0,K*5B
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.0,0.0*50
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,112048.00,A,5423.8757,N,01013.4590,E,2.0,29.7,290526,,*0F
$GPGGA,112048.00,5423.8757,N,01013.4590,E,1,08,1.0,7.0,M,46.0,M,,*68
$GPVTG,29.7,T,,M,2.0,N,3.7,K*5A
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.0,0.0*50
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D