feat: Tankkapazitäten, Grauwasser und Slider im Journal

Schiffsdaten speichern optionale Tankvolumina; Reisetage erfassen Grauwasser-Füllstand und nutzen Slider bei bekannter Kapazität, inkl. Tooltips und CSV/PDF-Export.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 13:33:40 +02:00
parent 6a61c9e06c
commit 25e1bdded3
14 changed files with 519 additions and 88 deletions
+44
View File
@@ -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;
+113 -78
View File
@@ -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, unknown>): string {
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
const fuel = (decrypted.fuel as Record<string, number> | 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, unknown>): 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<VesselTankCapacities>({})
// Signatures
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
@@ -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({
<h3>{t('logs.freshwater')}</h3>
</div>
<div className="consumption-grid">
<TankLiterInput
id="fw-morning"
label={t('logs.morning')}
value={fwMorning}
onChange={setFwMorning}
maxLiters={tankCapacities.freshwaterCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<TankLiterInput
id="fw-refilled"
label={t('logs.refilled')}
value={fwRefilled}
onChange={setFwRefilled}
maxLiters={tankCapacities.freshwaterCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<TankLiterInput
id="fw-evening"
label={t('logs.evening')}
value={fwEvening}
onChange={setFwEvening}
maxLiters={tankCapacities.freshwaterCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<div className="input-group">
<label>{t('logs.morning')}</label>
<input
type="number"
className="input-text"
value={fwMorning}
onChange={(e) => setFwMorning(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.refilled')}</label>
<input
type="number"
className="input-text"
value={fwRefilled}
onChange={(e) => setFwRefilled(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.evening')}</label>
<input
type="number"
className="input-text"
value={fwEvening}
onChange={(e) => setFwEvening(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.consumption')} (L)</label>
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
<input
type="number"
className="input-text consumption-value"
@@ -1252,6 +1271,7 @@ export default function LogEntryEditor({
readOnly
tabIndex={-1}
aria-readonly="true"
title={tankCapacityTooltip}
/>
</div>
</div>
@@ -1264,41 +1284,35 @@ export default function LogEntryEditor({
<h3>{t('logs.fuel')}</h3>
</div>
<div className="consumption-grid">
<TankLiterInput
id="fuel-morning"
label={t('logs.morning')}
value={fuelMorning}
onChange={setFuelMorning}
maxLiters={tankCapacities.fuelCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<TankLiterInput
id="fuel-refilled"
label={t('logs.refilled')}
value={fuelRefilled}
onChange={setFuelRefilled}
maxLiters={tankCapacities.fuelCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<TankLiterInput
id="fuel-evening"
label={t('logs.evening')}
value={fuelEvening}
onChange={setFuelEvening}
maxLiters={tankCapacities.fuelCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<div className="input-group">
<label>{t('logs.morning')}</label>
<input
type="number"
className="input-text"
value={fuelMorning}
onChange={(e) => setFuelMorning(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.refilled')}</label>
<input
type="number"
className="input-text"
value={fuelRefilled}
onChange={(e) => setFuelRefilled(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.evening')}</label>
<input
type="number"
className="input-text"
value={fuelEvening}
onChange={(e) => setFuelEvening(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.consumption')} (L)</label>
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
<input
type="number"
className="input-text consumption-value"
@@ -1306,11 +1320,12 @@ export default function LogEntryEditor({
readOnly
tabIndex={-1}
aria-readonly="true"
title={tankCapacityTooltip}
/>
</div>
<div className="input-group">
<label>{t('logs.fuel_per_motor_hour')}</label>
<label title={tankCapacityTooltip}>{t('logs.fuel_per_motor_hour')}</label>
<input
type="text"
className="input-text consumption-value"
@@ -1322,10 +1337,30 @@ export default function LogEntryEditor({
readOnly
tabIndex={-1}
aria-readonly="true"
title={tankCapacityTooltip}
/>
</div>
</div>
</div>
{/* Greywater card */}
<div className="form-card">
<div className="form-header">
<Compass size={20} className="form-icon" />
<h3>{t('logs.greywater')}</h3>
</div>
<div className="consumption-grid">
<TankLiterInput
id="greywater-level"
label={t('logs.greywater_level')}
value={greywaterLevel}
onChange={setGreywaterLevel}
maxLiters={tankCapacities.greywaterCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
</div>
</div>
</div>
{/* Section 3: Event Journal Entries */}
+103
View File
@@ -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<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleNumberBlur = () => {
if (!useSlider) return
emitValue(parseInputLiters(value))
}
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
emitValue(Number(e.target.value))
}
const numericValue = parseInputLiters(value)
const sliderValue = useSlider ? clampTankLiters(numericValue, maxLiters) : 0
return (
<div className="input-group tank-liter-input">
<label htmlFor={id} title={titleTooltip}>{label}</label>
{useSlider && (
<>
<input
type="range"
className="tank-liter-slider"
min={0}
max={maxLiters}
step={1}
value={sliderValue}
onChange={handleSliderChange}
disabled={disabled}
title={titleTooltip}
aria-valuemin={0}
aria-valuemax={maxLiters}
aria-valuenow={sliderValue}
aria-label={label}
/>
<div className="tank-liter-slider-hint" aria-hidden="true">
{t('logs.tank_slider_of_max', {
current: sliderValue,
max: maxLiters
})}
</div>
</>
)}
<input
id={id}
type="number"
className="input-text"
value={value}
onChange={handleNumberChange}
onBlur={handleNumberBlur}
disabled={disabled}
min={0}
max={useSlider ? maxLiters : undefined}
step="any"
title={titleTooltip}
/>
</div>
)
}
+65 -2
View File
@@ -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<string[]>([])
const [newSailName, setNewSailName] = useState('')
const [freshwaterCapacityL, setFreshwaterCapacityL] = useState('')
const [fuelCapacityL, setFuelCapacityL] = useState('')
const [greywaterCapacityL, setGreywaterCapacityL] = useState('')
const fileInputRef = React.useRef<HTMLInputElement>(null)
const [photo, setPhoto] = useState<string | null>(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
/>
</div>
<div className="vessel-tanks-section">
<h3>{t('vessel.tanks_section')}</h3>
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
<div className="vessel-tanks-grid">
<div className="input-group">
<label>{t('vessel.freshwater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={freshwaterCapacityL}
onChange={(e) => setFreshwaterCapacityL(e.target.value)}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.fuel_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={fuelCapacityL}
onChange={(e) => setFuelCapacityL(e.target.value)}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.greywater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={greywaterCapacityL}
onChange={(e) => setGreywaterCapacityL(e.target.value)}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
</div>
</div>
<div className="sails-section">
<h3>{t('vessel.sails_list')}</h3>
<p className="help-text">{t('vessel.sails_help')}</p>
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+4
View File
@@ -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));
}
+16 -1
View File
@@ -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<Record<string, string>>
}
@@ -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<string, unknown> {
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
+13 -5
View File
@@ -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;
+23
View File
@@ -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()
})
})
+8
View File
@@ -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<string
payload.motorHours = Number(input.motorHours.toFixed(2))
}
if (input.greywater !== undefined) {
const level = Number(input.greywater.level) || 0
if (level > 0) {
payload.greywater = { level: Number(level.toFixed(1)) }
}
}
return payload
}
+1
View File
@@ -41,6 +41,7 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
export interface LogEntryTankSource {
freshwater?: Partial<TankLevels>
fuel?: Partial<TankLevels>
greywater?: { level?: number }
destination?: string
}
+47
View File
@@ -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)
})
})
+60
View File
@@ -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<string, unknown>
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
}