Compare commits

...

11 Commits

Author SHA1 Message Date
elpatron 73e86d28b3 chore: release v0.1.0.62 2026-05-31 13:47:49 +02:00
elpatron ad4721e694 fix: Fehler-Alert nach Push-Aktivierung korrekt awaiten
Stellt sicher, dass die Fehlermeldung angezeigt wird, bevor
promptPushAfterInviteCreated zurückkehrt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:47:36 +02:00
elpatron 8037b3b63e chore: release v0.1.0.61 2026-05-31 13:45:05 +02:00
elpatron c4cd566da0 fix: Tank-Nachfüll-Clamping und Deaktivierung bei vollem Tank
Korrigiert fehlende useEffect-Dependencies beim Auto-Clamping und
deaktiviert Refill-Eingaben, wenn der Morgenstand die Tankkapazität erreicht.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:44:49 +02:00
elpatron 3a267905b0 feat: Push-Hinweis nach Erstellen eines Crew-Einladungslinks
Owner sieht einen Dialog zur Aktivierung von Crew-Push-Benachrichtigungen, sofern diese noch nicht aktiv sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:43:52 +02:00
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
18 changed files with 778 additions and 90 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.59
0.1.0.63
+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 })
+196 -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,59 @@ export default function LogEntryEditor({
setFuelConsumption(cons >= 0 ? String(cons) : '0')
}, [fuelMorning, fuelRefilled, fuelEvening])
// Load Yacht Sails
const fwRefilledNoCapacity =
(tankCapacities.freshwaterCapacityL ?? 0) > 0 && fwRefilledMax == null
const fuelRefilledNoCapacity =
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
useEffect(() => {
async function loadYachtSails() {
if (readOnly && preloadedYacht?.sails) {
setYachtSails(preloadedYacht.sails)
const refilled = parseFloat(fwRefilled) || 0
if (fwRefilledMax == null) {
if (fwRefilledNoCapacity && refilled > 0) {
setFwRefilled(formatTankLitersForInput(0))
}
return
}
if (refilled > fwRefilledMax) {
setFwRefilled(formatTankLitersForInput(fwRefilledMax))
}
}, [fwRefilledMax, fwRefilled, fwRefilledNoCapacity])
useEffect(() => {
if (fwEveningMax == null) return
const evening = parseFloat(fwEvening) || 0
if (evening > fwEveningMax) {
setFwEvening(formatTankLitersForInput(fwEveningMax))
}
}, [fwEveningMax, fwEvening])
useEffect(() => {
const refilled = parseFloat(fuelRefilled) || 0
if (fuelRefilledMax == null) {
if (fuelRefilledNoCapacity && refilled > 0) {
setFuelRefilled(formatTankLitersForInput(0))
}
return
}
if (refilled > fuelRefilledMax) {
setFuelRefilled(formatTankLitersForInput(fuelRefilledMax))
}
}, [fuelRefilledMax, fuelRefilled, fuelRefilledNoCapacity])
useEffect(() => {
if (fuelEveningMax == null) return
const evening = parseFloat(fuelEvening) || 0
if (evening > fuelEveningMax) {
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
}
}, [fuelEveningMax, fuelEvening])
// 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 +636,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 +678,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 +716,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 +1318,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}
disabled={saving || readOnly || fwRefilledNoCapacity}
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 +1354,7 @@ export default function LogEntryEditor({
readOnly
tabIndex={-1}
aria-readonly="true"
title={tankCapacityTooltip}
/>
</div>
</div>
@@ -1264,41 +1367,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}
disabled={saving || readOnly || fuelRefilledNoCapacity}
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 +1403,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 +1420,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 */}
+44
View File
@@ -6,6 +6,12 @@ import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import { useDialog } from './ModalDialog.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js'
import {
enableCollaboratorChangePush,
isCollaboratorPushActive,
isPushSupported
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
interface SettingsFormProps {
logbookId?: string | null
@@ -151,6 +157,43 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
}
const promptPushAfterInviteCreated = async () => {
if (!isPushSupported()) return
if (await isCollaboratorPushActive()) return
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
if (iosNeedsInstall) {
await showAlert(
t('settings.invite_push_prompt_ios_message'),
t('settings.invite_push_prompt_title'),
t('settings.invite_push_prompt_later')
)
return
}
const enable = await showConfirm(
t('settings.invite_push_prompt_message'),
t('settings.invite_push_prompt_title'),
t('settings.invite_push_prompt_enable'),
t('settings.invite_push_prompt_later')
)
if (!enable) return
try {
await enableCollaboratorChangePush()
await showAlert(
t('settings.invite_push_prompt_success'),
t('settings.invite_push_prompt_title')
)
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} catch (err: unknown) {
console.error('Failed to enable push after invite:', err)
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
}
}
const handleGenerateInvite = async () => {
if (!logbookId) return
setGeneratingInvite(true)
@@ -175,6 +218,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
setInviteLink(link)
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
await promptPushAfterInviteCreated()
} catch (err: unknown) {
console.error('Failed to generate invite:', err)
showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
+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>
+17 -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",
@@ -472,6 +482,12 @@
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
"deleting_account": "Konto wird gelöscht…",
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
"invite_push_prompt_ios_message": "Sobald Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), dann Push im Benutzerprofil aktivieren.",
"invite_push_prompt_enable": "Jetzt aktivieren",
"invite_push_prompt_later": "Später",
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"backup_title": "Backup & Wiederherstellung",
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen",
+17 -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",
@@ -472,6 +482,12 @@
"delete_account_failed": "Failed to delete account. Please try again.",
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
"deleting_account": "Deleting account…",
"invite_push_prompt_title": "Enable push notifications?",
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
"invite_push_prompt_ios_message": "When crew members sync changes, you can get push notifications. On iPhone/iPad: add the app to your Home Screen (iOS 16.4+), then enable push in your user profile.",
"invite_push_prompt_enable": "Enable now",
"invite_push_prompt_later": "Later",
"invite_push_prompt_success": "Push notifications are active on this device.",
"backup_title": "Backup & restore",
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup",
+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;
+12
View File
@@ -43,6 +43,18 @@ async function fetchVapidPublicKey(): Promise<string | null> {
}
}
/** True when crew-change push is enabled and notification permission is granted. */
export async function isCollaboratorPushActive(): Promise<boolean> {
if (!isPushSupported()) return false
if (getNotificationPermission() !== 'granted') return false
try {
const prefs = await fetchPushPrefs()
return prefs.collaboratorChangesEnabled
} catch {
return false
}
}
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
if (!localStorage.getItem('active_userid')) {
return { collaboratorChangesEnabled: false }
+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
}