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:
@@ -3359,6 +3359,31 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
color: var(--app-text-muted);
|
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 {
|
.live-log-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(148px, 200px) 1fr;
|
grid-template-columns: minmax(148px, 200px) 1fr;
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ import {
|
|||||||
} from '../utils/liveEventCodes.js'
|
} from '../utils/liveEventCodes.js'
|
||||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.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 { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
dedupeSailNames,
|
dedupeSailNames,
|
||||||
@@ -167,11 +171,13 @@ export default function LiveLogView({
|
|||||||
const undoPhotoIdRef = useRef<string | null>(null)
|
const undoPhotoIdRef = useRef<string | null>(null)
|
||||||
const undoTimerRef = useRef<number | null>(null)
|
const undoTimerRef = useRef<number | null>(null)
|
||||||
const autoPositionBusyRef = useRef(false)
|
const autoPositionBusyRef = useRef(false)
|
||||||
|
const busyRef = useRef(busy)
|
||||||
const initSeqRef = useRef(0)
|
const initSeqRef = useRef(0)
|
||||||
const eventsRef = useRef(events)
|
const eventsRef = useRef(events)
|
||||||
const dateRef = useRef(date)
|
const dateRef = useRef(date)
|
||||||
eventsRef.current = events
|
eventsRef.current = events
|
||||||
dateRef.current = date
|
dateRef.current = date
|
||||||
|
busyRef.current = busy
|
||||||
|
|
||||||
const defaultSails = useMemo(
|
const defaultSails = useMemo(
|
||||||
() => (i18n.language === 'de'
|
() => (i18n.language === 'de'
|
||||||
@@ -185,6 +191,10 @@ export default function LiveLogView({
|
|||||||
)
|
)
|
||||||
const motorRunning = isMotorRunningFromEvents(events)
|
const motorRunning = isMotorRunningFromEvents(events)
|
||||||
const motorLabel = t('logs.motor_propulsion')
|
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 applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
||||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||||
@@ -276,7 +286,9 @@ export default function LiveLogView({
|
|||||||
return () => {
|
return () => {
|
||||||
initSeqRef.current += 1
|
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(() => {
|
useEffect(() => {
|
||||||
if (!loading && entryId) {
|
if (!loading && entryId) {
|
||||||
@@ -297,15 +309,34 @@ export default function LiveLogView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!entryId || loading) return
|
if (!entryId || loading) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let startTimer: number | undefined
|
||||||
|
let intervalRef: number | undefined
|
||||||
|
|
||||||
const maybeAutoPosition = async () => {
|
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)
|
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
|
||||||
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
|
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
|
||||||
|
|
||||||
autoPositionBusyRef.current = true
|
autoPositionBusyRef.current = true
|
||||||
try {
|
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, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
gpsLat: coords.lat,
|
gpsLat: coords.lat,
|
||||||
gpsLng: coords.lng,
|
gpsLng: coords.lng,
|
||||||
@@ -313,23 +344,26 @@ export default function LiveLogView({
|
|||||||
})
|
})
|
||||||
await refreshEntry(entryId)
|
await refreshEntry(entryId)
|
||||||
} catch {
|
} catch {
|
||||||
// Silent — auto-position is best-effort
|
// Best-effort; hint banner shows when no position fix exists yet.
|
||||||
} finally {
|
} finally {
|
||||||
autoPositionBusyRef.current = false
|
autoPositionBusyRef.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let intervalRef: number | undefined
|
void queryGeolocationPermission().then((permission) => {
|
||||||
const startTimer = window.setTimeout(() => {
|
if (cancelled || permission !== 'granted') return
|
||||||
void maybeAutoPosition()
|
startTimer = window.setTimeout(() => {
|
||||||
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
|
void maybeAutoPosition()
|
||||||
}, AUTO_POSITION_START_DELAY_MS)
|
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
|
||||||
|
}, AUTO_POSITION_START_DELAY_MS)
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(startTimer)
|
cancelled = true
|
||||||
|
if (startTimer !== undefined) window.clearTimeout(startTimer)
|
||||||
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
||||||
}
|
}
|
||||||
}, [entryId, loading, logbookId, refreshEntry, busy])
|
}, [entryId, loading, logbookId, refreshEntry])
|
||||||
|
|
||||||
const runQuickAction = async (
|
const runQuickAction = async (
|
||||||
action: () => Promise<boolean | void>,
|
action: () => Promise<boolean | void>,
|
||||||
@@ -364,8 +398,15 @@ export default function LiveLogView({
|
|||||||
const openSogModal = async () => {
|
const openSogModal = async () => {
|
||||||
let prefill = ''
|
let prefill = ''
|
||||||
try {
|
try {
|
||||||
const pos = await getCurrentPosition()
|
const permission = await queryGeolocationPermission()
|
||||||
if (pos.speedKn != null) prefill = String(pos.speedKn)
|
if (permission === 'granted') {
|
||||||
|
const pos = await getCurrentPosition({
|
||||||
|
timeoutMs: 8000,
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 60_000
|
||||||
|
})
|
||||||
|
if (pos.speedKn != null) prefill = String(pos.speedKn)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Manual entry when GPS speed unavailable
|
// Manual entry when GPS speed unavailable
|
||||||
}
|
}
|
||||||
@@ -405,7 +446,16 @@ export default function LiveLogView({
|
|||||||
setFixGpsLoading(true)
|
setFixGpsLoading(true)
|
||||||
setModal('fix')
|
setModal('fix')
|
||||||
try {
|
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)
|
setFixLat(coords.lat)
|
||||||
setFixLng(coords.lng)
|
setFixLng(coords.lng)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -419,12 +469,28 @@ export default function LiveLogView({
|
|||||||
setFixGpsLoading(true)
|
setFixGpsLoading(true)
|
||||||
setFixGpsUnavailable(false)
|
setFixGpsUnavailable(false)
|
||||||
try {
|
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)
|
setFixLat(coords.lat)
|
||||||
setFixLng(coords.lng)
|
setFixLng(coords.lng)
|
||||||
} catch {
|
} catch {
|
||||||
setFixGpsUnavailable(true)
|
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 {
|
} finally {
|
||||||
setFixGpsLoading(false)
|
setFixGpsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -786,6 +852,13 @@ export default function LiveLogView({
|
|||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{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">
|
<div className="live-log-layout">
|
||||||
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
|
<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}>
|
<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()}>
|
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>{t('logs.live_fix')}</h3>
|
<h3>{t('logs.live_fix')}</h3>
|
||||||
{fixGpsUnavailable && (
|
{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}>
|
<fieldset className="live-log-fix-coords" disabled={busy}>
|
||||||
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
||||||
|
|||||||
@@ -268,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Indtast tekst…",
|
"live_comment_placeholder": "Indtast tekst…",
|
||||||
"live_comment_confirm": "Indtast",
|
"live_comment_confirm": "Indtast",
|
||||||
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
"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_event_generic": "Hændelse",
|
||||||
"live_weather_btn": "Vejr",
|
"live_weather_btn": "Vejr",
|
||||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||||
|
|||||||
@@ -268,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Freitext eingeben…",
|
"live_comment_placeholder": "Freitext eingeben…",
|
||||||
"live_comment_confirm": "Eintragen",
|
"live_comment_confirm": "Eintragen",
|
||||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
"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_event_generic": "Ereignis",
|
||||||
"live_weather_btn": "Wetter",
|
"live_weather_btn": "Wetter",
|
||||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||||
|
|||||||
@@ -268,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Enter text…",
|
"live_comment_placeholder": "Enter text…",
|
||||||
"live_comment_confirm": "Log entry",
|
"live_comment_confirm": "Log entry",
|
||||||
"live_gps_error": "Could not determine GPS position.",
|
"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_event_generic": "Event",
|
||||||
"live_weather_btn": "Weather",
|
"live_weather_btn": "Weather",
|
||||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||||
|
|||||||
@@ -268,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Skriv inn tekst…",
|
"live_comment_placeholder": "Skriv inn tekst…",
|
||||||
"live_comment_confirm": "Loggfør",
|
"live_comment_confirm": "Loggfør",
|
||||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||||
|
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
|
||||||
"live_event_generic": "Hendelse",
|
"live_event_generic": "Hendelse",
|
||||||
"live_weather_btn": "Vær",
|
"live_weather_btn": "Vær",
|
||||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||||
|
|||||||
@@ -268,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Ange text…",
|
"live_comment_placeholder": "Ange text…",
|
||||||
"live_comment_confirm": "Logga",
|
"live_comment_confirm": "Logga",
|
||||||
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
"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_event_generic": "Händelse",
|
||||||
"live_weather_btn": "Väder",
|
"live_weather_btn": "Väder",
|
||||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js'
|
import {
|
||||||
|
getCurrentPosition,
|
||||||
|
normalizeGpsCoordinates,
|
||||||
|
parseGpsCoordinate,
|
||||||
|
queryGeolocationPermission
|
||||||
|
} from './geolocation.js'
|
||||||
|
|
||||||
describe('geolocation helpers', () => {
|
describe('geolocation helpers', () => {
|
||||||
it('parses coordinates with comma decimals', () => {
|
it('parses coordinates with comma decimals', () => {
|
||||||
@@ -17,4 +22,59 @@ describe('geolocation helpers', () => {
|
|||||||
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
|
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
|
||||||
expect(normalizeGpsCoordinates('0', '181')).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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
const MPS_TO_KNOTS = 1.9438444924406
|
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 {
|
export interface GeoCoordinates {
|
||||||
lat: string
|
lat: string
|
||||||
lng: string
|
lng: string
|
||||||
@@ -7,6 +10,15 @@ export interface GeoCoordinates {
|
|||||||
speedKn: number | null
|
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 {
|
export function parseGpsCoordinate(value: string): number | null {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
@@ -26,26 +38,75 @@ export function normalizeGpsCoordinates(
|
|||||||
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
reject(new Error('geolocation_unavailable'))
|
reject(new Error('geolocation_unavailable'))
|
||||||
return
|
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(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => {
|
(pos) => {
|
||||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
finish(() => resolve(positionFromGeolocationPosition(pos)))
|
||||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
|
||||||
: null
|
|
||||||
resolve({
|
|
||||||
lat: pos.coords.latitude.toFixed(6),
|
|
||||||
lng: pos.coords.longitude.toFixed(6),
|
|
||||||
speedKn
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
(err) => reject(err),
|
(err) => {
|
||||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
|
finish(() => reject(err))
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy, timeout: timeoutMs, maximumAge }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -5,9 +5,11 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "prisma generate && tsc",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
"start": "node dist/index.js",
|
"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": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -506,17 +506,33 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
|
|
||||||
router.get('/person-pool', requireUser, async (req: any, res) => {
|
router.get('/person-pool', requireUser, async (req: any, res) => {
|
||||||
try {
|
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({
|
const persons = await prisma.personPayload.findMany({
|
||||||
where: { userId: req.userId }
|
where: { userId: req.userId }
|
||||||
})
|
})
|
||||||
return res.json({ persons })
|
return res.json({ persons })
|
||||||
} catch (error: unknown) {
|
} 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')
|
return sendInternalError(res, error, 'auth/person-pool-get')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/person-pool/push', requireUser, async (req: any, res) => {
|
router.post('/person-pool/push', requireUser, async (req: any, res) => {
|
||||||
try {
|
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
|
const { items } = req.body
|
||||||
if (!items || !Array.isArray(items)) {
|
if (!items || !Array.isArray(items)) {
|
||||||
return res.status(400).json({ error: 'items array is required' })
|
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 })
|
return res.json({ results })
|
||||||
} catch (error: unknown) {
|
} 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')
|
return sendInternalError(res, error, 'auth/person-pool-push')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -248,6 +248,16 @@ router.post('/push', async (req: any, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (type === 'logbookCrew') {
|
} 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 } })
|
const existing = await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } })
|
||||||
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
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 entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||||
const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
|
let logbookCrewSelection = null
|
||||||
where: { logbookId }
|
const { hasCrewPoolPrismaModels } = await import('../utils/crewPoolSchema.js')
|
||||||
})
|
if (hasCrewPoolPrismaModels()) {
|
||||||
|
logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
|
||||||
|
where: { logbookId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
yacht,
|
yacht,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user