diff --git a/client/src/App.css b/client/src/App.css index df63536..40ec92c 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -3068,6 +3068,50 @@ html.theme-cupertino .events-scroll-container { } } +.tank-liter-input .tank-liter-slider { + width: 100%; + margin: 4px 0 2px; + accent-color: #4ade80; + cursor: pointer; +} + +.tank-liter-input .tank-liter-slider:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tank-liter-input .tank-liter-slider-hint { + font-size: 0.75rem; + color: #94a3b8; + margin-bottom: 4px; + text-align: center; +} + +.vessel-tanks-section { + grid-column: 1 / -1; + margin-top: 8px; + padding-top: 16px; + border-top: 1px solid rgba(148, 163, 184, 0.2); +} + +.vessel-tanks-section h3 { + font-size: 1rem; + margin: 0 0 4px; + color: #e2e8f0; +} + +.vessel-tanks-help { + font-size: 0.85rem; + color: #94a3b8; + margin: 0 0 12px; +} + +.vessel-tanks-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + /* GPS Track Upload & Map Styling */ .track-upload-zone { display: flex; diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 69df5a5..591f435 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -42,6 +42,8 @@ import { import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js' import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js' import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx' +import TankLiterInput from './TankLiterInput.tsx' +import { extractTankCapacitiesFromYacht, type VesselTankCapacities } from '../utils/tankCapacity.js' function emptyTankLevels() { return { morning: 0, refilled: 0, evening: 0, consumption: 0 } @@ -50,6 +52,7 @@ function emptyTankLevels() { function fingerprintFromStoredEntry(decrypted: Record): string { const fw = (decrypted.freshwater as Record | undefined) ?? emptyTankLevels() const fuel = (decrypted.fuel as Record | undefined) ?? emptyTankLevels() + const gw = decrypted.greywater as { level?: number } | undefined const trackDistance = decrypted.trackDistanceNm const trackSpeedMax = decrypted.trackSpeedMaxKn const trackSpeedAvg = decrypted.trackSpeedAvgKn @@ -72,6 +75,7 @@ function fingerprintFromStoredEntry(decrypted: Record): string evening: fuel.evening || 0, consumption: fuel.consumption ?? 0 }, + greywater: gw ? { level: gw.level || 0 } : undefined, trackDistanceNm: trackDistance != null && trackDistance !== '' ? parseFloat(String(trackDistance)) @@ -145,6 +149,9 @@ export default function LogEntryEditor({ const [fuelEvening, setFuelEvening] = useState('0') const [fuelConsumption, setFuelConsumption] = useState('0') + const [greywaterLevel, setGreywaterLevel] = useState('0') + const [tankCapacities, setTankCapacities] = useState({}) + // Signatures const [signSkipper, setSignSkipper] = useState('') const [signCrew, setSignCrew] = useState('') @@ -249,6 +256,7 @@ export default function LogEntryEditor({ evening: parseFloat(fuelEvening) || 0, consumption: parseFloat(fuelConsumption) || 0 }, + greywater: { level: parseFloat(greywaterLevel) || 0 }, trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined, trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, @@ -259,6 +267,7 @@ export default function LogEntryEditor({ date, dayOfTravel, departure, destination, fwMorning, fwRefilled, fwEvening, fwConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, + greywaterLevel, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours, events ]) @@ -268,6 +277,8 @@ export default function LogEntryEditor({ [fuelConsumption, motorHours] ) + const tankCapacityTooltip = t('logs.tank_capacity_tooltip') + const currentFingerprint = useMemo(() => { const payload = buildPayloadForSigning() return JSON.stringify({ @@ -527,11 +538,12 @@ export default function LogEntryEditor({ setFuelConsumption(cons >= 0 ? String(cons) : '0') }, [fuelMorning, fuelRefilled, fuelEvening]) - // Load Yacht Sails + // Load yacht sails and tank capacities useEffect(() => { - async function loadYachtSails() { - if (readOnly && preloadedYacht?.sails) { - setYachtSails(preloadedYacht.sails) + async function loadYachtMeta() { + if (readOnly && preloadedYacht) { + if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails) + setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht)) return } try { @@ -541,16 +553,19 @@ export default function LogEntryEditor({ const yacht = await db.yachts.get(logbookId) if (yacht) { const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey) - if (decrypted && decrypted.sails && Array.isArray(decrypted.sails)) { - setYachtSails(decrypted.sails) + if (decrypted) { + if (decrypted.sails && Array.isArray(decrypted.sails)) { + setYachtSails(decrypted.sails) + } + setTankCapacities(extractTankCapacitiesFromYacht(decrypted)) } } } catch (err) { - console.error('Failed to load yacht sails in editor:', err) + console.error('Failed to load yacht meta in editor:', err) } } - loadYachtSails() - }, [logbookId, preloadedYacht]) + loadYachtMeta() + }, [logbookId, preloadedYacht, readOnly]) // Load entry details useEffect(() => { @@ -580,6 +595,11 @@ export default function LogEntryEditor({ setFuelEvening(String(preloadedEntry.fuel.evening || 0)) setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0)) } + if (preloadedEntry.greywater) { + setGreywaterLevel(String(preloadedEntry.greywater.level || 0)) + } else { + setGreywaterLevel('0') + } setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') @@ -613,6 +633,11 @@ export default function LogEntryEditor({ setFuelEvening(String(decrypted.fuel.evening || 0)) setFuelConsumption(String(decrypted.fuel.consumption ?? 0)) } + if (decrypted.greywater) { + setGreywaterLevel(String(decrypted.greywater.level || 0)) + } else { + setGreywaterLevel('0') + } setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') setSignCrew(normalizeSignature(decrypted.signCrew) || '') @@ -1210,41 +1235,35 @@ export default function LogEntryEditor({

{t('logs.freshwater')}

+ + +
- - setFwMorning(e.target.value)} - disabled={saving || readOnly} - /> -
- -
- - setFwRefilled(e.target.value)} - disabled={saving || readOnly} - /> -
- -
- - setFwEvening(e.target.value)} - disabled={saving || readOnly} - /> -
- -
- +
@@ -1264,41 +1284,35 @@ export default function LogEntryEditor({

{t('logs.fuel')}

+ + +
- - setFuelMorning(e.target.value)} - disabled={saving || readOnly} - /> -
- -
- - setFuelRefilled(e.target.value)} - disabled={saving || readOnly} - /> -
- -
- - setFuelEvening(e.target.value)} - disabled={saving || readOnly} - /> -
- -
- +
- +
+ + {/* Greywater card */} +
+
+ +

