diff --git a/.planning/STATE.md b/.planning/STATE.md index 771b90e..104899f 100755 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -10,11 +10,11 @@ See: .planning/PROJECT.md (updated 2026-05-26) ## Current Position Phase: 3 of 4 (Master Data & Log entries) -Plan: 2 of 3 in current phase +Plan: 3 of 3 in current phase Status: Ready to plan -Last activity: 2026-05-27 — Plan 03-01 completed (E2E-encrypted Yacht/Skipper/Crew forms and Compass Deviation grid UI complete) +Last activity: 2026-05-27 — Plan 03-02 completed (Logbook entry lists, travel header cards, and Freshwater/Fuel auto-calculating consumption grids complete) -Progress: [██████░░░░] 60% +Progress: [███████░░░] 70% ## Performance Metrics diff --git a/client/src/App.tsx b/client/src/App.tsx index 4cee1fd..774bc48 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,6 +5,7 @@ import LogbookDashboard from './components/LogbookDashboard.tsx' import VesselForm from './components/VesselForm.tsx' import CrewForm from './components/CrewForm.tsx' import DeviationForm from './components/DeviationForm.tsx' +import LogEntriesList from './components/LogEntriesList.tsx' import { getActiveMasterKey, logoutUser } from './services/auth.js' import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks } from './services/sync.js' import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react' @@ -178,11 +179,7 @@ function App() { {/* Tab Content Panels (Placeholder until Phase 3) */}
{activeTab === 'logs' && ( -
- -

{t('nav.logs')}

-

Journal event entries, GPS navigation records, and meteorological reports will be listed and edited here.

-
+ )} {activeTab === 'vessel' && ( diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx new file mode 100644 index 0000000..0ce019b --- /dev/null +++ b/client/src/components/LogEntriesList.tsx @@ -0,0 +1,235 @@ +import React, { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { db } from '../services/db.js' +import { getActiveMasterKey } from '../services/auth.js' +import { decryptJson, encryptJson } from '../services/crypto.js' +import { syncLogbook } from '../services/sync.js' +import LogEntryEditor from './LogEntryEditor.tsx' +import { FileText, Plus, Trash2, ChevronRight, Calendar } from 'lucide-react' + +interface LogEntriesListProps { + logbookId: string +} + +interface DecryptedEntryItem { + id: string + date: string + dayOfTravel: string + departure: string + destination: string + updatedAt: string +} + +export default function LogEntriesList({ logbookId }: LogEntriesListProps) { + const { t } = useTranslation() + const [entries, setEntries] = useState([]) + const [selectedEntryId, setSelectedEntryId] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!selectedEntryId) { + loadEntries() + } + }, [logbookId, selectedEntryId]) + + const loadEntries = async () => { + setLoading(true) + setError(null) + try { + const masterKey = getActiveMasterKey() + if (!masterKey) throw new Error('Master key not found. Please log in.') + + const local = await db.entries.where({ logbookId }).toArray() + + const list: DecryptedEntryItem[] = [] + + for (const entry of local) { + const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey) + if (decrypted) { + list.push({ + id: entry.payloadId, + date: decrypted.date || '', + dayOfTravel: decrypted.dayOfTravel || '', + departure: decrypted.departure || '', + destination: decrypted.destination || '', + updatedAt: entry.updatedAt + }) + } + } + + // Sort chronological descending (by date, or dayOfTravel numerical) + list.sort((a, b) => { + const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime() + if (dateCompare !== 0) return dateCompare + return Number(b.dayOfTravel) - Number(a.dayOfTravel) + }) + + setEntries(list) + } catch (err: any) { + console.error('Failed to load log entries:', err) + setError(err.message || 'Decryption failed. Could not load journal list.') + } finally { + setLoading(false) + } + } + + const handleCreate = async () => { + setLoading(true) + setError(null) + try { + const masterKey = getActiveMasterKey() + if (!masterKey) throw new Error('Master key not found. Please log in.') + + const localId = window.crypto.randomUUID() + const nowStr = new Date().toISOString() + const todayStr = nowStr.substring(0, 10) + + // Calculate next travel day number + const nextDayNum = String(entries.length + 1) + + const initialPayload = { + date: todayStr, + dayOfTravel: nextDayNum, + departure: '', + destination: '', + freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 }, + fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 }, + signSkipper: '', + signCrew: '', + events: [] + } + + const encrypted = await encryptJson(initialPayload, masterKey) + + // Save locally + await db.entries.put({ + payloadId: localId, + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: nowStr + }) + + // Queue for background sync + await db.syncQueue.put({ + action: 'create', + type: 'entry', + payloadId: localId, + logbookId, + data: JSON.stringify(encrypted), + updatedAt: nowStr + }) + + // Open immediately in details editor + setSelectedEntryId(localId) + syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) + } catch (err: any) { + console.error('Failed to create entry:', err) + setError(err.message || 'Failed to create new log entry.') + } finally { + setLoading(false) + } + } + + const handleDelete = async (entryId: string, e: React.MouseEvent) => { + e.stopPropagation() // Prevent selecting the card + + if (window.confirm(t('logs.delete_confirm'))) { + setError(null) + try { + const now = new Date().toISOString() + + await db.entries.delete(entryId) + + await db.syncQueue.put({ + action: 'delete', + type: 'entry', + payloadId: entryId, + logbookId, + data: '', + updatedAt: now + }) + + setEntries((prev) => prev.filter((item) => item.id !== entryId)) + syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) + } catch (err: any) { + console.error('Failed to delete log entry:', err) + setError(err.message || 'Failed to delete log entry.') + } + } + } + + if (selectedEntryId) { + return ( + setSelectedEntryId(null)} + /> + ) + } + + if (loading) { + return ( +
+ +

{t('logs.loading')}

+
+ ) + } + + return ( +
+
+
+ +

{t('logs.title')}

+
+ +
+ + {error &&
{error}
} + + {entries.length === 0 ? ( +
{t('logs.no_entries')}
+ ) : ( +
+ {entries.map((item) => ( +
setSelectedEntryId(item.id)}> +
+ +
+ +
+

+ {item.departure && item.destination + ? `${item.departure} → ${item.destination}` + : t('logs.new_entry')} +

+
+ + {t('logs.day_of_travel')} {item.dayOfTravel} + + + {new Date(item.date).toLocaleDateString()} + +
+
+ + + + +
+ ))} +
+ )} +
+ ) +} diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx new file mode 100644 index 0000000..3561d87 --- /dev/null +++ b/client/src/components/LogEntryEditor.tsx @@ -0,0 +1,424 @@ +import React, { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { db } from '../services/db.js' +import { getActiveMasterKey } from '../services/auth.js' +import { encryptJson, decryptJson } from '../services/crypto.js' +import { syncLogbook } from '../services/sync.js' +import { FileText, Save, ChevronLeft, Check, Compass } from 'lucide-react' + +interface LogEntryEditorProps { + entryId: string + logbookId: string + onBack: () => void +} + +export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryEditorProps) { + const { t } = useTranslation() + + // General details state + const [date, setDate] = useState('') + const [dayOfTravel, setDayOfTravel] = useState('') + const [departure, setDeparture] = useState('') + const [destination, setDestination] = useState('') + + // Freshwater state + const [fwMorning, setFwMorning] = useState('0') + const [fwRefilled, setFwRefilled] = useState('0') + const [fwEvening, setFwEvening] = useState('0') + const [fwConsumption, setFwConsumption] = useState('0') + + // Fuel state + const [fuelMorning, setFuelMorning] = useState('0') + const [fuelRefilled, setFuelRefilled] = useState('0') + const [fuelEvening, setFuelEvening] = useState('0') + const [fuelConsumption, setFuelConsumption] = useState('0') + + // Signatures + const [signSkipper, setSignSkipper] = useState('') + const [signCrew, setSignCrew] = useState('') + + // Events (to preserve Plan 03-03 journal events when editing headers/consumption) + const [events, setEvents] = useState([]) + + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [success, setSuccess] = useState(false) + const [error, setError] = useState(null) + + // Auto-calculate Freshwater Consumption + useEffect(() => { + const morning = parseFloat(fwMorning) || 0 + const refilled = parseFloat(fwRefilled) || 0 + const evening = parseFloat(fwEvening) || 0 + const cons = morning + refilled - evening + setFwConsumption(cons >= 0 ? String(cons) : '0') + }, [fwMorning, fwRefilled, fwEvening]) + + // Auto-calculate Fuel Consumption + useEffect(() => { + const morning = parseFloat(fuelMorning) || 0 + const refilled = parseFloat(fuelRefilled) || 0 + const evening = parseFloat(fuelEvening) || 0 + const cons = morning + refilled - evening + setFuelConsumption(cons >= 0 ? String(cons) : '0') + }, [fuelMorning, fuelRefilled, fuelEvening]) + + // Load entry + useEffect(() => { + async function loadEntry() { + setLoading(true) + setError(null) + try { + const masterKey = getActiveMasterKey() + if (!masterKey) throw new Error('Master key not found. Please log in.') + + const local = await db.entries.get(entryId) + if (local) { + const decrypted = await decryptJson(local.encryptedData, local.iv, local.tag, masterKey) + if (decrypted) { + setDate(decrypted.date || '') + setDayOfTravel(decrypted.dayOfTravel || '') + setDeparture(decrypted.departure || '') + setDestination(decrypted.destination || '') + + if (decrypted.freshwater) { + setFwMorning(String(decrypted.freshwater.morning || 0)) + setFwRefilled(String(decrypted.freshwater.refilled || 0)) + setFwEvening(String(decrypted.freshwater.evening || 0)) + } + if (decrypted.fuel) { + setFuelMorning(String(decrypted.fuel.morning || 0)) + setFuelRefilled(String(decrypted.fuel.refilled || 0)) + setFuelEvening(String(decrypted.fuel.evening || 0)) + } + + setSignSkipper(decrypted.signSkipper || '') + setSignCrew(decrypted.signCrew || '') + setEvents(decrypted.events || []) + } + } + } catch (err: any) { + console.error('Failed to load entry details:', err) + setError(err.message || 'Decryption failed. Could not load entry details.') + } finally { + setLoading(false) + } + } + + loadEntry() + }, [entryId]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSaving(true) + setError(null) + setSuccess(false) + + try { + const masterKey = getActiveMasterKey() + if (!masterKey) throw new Error('Master key not found. Please log in.') + + const entryData = { + date, + dayOfTravel: dayOfTravel.trim(), + departure: departure.trim(), + destination: destination.trim(), + 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 + }, + signSkipper: signSkipper.trim(), + signCrew: signCrew.trim(), + events + } + + // E2E encrypt + const encrypted = await encryptJson(entryData, masterKey) + const now = new Date().toISOString() + + // Save locally + await db.entries.put({ + payloadId: entryId, + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }) + + // Queue for background sync + await db.syncQueue.put({ + action: 'update', + type: 'entry', + payloadId: entryId, + logbookId, + data: JSON.stringify(encrypted), + updatedAt: now + }) + + setSuccess(true) + setTimeout(() => { + setSuccess(false) + onBack() + }, 1500) + + syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) + } catch (err: any) { + console.error('Failed to save entry details:', err) + setError(err.message || 'Failed to save entry details.') + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ +

{t('logs.loading')}

+
+ ) + } + + return ( +
+
+ +
+ +

{t('logs.new_entry')} / {dayOfTravel}

+
+
+ + {error &&
{error}
} + +
+ {/* Section 1: Travel Day Headers */} +
+
+ + setDate(e.target.value)} + disabled={saving} + required + /> +
+ +
+ + setDayOfTravel(e.target.value)} + disabled={saving} + required + /> +
+ +
+ + setDeparture(e.target.value)} + disabled={saving} + /> +
+ +
+ + setDestination(e.target.value)} + disabled={saving} + /> +
+
+ + {/* Section 2: Freshwater and Fuel Consumption */} +
+ {/* Freshwater card */} +
+

