feat & docs: implement daily logbook entry list and consumption grids

This commit is contained in:
2026-05-27 21:57:23 +02:00
parent 9ce60f21ce
commit a8e2f8bb11
6 changed files with 707 additions and 17 deletions
+3 -3
View File
@@ -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
+2 -5
View File
@@ -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) */}
<main className="app-content">
{activeTab === 'logs' && (
<div className="tab-placeholder">
<FileText size={48} className="header-logo" />
<h3>{t('nav.logs')}</h3>
<p>Journal event entries, GPS navigation records, and meteorological reports will be listed and edited here.</p>
</div>
<LogEntriesList logbookId={activeLogbookId} />
)}
{activeTab === 'vessel' && (
+235
View File
@@ -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<DecryptedEntryItem[]>([])
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<LogEntryEditor
entryId={selectedEntryId}
logbookId={logbookId}
onBack={() => setSelectedEntryId(null)}
/>
)
}
if (loading) {
return (
<div className="tab-placeholder">
<FileText className="header-logo spin" size={48} />
<p>{t('logs.loading')}</p>
</div>
)
}
return (
<div className="form-card">
<div className="section-title-bar mb-6">
<div className="form-header" style={{ margin: 0 }}>
<Calendar size={24} className="form-icon" />
<h2>{t('logs.title')}</h2>
</div>
<button className="btn primary" onClick={handleCreate} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} />
{t('logs.new_entry')}
</button>
</div>
{error && <div className="auth-error mb-4">{error}</div>}
{entries.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
) : (
<div className="logbooks-grid">
{entries.map((item) => (
<div key={item.id} className="logbook-card glass" onClick={() => setSelectedEntryId(item.id)}>
<div className="card-icon">
<FileText size={24} />
</div>
<div className="card-info">
<h3 style={{ textTransform: 'capitalize' }}>
{item.departure && item.destination
? `${item.departure}${item.destination}`
: t('logs.new_entry')}
</h3>
<div className="card-meta">
<span className="sync-badge synced">
{t('logs.day_of_travel')} {item.dayOfTravel}
</span>
<span className="date-badge">
{new Date(item.date).toLocaleDateString()}
</span>
</div>
</div>
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} />
</button>
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
</div>
))}
</div>
)}
</div>
)
}
+424
View File
@@ -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<any[]>([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="tab-placeholder">
<FileText className="header-logo spin" size={48} />
<p>{t('logs.loading')}</p>
</div>
)
}
return (
<div className="form-card">
<div className="section-title-bar mb-4">
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}>
<ChevronLeft size={16} />
{t('logs.back_to_list')}
</button>
<div className="form-header" style={{ margin: 0 }}>
<FileText size={24} className="form-icon" />
<h2>{t('logs.new_entry')} / {dayOfTravel}</h2>
</div>
</div>
{error && <div className="auth-error mb-4">{error}</div>}
<form onSubmit={handleSubmit} className="vessel-form">
{/* Section 1: Travel Day Headers */}
<div className="form-grid">
<div className="input-group">
<label>{t('logs.date')}</label>
<input
type="date"
className="input-text"
value={date}
onChange={(e) => setDate(e.target.value)}
disabled={saving}
required
/>
</div>
<div className="input-group">
<label>{t('logs.day_of_travel')} *</label>
<input
type="text"
className="input-text"
placeholder="e.g. 1"
value={dayOfTravel}
onChange={(e) => setDayOfTravel(e.target.value)}
disabled={saving}
required
/>
</div>
<div className="input-group">
<label>{t('logs.departure')}</label>
<input
type="text"
className="input-text"
value={departure}
onChange={(e) => setDeparture(e.target.value)}
disabled={saving}
/>
</div>
<div className="input-group">
<label>{t('logs.destination')}</label>
<input
type="text"
className="input-text"
value={destination}
onChange={(e) => setDestination(e.target.value)}
disabled={saving}
/>
</div>
</div>
{/* Section 2: Freshwater and Fuel Consumption */}
<div className="form-grid mt-4">
{/* Freshwater card */}
<div className="member-editor-card glass">
<h3 style={{ marginTop: 0, marginBottom: '16px', color: '#fbbf24' }}>{t('logs.freshwater')}</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div className="input-group">
<label>{t('logs.morning')} (L)</label>
<input
type="number"
step="any"
className="input-text"
value={fwMorning}
onChange={(e) => setFwMorning(e.target.value)}
disabled={saving}
/>
</div>
<div className="input-group">
<label>{t('logs.refilled')} (L)</label>
<input
type="number"
step="any"
className="input-text"
value={fwRefilled}
onChange={(e) => setFwRefilled(e.target.value)}
disabled={saving}
/>
</div>
<div className="input-group">
<label>{t('logs.evening')} (L)</label>
<input
type="number"
step="any"
className="input-text"
value={fwEvening}
onChange={(e) => setFwEvening(e.target.value)}
disabled={saving}
/>
</div>
<div className="input-group">
<label>{t('logs.consumption')} (L)</label>
<input
type="text"
className="input-text cell-input"
style={{ color: '#4ade80', fontWeight: 'bold' }}
value={fwConsumption}
disabled
/>
</div>
</div>
</div>
{/* Fuel card */}
<div className="member-editor-card glass">
<h3 style={{ marginTop: 0, marginBottom: '16px', color: '#fbbf24' }}>{t('logs.fuel')}</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div className="input-group">
<label>{t('logs.morning')} (L)</label>
<input
type="number"
step="any"
className="input-text"
value={fuelMorning}
onChange={(e) => setFuelMorning(e.target.value)}
disabled={saving}
/>
</div>
<div className="input-group">
<label>{t('logs.refilled')} (L)</label>
<input
type="number"
step="any"
className="input-text"
value={fuelRefilled}
onChange={(e) => setFuelRefilled(e.target.value)}
disabled={saving}
/>
</div>
<div className="input-group">
<label>{t('logs.evening')} (L)</label>
<input
type="number"
step="any"
className="input-text"
value={fuelEvening}
onChange={(e) => setFuelEvening(e.target.value)}
disabled={saving}
/>
</div>
<div className="input-group">
<label>{t('logs.consumption')} (L)</label>
<input
type="text"
className="input-text cell-input"
style={{ color: '#4ade80', fontWeight: 'bold' }}
value={fuelConsumption}
disabled
/>
</div>
</div>
</div>
</div>
{/* Section 3: Sign-Off Signatures */}
<div className="member-editor-card glass mt-4">
<h3 style={{ marginTop: 0, marginBottom: '16px', color: '#fbbf24' }}>{t('logs.signatures')}</h3>
<div className="form-grid">
<div className="input-group">
<label>{t('logs.sign_skipper')} *</label>
<input
type="text"
placeholder="e.g. MARKUS SKIPPER"
className="input-text"
value={signSkipper}
onChange={(e) => setSignSkipper(e.target.value)}
disabled={saving}
required
/>
</div>
<div className="input-group">
<label>{t('logs.sign_crew')} *</label>
<input
type="text"
placeholder="e.g. JAN MATE"
className="input-text"
value={signCrew}
onChange={(e) => setSignCrew(e.target.value)}
disabled={saving}
required
/>
</div>
</div>
</div>
{/* Section 4: Logbook journal events placeholder */}
<div className="member-editor-card glass mt-4" style={{ borderStyle: 'dashed' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#94a3b8' }}>
<Compass size={18} />
<span style={{ fontSize: '13.5px', fontWeight: 500 }}>
Journal Events and GPS tracking coordinates will be configured here in Plan 03-03.
</span>
</div>
</div>
<div className="form-actions mt-4">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('logs.saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim() || !signSkipper.trim() || !signCrew.trim()}>
<Save size={18} />
{saving ? t('logs.saving') : t('logs.save')}
</button>
</div>
</form>
</div>
)
}
+21 -4
View File
@@ -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",
+22 -5
View File
@@ -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",