{t('logs.greywater')}

+
+
+ +
+
{/* Section 3: Event Journal Entries */} diff --git a/client/src/components/TankLiterInput.tsx b/client/src/components/TankLiterInput.tsx new file mode 100644 index 0000000..f5c717b --- /dev/null +++ b/client/src/components/TankLiterInput.tsx @@ -0,0 +1,103 @@ +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { clampTankLiters } from '../utils/tankCapacity.js' + +interface TankLiterInputProps { + id?: string + label: string + value: string + onChange: (value: string) => void + maxLiters?: number + disabled?: boolean + titleTooltip?: string +} + +function parseInputLiters(value: string): number { + const trimmed = value.trim().replace(',', '.') + if (!trimmed) return 0 + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : 0 +} + +export default function TankLiterInput({ + id, + label, + value, + onChange, + maxLiters, + disabled = false, + titleTooltip +}: TankLiterInputProps) { + const { t } = useTranslation() + const useSlider = maxLiters != null && maxLiters > 0 + + const emitValue = useCallback( + (liters: number) => { + const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined) + const str = + Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1))) + onChange(str) + }, + [onChange, maxLiters, useSlider] + ) + + const handleNumberChange = (e: React.ChangeEvent) => { + onChange(e.target.value) + } + + const handleNumberBlur = () => { + if (!useSlider) return + emitValue(parseInputLiters(value)) + } + + const handleSliderChange = (e: React.ChangeEvent) => { + emitValue(Number(e.target.value)) + } + + const numericValue = parseInputLiters(value) + const sliderValue = useSlider ? clampTankLiters(numericValue, maxLiters) : 0 + + return ( +
+ + {useSlider && ( + <> + + + + )} + +
+ ) +} diff --git a/client/src/components/VesselForm.tsx b/client/src/components/VesselForm.tsx index 88d9ef7..85c6922 100644 --- a/client/src/components/VesselForm.tsx +++ b/client/src/components/VesselForm.tsx @@ -7,6 +7,7 @@ import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react' +import { parseOptionalTankLiters, tankCapacityInputFromStored } from '../utils/tankCapacity.js' interface VesselFormProps { logbookId: string @@ -47,6 +48,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData const [mmsi, setMmsi] = useState('') const [sails, setSails] = useState([]) const [newSailName, setNewSailName] = useState('') + const [freshwaterCapacityL, setFreshwaterCapacityL] = useState('') + const [fuelCapacityL, setFuelCapacityL] = useState('') + const [greywaterCapacityL, setGreywaterCapacityL] = useState('') const fileInputRef = React.useRef(null) const [photo, setPhoto] = useState(null) @@ -78,6 +82,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData setMmsi(preloadedData.mmsi || '') setSails(preloadedData.sails || []) setPhoto(preloadedData.photo || null) + setFreshwaterCapacityL(tankCapacityInputFromStored(preloadedData.freshwaterCapacityL)) + setFuelCapacityL(tankCapacityInputFromStored(preloadedData.fuelCapacityL)) + setGreywaterCapacityL(tankCapacityInputFromStored(preloadedData.greywaterCapacityL)) return } @@ -103,6 +110,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData setMmsi(decrypted.mmsi || '') setSails(decrypted.sails || []) setPhoto(decrypted.photo || null) + setFreshwaterCapacityL(tankCapacityInputFromStored(decrypted.freshwaterCapacityL)) + setFuelCapacityL(tankCapacityInputFromStored(decrypted.fuelCapacityL)) + setGreywaterCapacityL(tankCapacityInputFromStored(decrypted.greywaterCapacityL)) } } } catch (err: any) { @@ -201,12 +211,19 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData let parsedLengthM: number | undefined let parsedDraftM: number | undefined let parsedAirDraftM: number | undefined + let parsedFreshwaterCapacityL: number | undefined + let parsedFuelCapacityL: number | undefined + let parsedGreywaterCapacityL: number | undefined try { parsedLengthM = parseOptionalMetricMeters(lengthM) parsedDraftM = parseOptionalMetricMeters(draftM) parsedAirDraftM = parseOptionalMetricMeters(airDraftM) - } catch { - setError(t('vessel.invalid_metric')) + parsedFreshwaterCapacityL = parseOptionalTankLiters(freshwaterCapacityL) + parsedFuelCapacityL = parseOptionalTankLiters(fuelCapacityL) + parsedGreywaterCapacityL = parseOptionalTankLiters(greywaterCapacityL) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '' + setError(msg === 'invalid_tank_liters' ? t('vessel.invalid_tank_liters') : t('vessel.invalid_metric')) setSaving(false) return } @@ -217,6 +234,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData lengthM: parsedLengthM, draftM: parsedDraftM, airDraftM: parsedAirDraftM, + freshwaterCapacityL: parsedFreshwaterCapacityL, + fuelCapacityL: parsedFuelCapacityL, + greywaterCapacityL: parsedGreywaterCapacityL, homePort: homePort.trim(), charterCompany: charterCompany.trim(), owner: owner.trim(), @@ -480,6 +500,49 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData /> +
+

