Compare commits

...

4 Commits

Author SHA1 Message Date
elpatron 9e42f828a0 chore: release v0.1.0.63 2026-05-31 14:00:00 +02:00
elpatron 4197e77b1e feat(auth): Passwortmanager für Wiederherstellungsschlüssel aktivieren
Das Eingabefeld nutzt jetzt Passwort-Semantik und Autocomplete-Attribute, damit OS-Passwortmanager gespeicherte Schlüssel vorschlagen können.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:59:50 +02:00
elpatron 1373c11de8 fix(pwa): Kaltstart nach verpassten Updates stabilisieren
Service Worker übernimmt Updates zuverlässig (SKIP_WAITING, clientsClaim),
wartende Versionen werden beim Start angewendet und veraltete Chunks führen
nicht mehr zum Hängenbleiben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:58:35 +02:00
elpatron 0bae3b29dc feat: Grauwasserstand beim neuen Reisetag vom Vortag übernehmen
Übernimmt den Grauwasser-Füllstand analog zu Frischwasser und Kraftstoff
beim Anlegen eines Reisetags und zeigt ihn im Übernahme-Dialog an.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:54:57 +02:00
13 changed files with 284 additions and 38 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.63
0.1.0.64
+31 -10
View File
@@ -379,16 +379,37 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
{t('auth.recovery_fallback_warning')}
</p>
<form onSubmit={handleRecoverySubmit} className="auth-form">
<textarea
className="input-textarea"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
disabled={loading}
rows={3}
required
/>
<form onSubmit={handleRecoverySubmit} className="auth-form" autoComplete="on">
{(username.trim() || encryptedPayloads?.username) && (
<input
type="text"
name="username"
autoComplete="username"
value={username.trim() || encryptedPayloads?.username || ''}
readOnly
tabIndex={-1}
aria-hidden="true"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
)}
<div className="input-group">
<label htmlFor="recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.enter_recovery')}
</label>
<input
id="recovery-key"
name="recovery-key"
type="password"
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
disabled={loading}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
{error && <div className="auth-error">{error}</div>}
+30 -9
View File
@@ -344,15 +344,36 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<h2>{t('auth.enter_recovery')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
<form onSubmit={handleRecoverySubmit}>
<textarea
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
rows={3}
required
/>
<form onSubmit={handleRecoverySubmit} autoComplete="on">
{(username.trim() || encryptedPayloads?.username) && (
<input
type="text"
name="username"
autoComplete="username"
value={username.trim() || encryptedPayloads?.username || ''}
readOnly
tabIndex={-1}
aria-hidden="true"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
)}
<div className="input-group">
<label htmlFor="invitation-recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.enter_recovery')}
</label>
<input
id="invitation-recovery-key"
name="recovery-key"
type="password"
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
<div className="auth-actions mt-4">
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
{t('auth.back')}
+6 -3
View File
@@ -241,14 +241,15 @@ export default function LogEntriesList({
decryptedEntries.sort(compareTravelDaysChronological)
const previousEntry = decryptedEntries.at(-1) ?? null
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
let { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, greywaterLevel, departure })) {
const confirmed = await showConfirm(
t('logs.carry_over_tanks_confirm', {
departure: departure || '—',
fw: formatTankLiters(freshwater.morning),
fuel: formatTankLiters(fuel.morning)
fuel: formatTankLiters(fuel.morning),
greywater: formatTankLiters(greywaterLevel)
}),
t('logs.carry_over_tanks_title'),
t('logs.carry_over_tanks_yes'),
@@ -257,6 +258,7 @@ export default function LogEntriesList({
if (!confirmed) {
freshwater = emptyTankLevels()
fuel = emptyTankLevels()
greywaterLevel = 0
departure = ''
}
}
@@ -274,6 +276,7 @@ export default function LogEntriesList({
destination: '',
freshwater,
fuel,
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
signSkipper: '',
signCrew: '',
events: []
+10 -11
View File
@@ -1,13 +1,12 @@
import { useEffect, useRef } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { markReloadAttempt, recentlyAttemptedReload } from '../services/pwaStartup.js'
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
const UPDATE_SUPPRESS_MS = 30_000
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2000
/** Prevent Android PWA cold-start reload loops from onNeedReload. */
const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
function isUpdateSuppressed(): boolean {
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
@@ -43,6 +42,13 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo
}
}
function reloadForServiceWorkerTakeover(): void {
if (recentlyAttemptedReload()) return
markReloadAttempt()
clearUpdateSuppression()
window.location.reload()
}
export function usePwaUpdate() {
const cleanupRef = useRef<(() => void) | null>(null)
@@ -52,14 +58,7 @@ export function usePwaUpdate() {
} = useRegisterSW({
immediate: !import.meta.env.DEV,
onNeedReload() {
// First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
return
}
sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
clearUpdateSuppression()
setNeedRefresh(false)
window.location.reload()
reloadForServiceWorkerTakeover()
},
onNeedRefresh() {
if (isUpdateSuppressed()) return
@@ -96,7 +95,7 @@ export function usePwaUpdate() {
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
window.setTimeout(() => {
window.location.reload()
reloadForServiceWorkerTakeover()
}, UPDATE_RELOAD_FALLBACK_MS)
}
+1 -1
View File
@@ -193,7 +193,7 @@
"delete_entry": "Tag löschen",
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser-, Kraftstoff- und Grauwasser-Startstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L\nGrauwasser: {{greywater}} L",
"carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll",
+1 -1
View File
@@ -193,7 +193,7 @@
"delete_entry": "Delete Day",
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
"carry_over_tanks_title": "Carry over from previous day?",
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L\nGreywater: {{greywater}} L",
"carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook",
+13
View File
@@ -6,6 +6,11 @@ import './index.css'
import './i18n'
import App from './App.tsx'
import { applyAppearanceToDocument } from './services/appearance.ts'
import {
installStaleAssetRecovery,
markReloadAttempt,
reconcileServiceWorkerOnStartup
} from './services/pwaStartup.ts'
/** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> {
@@ -35,8 +40,16 @@ function renderBootstrapError(message: string): void {
async function bootstrap(): Promise<void> {
applyAppearanceToDocument()
installStaleAssetRecovery()
await clearDevServiceWorkerCaches()
const shouldReloadForWaitingSw = await reconcileServiceWorkerOnStartup()
if (shouldReloadForWaitingSw) {
markReloadAttempt()
window.location.reload()
return
}
const rootEl = document.getElementById('root')
if (!rootEl) {
throw new Error('Missing #root element')
+45
View File
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
markReloadAttempt,
recentlyAttemptedReload,
reconcileServiceWorkerOnStartup
} from './pwaStartup.js'
describe('pwaStartup reload guards', () => {
beforeEach(() => {
sessionStorage.clear()
})
it('blocks repeated reload attempts within the debounce window', () => {
expect(recentlyAttemptedReload(10_000)).toBe(false)
markReloadAttempt(10_000)
expect(recentlyAttemptedReload(12_000)).toBe(true)
expect(recentlyAttemptedReload(15_000)).toBe(false)
})
})
describe('reconcileServiceWorkerOnStartup', () => {
beforeEach(() => {
sessionStorage.clear()
vi.unstubAllEnvs()
})
it('returns false in dev mode', async () => {
vi.stubEnv('DEV', true)
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
})
it('returns false when no waiting worker exists', async () => {
vi.stubEnv('DEV', false)
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
controller: {},
getRegistration: vi.fn().mockResolvedValue({ waiting: null }),
addEventListener: vi.fn()
}
})
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
})
})
+87
View File
@@ -0,0 +1,87 @@
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
const RELOAD_DEBOUNCE_MS = 4_000
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
export function recentlyAttemptedReload(now = Date.now()): boolean {
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
return now - last < RELOAD_DEBOUNCE_MS
}
export function markReloadAttempt(now = Date.now()): void {
sessionStorage.setItem(RELOAD_ATTEMPT_KEY, String(now))
}
function recentlyAttemptedColdStartUpdate(now = Date.now()): boolean {
const last = Number(sessionStorage.getItem(COLD_START_UPDATE_KEY) || '0')
return now - last < COLD_START_UPDATE_DEBOUNCE_MS
}
function markColdStartUpdateAttempt(now = Date.now()): void {
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
}
function isStaleModuleLoadError(error: unknown): boolean {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: ''
return (
message.includes('Failed to fetch dynamically imported module') ||
message.includes('Importing a module script failed') ||
message.includes('error loading dynamically imported module')
)
}
/**
* After missed deploys, a waiting SW may exist while the page still runs an old bundle.
* Apply the waiting worker once on cold start (one controlled reload) instead of hanging.
*/
export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
return false
}
if (recentlyAttemptedColdStartUpdate()) {
return false
}
const registration = await navigator.serviceWorker.getRegistration()
const waiting = registration?.waiting
if (!waiting || !navigator.serviceWorker.controller) {
return false
}
markColdStartUpdateAttempt()
waiting.postMessage({ type: 'SKIP_WAITING' })
await new Promise<void>((resolve) => {
const timeoutId = window.setTimeout(resolve, 4_000)
navigator.serviceWorker.addEventListener(
'controllerchange',
() => {
window.clearTimeout(timeoutId)
resolve()
},
{ once: true }
)
})
return true
}
export function installStaleAssetRecovery(): void {
if (import.meta.env.DEV) return
window.addEventListener('unhandledrejection', (event) => {
if (!isStaleModuleLoadError(event.reason)) return
if (recentlyAttemptedReload()) return
markReloadAttempt()
event.preventDefault()
window.location.reload()
})
}
+8
View File
@@ -1,10 +1,18 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core'
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
declare let self: ServiceWorkerGlobalScope
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
clientsClaim()
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
void self.skipWaiting()
}
})
interface PushPayload {
title?: string
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
carryOverFromPreviousDay,
getClosingGreywaterLevel,
hasCarryOverFromPreviousDay
} from './logEntryTankLevels.js'
describe('logEntryTankLevels greywater carry-over', () => {
it('returns previous greywater level as starting value', () => {
const carryOver = carryOverFromPreviousDay({
destination: 'Oslo',
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
fuel: { morning: 200, refilled: 0, evening: 150, consumption: 50 },
greywater: { level: 42 }
})
expect(carryOver.greywaterLevel).toBe(42)
expect(carryOver.freshwater.morning).toBe(80)
expect(carryOver.fuel.morning).toBe(150)
expect(carryOver.departure).toBe('Oslo')
})
it('defaults greywater to 0 when previous day has none', () => {
expect(carryOverFromPreviousDay(null).greywaterLevel).toBe(0)
expect(getClosingGreywaterLevel(undefined)).toBe(0)
})
it('treats greywater level as carry-over candidate', () => {
expect(
hasCarryOverFromPreviousDay({
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
greywaterLevel: 15,
departure: ''
})
).toBe(true)
})
})
+13 -2
View File
@@ -48,6 +48,7 @@ export interface LogEntryTankSource {
export interface CarryOverFromPreviousDay {
freshwater: TankLevels
fuel: TankLevels
greywaterLevel: number
departure: string
}
@@ -60,6 +61,10 @@ export function formatTankLiters(liters: number): string {
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
}
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
return Number(greywater?.level) || 0
}
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
if (!previousEntry) {
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
@@ -74,10 +79,16 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
const departure = previousEntry?.destination?.trim() || ''
const greywaterLevel = getClosingGreywaterLevel(previousEntry?.greywater)
return { freshwater, fuel, departure }
return { freshwater, fuel, greywaterLevel, departure }
}
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
return (
carryOver.freshwater.morning > 0 ||
carryOver.fuel.morning > 0 ||
carryOver.greywaterLevel > 0 ||
carryOver.departure.length > 0
)
}