Compare commits

...

6 Commits

Author SHA1 Message Date
elpatron c856c2e903 chore: release v0.1.0.60 2026-05-31 13:41:29 +02:00
elpatron b3256d1685 fix: Tank-Slider für Touch auf Mobilgeräten vergrößern
Größerer Thumb und sichtbarere Spur mit touch-action, damit Füllstände am Handy leichter einstellbar sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:40:59 +02:00
elpatron 23fc940324 feat: dynamische Slider-Obergrenzen für Frischwasser und Treibstoff
Nachgefüllt ist auf Restkapazität nach Morgen begrenzt, Stand abends auf Morgen plus Nachgefüllt; Werte werden bei Änderungen automatisch gekürzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:39:46 +02:00
elpatron 25e1bdded3 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>
2026-05-31 13:33:40 +02:00
elpatron 6a61c9e06c chore: release v0.1.0.59 2026-05-31 13:25:11 +02:00
elpatron d3683ad6aa fix: DemoViewer an erweiterte TourNavigation-Schnittstelle anpassen
Ergänzt No-Op-Stubs für setLogbookActive und setProfileOpen, damit der
Production-Build nach der Profil-Tour-Erweiterung wieder durchläuft.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:25:04 +02:00
16 changed files with 695 additions and 90 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.59
0.1.0.61
+92
View File
@@ -3068,6 +3068,98 @@ html.theme-cupertino .events-scroll-container {
}
}
.tank-liter-input .tank-liter-slider {
--tank-slider-track-h: 10px;
--tank-slider-thumb: 26px;
width: 100%;
height: var(--tank-slider-thumb);
margin: 10px 0 6px;
padding: 0;
-webkit-appearance: none;
appearance: none;
background: transparent;
accent-color: #4ade80;
cursor: pointer;
touch-action: none;
}
.tank-liter-input .tank-liter-slider::-webkit-slider-runnable-track {
height: var(--tank-slider-track-h);
border-radius: 999px;
background: rgba(148, 163, 184, 0.35);
}
.tank-liter-input .tank-liter-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: var(--tank-slider-thumb);
height: var(--tank-slider-thumb);
margin-top: calc((var(--tank-slider-track-h) - var(--tank-slider-thumb)) / 2);
border-radius: 50%;
background: #4ade80;
border: 2px solid rgba(15, 23, 42, 0.85);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.tank-liter-input .tank-liter-slider::-moz-range-track {
height: var(--tank-slider-track-h);
border-radius: 999px;
background: rgba(148, 163, 184, 0.35);
}
.tank-liter-input .tank-liter-slider::-moz-range-thumb {
width: var(--tank-slider-thumb);
height: var(--tank-slider-thumb);
border-radius: 50%;
background: #4ade80;
border: 2px solid rgba(15, 23, 42, 0.85);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.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: 6px;
text-align: center;
}
@media (max-width: 480px) {
.tank-liter-input .tank-liter-slider {
--tank-slider-track-h: 12px;
--tank-slider-thumb: 32px;
margin: 12px 0 8px;
}
}
.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;
+3 -1
View File
@@ -31,7 +31,9 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: () => {}
setFeedbackOpen: () => {},
setLogbookActive: () => {},
setProfileOpen: () => {}
})
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
+181 -78
View File
@@ -42,6 +42,14 @@ 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 {
computeEveningTankMaxLiters,
computeRefilledTankMaxLiters,
extractTankCapacitiesFromYacht,
formatTankLitersForInput,
type VesselTankCapacities
} from '../utils/tankCapacity.js'
function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
@@ -50,6 +58,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 +81,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 +155,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 +262,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 +273,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 +283,38 @@ export default function LogEntryEditor({
[fuelConsumption, motorHours]
)
const tankCapacityTooltip = t('logs.tank_capacity_tooltip')
const fwRefilledMax = useMemo(
() => computeRefilledTankMaxLiters(fwMorning, tankCapacities.freshwaterCapacityL),
[fwMorning, tankCapacities.freshwaterCapacityL]
)
const fwEveningMax = useMemo(
() =>
computeEveningTankMaxLiters(
fwMorning,
fwRefilled,
tankCapacities.freshwaterCapacityL
),
[fwMorning, fwRefilled, tankCapacities.freshwaterCapacityL]
)
const fuelRefilledMax = useMemo(
() => computeRefilledTankMaxLiters(fuelMorning, tankCapacities.fuelCapacityL),
[fuelMorning, tankCapacities.fuelCapacityL]
)
const fuelEveningMax = useMemo(
() =>
computeEveningTankMaxLiters(
fuelMorning,
fuelRefilled,
tankCapacities.fuelCapacityL
),
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
)
const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning()
return JSON.stringify({
@@ -527,11 +574,44 @@ export default function LogEntryEditor({
setFuelConsumption(cons >= 0 ? String(cons) : '0')
}, [fuelMorning, fuelRefilled, fuelEvening])
// Load Yacht Sails
useEffect(() => {
async function loadYachtSails() {
if (readOnly && preloadedYacht?.sails) {
setYachtSails(preloadedYacht.sails)
if (fwRefilledMax == null) return
const refilled = parseFloat(fwRefilled) || 0
if (refilled > fwRefilledMax) {
setFwRefilled(formatTankLitersForInput(fwRefilledMax))
}
}, [fwRefilledMax, fwMorning])
useEffect(() => {
if (fwEveningMax == null) return
const evening = parseFloat(fwEvening) || 0
if (evening > fwEveningMax) {
setFwEvening(formatTankLitersForInput(fwEveningMax))
}
}, [fwEveningMax, fwMorning, fwRefilled])
useEffect(() => {
if (fuelRefilledMax == null) return
const refilled = parseFloat(fuelRefilled) || 0
if (refilled > fuelRefilledMax) {
setFuelRefilled(formatTankLitersForInput(fuelRefilledMax))
}
}, [fuelRefilledMax, fuelMorning])
useEffect(() => {
if (fuelEveningMax == null) return
const evening = parseFloat(fuelEvening) || 0
if (evening > fuelEveningMax) {
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
}
}, [fuelEveningMax, fuelMorning, fuelRefilled])
// Load yacht sails and tank capacities
useEffect(() => {
async function loadYachtMeta() {
if (readOnly && preloadedYacht) {
if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails)
setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht))
return
}
try {
@@ -541,16 +621,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 +663,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 +701,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 +1303,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={fwRefilledMax ?? tankCapacities.freshwaterCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<TankLiterInput
id="fw-evening"
label={t('logs.evening')}
value={fwEvening}
onChange={setFwEvening}
maxLiters={fwEveningMax}
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 +1339,7 @@ export default function LogEntryEditor({
readOnly
tabIndex={-1}
aria-readonly="true"
title={tankCapacityTooltip}
/>
</div>
</div>
@@ -1264,41 +1352,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={fuelRefilledMax ?? tankCapacities.fuelCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<TankLiterInput
id="fuel-evening"
label={t('logs.evening')}
value={fuelEvening}
onChange={setFuelEvening}
maxLiters={fuelEveningMax}
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 +1388,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 +1405,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
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
clampTankLiters,
computeEveningTankMaxLiters,
computeRefilledTankMaxLiters,
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)
})
it('computes refilled max as capacity minus morning', () => {
expect(computeRefilledTankMaxLiters('10', 60)).toBe(50)
expect(computeRefilledTankMaxLiters('60', 60)).toBeUndefined()
expect(computeRefilledTankMaxLiters('10', undefined)).toBeUndefined()
})
it('computes evening max as morning plus refilled capped by capacity', () => {
expect(computeEveningTankMaxLiters('10', '20', 60)).toBe(30)
expect(computeEveningTankMaxLiters('40', '40', 60)).toBe(60)
expect(computeEveningTankMaxLiters('10', '20')).toBe(30)
expect(computeEveningTankMaxLiters('0', '0', 60)).toBeUndefined()
})
})
+101
View File
@@ -0,0 +1,101 @@
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
}
/** Parse a liter amount from form state (string). */
export function parseTankLitersFromInput(input: string): number {
const trimmed = input.trim().replace(',', '.')
if (!trimmed) return 0
const parsed = Number(trimmed)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
}
/**
* Max for refilled amount: remaining capacity after morning level.
* Returns undefined when no positive max (no slider).
*/
export function computeRefilledTankMaxLiters(
morningInput: string,
tankCapacityL?: number
): number | undefined {
if (tankCapacityL == null || tankCapacityL <= 0) return undefined
const remaining = tankCapacityL - parseTankLitersFromInput(morningInput)
if (remaining <= 0) return undefined
return remaining
}
/**
* Max for evening fill level: morning + refilled, capped by tank capacity when known.
* Returns undefined when no positive max (no slider).
*/
export function computeEveningTankMaxLiters(
morningInput: string,
refilledInput: string,
tankCapacityL?: number
): number | undefined {
const sum = parseTankLitersFromInput(morningInput) + parseTankLitersFromInput(refilledInput)
if (sum <= 0) return undefined
if (tankCapacityL != null && tankCapacityL > 0) {
return Math.min(tankCapacityL, sum)
}
return sum
}
/** 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
}