feat(gps): klare Fehlerhinweise, Empfangsqualität und Live-Log-Freigabe

Nutzer sehen spezifische Meldungen bei GPS-Problemen, eine Schätzung des
Empfangs aus der Browser-Genauigkeit und beim ersten Live-Log-Besuch nur
dann einen Freigabe-Hinweis, wenn die Standortberechtigung noch offen ist.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 17:53:58 +02:00
parent 1326045b25
commit 24160b6c5d
11 changed files with 505 additions and 45 deletions
+38 -3
View File
@@ -1,12 +1,28 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
classifyGpsAccuracyMeters,
geolocationErrorI18nKey,
GEOLOCATION_LIVE_INTRO_STORAGE_KEY,
getCurrentPosition,
getGeolocationErrorReason,
hasSeenGeolocationLiveIntro,
markGeolocationLiveIntroSeen,
normalizeGpsCoordinates,
parseGpsCoordinate,
queryGeolocationPermission
} from './geolocation.js'
describe('geolocation helpers', () => {
beforeEach(() => {
localStorage.removeItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY)
})
it('tracks Live-Log geolocation intro in localStorage', () => {
expect(hasSeenGeolocationLiveIntro()).toBe(false)
markGeolocationLiveIntroSeen()
expect(hasSeenGeolocationLiveIntro()).toBe(true)
})
it('parses coordinates with comma decimals', () => {
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
})
@@ -50,7 +66,7 @@ describe('geolocation helpers', () => {
geolocation: {
getCurrentPosition: (success: PositionCallback) => {
success({
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5, accuracy: 12 }
} as GeolocationPosition)
}
}
@@ -59,10 +75,29 @@ describe('geolocation helpers', () => {
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
lat: '59.910000',
lng: '10.750000',
speedKn: 4.9
speedKn: 4.9,
accuracyM: 12,
signalQuality: 'excellent'
})
})
it('classifies GPS accuracy into signal quality', () => {
expect(classifyGpsAccuracyMeters(8)).toBe('excellent')
expect(classifyGpsAccuracyMeters(30)).toBe('good')
expect(classifyGpsAccuracyMeters(80)).toBe('fair')
expect(classifyGpsAccuracyMeters(250)).toBe('poor')
expect(classifyGpsAccuracyMeters(null)).toBe('unknown')
})
it('maps GeolocationPositionError codes to reasons', () => {
expect(getGeolocationErrorReason({ code: 1 } as GeolocationPositionError)).toBe('permission_denied')
expect(getGeolocationErrorReason({ code: 2 } as GeolocationPositionError)).toBe('position_unavailable')
expect(getGeolocationErrorReason({ code: 3 } as GeolocationPositionError)).toBe('timeout')
expect(getGeolocationErrorReason(new Error('geolocation_timeout'))).toBe('timeout')
expect(getGeolocationErrorReason(new Error('geolocation_unavailable'))).toBe('unavailable')
expect(geolocationErrorI18nKey('permission_denied')).toBe('logs.gps_permission_denied')
})
it('reads permission state when supported', async () => {
vi.stubGlobal('navigator', {
geolocation: {},
+85 -1
View File
@@ -3,15 +3,75 @@ const MPS_TO_KNOTS = 1.9438444924406
/** Extra ms beyond the native timeout so hung browsers still reject. */
const TIMEOUT_GRACE_MS = 750
/** Estimated fix quality from browser accuracy (metres). Real satellite count is not exposed to web apps. */
export type GpsSignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'unknown'
export interface GeoCoordinates {
lat: string
lng: string
/** SOG from GPS when available (kn), otherwise null. */
speedKn: number | null
/** Estimated horizontal accuracy in metres, when reported by the browser. */
accuracyM: number | null
/** Derived signal quality indicator for UI hints. */
signalQuality: GpsSignalQuality
}
/** Classifies GPS fix quality from reported accuracy (lower metres = better). */
export function classifyGpsAccuracyMeters(accuracyM: number | null | undefined): GpsSignalQuality {
if (accuracyM == null || !Number.isFinite(accuracyM) || accuracyM < 0) return 'unknown'
if (accuracyM <= 15) return 'excellent'
if (accuracyM <= 40) return 'good'
if (accuracyM <= 100) return 'fair'
return 'poor'
}
export function gpsQualityI18nKey(quality: GpsSignalQuality): string {
return `logs.gps_quality_${quality}`
}
export function formatGpsAccuracyMeters(accuracyM: number): string {
return accuracyM < 100 ? String(Math.round(accuracyM)) : String(Math.round(accuracyM))
}
export type GeolocationPermissionState = PermissionState | 'unsupported'
export type GeolocationErrorReason =
| 'unavailable'
| 'timeout'
| 'permission_denied'
| 'position_unavailable'
| 'unknown'
/** Maps browser / wrapper errors to a stable reason for i18n. */
export function getGeolocationErrorReason(error: unknown): GeolocationErrorReason {
if (error instanceof Error) {
if (error.message === 'geolocation_unavailable') return 'unavailable'
if (error.message === 'geolocation_timeout') return 'timeout'
}
const code = (error as GeolocationPositionError | undefined)?.code
if (code === 1) return 'permission_denied'
if (code === 2) return 'position_unavailable'
if (code === 3) return 'timeout'
return 'unknown'
}
/** i18n key (full path, e.g. logs.gps_timeout) for a geolocation failure reason. */
export function geolocationErrorI18nKey(reason: GeolocationErrorReason): string {
switch (reason) {
case 'unavailable':
return 'logs.gps_unavailable'
case 'timeout':
return 'logs.gps_timeout'
case 'permission_denied':
return 'logs.gps_permission_denied'
case 'position_unavailable':
return 'logs.gps_position_unavailable'
default:
return 'logs.gps_failed'
}
}
export interface GetPositionOptions {
timeoutMs?: number
/** Manual fixes may use high accuracy; background auto-position should not. */
@@ -38,6 +98,25 @@ export function normalizeGpsCoordinates(
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
}
/** localStorage: user has seen the Live-Log geolocation intro (allow or dismiss). */
export const GEOLOCATION_LIVE_INTRO_STORAGE_KEY = 'kdb_geolocation_live_intro_seen'
export function hasSeenGeolocationLiveIntro(): boolean {
try {
return localStorage.getItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY) === '1'
} catch {
return false
}
}
export function markGeolocationLiveIntroSeen(): void {
try {
localStorage.setItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY, '1')
} catch {
// Private mode / quota — non-fatal
}
}
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
if (!navigator.geolocation) return 'unsupported'
if (!navigator.permissions?.query) return 'prompt'
@@ -65,10 +144,15 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy)
? pos.coords.accuracy
: null
return {
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
speedKn,
accuracyM,
signalQuality: classifyGpsAccuracyMeters(accuracyM)
}
}