Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87b0fa7bde | |||
| d90f292a21 | |||
| 9e42f828a0 | |||
| 4197e77b1e | |||
| 1373c11de8 | |||
| 0bae3b29dc |
@@ -29,6 +29,7 @@ import {
|
||||
resolveColorScheme,
|
||||
subscribeToSystemColorScheme
|
||||
} from './services/appearance.js'
|
||||
import { syncAppearancePrefs } from './services/appearancePrefs.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||
import DemoViewer from './components/DemoViewer.tsx'
|
||||
@@ -151,6 +152,13 @@ function App() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
void syncAppearancePrefs(userId)
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setOnline(true)
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -5,6 +5,7 @@ import ThemedSelect from './ThemedSelect.tsx'
|
||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||
import { saveAppearancePrefsToServer } from '../services/appearancePrefs.js'
|
||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||
import {
|
||||
getColorSchemePreference,
|
||||
@@ -32,6 +33,9 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
setThemePreference(userId, nextTheme)
|
||||
setColorSchemePreference(userId, nextColorScheme)
|
||||
notifyAppearanceChanged()
|
||||
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
|
||||
console.warn('Failed to save appearance prefs to server:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const handleThemeChange = (nextTheme: string) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
fetchAppearancePrefs,
|
||||
saveAppearancePrefsToServer,
|
||||
syncAppearancePrefs
|
||||
} from './appearancePrefs.js'
|
||||
import { setThemePreference } from './userPreferences.js'
|
||||
|
||||
const USER_ID = 'appearance-sync-user'
|
||||
|
||||
vi.mock('./api.js', () => ({
|
||||
apiJson: vi.fn()
|
||||
}))
|
||||
|
||||
import { apiJson } from './api.js'
|
||||
|
||||
const mockedApiJson = vi.mocked(apiJson)
|
||||
|
||||
describe('appearancePrefs', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetchAppearancePrefs returns defaults when not authenticated', async () => {
|
||||
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
||||
theme: 'auto',
|
||||
colorScheme: 'auto',
|
||||
persisted: false
|
||||
})
|
||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncAppearancePrefs applies server prefs after cache wipe', async () => {
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
mockedApiJson.mockResolvedValueOnce({
|
||||
theme: 'ocean',
|
||||
colorScheme: 'dark',
|
||||
persisted: true
|
||||
})
|
||||
|
||||
const changed = vi.fn()
|
||||
window.addEventListener('appearance-changed', changed)
|
||||
|
||||
await syncAppearancePrefs(USER_ID)
|
||||
|
||||
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('syncAppearancePrefs uploads local prefs when server has none', async () => {
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
setThemePreference(USER_ID, 'material')
|
||||
mockedApiJson
|
||||
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
|
||||
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
|
||||
|
||||
await syncAppearancePrefs(USER_ID)
|
||||
|
||||
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
||||
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
|
||||
})
|
||||
})
|
||||
|
||||
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
||||
await saveAppearancePrefsToServer('ocean', 'light')
|
||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { apiJson } from './api.js'
|
||||
import { notifyAppearanceChanged } from './appearance.js'
|
||||
import {
|
||||
getActiveUserId,
|
||||
getColorSchemePreference,
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setThemePreference
|
||||
} from './userPreferences.js'
|
||||
|
||||
const API_BASE = '/api/auth/appearance-prefs'
|
||||
|
||||
export interface AppearancePrefs {
|
||||
theme: string
|
||||
colorScheme: string
|
||||
persisted: boolean
|
||||
}
|
||||
|
||||
function hasLocalAppearancePrefs(userId: string): boolean {
|
||||
return (
|
||||
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
||||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchAppearancePrefs(): Promise<AppearancePrefs> {
|
||||
if (!getActiveUserId()) {
|
||||
return { theme: 'auto', colorScheme: 'auto', persisted: false }
|
||||
}
|
||||
|
||||
return apiJson<AppearancePrefs>(API_BASE)
|
||||
}
|
||||
|
||||
export async function saveAppearancePrefsToServer(theme: string, colorScheme: string): Promise<void> {
|
||||
if (!getActiveUserId()) return
|
||||
|
||||
await apiJson<AppearancePrefs>(API_BASE, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ theme, colorScheme })
|
||||
})
|
||||
}
|
||||
|
||||
/** Merge server-stored appearance with local cache (server wins after cache wipe). */
|
||||
export async function syncAppearancePrefs(userId?: string | null): Promise<void> {
|
||||
const id = userId?.trim() || getActiveUserId()
|
||||
if (!id) return
|
||||
|
||||
try {
|
||||
const server = await fetchAppearancePrefs()
|
||||
|
||||
if (server.persisted) {
|
||||
setThemePreference(id, server.theme)
|
||||
setColorSchemePreference(id, server.colorScheme)
|
||||
} else if (hasLocalAppearancePrefs(id)) {
|
||||
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync appearance preferences:', err)
|
||||
}
|
||||
|
||||
notifyAppearanceChanged()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ model User {
|
||||
collaborations Collaboration[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
notificationPrefs UserNotificationPrefs?
|
||||
appearancePrefs UserAppearancePrefs?
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
@@ -48,6 +49,15 @@ model UserNotificationPrefs {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model UserAppearancePrefs {
|
||||
userId String @id
|
||||
theme String @default("auto")
|
||||
colorScheme String @default("auto")
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Credential {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
|
||||
@@ -38,6 +38,17 @@ function normalizeCredentialLabel(label: unknown): string | null {
|
||||
return trimmed.slice(0, 64)
|
||||
}
|
||||
|
||||
const VALID_THEMES = new Set(['auto', 'ocean', 'material', 'cupertino'])
|
||||
const VALID_COLOR_SCHEMES = new Set(['auto', 'light', 'dark'])
|
||||
|
||||
function parseThemePreference(value: unknown): string | null {
|
||||
return typeof value === 'string' && VALID_THEMES.has(value) ? value : null
|
||||
}
|
||||
|
||||
function parseColorSchemePreference(value: unknown): string | null {
|
||||
return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null
|
||||
}
|
||||
|
||||
router.post('/register-options', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
@@ -426,6 +437,57 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const prefs = await prisma.userAppearancePrefs.findUnique({
|
||||
where: { userId: req.userId }
|
||||
})
|
||||
|
||||
return res.json({
|
||||
theme: prefs?.theme ?? 'auto',
|
||||
colorScheme: prefs?.colorScheme ?? 'auto',
|
||||
persisted: prefs != null
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error reading appearance prefs:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const theme = parseThemePreference(req.body?.theme)
|
||||
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
|
||||
if (!theme || !colorScheme) {
|
||||
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
|
||||
}
|
||||
|
||||
const prefs = await prisma.userAppearancePrefs.upsert({
|
||||
where: { userId: req.userId },
|
||||
create: {
|
||||
userId: req.userId,
|
||||
theme,
|
||||
colorScheme,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
update: {
|
||||
theme,
|
||||
colorScheme,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({
|
||||
theme: prefs.theme,
|
||||
colorScheme: prefs.colorScheme,
|
||||
persisted: true
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating appearance prefs:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/profile', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
Reference in New Issue
Block a user