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:
@@ -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: {},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user