{t('vessel.tanks_section')}

+

{t('vessel.tanks_help')}

+
+
+ + setFreshwaterCapacityL(e.target.value)} + disabled={saving || readOnly} + placeholder="0" + /> +
+
+ + setFuelCapacityL(e.target.value)} + disabled={saving || readOnly} + placeholder="0" + /> +
+
+ + setGreywaterCapacityL(e.target.value)} + disabled={saving || readOnly} + placeholder="0" + /> +
+
+
+

{t('vessel.sails_list')}

{t('vessel.sails_help')}

diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 9671ba0..9fd63c1 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -117,7 +117,13 @@ "no_sails": "Keine Segel hinterlegt.", "photo_add": "Foto hinzufügen", "photo_change": "Foto ändern", - "photo_delete": "Foto löschen" + "photo_delete": "Foto löschen", + "tanks_section": "Tanks (Fassungsvermögen)", + "tanks_help": "Optional in Liter — ermöglicht Slider im Journal bei bekannten Tankgrößen.", + "freshwater_capacity_l": "Trinkwasser (Liter)", + "fuel_capacity_l": "Treibstoff (Liter)", + "greywater_capacity_l": "Grauwasser (Liter)", + "invalid_tank_liters": "Ungültiger Zahlenwert — bitte Liter als Zahl eingeben (z. B. 200)." }, "logs": { "title": "Logbuch-Journal", @@ -138,6 +144,10 @@ "route": "Reise von/nach", "freshwater": "Frischwasser (Liter)", "fuel": "Treibstoff / Fuel (Liter)", + "greywater": "Grauwasser (Liter)", + "greywater_level": "Füllstand", + "tank_slider_of_max": "{{current}} / {{max}} L", + "tank_capacity_tooltip": "Wenn in den Schiffsdaten die Tank-Fassungsvermögen (Liter) hinterlegt sind, kannst du Füllstände hier per Slider eingeben.", "morning": "Stand morgens", "refilled": "Nachgefüllt", "evening": "Stand abends", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 322c8f3..677750a 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -117,7 +117,13 @@ "no_sails": "No sails defined.", "photo_add": "Add Photo", "photo_change": "Change Photo", - "photo_delete": "Delete Photo" + "photo_delete": "Delete Photo", + "tanks_section": "Tanks (capacity)", + "tanks_help": "Optional, in liters — enables sliders in the journal when tank sizes are known.", + "freshwater_capacity_l": "Freshwater (liters)", + "fuel_capacity_l": "Fuel (liters)", + "greywater_capacity_l": "Greywater (liters)", + "invalid_tank_liters": "Invalid number — please enter capacity in liters (e.g. 200)." }, "logs": { "title": "Logbook Journal", @@ -138,6 +144,10 @@ "route": "Route / Journey", "freshwater": "Freshwater (Liters)", "fuel": "Fuel (Liters)", + "greywater": "Greywater (Liters)", + "greywater_level": "Fill level", + "tank_slider_of_max": "{{current}} / {{max}} L", + "tank_capacity_tooltip": "If tank capacities (liters) are set in vessel master data, you can enter fill levels here using sliders.", "morning": "Morning Level", "refilled": "Refilled", "evening": "Evening Level", diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts index 8ca380e..b4332ee 100644 --- a/client/src/services/csvExport.ts +++ b/client/src/services/csvExport.ts @@ -88,6 +88,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya 'Latitude', 'Longitude', 'Remarks', 'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)', 'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)', + 'Greywater Level (L)', 'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI' ]; @@ -123,6 +124,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya const fuelR = entry.fuel?.refilled ?? ''; const fuelE = entry.fuel?.evening ?? ''; const fuelCons = entry.fuel?.consumption ?? ''; + const greywaterLevel = entry.greywater?.level ?? ''; const eventsList = entry.events || []; if (eventsList.length === 0) { @@ -137,6 +139,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya '', '', '', fwM, fwR, fwE, fwCons, fuelM, fuelR, fuelE, fuelCons, + greywaterLevel, yachtName, homePort, owner, charter, registration, callsign, atis, mmsi ].map(escapeCsvValue)); } else { @@ -153,6 +156,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '', fwM, fwR, fwE, fwCons, fuelM, fuelR, fuelE, fuelCons, + greywaterLevel, yachtName, homePort, owner, charter, registration, callsign, atis, mmsi ].map(escapeCsvValue)); } diff --git a/client/src/services/demoLogbookData.ts b/client/src/services/demoLogbookData.ts index 52001d1..036c838 100644 --- a/client/src/services/demoLogbookData.ts +++ b/client/src/services/demoLogbookData.ts @@ -26,6 +26,7 @@ export interface DemoDaySpec { filename: string freshwater: { morning: number; refilled: number; evening: number; consumption: number } fuel: { morning: number; refilled: number; evening: number; consumption: number } + greywaterLevel?: number motorHours?: number events: Array> } @@ -69,6 +70,7 @@ export function buildDemoDays(): DemoDaySpec[] { filename: 'kiel-laboe.gpx', freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 }, fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 }, + greywaterLevel: 25, events: [ { time: '10:15', @@ -101,6 +103,7 @@ export function buildDemoDays(): DemoDaySpec[] { filename: 'laboe-damp.gpx', freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 }, fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 }, + greywaterLevel: 38, motorHours: 1.5, events: [ { @@ -134,6 +137,7 @@ export function buildDemoDays(): DemoDaySpec[] { filename: 'damp-schleimuende.gpx', freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 }, fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 }, + greywaterLevel: 52, events: [ { time: '08:30', @@ -176,7 +180,10 @@ export function buildDemoYachtData(): Record { atis: '', mmsi: '', sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'], - photo: null + photo: null, + freshwaterCapacityL: 200, + fuelCapacityL: 100, + greywaterCapacityL: 80 } } @@ -244,6 +251,10 @@ export function buildPublicDemoFixture(): PublicDemoFixture { events: day.events } + if (day.greywaterLevel != null && day.greywaterLevel > 0) { + entryPayload.greywater = { level: day.greywaterLevel } + } + if (stats) { entryPayload.trackDistanceNm = stats.distanceNm entryPayload.trackSpeedMaxKn = stats.speedMaxKn @@ -303,6 +314,10 @@ export function buildDemoEntryPayloads(): Array<{ events: day.events } + if (day.greywaterLevel != null && day.greywaterLevel > 0) { + entryPayload.greywater = { level: day.greywaterLevel } + } + if (stats) { entryPayload.trackDistanceNm = stats.distanceNm entryPayload.trackSpeedMaxKn = stats.speedMaxKn diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts index 3d5ce13..ec4c586 100644 --- a/client/src/services/pdfExport.ts +++ b/client/src/services/pdfExport.ts @@ -197,13 +197,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string, doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3); let fwY = footerY + 5; - doc.rect(10, fwY, 110, rowHeight * 3, 'S'); + const tankRows = 4; + doc.rect(10, fwY, 110, rowHeight * tankRows, 'S'); doc.line(10, fwY + rowHeight, 120, fwY + rowHeight); doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2); - doc.line(40, fwY, 40, fwY + rowHeight * 3); - doc.line(60, fwY, 60, fwY + rowHeight * 3); - doc.line(80, fwY, 80, fwY + rowHeight * 3); - doc.line(100, fwY, 100, fwY + rowHeight * 3); + doc.line(10, fwY + rowHeight * 3, 120, fwY + rowHeight * 3); + doc.line(40, fwY, 40, fwY + rowHeight * tankRows); + doc.line(60, fwY, 60, fwY + rowHeight * tankRows); + doc.line(80, fwY, 80, fwY + rowHeight * tankRows); + doc.line(100, fwY, 100, fwY + rowHeight * tankRows); doc.setFont('Helvetica', 'bold'); doc.setFontSize(7.5); @@ -226,6 +228,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string, doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2); doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2); + doc.text('Grauwasser', 11, fwY + rowHeight * 3 + 4.2); + doc.text('—', 41, fwY + rowHeight * 3 + 4.2); + doc.text('—', 61, fwY + rowHeight * 3 + 4.2); + doc.text(String(entry.greywater?.level ?? '0'), 81, fwY + rowHeight * 3 + 4.2); + doc.text('—', 101, fwY + rowHeight * 3 + 4.2); + // Signatures Box let sigX = 130; let sigY = footerY + 5; diff --git a/client/src/utils/logEntryPayload.test.ts b/client/src/utils/logEntryPayload.test.ts index 0d1249e..434372d 100644 --- a/client/src/utils/logEntryPayload.test.ts +++ b/client/src/utils/logEntryPayload.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + buildLogEntryPayload, hasUnsavedEventDraft, isLogEventDraftEmpty, normalizeLogEvent, @@ -40,3 +41,25 @@ describe('logEntryPayload event drafts', () => { expect(hasUnsavedEventDraft(filledDraft(), 0, events)).toBe(false) }) }) + +describe('buildLogEntryPayload greywater', () => { + const base = { + date: '2026-05-31', + dayOfTravel: '1', + departure: 'Kiel', + destination: 'Laboe', + freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 }, + fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 }, + events: [] as LogEventPayload[] + } + + it('includes greywater when level > 0', () => { + const payload = buildLogEntryPayload({ ...base, greywater: { level: 45 } }) + expect(payload.greywater).toEqual({ level: 45 }) + }) + + it('omits greywater when level is 0', () => { + const payload = buildLogEntryPayload({ ...base, greywater: { level: 0 } }) + expect(payload.greywater).toBeUndefined() + }) +}) diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index 38e3e17..f004af4 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -144,6 +144,7 @@ export interface LogEntryPayloadInput { destination: string freshwater: { morning: number; refilled: number; evening: number; consumption: number } fuel: { morning: number; refilled: number; evening: number; consumption: number } + greywater?: { level: number } trackDistanceNm?: number trackSpeedMaxKn?: number trackSpeedAvgKn?: number @@ -169,5 +170,12 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record 0) { + payload.greywater = { level: Number(level.toFixed(1)) } + } + } + return payload } diff --git a/client/src/utils/logEntryTankLevels.ts b/client/src/utils/logEntryTankLevels.ts index 618b2c2..77575a1 100644 --- a/client/src/utils/logEntryTankLevels.ts +++ b/client/src/utils/logEntryTankLevels.ts @@ -41,6 +41,7 @@ export function getClosingTankLevel(tank?: Partial | null): number { export interface LogEntryTankSource { freshwater?: Partial fuel?: Partial + greywater?: { level?: number } destination?: string } diff --git a/client/src/utils/tankCapacity.test.ts b/client/src/utils/tankCapacity.test.ts new file mode 100644 index 0000000..c0705d9 --- /dev/null +++ b/client/src/utils/tankCapacity.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + clampTankLiters, + extractTankCapacitiesFromYacht, + formatTankLitersForInput, + parseOptionalTankLiters, + tankCapacityInputFromStored +} from './tankCapacity.js' + +describe('tankCapacity', () => { + it('parses optional liters with comma decimal', () => { + expect(parseOptionalTankLiters('200')).toBe(200) + expect(parseOptionalTankLiters('12,5')).toBe(12.5) + expect(parseOptionalTankLiters('')).toBeUndefined() + }) + + it('rejects negative or invalid liters', () => { + expect(() => parseOptionalTankLiters('-1')).toThrow('invalid_tank_liters') + expect(() => parseOptionalTankLiters('abc')).toThrow('invalid_tank_liters') + }) + + it('extracts capacities from yacht payload', () => { + expect( + extractTankCapacitiesFromYacht({ + freshwaterCapacityL: 300, + fuelCapacityL: 120, + greywaterCapacityL: 80 + }) + ).toEqual({ + freshwaterCapacityL: 300, + fuelCapacityL: 120, + greywaterCapacityL: 80 + }) + expect(extractTankCapacitiesFromYacht({ name: 'Test' })).toEqual({}) + }) + + it('formats stored capacity for input', () => { + expect(tankCapacityInputFromStored(150)).toBe('150') + expect(formatTankLitersForInput(12.5)).toBe('12.5') + }) + + it('clamps liters to max when set', () => { + expect(clampTankLiters(250, 200)).toBe(200) + expect(clampTankLiters(-5, 200)).toBe(0) + expect(clampTankLiters(50)).toBe(50) + }) +}) diff --git a/client/src/utils/tankCapacity.ts b/client/src/utils/tankCapacity.ts new file mode 100644 index 0000000..81e0ee0 --- /dev/null +++ b/client/src/utils/tankCapacity.ts @@ -0,0 +1,60 @@ +import { formatTankLiters } from './logEntryTankLevels.js' + +export interface VesselTankCapacities { + freshwaterCapacityL?: number + fuelCapacityL?: number + greywaterCapacityL?: number +} + +export function parseOptionalTankLiters(input: string): number | undefined { + const trimmed = input.trim().replace(',', '.') + if (!trimmed) return undefined + const parsed = Number(trimmed) + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error('invalid_tank_liters') + } + return parsed +} + +export function formatTankLitersForInput(liters: number): string { + return formatTankLiters(liters) +} + +function capacityFromStored(value: unknown): number | undefined { + if (value == null || value === '') return undefined + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value + if (typeof value === 'string') { + const trimmed = value.trim().replace(',', '.') + if (!trimmed) return undefined + const parsed = Number(trimmed) + if (Number.isFinite(parsed) && parsed >= 0) return parsed + } + return undefined +} + +export function tankCapacityInputFromStored(value: unknown): string { + const n = capacityFromStored(value) + return n != null ? formatTankLitersForInput(n) : '' +} + +export function extractTankCapacitiesFromYacht(decrypted: unknown): VesselTankCapacities { + if (!decrypted || typeof decrypted !== 'object') return {} + const y = decrypted as Record + const capacities: VesselTankCapacities = {} + const fw = capacityFromStored(y.freshwaterCapacityL) + const fuel = capacityFromStored(y.fuelCapacityL) + const gw = capacityFromStored(y.greywaterCapacityL) + if (fw != null) capacities.freshwaterCapacityL = fw + if (fuel != null) capacities.fuelCapacityL = fuel + if (gw != null) capacities.greywaterCapacityL = gw + return capacities +} + +/** Clamp numeric liter value to [0, max] when max is known. */ +export function clampTankLiters(value: number, maxLiters?: number): number { + const clamped = Math.max(0, value) + if (maxLiters != null && maxLiters > 0) { + return Math.min(clamped, maxLiters) + } + return clamped +}