Add AI travel day summaries via OpenRouter for skippers.

Skipper-only proxy with per-entry rate limiting, encrypted payload storage, CSV export, and Plausible tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 11:26:19 +02:00
co-authored by Cursor
parent 85e641ed39
commit 3ac4201734
19 changed files with 752 additions and 7 deletions
+166 -3
View File
@@ -8,7 +8,7 @@ import { syncLogbook } from '../services/sync.js'
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
import { getErrorMessage } from '../utils/errors.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx'
import EntryCrewSection from './EntryCrewSection.tsx'
@@ -37,6 +37,12 @@ import { putEntryRecord } from '../utils/entryListCache.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import {
buildTravelDayContext,
fetchTravelDaySummaryUsage,
generateTravelDaySummary,
TravelDaySummaryApiError
} from '../services/aiSummary.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -199,6 +205,13 @@ export default function LogEntryEditor({
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
const [canSignSkipper, setCanSignSkipper] = useState(false)
const [canSignCrew, setCanSignCrew] = useState(false)
const [aiSummary, setAiSummary] = useState('')
const [aiSummaryGeneratedAt, setAiSummaryGeneratedAt] = useState('')
const [aiSummaryLoading, setAiSummaryLoading] = useState(false)
const [aiSummaryError, setAiSummaryError] = useState<string | null>(null)
const [aiSummaryRemaining, setAiSummaryRemaining] = useState<number | null>(null)
const [aiSummaryMaxAttempts, setAiSummaryMaxAttempts] = useState(3)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [entryHash, setEntryHash] = useState('')
@@ -434,6 +447,8 @@ export default function LogEntryEditor({
eventsOverride?: LogEvent[]
signSkipper?: SignatureValue | ''
signCrew?: SignatureValue | ''
aiSummary?: string
aiSummaryGeneratedAt?: string
}
) => {
if (readOnly) return
@@ -442,15 +457,22 @@ export default function LogEntryEditor({
const eventsOverride = normalized.eventsOverride
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
const summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
const summaryAtToSave =
normalized.aiSummaryGeneratedAt !== undefined ? normalized.aiSummaryGeneratedAt : aiSummaryGeneratedAt
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryData = {
const entryData: Record<string, unknown> = {
...buildPayloadForSigning(eventsOverride),
signSkipper: normalizedSerializedSignature(skipperToSave),
signCrew: normalizedSerializedSignature(crewToSave)
}
if (summaryToSave.trim()) {
entryData.aiSummary = summaryToSave.trim()
entryData.aiSummaryGeneratedAt = summaryAtToSave || new Date().toISOString()
}
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
@@ -489,7 +511,8 @@ export default function LogEntryEditor({
setEntryHash(hash)
lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null
}, [
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew,
aiSummary, aiSummaryGeneratedAt
])
useEffect(() => {
@@ -514,6 +537,24 @@ export default function LogEntryEditor({
})
}, [logbookId])
useEffect(() => {
if (!canSignSkipper || readOnly) {
setAiSummaryRemaining(null)
return
}
let cancelled = false
fetchTravelDaySummaryUsage(logbookId, entryId)
.then((usage) => {
if (cancelled) return
setAiSummaryRemaining(usage.remainingAttempts)
setAiSummaryMaxAttempts(usage.maxAttempts)
})
.catch((err) => {
console.warn('Failed to load AI summary usage:', err)
})
return () => { cancelled = true }
}, [canSignSkipper, readOnly, logbookId, entryId])
useEffect(() => {
const seq = ++entryHashSeqRef.current
let cancelled = false
@@ -740,6 +781,8 @@ export default function LogEntryEditor({
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
loadTrackStatsFromEntry(preloadedEntry)
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
setAiSummary(String(preloadedEntry.aiSummary || ''))
setAiSummaryGeneratedAt(String(preloadedEntry.aiSummaryGeneratedAt || ''))
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return
}
@@ -779,6 +822,8 @@ export default function LogEntryEditor({
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
loadTrackStatsFromEntry(decrypted)
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
setAiSummary(String(decrypted.aiSummary || ''))
setAiSummaryGeneratedAt(String(decrypted.aiSummaryGeneratedAt || ''))
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
}
}
@@ -1032,6 +1077,83 @@ export default function LogEntryEditor({
}
}
const handleGenerateAiSummary = async () => {
if (!canSignSkipper || readOnly || aiSummaryLoading) return
if (aiSummaryRemaining === 0) {
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
return
}
setAiSummaryLoading(true)
setAiSummaryError(null)
try {
const context = buildTravelDayContext(
{
date,
dayOfTravel,
departure,
destination,
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
freshwater: {
morning: parseFloat(fwMorning) || 0,
refilled: parseFloat(fwRefilled) || 0,
evening: parseFloat(fwEvening) || 0,
consumption: parseFloat(fwConsumption) || 0
},
fuel: {
morning: parseFloat(fuelMorning) || 0,
refilled: parseFloat(fuelRefilled) || 0,
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
},
greywaterLevel: parseFloat(greywaterLevel) || 0,
events
},
t
)
const language = i18n.language.split('-')[0] || 'en'
const result = await generateTravelDaySummary({
logbookId,
entryId,
language,
context
})
const generatedAt = new Date().toISOString()
setAiSummary(result.summary)
setAiSummaryGeneratedAt(generatedAt)
setAiSummaryRemaining(result.remainingAttempts)
setAiSummaryMaxAttempts(result.maxAttempts)
await persistEntryToDb({
aiSummary: result.summary,
aiSummaryGeneratedAt: generatedAt
})
} catch (err) {
if (err instanceof TravelDaySummaryApiError) {
if (err.code === 'NO_KEY') {
setAiSummaryError(t('logs.ai_summary_error_no_key'))
} else if (err.code === 'RATE_LIMITED') {
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
setAiSummaryRemaining(0)
} else if (err.code === 'FORBIDDEN') {
setAiSummaryError(t('logs.ai_summary_error_forbidden'))
} else {
setAiSummaryError(err.message || t('logs.ai_summary_error'))
}
} else {
console.error('AI summary generation failed:', err)
setAiSummaryError(getErrorMessage(err, t('logs.ai_summary_error')))
}
} finally {
setAiSummaryLoading(false)
}
}
const defaultSails = i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
@@ -1398,6 +1520,47 @@ export default function LogEntryEditor({
</div>
</div>
{(aiSummary.trim() || canSignSkipper) && (
<div className="form-card">
<div className="form-header">
<Sparkles size={20} className="form-icon" />
<h3>{t('logs.ai_summary_title')}</h3>
</div>
{aiSummary.trim() ? (
<p style={{ whiteSpace: 'pre-wrap', margin: '0 0 16px', lineHeight: 1.5 }}>{aiSummary}</p>
) : (
<p style={{ margin: '0 0 16px', opacity: 0.75 }}>{t('logs.ai_summary_empty')}</p>
)}
{canSignSkipper && !readOnly && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
<button
type="button"
className="btn secondary"
onClick={() => void handleGenerateAiSummary()}
disabled={saving || aiSummaryLoading || aiSummaryRemaining === 0}
style={{ width: 'auto' }}
>
<Sparkles size={16} />
{aiSummaryLoading
? t('logs.ai_summary_generating')
: aiSummary.trim()
? t('logs.ai_summary_regenerate')
: t('logs.ai_summary_generate')}
</button>
{aiSummaryRemaining !== null && (
<span style={{ fontSize: '0.9rem', opacity: 0.8 }}>
{t('logs.ai_summary_attempts_remaining', {
remaining: aiSummaryRemaining,
max: aiSummaryMaxAttempts
})}
</span>
)}
</div>
)}
{aiSummaryError && <div className="auth-error" style={{ marginTop: '12px' }}>{aiSummaryError}</div>}
</div>
)}
{/* Section 2: Freshwater and Fuel Consumption */}
<div className="form-grid">
{/* Freshwater card */}
+10
View File
@@ -371,6 +371,16 @@
"share_csv": "CSV andel",
"export_pdf": "Download PDF.",
"exporting_pdf": "PDF er genereret...",
"ai_summary_title": "AI-resumé",
"ai_summary_empty": "Intet resumé endnu.",
"ai_summary_generate": "Generér resumé",
"ai_summary_regenerate": "Generér igen",
"ai_summary_generating": "Genererer…",
"ai_summary_attempts_remaining": "{{remaining}} af {{max}} forsøg tilbage",
"ai_summary_error": "AI-resumé mislykkedes. Prøv igen senere.",
"ai_summary_error_no_key": "Ingen OpenRouter API-nøgle konfigureret på serveren.",
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
+10
View File
@@ -371,6 +371,16 @@
"share_csv": "CSV teilen",
"export_pdf": "PDF herunterladen",
"exporting_pdf": "PDF wird generiert...",
"ai_summary_title": "KI-Zusammenfassung",
"ai_summary_empty": "Noch keine Zusammenfassung vorhanden.",
"ai_summary_generate": "Zusammenfassung generieren",
"ai_summary_regenerate": "Neu generieren",
"ai_summary_generating": "Wird generiert…",
"ai_summary_attempts_remaining": "Noch {{remaining}} von {{max}} Versuchen",
"ai_summary_error": "KI-Zusammenfassung fehlgeschlagen. Bitte später erneut versuchen.",
"ai_summary_error_no_key": "Kein OpenRouter API-Schlüssel auf dem Server konfiguriert.",
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
+10
View File
@@ -371,6 +371,16 @@
"share_csv": "Share CSV",
"export_pdf": "Download PDF",
"exporting_pdf": "Generating PDF...",
"ai_summary_title": "AI Summary",
"ai_summary_empty": "No summary yet.",
"ai_summary_generate": "Generate summary",
"ai_summary_regenerate": "Regenerate",
"ai_summary_generating": "Generating…",
"ai_summary_attempts_remaining": "{{remaining}} of {{max}} attempts remaining",
"ai_summary_error": "AI summary failed. Please try again later.",
"ai_summary_error_no_key": "No OpenRouter API key configured on the server.",
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
"photos_title": "Photo Attachments (E2E Encrypted)",
"photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
+10
View File
@@ -371,6 +371,16 @@
"share_csv": "CSV andel",
"export_pdf": "Last ned PDF",
"exporting_pdf": "PDF genereres...",
"ai_summary_title": "AI-sammendrag",
"ai_summary_empty": "Ingen sammendrag ennå.",
"ai_summary_generate": "Generer sammendrag",
"ai_summary_regenerate": "Generer på nytt",
"ai_summary_generating": "Genererer…",
"ai_summary_attempts_remaining": "{{remaining}} av {{max}} forsøk igjen",
"ai_summary_error": "AI-sammendrag mislyktes. Prøv igjen senere.",
"ai_summary_error_no_key": "Ingen OpenRouter API-nøkkel konfigurert på serveren.",
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
"photos_title": "Bildevedlegg (E2E-kryptert)",
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
+10
View File
@@ -371,6 +371,16 @@
"share_csv": "Aktie",
"export_pdf": "Hämta PDF.",
"exporting_pdf": "PDF genereras...",
"ai_summary_title": "AI-sammanfattning",
"ai_summary_empty": "Ingen sammanfattning ännu.",
"ai_summary_generate": "Generera sammanfattning",
"ai_summary_regenerate": "Generera igen",
"ai_summary_generating": "Genererar…",
"ai_summary_attempts_remaining": "{{remaining}} av {{max}} försök kvar",
"ai_summary_error": "AI-sammanfattning misslyckades. Försök igen senare.",
"ai_summary_error_no_key": "Ingen OpenRouter API-nyckel konfigurerad på servern.",
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
"photos_title": "Fotobilagor (E2E-krypterade)",
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from 'vitest'
import { buildTravelDayContext } from './aiSummary.js'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
const t = ((key: string, opts?: Record<string, unknown>) => {
if (key === 'logs.live_motor_start') return 'Motor started'
if (key === 'logs.live_event_generic') return 'Event'
if (opts && 'course' in opts) return `Course ${opts.course}`
return key
}) as any
describe('buildTravelDayContext', () => {
it('includes route metadata and formatted events', () => {
const events: LogEventPayload[] = [
{
time: '09:00',
mgk: '180',
rwk: '',
windPressure: '',
windDirection: '',
windStrength: '',
seaState: '',
visibility: '',
weatherIcon: '',
current: '',
heel: '',
sailsOrMotor: 'Genua',
logReading: '',
distance: '',
gpsLat: '',
gpsLng: '',
remarks: '__live:motor_start'
}
]
const context = buildTravelDayContext(
{
date: '2026-06-03',
dayOfTravel: '5',
departure: 'Kiel',
destination: 'Copenhagen',
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
fuel: { morning: 50, refilled: 10, evening: 40, consumption: 20 },
greywaterLevel: 0,
trackDistanceNm: 42.5,
motorHours: 3.5,
events
},
t
)
expect(context.departure).toBe('Kiel')
expect(context.destination).toBe('Copenhagen')
expect(context.trackDistanceNm).toBe(42.5)
expect(context.motorHours).toBe(3.5)
expect(context.events).toHaveLength(1)
expect(context.events[0].summary).toBe('Motor started')
expect(context.events[0].sailsOrMotor).toBe('Genua')
expect(context.greywater).toBeUndefined()
})
})
+174
View File
@@ -0,0 +1,174 @@
import type { TFunction } from 'i18next'
import { apiFetch } from './api.js'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
export class TravelDaySummaryApiError extends Error {
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED'
constructor(
message: string,
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'TravelDaySummaryApiError'
this.code = code
}
}
export interface TravelDaySummaryContext {
date: string
dayOfTravel: string
departure: string
destination: string
trackDistanceNm?: number
trackSpeedMaxKn?: number
trackSpeedAvgKn?: number
motorHours?: number
freshwater?: {
morning: number
refilled: number
evening: number
consumption: number
}
fuel?: {
morning: number
refilled: number
evening: number
consumption: number
}
greywater?: { level: number }
events: Array<{
time: string
summary: string
sailsOrMotor?: string
mgk?: string
windDirection?: string
windStrength?: string
windPressure?: string
seaState?: string
visibility?: string
distance?: string
}>
}
export interface TravelDaySummaryInput {
date: string
dayOfTravel: string
departure: string
destination: string
trackDistanceNm?: number
trackSpeedMaxKn?: number
trackSpeedAvgKn?: number
motorHours?: number
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
greywaterLevel?: number
events: LogEventPayload[]
}
const SUMMARY_FETCH_TIMEOUT_MS = 90_000
export function buildTravelDayContext(
input: TravelDaySummaryInput,
t: TFunction
): TravelDaySummaryContext {
const context: TravelDaySummaryContext = {
date: input.date,
dayOfTravel: input.dayOfTravel,
departure: input.departure,
destination: input.destination,
freshwater: input.freshwater,
fuel: input.fuel,
events: sortLogEventsByTime(input.events).map((event) => ({
time: event.time,
summary: formatEventSummary(event, t),
...(event.sailsOrMotor ? { sailsOrMotor: event.sailsOrMotor } : {}),
...(event.mgk ? { mgk: event.mgk } : {}),
...(event.windDirection ? { windDirection: event.windDirection } : {}),
...(event.windStrength ? { windStrength: event.windStrength } : {}),
...(event.windPressure ? { windPressure: event.windPressure } : {}),
...(event.seaState ? { seaState: event.seaState } : {}),
...(event.visibility ? { visibility: event.visibility } : {}),
...(event.distance ? { distance: event.distance } : {})
}))
}
if (input.trackDistanceNm !== undefined) context.trackDistanceNm = input.trackDistanceNm
if (input.trackSpeedMaxKn !== undefined) context.trackSpeedMaxKn = input.trackSpeedMaxKn
if (input.trackSpeedAvgKn !== undefined) context.trackSpeedAvgKn = input.trackSpeedAvgKn
if (input.motorHours !== undefined && input.motorHours > 0) context.motorHours = input.motorHours
if (input.greywaterLevel !== undefined && input.greywaterLevel > 0) {
context.greywater = { level: input.greywaterLevel }
}
return context
}
function mapApiError(status: number, data: unknown): TravelDaySummaryApiError {
const code =
typeof data === 'object' && data !== null && 'code' in data
? String((data as { code?: string }).code)
: ''
if (status === 503 || code === 'NO_KEY') {
return new TravelDaySummaryApiError('No OpenRouter API key configured', 'NO_KEY')
}
if (status === 403) {
return new TravelDaySummaryApiError('Forbidden', 'FORBIDDEN')
}
if (status === 429 || code === 'RATE_LIMITED') {
return new TravelDaySummaryApiError('Rate limit exceeded', 'RATE_LIMITED')
}
const message =
typeof data === 'object' && data !== null && 'error' in data && typeof (data as { error: unknown }).error === 'string'
? (data as { error: string }).error
: 'Request failed'
return new TravelDaySummaryApiError(message, 'REQUEST_FAILED')
}
export async function fetchTravelDaySummaryUsage(
logbookId: string,
entryId: string
): Promise<{ remainingAttempts: number; maxAttempts: number }> {
const params = new URLSearchParams({ logbookId, entryId })
const res = await apiFetch(`/api/ai/usage?${params.toString()}`)
const data = await res.json().catch(() => ({}))
if (!res.ok) throw mapApiError(res.status, data)
return data as { remainingAttempts: number; maxAttempts: number }
}
export async function generateTravelDaySummary(params: {
logbookId: string
entryId: string
language: string
context: TravelDaySummaryContext
}): Promise<{ summary: string; remainingAttempts: number; maxAttempts: number }> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SUMMARY_FETCH_TIMEOUT_MS)
let res: Response
try {
res = await apiFetch('/api/ai/summary', {
method: 'POST',
body: JSON.stringify(params),
signal: controller.signal
})
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new TravelDaySummaryApiError('AI summary request timed out')
}
throw err
} finally {
window.clearTimeout(timeoutId)
}
const data = await res.json().catch(() => ({}))
if (!res.ok) throw mapApiError(res.status, data)
trackPlausibleEvent(PlausibleEvents.AI_SUMMARY_GENERATED)
return data as { summary: string; remainingAttempts: number; maxAttempts: number }
}
+1
View File
@@ -42,6 +42,7 @@ export const PlausibleEvents = {
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
+4 -3
View File
@@ -74,7 +74,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
// Headers matching the requested event fields & metadata
const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course',
@@ -120,12 +120,13 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const fuelE = entry.fuel?.evening ?? '';
const fuelCons = entry.fuel?.consumption ?? '';
const greywaterLevel = entry.greywater?.level ?? '';
const aiSummary = entry.aiSummary ?? '';
const eventsList = entry.events || [];
if (eventsList.length === 0) {
// Create one row even if there are no events for the day
rows.push([
dateVal, travelDay, dep, dest,
dateVal, travelDay, dep, dest, aiSummary,
signS, signC,
trackDist, trackMax, trackAvg, motorH,
'', '', '',
@@ -142,7 +143,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) {
rows.push([
dateVal, travelDay, dep, dest,
dateVal, travelDay, dep, dest, aiSummary,
signS, signC,
trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '',
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { hashEntryForSigning } from './entryCanonicalHash.js'
describe('hashEntryForSigning', () => {
it('excludes aiSummary fields from the signing hash', async () => {
const base = {
date: '2026-06-03',
dayOfTravel: '1',
departure: 'A',
destination: 'B',
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
events: []
}
const withoutSummary = await hashEntryForSigning(base)
const withSummary = await hashEntryForSigning({
...base,
aiSummary: 'A calm day at sea.',
aiSummaryGeneratedAt: '2026-06-03T12:00:00.000Z'
})
expect(withSummary).toBe(withoutSummary)
})
})
+2 -1
View File
@@ -1,4 +1,5 @@
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
const AI_SUMMARY_KEYS = new Set(['aiSummary', 'aiSummaryGeneratedAt'])
function sortEventsByTime(items: unknown[]): unknown[] {
return [...items]
@@ -25,7 +26,7 @@ function sortValue(value: unknown, parentKey?: string): unknown {
const obj = value as Record<string, unknown>
const sorted: Record<string, unknown> = {}
for (const key of Object.keys(obj).sort()) {
if (SIGNATURE_KEYS.has(key)) continue
if (SIGNATURE_KEYS.has(key) || AI_SUMMARY_KEYS.has(key)) continue
sorted[key] = sortValue(obj[key], key)
}
return sorted