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
parent 85e641ed39
commit 3ac4201734
19 changed files with 752 additions and 7 deletions
+6
View File
@@ -1,5 +1,11 @@
OpenWeatherMapAPIKey=<owm_api_key> OpenWeatherMapAPIKey=<owm_api_key>
# OpenRouter API (AI travel day summaries — server-side proxy)
OpenRouterAPIKey=
# Optional model override (default: anthropic/claude-3.5-haiku)
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
# OpenRouterModel=anthropic/claude-3.5-haiku
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs) # DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
# Free plan keys use api-free.deepl.com automatically (suffix :fx) # Free plan keys use api-free.deepl.com automatically (suffix :fx)
DeepLAPIKey= DeepLAPIKey=
+166 -3
View File
@@ -8,7 +8,7 @@ import { syncLogbook } from '../services/sync.js'
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js' import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.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 PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx' import SignatureSection from './SignatureSection.tsx'
import EntryCrewSection from './EntryCrewSection.tsx' import EntryCrewSection from './EntryCrewSection.tsx'
@@ -37,6 +37,12 @@ import { putEntryRecord } from '../utils/entryListCache.js'
import { getLogbookAccess } from '../services/logbookAccess.js' import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import {
buildTravelDayContext,
fetchTravelDaySummaryUsage,
generateTravelDaySummary,
TravelDaySummaryApiError
} from '../services/aiSummary.js'
import { import {
getDecryptedTrack, getDecryptedTrack,
saveUploadedTrack, saveUploadedTrack,
@@ -199,6 +205,13 @@ export default function LogEntryEditor({
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('') const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
const [canSignSkipper, setCanSignSkipper] = useState(false) const [canSignSkipper, setCanSignSkipper] = useState(false)
const [canSignCrew, setCanSignCrew] = 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 [isOnline, setIsOnline] = useState(navigator.onLine)
const [entryHash, setEntryHash] = useState('') const [entryHash, setEntryHash] = useState('')
@@ -434,6 +447,8 @@ export default function LogEntryEditor({
eventsOverride?: LogEvent[] eventsOverride?: LogEvent[]
signSkipper?: SignatureValue | '' signSkipper?: SignatureValue | ''
signCrew?: SignatureValue | '' signCrew?: SignatureValue | ''
aiSummary?: string
aiSummaryGeneratedAt?: string
} }
) => { ) => {
if (readOnly) return if (readOnly) return
@@ -442,15 +457,22 @@ export default function LogEntryEditor({
const eventsOverride = normalized.eventsOverride const eventsOverride = normalized.eventsOverride
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew 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() const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.') if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryData = { const entryData: Record<string, unknown> = {
...buildPayloadForSigning(eventsOverride), ...buildPayloadForSigning(eventsOverride),
signSkipper: normalizedSerializedSignature(skipperToSave), signSkipper: normalizedSerializedSignature(skipperToSave),
signCrew: normalizedSerializedSignature(crewToSave) signCrew: normalizedSerializedSignature(crewToSave)
} }
if (summaryToSave.trim()) {
entryData.aiSummary = summaryToSave.trim()
entryData.aiSummaryGeneratedAt = summaryAtToSave || new Date().toISOString()
}
const encrypted = await encryptJson(entryData, masterKey) const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString() const now = new Date().toISOString()
@@ -489,7 +511,8 @@ export default function LogEntryEditor({
setEntryHash(hash) setEntryHash(hash)
lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null
}, [ }, [
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew,
aiSummary, aiSummaryGeneratedAt
]) ])
useEffect(() => { useEffect(() => {
@@ -514,6 +537,24 @@ export default function LogEntryEditor({
}) })
}, [logbookId]) }, [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(() => { useEffect(() => {
const seq = ++entryHashSeqRef.current const seq = ++entryHashSeqRef.current
let cancelled = false let cancelled = false
@@ -740,6 +781,8 @@ export default function LogEntryEditor({
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>)) setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
loadTrackStatsFromEntry(preloadedEntry) loadTrackStatsFromEntry(preloadedEntry)
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent))) setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
setAiSummary(String(preloadedEntry.aiSummary || ''))
setAiSummaryGeneratedAt(String(preloadedEntry.aiSummaryGeneratedAt || ''))
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry)) setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return return
} }
@@ -779,6 +822,8 @@ export default function LogEntryEditor({
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)) setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
loadTrackStatsFromEntry(decrypted) loadTrackStatsFromEntry(decrypted)
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent))) setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
setAiSummary(String(decrypted.aiSummary || ''))
setAiSummaryGeneratedAt(String(decrypted.aiSummaryGeneratedAt || ''))
setSavedFingerprint(fingerprintFromStoredEntry(decrypted)) 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' const defaultSails = i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker'] : ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
@@ -1398,6 +1520,47 @@ export default function LogEntryEditor({
</div> </div>
</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 */} {/* Section 2: Freshwater and Fuel Consumption */}
<div className="form-grid"> <div className="form-grid">
{/* Freshwater card */} {/* Freshwater card */}
+10
View File
@@ -371,6 +371,16 @@
"share_csv": "CSV andel", "share_csv": "CSV andel",
"export_pdf": "Download PDF.", "export_pdf": "Download PDF.",
"exporting_pdf": "PDF er genereret...", "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)", "photos_title": "Vedhæftede billeder (E2E-krypteret)",
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)", "photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen", "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", "share_csv": "CSV teilen",
"export_pdf": "PDF herunterladen", "export_pdf": "PDF herunterladen",
"exporting_pdf": "PDF wird generiert...", "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)", "photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)", "photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
+10
View File
@@ -371,6 +371,16 @@
"share_csv": "Share CSV", "share_csv": "Share CSV",
"export_pdf": "Download PDF", "export_pdf": "Download PDF",
"exporting_pdf": "Generating 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)", "photos_title": "Photo Attachments (E2E Encrypted)",
"photo_caption_label": "Photo Caption / Label (Optional)", "photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance", "photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
+10
View File
@@ -371,6 +371,16 @@
"share_csv": "CSV andel", "share_csv": "CSV andel",
"export_pdf": "Last ned PDF", "export_pdf": "Last ned PDF",
"exporting_pdf": "PDF genereres...", "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)", "photos_title": "Bildevedlegg (E2E-kryptert)",
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)", "photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen", "photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
+10
View File
@@ -371,6 +371,16 @@
"share_csv": "Aktie", "share_csv": "Aktie",
"export_pdf": "Hämta PDF.", "export_pdf": "Hämta PDF.",
"exporting_pdf": "PDF genereras...", "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)", "photos_title": "Fotobilagor (E2E-krypterade)",
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)", "photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet", "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_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded', LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched', OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft', PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard', PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback', 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 // Headers matching the requested event fields & metadata
const headers = [ const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
'Skipper Signature', 'Crew Signature', 'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)', 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course', 'Event Time', 'MgK Course', 'RwK Course',
@@ -120,12 +120,13 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const fuelE = entry.fuel?.evening ?? ''; const fuelE = entry.fuel?.evening ?? '';
const fuelCons = entry.fuel?.consumption ?? ''; const fuelCons = entry.fuel?.consumption ?? '';
const greywaterLevel = entry.greywater?.level ?? ''; const greywaterLevel = entry.greywater?.level ?? '';
const aiSummary = entry.aiSummary ?? '';
const eventsList = entry.events || []; const eventsList = entry.events || [];
if (eventsList.length === 0) { if (eventsList.length === 0) {
// Create one row even if there are no events for the day // Create one row even if there are no events for the day
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
'', '', '', '', '', '',
@@ -142,7 +143,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const sortedEvents = sortLogEventsByTime(eventsList); const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) { for (const ev of sortedEvents) {
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '', 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 SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
const AI_SUMMARY_KEYS = new Set(['aiSummary', 'aiSummaryGeneratedAt'])
function sortEventsByTime(items: unknown[]): unknown[] { function sortEventsByTime(items: unknown[]): unknown[] {
return [...items] return [...items]
@@ -25,7 +26,7 @@ function sortValue(value: unknown, parentKey?: string): unknown {
const obj = value as Record<string, unknown> const obj = value as Record<string, unknown>
const sorted: Record<string, unknown> = {} const sorted: Record<string, unknown> = {}
for (const key of Object.keys(obj).sort()) { 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) sorted[key] = sortValue(obj[key], key)
} }
return sorted return sorted
+2
View File
@@ -32,6 +32,8 @@ services:
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu} VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-} OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
SESSION_SECRET: ${SESSION_SECRET:-} SESSION_SECRET: ${SESSION_SECRET:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh} NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
NTFY_TOPIC: ${NTFY_TOPIC:-} NTFY_TOPIC: ${NTFY_TOPIC:-}
+1
View File
@@ -39,6 +39,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` | | Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — | | Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) | | OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) | | Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` | | Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — | | Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
+11
View File
@@ -252,3 +252,14 @@ model GpsTrackPayload {
@@index([logbookId]) @@index([logbookId])
} }
model AiSummaryUsage {
id String @id @default(uuid())
logbookId String
entryId String
count Int @default(0)
updatedAt DateTime @updatedAt
@@unique([logbookId, entryId])
@@index([logbookId])
}
+14
View File
@@ -45,4 +45,18 @@ describe('API smoke', () => {
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(res.body.error).toMatch(/Token/i) expect(res.body.error).toMatch(/Token/i)
}) })
it('POST /api/ai/summary requires session', async () => {
const res = await request(app)
.post('/api/ai/summary')
.send({ logbookId: 'x', entryId: 'y', context: {} })
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
it('GET /api/ai/usage requires session', async () => {
const res = await request(app).get('/api/ai/usage')
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
}) })
+2
View File
@@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js' import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js' import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js' import weatherRouter from './routes/weather.js'
import aiRouter from './routes/ai.js'
import feedbackRouter from './routes/feedback.js' import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js' import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js' import { buildCorsOptions } from './cors.js'
@@ -118,6 +119,7 @@ export function createApp(): express.Express {
app.use('/api/sign', signRouter) app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter) app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter) app.use('/api/weather', weatherRouter)
app.use('/api/ai', aiRouter)
app.use('/api/feedback', feedbackRouter) app.use('/api/feedback', feedbackRouter)
app.get('/api/health', async (_req, res) => { app.get('/api/health', async (_req, res) => {
+233
View File
@@ -0,0 +1,233 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
const MAX_ATTEMPTS_PER_ENTRY = 3
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
const FETCH_TIMEOUT_MS = 60_000
/** Common misconfiguration aliases → valid OpenRouter model IDs */
const MODEL_ALIASES: Record<string, string> = {
'anthropic/claude-haiku-latest': 'anthropic/claude-3.5-haiku',
'claude-haiku-latest': 'anthropic/claude-3.5-haiku'
}
const LANGUAGE_LABELS: Record<string, string> = {
de: 'German',
en: 'English',
da: 'Danish',
nb: 'Norwegian Bokmål',
sv: 'Swedish'
}
function resolveOpenRouterApiKey(): string | null {
const fromEnv =
process.env.OpenRouterAPIKey?.trim() ||
process.env.OPENROUTER_API_KEY?.trim()
return fromEnv || null
}
function resolveOpenRouterModel(): string {
const configured =
process.env.OpenRouterModel?.trim() ||
process.env.OPENROUTER_MODEL?.trim() ||
DEFAULT_MODEL
return MODEL_ALIASES[configured] ?? configured
}
function extractOpenRouterError(data: unknown): string | null {
if (typeof data !== 'object' || data === null) return null
const nested = (data as { error?: { message?: string } }).error
if (nested && typeof nested.message === 'string' && nested.message.trim()) {
return nested.message.trim()
}
const topLevel = (data as { error?: string }).error
if (typeof topLevel === 'string' && topLevel.trim()) return topLevel.trim()
return null
}
async function getLogbookOwner(logbookId: string) {
return prisma.logbook.findUnique({
where: { id: logbookId },
select: { userId: true }
})
}
async function getUsageCount(logbookId: string, entryId: string): Promise<number> {
const row = await prisma.aiSummaryUsage.findUnique({
where: { logbookId_entryId: { logbookId, entryId } },
select: { count: true }
})
return row?.count ?? 0
}
function remainingAttempts(used: number): number {
return Math.max(0, MAX_ATTEMPTS_PER_ENTRY - used)
}
function resolveLanguageLabel(language: unknown): string {
if (typeof language === 'string' && LANGUAGE_LABELS[language]) {
return LANGUAGE_LABELS[language]
}
return LANGUAGE_LABELS.en
}
function buildSystemPrompt(languageLabel: string): string {
return [
'You are a maritime logbook assistant for sailing yachts.',
`Write a concise narrative summary of one travel day in ${languageLabel}.`,
'Use 24 short paragraphs in plain prose.',
'Cover route, sailing conditions, notable events, and tank/fuel highlights when data is present.',
'Do not invent facts not supported by the input.',
'Do not include coordinates, personal names, or signature metadata.',
'Respond with the summary text only — no title, markdown, or JSON.'
].join(' ')
}
router.use(requireUser)
router.get('/usage', async (req: any, res) => {
try {
const logbookId = String(req.query.logbookId || '')
const entryId = String(req.query.entryId || '')
if (!logbookId || !entryId) {
return res.status(400).json({ error: 'logbookId and entryId are required' })
}
const logbook = await getLogbookOwner(logbookId)
if (!logbook) return res.status(404).json({ error: 'Logbook not found' })
if (logbook.userId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: Skipper only' })
}
const used = await getUsageCount(logbookId, entryId)
return res.json({ remainingAttempts: remainingAttempts(used), maxAttempts: MAX_ATTEMPTS_PER_ENTRY })
} catch (error: unknown) {
console.error('AI summary usage lookup failed:', error)
return res.status(500).json({ error: 'Failed to load AI summary usage' })
}
})
router.post('/summary', async (req: any, res) => {
try {
const { logbookId, entryId, language, context } = req.body ?? {}
if (!logbookId || !entryId || !context || typeof context !== 'object') {
return res.status(400).json({ error: 'logbookId, entryId, and context are required' })
}
const logbook = await getLogbookOwner(String(logbookId))
if (!logbook) return res.status(404).json({ error: 'Logbook not found' })
if (logbook.userId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: Skipper only' })
}
const used = await getUsageCount(String(logbookId), String(entryId))
if (used >= MAX_ATTEMPTS_PER_ENTRY) {
return res.status(429).json({
error: 'Rate limit exceeded for this travel day',
code: 'RATE_LIMITED',
remainingAttempts: 0,
maxAttempts: MAX_ATTEMPTS_PER_ENTRY
})
}
const apiKey = resolveOpenRouterApiKey()
if (!apiKey) {
return res.status(503).json({
error: 'No OpenRouter API key configured',
code: 'NO_KEY'
})
}
const languageLabel = resolveLanguageLabel(language)
const model = resolveOpenRouterModel()
const contextJson = JSON.stringify(context)
if (contextJson.length > 100_000) {
return res.status(400).json({ error: 'Travel day context is too large' })
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
let openRouterRes: Response
try {
openRouterRes = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': process.env.ORIGIN || 'https://kapteins-daagbok.eu',
'X-Title': 'Kapteins Daagbok'
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: buildSystemPrompt(languageLabel) },
{
role: 'user',
content: `Summarize this travel day from the structured log data:\n\n${contextJson}`
}
],
max_tokens: 800,
temperature: 0.4
}),
signal: controller.signal
})
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return res.status(504).json({ error: 'OpenRouter request timed out' })
}
throw error
} finally {
clearTimeout(timeoutId)
}
const data = await openRouterRes.json().catch(() => ({}))
if (!openRouterRes.ok) {
const detail = extractOpenRouterError(data)
console.error('OpenRouter error:', openRouterRes.status, data)
return res.status(502).json({
error: detail || 'OpenRouter request failed',
code: 'OPENROUTER_ERROR'
})
}
const summary =
typeof data === 'object' &&
data !== null &&
Array.isArray((data as { choices?: unknown[] }).choices) &&
(data as { choices: Array<{ message?: { content?: string } }> }).choices[0]?.message?.content
? String((data as { choices: Array<{ message?: { content?: string } }> }).choices[0].message?.content).trim()
: ''
if (!summary) {
return res.status(502).json({ error: 'OpenRouter returned an empty summary' })
}
const updated = await prisma.aiSummaryUsage.upsert({
where: {
logbookId_entryId: { logbookId: String(logbookId), entryId: String(entryId) }
},
create: {
logbookId: String(logbookId),
entryId: String(entryId),
count: 1
},
update: { count: { increment: 1 } }
})
return res.json({
summary,
remainingAttempts: remainingAttempts(updated.count),
maxAttempts: MAX_ATTEMPTS_PER_ENTRY
})
} catch (error: unknown) {
console.error('AI summary generation failed:', error)
return res.status(500).json({ error: 'Failed to generate AI summary' })
}
})
export default router