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
+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 }
)
})
}