Compare commits
9 Commits
v0.1.0.102
...
v0.1.0.106
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ecebc5dbb | |||
| caf85ad9eb | |||
| d637fbea16 | |||
| 8e03563f65 | |||
| 3ac4201734 | |||
| 85e641ed39 | |||
| 9bf59280b2 | |||
| aee8f4f3db | |||
| 2b029a26f0 |
+8
-2
@@ -1,5 +1,11 @@
|
||||
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)
|
||||
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
||||
DeepLAPIKey=
|
||||
@@ -13,8 +19,8 @@ RP_ID=localhost
|
||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
ORIGIN=http://localhost:5173
|
||||
|
||||
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
|
||||
# TRUST_PROXY=172.16.10.10
|
||||
# Behind reverse proxy — see docs/deployment/npm-security.md
|
||||
# Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
|
||||
# TRUST_PROXY=1
|
||||
|
||||
# Docker Compose database (required for production deploy)
|
||||
|
||||
@@ -43,6 +43,9 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -103,7 +103,7 @@ function App() {
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLogbookId) {
|
||||
@@ -574,7 +574,8 @@ function App() {
|
||||
const logbookReadOnly =
|
||||
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
||||
const isLogbookOwner =
|
||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
||||
activeAccessRole === 'OWNER' ||
|
||||
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
|
||||
|
||||
if (showUserProfile) {
|
||||
return (
|
||||
|
||||
@@ -152,6 +152,7 @@ export default function LiveLogView({
|
||||
const [modal, setModal] = useState<LiveModal>('none')
|
||||
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
||||
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [valueInput, setValueInput] = useState('')
|
||||
const [valueInputSecondary, setValueInputSecondary] = useState('')
|
||||
@@ -269,6 +270,17 @@ export default function LiveLogView({
|
||||
}
|
||||
}, [logbookId, applyLoadedEntry, t])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void runInit()
|
||||
return () => {
|
||||
@@ -503,6 +515,10 @@ export default function LiveLogView({
|
||||
|
||||
const handleFetchOwmWeather = () => {
|
||||
if (!entryId || busy || weatherOwmLoading) return
|
||||
if (!isOnline) {
|
||||
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
||||
return
|
||||
}
|
||||
|
||||
const position = getLastPositionFixWithin(
|
||||
events,
|
||||
@@ -533,6 +549,10 @@ export default function LiveLogView({
|
||||
{ analyticsSource: 'live_log' }
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
|
||||
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
||||
return
|
||||
}
|
||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
||||
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { findTodayEntryId } from '../services/quickEventLog.js'
|
||||
import { findTodayEntryId, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import LiveLogView from './LiveLogView.tsx'
|
||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||
@@ -136,7 +136,7 @@ export default function LogEntriesList({
|
||||
}
|
||||
|
||||
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (!decrypted) return
|
||||
|
||||
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
|
||||
@@ -266,7 +266,7 @@ export default function LogEntriesList({
|
||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||
|
||||
for (const entry of localEntries) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -504,6 +527,8 @@ export default function LogEntryEditor({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setCanSignSkipper(false)
|
||||
setCanSignCrew(false)
|
||||
getLogbookAccess(logbookId).then((access) => {
|
||||
if (!access) return
|
||||
setCanSignSkipper(access.isOwner)
|
||||
@@ -514,6 +539,24 @@ export default function LogEntryEditor({
|
||||
})
|
||||
}, [logbookId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSignSkipper || readOnly || !isOnline) {
|
||||
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, isOnline, logbookId, entryId])
|
||||
|
||||
useEffect(() => {
|
||||
const seq = ++entryHashSeqRef.current
|
||||
let cancelled = false
|
||||
@@ -740,6 +783,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 +824,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))
|
||||
}
|
||||
}
|
||||
@@ -941,6 +988,10 @@ export default function LogEntryEditor({
|
||||
showAlert('GPS capturing failed, and no location name is entered in "Ort / Hafen" or "Start-Hafen" to look up coordinates.')
|
||||
return
|
||||
}
|
||||
if (!isOnline) {
|
||||
showAlert(t('logs.weather_offline'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchOpenWeatherCurrent(
|
||||
@@ -954,6 +1005,10 @@ export default function LogEntryEditor({
|
||||
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof WeatherApiError && e.code === 'OFFLINE') {
|
||||
showAlert(t('logs.weather_offline'))
|
||||
return
|
||||
}
|
||||
if (e instanceof WeatherApiError && e.code === 'NO_KEY') {
|
||||
showAlert(t('settings.no_key'))
|
||||
return
|
||||
@@ -980,6 +1035,11 @@ export default function LogEntryEditor({
|
||||
}
|
||||
|
||||
const handleFetchWeather = async () => {
|
||||
if (!isOnline) {
|
||||
showAlert(t('logs.weather_offline'))
|
||||
return
|
||||
}
|
||||
|
||||
const localToday = new Date()
|
||||
const todayStr = `${localToday.getFullYear()}-${String(localToday.getMonth() + 1).padStart(2, '0')}-${String(localToday.getDate()).padStart(2, '0')}`
|
||||
|
||||
@@ -1021,6 +1081,10 @@ export default function LogEntryEditor({
|
||||
|
||||
showAlert(t('settings.weather_success'))
|
||||
} catch (err) {
|
||||
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
|
||||
showAlert(t('logs.weather_offline'))
|
||||
return
|
||||
}
|
||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
||||
showAlert(t('settings.no_key'))
|
||||
return
|
||||
@@ -1032,6 +1096,89 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateAiSummary = async () => {
|
||||
if (!canSignSkipper || readOnly || aiSummaryLoading) return
|
||||
if (!isOnline) {
|
||||
setAiSummaryError(t('logs.ai_summary_offline'))
|
||||
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 === 'OFFLINE') {
|
||||
setAiSummaryError(t('logs.ai_summary_offline'))
|
||||
} else 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 +1545,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 */}
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
"event_remarks": "Bemærkninger / hændelser",
|
||||
"gps_btn": "Hent GPS-koordinater",
|
||||
"weather_btn": "OpenWeatherMap Kald vejret op",
|
||||
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
|
||||
"event_wind_pressure": "Lufttryk (hPa)",
|
||||
"event_heel": "Krængning (°)",
|
||||
"event_sails": "Sejlhåndtering/motor",
|
||||
@@ -371,6 +372,17 @@
|
||||
"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.",
|
||||
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
|
||||
"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",
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
"event_remarks": "Bemerkungen / Vorkommnisse",
|
||||
"gps_btn": "GPS-Koordinaten abrufen",
|
||||
"weather_btn": "OpenWeatherMap Wetter abrufen",
|
||||
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||
"event_wind_pressure": "Luftdruck (hPa)",
|
||||
"event_heel": "Krängung (°)",
|
||||
"event_sails": "Segelführung / Motor",
|
||||
@@ -371,6 +372,17 @@
|
||||
"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.",
|
||||
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
"event_remarks": "Remarks / Events",
|
||||
"gps_btn": "Get GPS Location",
|
||||
"weather_btn": "Fetch OpenWeatherMap Weather",
|
||||
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
|
||||
"event_wind_pressure": "Barometer (hPa)",
|
||||
"event_heel": "Heel Angle (°)",
|
||||
"event_sails": "Sails / Motor Status",
|
||||
@@ -371,6 +372,17 @@
|
||||
"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.",
|
||||
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
||||
"photos_title": "Photo Attachments (E2E Encrypted)",
|
||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
"event_remarks": "Merknader / hendelser",
|
||||
"gps_btn": "Hent GPS-koordinater",
|
||||
"weather_btn": "OpenWeatherMap Ring opp været",
|
||||
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
|
||||
"event_wind_pressure": "Lufttrykk (hPa)",
|
||||
"event_heel": "Helning (°)",
|
||||
"event_sails": "Seilhåndtering / motor",
|
||||
@@ -371,6 +372,17 @@
|
||||
"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.",
|
||||
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
|
||||
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
||||
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
||||
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
"event_remarks": "Anmärkningar / incidenter",
|
||||
"gps_btn": "Hämta GPS-koordinater",
|
||||
"weather_btn": "OpenWeatherMap Ring upp väder",
|
||||
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
|
||||
"event_wind_pressure": "Lufttryck (hPa)",
|
||||
"event_heel": "Krängning (°)",
|
||||
"event_sails": "Segelhantering / motor",
|
||||
@@ -371,6 +372,17 @@
|
||||
"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.",
|
||||
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
|
||||
"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,178 @@
|
||||
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' | 'OFFLINE' | 'REQUEST_FAILED'
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | '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 }> {
|
||||
if (!navigator.onLine) {
|
||||
throw new TravelDaySummaryApiError('Offline', 'OFFLINE')
|
||||
}
|
||||
|
||||
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 || '',
|
||||
|
||||
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
|
||||
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
const localLb = await db.logbooks.get(logbookId)
|
||||
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
|
||||
const isShared = localLb?.isShared === 1
|
||||
const masterKey = getActiveMasterKey()
|
||||
|
||||
let key = await getLogbookKey(logbookId)
|
||||
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
|
||||
// Key works, return it
|
||||
return key
|
||||
} catch (err) {
|
||||
if (isShared) {
|
||||
throw new Error(
|
||||
'Shared logbook encryption key is missing or invalid. Please go online and refresh your logbooks.'
|
||||
)
|
||||
}
|
||||
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
|
||||
try {
|
||||
const parsed = JSON.parse(encryptedTitle)
|
||||
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
|
||||
|
||||
// If no logbook key exists yet
|
||||
if (!key) {
|
||||
if (isShared) {
|
||||
throw new Error(
|
||||
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
|
||||
)
|
||||
}
|
||||
|
||||
if (encryptedTitle && masterKey) {
|
||||
try {
|
||||
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
|
||||
|
||||
@@ -124,11 +124,22 @@ function buildEncryptedPayload(
|
||||
})
|
||||
|
||||
const clear = options.clearSignatures
|
||||
return {
|
||||
const entryData: Record<string, unknown> = {
|
||||
...payload,
|
||||
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
||||
signCrew: clear ? '' : (data.signCrew ?? '')
|
||||
}
|
||||
|
||||
const summary = typeof data.aiSummary === 'string' ? data.aiSummary.trim() : ''
|
||||
if (summary) {
|
||||
entryData.aiSummary = summary
|
||||
entryData.aiSummaryGeneratedAt =
|
||||
typeof data.aiSummaryGeneratedAt === 'string' && data.aiSummaryGeneratedAt
|
||||
? data.aiSummaryGeneratedAt
|
||||
: new Date().toISOString()
|
||||
}
|
||||
|
||||
return entryData
|
||||
}
|
||||
|
||||
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
|
||||
|
||||
@@ -44,6 +44,17 @@ describe('fetchOpenWeatherCurrent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('throws OFFLINE when navigator.onLine is false', async () => {
|
||||
vi.stubGlobal('navigator', { ...navigator, onLine: false })
|
||||
|
||||
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(WeatherApiError)
|
||||
expect((err as InstanceType<typeof WeatherApiError>).code).toBe('OFFLINE')
|
||||
|
||||
expect(apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not track when the API request fails', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
} from './analytics.js'
|
||||
|
||||
export class WeatherApiError extends Error {
|
||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
|
||||
|
||||
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
||||
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
||||
super(message)
|
||||
this.name = 'WeatherApiError'
|
||||
this.code = code
|
||||
@@ -26,6 +26,10 @@ export async function fetchOpenWeatherCurrent(
|
||||
},
|
||||
options?: { analyticsSource: OwmAnalyticsSource }
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!navigator.onLine) {
|
||||
throw new WeatherApiError('Offline', 'OFFLINE')
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params.lat && params.lon) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,8 @@ services:
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
||||
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
||||
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||
|
||||
@@ -30,8 +30,9 @@ proxy_set_header X-Real-IP $remote_addr;
|
||||
ORIGIN=https://kapteins-daagbok.eu
|
||||
RP_ID=kapteins-daagbok.eu
|
||||
SESSION_SECRET=<min. 32 Zeichen, openssl rand -base64 48>
|
||||
TRUST_PROXY=172.16.10.10
|
||||
# oder TRUST_PROXY=1 für genau einen Proxy-Hop
|
||||
# Docker Compose: Frontend-Nginx ist der direkte Proxy zum Backend → 1 Hop
|
||||
TRUST_PROXY=1
|
||||
# Nur bei direktem Backend-Zugriff ohne Frontend-Nginx: NPM-IP, z. B. TRUST_PROXY=172.16.10.10
|
||||
```
|
||||
|
||||
`ORIGIN` muss **exakt** der Browser-URL entsprechen (ohne trailing slash).
|
||||
|
||||
@@ -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` |
|
||||
| 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) |
|
||||
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
|
||||
| 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` |
|
||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
|
||||
@@ -34,7 +34,7 @@ if ! grep -q "^POSTGRES_PASSWORD=" "$ENV_FILE" || grep -q "^POSTGRES_PASSWORD=$"
|
||||
else
|
||||
echo " keep POSTGRES_PASSWORD (already set)"
|
||||
fi
|
||||
# NPM on 172.16.10.10 → app on this host
|
||||
ensure_var TRUST_PROXY "172.16.10.10"
|
||||
# Frontend-Nginx → Backend (one hop); NPM is in front of Nginx, not Backend directly
|
||||
ensure_var TRUST_PROXY "1"
|
||||
|
||||
echo "Done. Verify with: docker exec daagbox-prod-db psql -U postgres -d daagbox -c 'SELECT 1'"
|
||||
|
||||
@@ -252,3 +252,14 @@ model GpsTrackPayload {
|
||||
|
||||
@@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])
|
||||
}
|
||||
|
||||
@@ -45,4 +45,18 @@ describe('API smoke', () => {
|
||||
expect(res.status).toBe(400)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
+44
-2
@@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js'
|
||||
import signRouter from './routes/sign.js'
|
||||
import pushRouter from './routes/push.js'
|
||||
import weatherRouter from './routes/weather.js'
|
||||
import aiRouter from './routes/ai.js'
|
||||
import feedbackRouter from './routes/feedback.js'
|
||||
import { prisma } from './db.js'
|
||||
import { buildCorsOptions } from './cors.js'
|
||||
@@ -45,13 +46,45 @@ export function createApp(): express.Express {
|
||||
app.use(cookieParser())
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
/** WebAuthn login/register/session — strict per IP; excludes high-volume sync routes. */
|
||||
const authFlowPaths = new Set([
|
||||
'/register-options',
|
||||
'/register-verify',
|
||||
'/login-options',
|
||||
'/login-verify',
|
||||
'/reauth-options',
|
||||
'/reauth-verify',
|
||||
'/logout',
|
||||
'/session'
|
||||
])
|
||||
|
||||
/** Account/key/credential mutations — also strict; separate bucket from login flow. */
|
||||
const sensitiveAuthExactPaths = new Set([
|
||||
'/delete-account',
|
||||
'/enroll-prf',
|
||||
'/rotate-recovery',
|
||||
'/add-credential-options',
|
||||
'/add-credential-verify'
|
||||
])
|
||||
|
||||
function isSensitiveAuthPath(path: string): boolean {
|
||||
return sensitiveAuthExactPaths.has(path) || path.startsWith('/credentials/')
|
||||
}
|
||||
|
||||
const authFlowLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
const sensitiveAuthLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000,
|
||||
max: 300,
|
||||
@@ -66,7 +99,15 @@ export function createApp(): express.Express {
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
app.use('/api/auth', authLimiter)
|
||||
app.use('/api/auth', (req, res, next) => {
|
||||
if (authFlowPaths.has(req.path)) {
|
||||
return authFlowLimiter(req, res, next)
|
||||
}
|
||||
if (isSensitiveAuthPath(req.path)) {
|
||||
return sensitiveAuthLimiter(req, res, next)
|
||||
}
|
||||
return next()
|
||||
})
|
||||
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
|
||||
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
|
||||
app.use('/api', apiLimiter)
|
||||
@@ -78,6 +119,7 @@ export function createApp(): express.Express {
|
||||
app.use('/api/sign', signRouter)
|
||||
app.use('/api/push', pushRouter)
|
||||
app.use('/api/weather', weatherRouter)
|
||||
app.use('/api/ai', aiRouter)
|
||||
app.use('/api/feedback', feedbackRouter)
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
|
||||
@@ -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 2–4 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
|
||||
Reference in New Issue
Block a user