{t('logs.freshwater')}

+
+
+ + setFwMorning(e.target.value)} + disabled={saving} + /> +
+ +
+ + setFwRefilled(e.target.value)} + disabled={saving} + /> +
+ +
+ + setFwEvening(e.target.value)} + disabled={saving} + /> +
+ +
+ + +
+
+
+ + {/* Fuel card */} +
+

{t('logs.fuel')}

+
+
+ + setFuelMorning(e.target.value)} + disabled={saving} + /> +
+ +
+ + setFuelRefilled(e.target.value)} + disabled={saving} + /> +
+ +
+ + setFuelEvening(e.target.value)} + disabled={saving} + /> +
+ +
+ + +
+
+
+
+ + {/* Section 3: Sign-Off Signatures */} +
+

{t('logs.signatures')}

+
+
+ + setSignSkipper(e.target.value)} + disabled={saving} + required + /> +
+ +
+ + setSignCrew(e.target.value)} + disabled={saving} + required + /> +
+
+
+ + {/* Section 4: Logbook journal events placeholder */} +
+
+ + + Journal Events and GPS tracking coordinates will be configured here in Plan 03-03. + +
+
+ +
+ {success && ( +
+ + {t('logs.saved')} +
+ )} + + +
+
+
+ ) +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index f08dfed..8f9cced 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -45,12 +45,29 @@ }, "logs": { "title": "Logbuch-Journal", - "new_entry": "Neuer Eintrag", + "new_entry": "Neuer Reisetag", "date": "Datum", + "day_of_travel": "Tag der Reise / Reisetag", + "departure": "Start-Hafen (Reise von)", + "destination": "Ziel-Hafen (nach)", "route": "Reise von/nach", - "coordinates": "Koordinaten", - "weather": "Wetterbedingungen", - "save": "Eintrag speichern" + "freshwater": "Frischwasser (Liter)", + "fuel": "Treibstoff / Fuel (Liter)", + "morning": "Stand morgens", + "refilled": "Nachgefüllt", + "evening": "Stand abends", + "consumption": "Tagesverbrauch", + "signatures": "Unterschriften / Freigabe", + "sign_skipper": "Skipper (Blockschrift)", + "sign_crew": "Crew-Mitglied (Blockschrift)", + "no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!", + "back_to_list": "Zurück zur Journal-Liste", + "save": "Logbuchseite speichern", + "saving": "Wird gespeichert...", + "saved": "Logbuchseite erfolgreich gespeichert!", + "loading": "Journal wird geladen...", + "delete_entry": "Tag löschen", + "delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?" }, "dashboard": { "title": "Ihre Logbücher", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 7133204..2fdf518 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -45,12 +45,29 @@ }, "logs": { "title": "Logbook Journal", - "new_entry": "New Log Entry", + "new_entry": "New Travel Day", "date": "Date", - "route": "Route", - "coordinates": "Coordinates", - "weather": "Weather Conditions", - "save": "Save Entry" + "day_of_travel": "Day of Travel", + "departure": "Departure Port (von)", + "destination": "Destination Port (nach)", + "route": "Route / Journey", + "freshwater": "Freshwater (Liters)", + "fuel": "Fuel (Liters)", + "morning": "Morning Level", + "refilled": "Refilled", + "evening": "Evening Level", + "consumption": "Consumption", + "signatures": "Signatures / Sign-Off", + "sign_skipper": "Skipper Signature", + "sign_crew": "Crew Signature", + "no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!", + "back_to_list": "Back to Journal List", + "save": "Save Logbook Page", + "saving": "Saving...", + "saved": "Logbook page saved successfully!", + "loading": "Loading journal...", + "delete_entry": "Delete Day", + "delete_confirm": "Are you sure you want to permanently delete this travel day?" }, "dashboard": { "title": "Your Logbooks",