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>
This commit is contained in:
2026-05-31 21:09:02 +02:00
parent 35bfbc1043
commit 039e4e2736
13 changed files with 1185 additions and 2 deletions
+175
View File
@@ -3167,6 +3167,181 @@ 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: 1000;
background: rgba(0, 0, 0, 0.55);
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);
}
.live-log-modal h3 {
margin: 0 0 16px;
font-size: 17px;
}
.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;
}
}
.grid-span-2 {
grid-column: span 2;
}
+395
View File
@@ -0,0 +1,395 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Anchor,
ChevronLeft,
FileText,
MapPin,
MessageSquare,
Radio,
Sailboat,
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,
findOrCreateTodayEntry,
loadEntry
} from '../services/quickEventLog.js'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import {
isMotorRunningFromEvents,
LIVE_EVENT_CODES,
liveCommentRemark,
liveSailsRemark
} from '../utils/liveEventCodes.js'
import { getCurrentPosition } from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import { useDialog } from './ModalDialog.tsx'
interface LiveLogViewProps {
logbookId: string
onOpenEditor: (entryId: string) => void
onSwitchToList: () => void
}
type LiveModal = 'none' | 'sails' | 'comment'
function hapticPulse() {
navigator.vibrate?.(40)
}
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 [commentText, setCommentText] = useState('')
const [selectedSails, setSelectedSails] = useState<string[]>([])
const streamEndRef = useRef<HTMLDivElement | null>(null)
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])
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(() => {
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [events.length])
const runQuickAction = async (
action: () => Promise<void>,
trackEvent?: string
) => {
if (!entryId || busy) return
setBusy(true)
setError(null)
try {
await action()
await refreshEntry(entryId)
if (trackEvent) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED, { context: trackEvent })
} 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 handleMotorToggle = () => {
hapticPulse()
void runQuickAction(async () => {
if (!entryId) return
const starting = !motorRunning
await appendQuickEvent(logbookId, entryId, {
sailsOrMotor: starting ? motorLabel : '',
remarks: starting ? LIVE_EVENT_CODES.MOTOR_START : LIVE_EVENT_CODES.MOTOR_STOP
})
}, 'live_motor')
}
const handleCastOff = () => {
void runQuickAction(async () => {
if (!entryId) return
await appendQuickEvent(logbookId, entryId, {
remarks: LIVE_EVENT_CODES.CAST_OFF
})
}, 'live_cast_off')
}
const handleMoor = () => {
void runQuickAction(async () => {
if (!entryId) return
await appendQuickEvent(logbookId, entryId, {
remarks: LIVE_EVENT_CODES.MOOR
})
}, 'live_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'))
}
}, 'live_fix')
}
const toggleSailSelection = (sail: string) => {
setSelectedSails((prev) =>
prev.some((s) => s.toLowerCase() === sail.toLowerCase())
? prev.filter((s) => s.toLowerCase() !== sail.toLowerCase())
: [...prev, sail]
)
}
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)
})
}, 'live_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)
})
}, 'live_comment')
}
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={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>
{modal === 'sails' && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal glass" 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 glass" 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>
)}
</div>
)
}
+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>
+27
View File
@@ -197,6 +197,33 @@
"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",
"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?",
+27
View File
@@ -197,6 +197,33 @@
"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",
"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?",
+27
View File
@@ -197,6 +197,33 @@
"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",
"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?",
+27
View File
@@ -197,6 +197,33 @@
"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",
"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?",
+27
View File
@@ -197,6 +197,33 @@
"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",
"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?",
+242
View File
@@ -0,0 +1,242 @@
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
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 payload = buildLogEntryPayload({
date: String(data.date || ''),
dayOfTravel: String(data.dayOfTravel || ''),
departure: options.departure ?? String(data.departure || ''),
destination: options.destination ?? String(data.destination || ''),
freshwater: {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
},
fuel: {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
},
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])
const entryData = buildEncryptedPayload(loaded.data, {
events: nextEvents,
departure: headerPatch?.departure,
destination: headerPatch?.destination,
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))
return { events: nextEvents, hadSignature }
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
isMotorRunningFromEvents,
LIVE_EVENT_CODES,
liveCommentRemark,
liveSailsRemark,
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.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')
})
})
+46
View File
@@ -0,0 +1,46 @@
import type { TFunction } from 'i18next'
import type { LogEventPayload } from './logEntryPayload.js'
import {
LIVE_EVENT_CODES,
parseLiveCommentRemark,
parseLiveSailsRemark
} 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
if (code === LIVE_EVENT_CODES.FIX) {
if (event.gpsLat && event.gpsLng) {
return t('logs.live_fix_coords', { lat: event.gpsLat, lng: event.gpsLng })
}
return t('logs.live_fix')
}
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')
}
+24
View File
@@ -0,0 +1,24 @@
export interface GeoCoordinates {
lat: string
lng: string
}
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) => {
resolve({
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6)
})
},
(err) => reject(err),
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
)
})
}
+42
View File
@@ -0,0 +1,42 @@
/** 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'
} 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 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
}
/** 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
}