fix(live-log): prevent freeze without GPS and prompt for day-start position

Harden geolocation with watchdog timeouts and permission checks so
desktop browsers without GPS no longer hang Live-Log. Show a hint to
log a position when none exists for the day.

Return 503 when crew-pool Prisma models are missing instead of crashing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 19:20:34 +02:00
parent 98c0ed81d4
commit 2304f95ac1
13 changed files with 324 additions and 36 deletions
+25
View File
@@ -3359,6 +3359,31 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text-muted);
}
.live-log-gps-hint {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
line-height: 1.45;
color: var(--app-text-muted);
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.25);
}
.live-log-gps-hint svg {
flex-shrink: 0;
margin-top: 2px;
color: var(--app-accent-light, #93c5fd);
}
.live-log-gps-hint-modal {
font-weight: 500;
color: var(--app-text, inherit);
}
.live-log-layout {
display: grid;
grid-template-columns: minmax(148px, 200px) 1fr;
+94 -18
View File
@@ -52,7 +52,11 @@ import {
} from '../utils/liveEventCodes.js'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { getCurrentPosition, normalizeGpsCoordinates } from '../utils/geolocation.js'
import {
getCurrentPosition,
normalizeGpsCoordinates,
queryGeolocationPermission
} from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import {
dedupeSailNames,
@@ -167,11 +171,13 @@ export default function LiveLogView({
const undoPhotoIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false)
const busyRef = useRef(busy)
const initSeqRef = useRef(0)
const eventsRef = useRef(events)
const dateRef = useRef(date)
eventsRef.current = events
dateRef.current = date
busyRef.current = busy
const defaultSails = useMemo(
() => (i18n.language === 'de'
@@ -185,6 +191,10 @@ export default function LiveLogView({
)
const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion')
const hasPositionFix = useMemo(
() => (date ? getLatestPositionFix(events, date) != null : false),
[events, date]
)
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
@@ -276,7 +286,9 @@ export default function LiveLogView({
return () => {
initSeqRef.current += 1
}
}, [runInit])
// Only re-init when the logbook changes — not when i18n `t` identity changes.
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
}, [logbookId])
useEffect(() => {
if (!loading && entryId) {
@@ -297,15 +309,34 @@ export default function LiveLogView({
useEffect(() => {
if (!entryId || loading) return
let cancelled = false
let startTimer: number | undefined
let intervalRef: number | undefined
const maybeAutoPosition = async () => {
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return
if (
cancelled
|| document.visibilityState !== 'visible'
|| autoPositionBusyRef.current
|| busyRef.current
) {
return
}
const permission = await queryGeolocationPermission()
if (cancelled || permission !== 'granted') return
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
autoPositionBusyRef.current = true
try {
const coords = await getCurrentPosition(8000)
const coords = await getCurrentPosition({
timeoutMs: 8000,
enableHighAccuracy: false,
maximumAge: 120_000
})
if (cancelled || busyRef.current) return
await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat,
gpsLng: coords.lng,
@@ -313,23 +344,26 @@ export default function LiveLogView({
})
await refreshEntry(entryId)
} catch {
// Silent — auto-position is best-effort
// Best-effort; hint banner shows when no position fix exists yet.
} finally {
autoPositionBusyRef.current = false
}
}
let intervalRef: number | undefined
const startTimer = window.setTimeout(() => {
void maybeAutoPosition()
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
}, AUTO_POSITION_START_DELAY_MS)
void queryGeolocationPermission().then((permission) => {
if (cancelled || permission !== 'granted') return
startTimer = window.setTimeout(() => {
void maybeAutoPosition()
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
}, AUTO_POSITION_START_DELAY_MS)
})
return () => {
window.clearTimeout(startTimer)
cancelled = true
if (startTimer !== undefined) window.clearTimeout(startTimer)
if (intervalRef !== undefined) window.clearInterval(intervalRef)
}
}, [entryId, loading, logbookId, refreshEntry, busy])
}, [entryId, loading, logbookId, refreshEntry])
const runQuickAction = async (
action: () => Promise<boolean | void>,
@@ -364,8 +398,15 @@ export default function LiveLogView({
const openSogModal = async () => {
let prefill = ''
try {
const pos = await getCurrentPosition()
if (pos.speedKn != null) prefill = String(pos.speedKn)
const permission = await queryGeolocationPermission()
if (permission === 'granted') {
const pos = await getCurrentPosition({
timeoutMs: 8000,
enableHighAccuracy: false,
maximumAge: 60_000
})
if (pos.speedKn != null) prefill = String(pos.speedKn)
}
} catch {
// Manual entry when GPS speed unavailable
}
@@ -405,7 +446,16 @@ export default function LiveLogView({
setFixGpsLoading(true)
setModal('fix')
try {
const coords = await getCurrentPosition()
const permission = await queryGeolocationPermission()
if (permission !== 'granted') {
setFixGpsUnavailable(true)
return
}
const coords = await getCurrentPosition({
timeoutMs: 10_000,
enableHighAccuracy: false,
maximumAge: 60_000
})
setFixLat(coords.lat)
setFixLng(coords.lng)
} catch {
@@ -419,12 +469,28 @@ export default function LiveLogView({
setFixGpsLoading(true)
setFixGpsUnavailable(false)
try {
const coords = await getCurrentPosition()
const permission = await queryGeolocationPermission()
if (permission !== 'granted') {
setFixGpsUnavailable(true)
await showAlert(
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
return
}
const coords = await getCurrentPosition({
timeoutMs: 10_000,
enableHighAccuracy: false,
maximumAge: 60_000
})
setFixLat(coords.lat)
setFixLng(coords.lng)
} catch {
setFixGpsUnavailable(true)
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
await showAlert(
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
} finally {
setFixGpsLoading(false)
}
@@ -786,6 +852,13 @@ export default function LiveLogView({
{error && <div className="auth-error mb-4">{error}</div>}
{!hasPositionFix && (
<p className="live-log-gps-hint" role="status">
<MapPin size={16} aria-hidden />
{t('logs.live_gps_start_hint')}
</p>
)}
<div className="live-log-layout">
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
@@ -974,7 +1047,10 @@ export default function LiveLogView({
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_fix')}</h3>
{fixGpsUnavailable && (
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
<>
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p>
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
</>
)}
<fieldset className="live-log-fix-coords" disabled={busy}>
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
+1
View File
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast",
"live_gps_error": "GPS-position kunne ikke bestemmes.",
"live_gps_start_hint": "Begynd altid dagens rejse med en position.",
"live_event_generic": "Hændelse",
"live_weather_btn": "Vejr",
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
+1
View File
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
"live_event_generic": "Ereignis",
"live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
+1
View File
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.",
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
"live_event_generic": "Event",
"live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
+1
View File
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør",
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
"live_event_generic": "Hendelse",
"live_weather_btn": "Vær",
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
+1
View File
@@ -268,6 +268,7 @@
"live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga",
"live_gps_error": "GPS-position kunde inte bestämmas.",
"live_gps_start_hint": "Börja alltid dagsresan med en position.",
"live_event_generic": "Händelse",
"live_weather_btn": "Väder",
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
+62 -2
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getCurrentPosition,
normalizeGpsCoordinates,
parseGpsCoordinate,
queryGeolocationPermission
} from './geolocation.js'
describe('geolocation helpers', () => {
it('parses coordinates with comma decimals', () => {
@@ -17,4 +22,59 @@ describe('geolocation helpers', () => {
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
})
it('reports unsupported when geolocation API is missing', async () => {
vi.stubGlobal('navigator', { geolocation: undefined })
await expect(getCurrentPosition({ timeoutMs: 100 })).rejects.toThrow('geolocation_unavailable')
})
it('rejects when the browser never calls back (watchdog)', async () => {
vi.useFakeTimers()
vi.stubGlobal('navigator', {
geolocation: {
getCurrentPosition: () => {
// Simulate a hung desktop location service.
}
}
})
const promise = getCurrentPosition({ timeoutMs: 50, enableHighAccuracy: false })
const assertion = expect(promise).rejects.toThrow('geolocation_timeout')
await vi.advanceTimersByTimeAsync(900)
await assertion
vi.useRealTimers()
})
it('resolves coordinates from getCurrentPosition', async () => {
vi.stubGlobal('navigator', {
geolocation: {
getCurrentPosition: (success: PositionCallback) => {
success({
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
} as GeolocationPosition)
}
}
})
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
lat: '59.910000',
lng: '10.750000',
speedKn: 4.9
})
})
it('reads permission state when supported', async () => {
vi.stubGlobal('navigator', {
geolocation: {},
permissions: {
query: vi.fn().mockResolvedValue({ state: 'denied' })
}
})
await expect(queryGeolocationPermission()).resolves.toBe('denied')
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
+72 -11
View File
@@ -1,5 +1,8 @@
const MPS_TO_KNOTS = 1.9438444924406
/** Extra ms beyond the native timeout so hung browsers still reject. */
const TIMEOUT_GRACE_MS = 750
export interface GeoCoordinates {
lat: string
lng: string
@@ -7,6 +10,15 @@ export interface GeoCoordinates {
speedKn: number | null
}
export type GeolocationPermissionState = PermissionState | 'unsupported'
export interface GetPositionOptions {
timeoutMs?: number
/** Manual fixes may use high accuracy; background auto-position should not. */
enableHighAccuracy?: boolean
maximumAge?: number
}
export function parseGpsCoordinate(value: string): number | null {
const trimmed = value.trim()
if (!trimmed) return null
@@ -26,26 +38,75 @@ export function normalizeGpsCoordinates(
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
}
export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
if (!navigator.geolocation) return 'unsupported'
if (!navigator.permissions?.query) return 'prompt'
try {
const status = await navigator.permissions.query({ name: 'geolocation' })
return status.state
} catch {
return 'prompt'
}
}
function normalizeGetPositionOptions(
options: number | GetPositionOptions | undefined
): Required<GetPositionOptions> {
const opts = typeof options === 'number' ? { timeoutMs: options } : (options ?? {})
const enableHighAccuracy = opts.enableHighAccuracy ?? true
return {
timeoutMs: opts.timeoutMs ?? 15000,
enableHighAccuracy,
maximumAge: opts.maximumAge ?? (enableHighAccuracy ? 0 : 120_000)
}
}
function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates {
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
return {
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
}
}
/**
* Resolves with coordinates or rejects. Uses both the native timeout and an outer
* watchdog so desktop browsers without GPS cannot hang indefinitely.
*/
export function getCurrentPosition(
options?: number | GetPositionOptions
): Promise<GeoCoordinates> {
const { timeoutMs, enableHighAccuracy, maximumAge } = normalizeGetPositionOptions(options)
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('geolocation_unavailable'))
return
}
let settled = false
const finish = (fn: () => void) => {
if (settled) return
settled = true
window.clearTimeout(watchdog)
fn()
}
const watchdog = window.setTimeout(() => {
finish(() => reject(new Error('geolocation_timeout')))
}, timeoutMs + TIMEOUT_GRACE_MS)
navigator.geolocation.getCurrentPosition(
(pos) => {
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
resolve({
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
})
finish(() => resolve(positionFromGeolocationPosition(pos)))
},
(err) => reject(err),
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
(err) => {
finish(() => reject(err))
},
{ enableHighAccuracy, timeout: timeoutMs, maximumAge }
)
})
}
+4 -2
View File
@@ -5,9 +5,11 @@
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"build": "prisma generate && tsc",
"postinstall": "prisma generate",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"dev": "prisma generate && tsx watch src/index.ts",
"db:push": "prisma db push",
"test": "vitest run",
"test:watch": "vitest"
},
+20
View File
@@ -506,17 +506,33 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
router.get('/person-pool', requireUser, async (req: any, res) => {
try {
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasCrewPoolPrismaModels()) {
console.warn('Person pool Prisma models missing — run prisma generate')
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
}
const persons = await prisma.personPayload.findMany({
where: { userId: req.userId }
})
return res.json({ persons })
} catch (error: unknown) {
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
if (isMissingPrismaTable(error)) {
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
}
return sendInternalError(res, error, 'auth/person-pool-get')
}
})
router.post('/person-pool/push', requireUser, async (req: any, res) => {
try {
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasCrewPoolPrismaModels()) {
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
}
const { items } = req.body
if (!items || !Array.isArray(items)) {
return res.status(400).json({ error: 'items array is required' })
@@ -569,6 +585,10 @@ router.post('/person-pool/push', requireUser, async (req: any, res) => {
return res.json({ results })
} catch (error: unknown) {
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
if (isMissingPrismaTable(error)) {
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
}
return sendInternalError(res, error, 'auth/person-pool-push')
}
})
+17 -3
View File
@@ -248,6 +248,16 @@ router.post('/push', async (req: any, res) => {
})
}
} else if (type === 'logbookCrew') {
const { hasCrewPoolPrismaModels, CREW_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasCrewPoolPrismaModels()) {
results.push({
payloadId,
status: 'error',
error: CREW_POOL_MIGRATION_HINT
})
continue
}
{
const existing = await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } })
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
@@ -325,9 +335,13 @@ router.get('/pull', async (req: any, res) => {
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
where: { logbookId }
})
let logbookCrewSelection = null
const { hasCrewPoolPrismaModels } = await import('../utils/crewPoolSchema.js')
if (hasCrewPoolPrismaModels()) {
logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
where: { logbookId }
})
}
return res.json({
yacht,
+25
View File
@@ -0,0 +1,25 @@
import { prisma } from '../db.js'
/** Prisma client includes delegates only after `npx prisma generate` on the current schema. */
export function hasCrewPoolPrismaModels(): boolean {
const client = prisma as unknown as {
personPayload?: { findMany: unknown }
logbookCrewSelectionPayload?: { findUnique: unknown }
}
return (
typeof client.personPayload?.findMany === 'function' &&
typeof client.logbookCrewSelectionPayload?.findUnique === 'function'
)
}
export const CREW_POOL_MIGRATION_HINT =
'Crew-Pool-Datenbank fehlt. Im Ordner server ausführen: npx prisma generate && npx prisma db push — danach Server neu starten.'
export function isMissingPrismaTable(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === 'P2021'
)
}