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:
@@ -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 */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user