From 24160b6c5d8bbd8e088921c0de5773c8dbe0c00f Mon Sep 17 00:00:00 2001 From: elpatron Date: Wed, 3 Jun 2026 17:53:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(gps):=20klare=20Fehlerhinweise,=20Empfangs?= =?UTF-8?q?qualit=C3=A4t=20und=20Live-Log-Freigabe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.css | 78 ++++++++++++++ client/src/components/GpsSignalHint.tsx | 47 +++++++++ client/src/components/LiveLogView.tsx | 129 +++++++++++++++++++---- client/src/components/LogEntryEditor.tsx | 79 ++++++++++---- client/src/i18n/locales/da.json | 18 ++++ client/src/i18n/locales/de.json | 18 ++++ client/src/i18n/locales/en.json | 18 ++++ client/src/i18n/locales/nb.json | 18 ++++ client/src/i18n/locales/sv.json | 18 ++++ client/src/utils/geolocation.test.ts | 41 ++++++- client/src/utils/geolocation.ts | 86 ++++++++++++++- 11 files changed, 505 insertions(+), 45 deletions(-) create mode 100644 client/src/components/GpsSignalHint.tsx diff --git a/client/src/App.css b/client/src/App.css index 4578d23..b67a0b8 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -3492,6 +3492,84 @@ html.theme-cupertino .events-scroll-container { color: var(--app-text, inherit); } +.live-log-gps-error-modal { + color: var(--app-warning-text, #b45309); + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.35); + border-radius: 8px; + padding: 8px 10px; +} + +.gps-signal-hint { + margin: 8px 0 0; + font-size: 13px; + line-height: 1.45; +} + +.gps-signal-hint-label { + display: inline-flex; + align-items: flex-start; + gap: 8px; +} + +.gps-signal-icon { + flex-shrink: 0; + margin-top: 2px; +} + +.gps-signal-bars { + display: inline-flex; + align-items: flex-end; + gap: 2px; + height: 14px; + flex-shrink: 0; +} + +.gps-signal-bar { + width: 4px; + border-radius: 1px; + background: var(--app-border, rgba(148, 163, 184, 0.45)); +} + +.gps-signal-bar:nth-child(1) { height: 4px; } +.gps-signal-bar:nth-child(2) { height: 7px; } +.gps-signal-bar:nth-child(3) { height: 10px; } +.gps-signal-bar:nth-child(4) { height: 14px; } + +.gps-signal-bar.is-active { + background: var(--app-accent-light, #22c55e); +} + +.gps-signal-excellent .gps-signal-bar.is-active { + background: #22c55e; +} + +.gps-signal-good .gps-signal-bar.is-active { + background: #84cc16; +} + +.gps-signal-fair .gps-signal-bar.is-active, +.gps-signal-unknown .gps-signal-bar.is-active { + background: #eab308; +} + +.gps-signal-poor .gps-signal-bar.is-active { + background: #f97316; +} + +.gps-signal-poor, +.gps-signal-fair { + color: var(--app-warning-text, #b45309); +} + +.gps-signal-hint-editor { + margin-top: 6px; +} + +.gps-signal-hint-modal { + margin: 0 0 10px; +} + .live-log-layout { display: grid; grid-template-columns: minmax(148px, 200px) 1fr; diff --git a/client/src/components/GpsSignalHint.tsx b/client/src/components/GpsSignalHint.tsx new file mode 100644 index 0000000..af1fac2 --- /dev/null +++ b/client/src/components/GpsSignalHint.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from 'react-i18next' +import { Signal } from 'lucide-react' +import { + formatGpsAccuracyMeters, + gpsQualityI18nKey, + type GpsSignalQuality +} from '../utils/geolocation.js' + +const SIGNAL_BARS: Record = { + excellent: 4, + good: 3, + fair: 2, + poor: 1, + unknown: 0 +} + +interface GpsSignalHintProps { + quality: GpsSignalQuality + accuracyM: number | null + className?: string +} + +export default function GpsSignalHint({ quality, accuracyM, className = '' }: GpsSignalHintProps) { + const { t } = useTranslation() + const bars = SIGNAL_BARS[quality] + const i18nParams = accuracyM != null ? { accuracy: formatGpsAccuracyMeters(accuracyM) } : undefined + + return ( +

+ + + + {[1, 2, 3, 4].map((level) => ( + + ))} + + {t(gpsQualityI18nKey(quality), i18nParams)} + +

+ ) +} diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index 54ea4d9..57b7355 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -53,9 +53,15 @@ import { import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { + geolocationErrorI18nKey, getCurrentPosition, + getGeolocationErrorReason, + hasSeenGeolocationLiveIntro, + markGeolocationLiveIntroSeen, normalizeGpsCoordinates, - queryGeolocationPermission + queryGeolocationPermission, + type GeolocationErrorReason, + type GpsSignalQuality } from '../utils/geolocation.js' import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js' import { @@ -66,6 +72,7 @@ import { } from '../utils/sailSelection.js' import { useDialog } from './ModalDialog.tsx' import CourseDialInput from './CourseDialInput.tsx' +import GpsSignalHint from './GpsSignalHint.tsx' import LiveCameraCapture from './LiveCameraCapture.tsx' import LiveVoiceCapture from './LiveVoiceCapture.tsx' import VoiceMemoPlayer from './VoiceMemoPlayer.tsx' @@ -142,13 +149,21 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string { return '' } +function gpsFailureAlertBody( + t: (key: string) => string, + reason: GeolocationErrorReason +): string { + return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}` +} + export default function LiveLogView({ logbookId, onOpenEditor, onSwitchToList }: LiveLogViewProps) { const { t, i18n } = useTranslation() - const { showAlert } = useDialog() + const { showAlert, showConfirm } = useDialog() + const [geolocationAccessEpoch, setGeolocationAccessEpoch] = useState(0) const [entryId, setEntryId] = useState(null) const [dayOfTravel, setDayOfTravel] = useState('') @@ -171,6 +186,11 @@ export default function LiveLogView({ const [positionLng, setPositionLng] = useState('') const [positionGpsLoading, setPositionGpsLoading] = useState(false) const [positionGpsUnavailable, setPositionGpsUnavailable] = useState(false) + const [positionGpsErrorReason, setPositionGpsErrorReason] = useState(null) + const [positionGpsSignal, setPositionGpsSignal] = useState<{ + quality: GpsSignalQuality + accuracyM: number | null + } | null>(null) const [photoCaption, setPhotoCaption] = useState('') const [photoSaving, setPhotoSaving] = useState(false) const [voiceCaption, setVoiceCaption] = useState('') @@ -310,6 +330,56 @@ export default function LiveLogView({ } }, [loading, entryId]) + useEffect(() => { + if (loading || !entryId || !navigator.geolocation) return + + let cancelled = false + + void (async () => { + const permission = await queryGeolocationPermission() + if (cancelled) return + + if (permission === 'granted') { + markGeolocationLiveIntroSeen() + setGeolocationAccessEpoch((n) => n + 1) + return + } + + // Only ask when the browser has not granted location yet (state "prompt"). + if (permission !== 'prompt' || hasSeenGeolocationLiveIntro()) return + + const allow = await showConfirm( + t('logs.gps_live_intro_body'), + t('logs.gps_live_intro_title'), + t('logs.gps_live_intro_allow'), + t('logs.gps_live_intro_later') + ) + markGeolocationLiveIntroSeen() + if (cancelled || !allow) return + + try { + await getCurrentPosition({ + timeoutMs: 15_000, + enableHighAccuracy: false, + maximumAge: 0 + }) + if (!cancelled) setGeolocationAccessEpoch((n) => n + 1) + } catch (err) { + const reason = getGeolocationErrorReason(err) + if (reason === 'permission_denied') { + await showAlert( + `${t('logs.gps_permission_denied')}\n\n${t('logs.gps_enable_in_settings_hint')}`, + t('logs.live_title') + ) + } + } + })() + + return () => { + cancelled = true + } + }, [loading, entryId, showAlert, showConfirm, t]) + useEffect(() => { streamEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [events.length]) @@ -377,7 +447,7 @@ export default function LiveLogView({ if (startTimer !== undefined) window.clearTimeout(startTimer) if (intervalRef !== undefined) window.clearInterval(intervalRef) } - }, [entryId, loading, logbookId, refreshEntry]) + }, [entryId, loading, logbookId, refreshEntry, geolocationAccessEpoch]) const runQuickAction = async ( action: () => Promise, @@ -453,16 +523,26 @@ export default function LiveLogView({ }, 'moor') } + const reportPositionGpsFailure = async (reason: GeolocationErrorReason) => { + setPositionGpsUnavailable(true) + setPositionGpsErrorReason(reason) + setPositionGpsSignal(null) + await showAlert(gpsFailureAlertBody(t, reason), t('logs.live_position')) + } + const openPositionModal = async () => { setPositionLat('') setPositionLng('') setPositionGpsUnavailable(false) + setPositionGpsErrorReason(null) + setPositionGpsSignal(null) setPositionGpsLoading(true) setModal('position') try { const permission = await queryGeolocationPermission() if (permission !== 'granted') { - setPositionGpsUnavailable(true) + const reason = permission === 'denied' ? 'permission_denied' : 'unavailable' + await reportPositionGpsFailure(reason) return } const coords = await getCurrentPosition({ @@ -472,8 +552,9 @@ export default function LiveLogView({ }) setPositionLat(coords.lat) setPositionLng(coords.lng) - } catch { - setPositionGpsUnavailable(true) + setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM }) + } catch (err) { + await reportPositionGpsFailure(getGeolocationErrorReason(err)) } finally { setPositionGpsLoading(false) } @@ -482,14 +563,13 @@ export default function LiveLogView({ const retryPositionGps = async () => { setPositionGpsLoading(true) setPositionGpsUnavailable(false) + setPositionGpsErrorReason(null) + setPositionGpsSignal(null) try { const permission = await queryGeolocationPermission() if (permission !== 'granted') { - setPositionGpsUnavailable(true) - await showAlert( - `${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`, - t('logs.live_position') - ) + const reason = permission === 'denied' ? 'permission_denied' : 'unavailable' + await reportPositionGpsFailure(reason) return } const coords = await getCurrentPosition({ @@ -499,12 +579,10 @@ export default function LiveLogView({ }) setPositionLat(coords.lat) setPositionLng(coords.lng) - } catch { - setPositionGpsUnavailable(true) - await showAlert( - `${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`, - t('logs.live_position') - ) + setPositionGpsUnavailable(false) + setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM }) + } catch (err) { + await reportPositionGpsFailure(getGeolocationErrorReason(err)) } finally { setPositionGpsLoading(false) } @@ -1170,7 +1248,11 @@ export default function LiveLogView({

{t('logs.live_position')}

{positionGpsUnavailable && ( <> -

{t('logs.live_gps_start_hint')}

+ {positionGpsErrorReason && ( +

+ {t(geolocationErrorI18nKey(positionGpsErrorReason))} +

+ )}

{t('logs.live_position_manual_hint')}

)} @@ -1185,7 +1267,7 @@ export default function LiveLogView({ className="input-text" placeholder="54.123456" value={positionLat} - onChange={(e) => setPositionLat(e.target.value)} + onChange={(e) => { setPositionGpsSignal(null); setPositionLat(e.target.value) }} autoFocus /> @@ -1197,11 +1279,18 @@ export default function LiveLogView({ className="input-text" placeholder="10.654321" value={positionLng} - onChange={(e) => setPositionLng(e.target.value)} + onChange={(e) => { setPositionGpsSignal(null); setPositionLng(e.target.value) }} onKeyDown={(e) => { if (e.key === 'Enter') confirmPosition() }} /> + {positionGpsSignal && ( + + )}
+ {gpsSignal && ( + + )} diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index f4331bb..ae098f7 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -381,6 +381,24 @@ "event_location_placeholder": "z. f.eks. Kiel", "event_remarks": "Bemærkninger / hændelser", "gps_btn": "Hent GPS-koordinater", + "gps_permission_denied": "Adgang til placering blev nægtet. Tillad det i browser- eller enhedsindstillinger og prøv igen.", + "gps_timeout": "GPS fik timeout. Prøv igen udendørs med frit udsyn til himlen.", + "gps_position_unavailable": "Intet GPS-signal tilgængeligt. Vent og prøv igen, eller indtast koordinater manuelt.", + "gps_unavailable": "GPS understøttes ikke af denne browser eller enhed.", + "gps_failed": "GPS-position kunne ikke bestemmes.", + "gps_fallback_no_location": "GPS mislykkedes. Angiv et sted under placering/havn, afgang eller destination, eller indtast koordinater manuelt.", + "gps_fallback_success": "Koordinater for \"{{location}}\" fundet via stedsnavn (ikke GPS).", + "gps_fallback_failed": "GPS og stedsnavnssøgning mislykkedes. Indtast koordinater manuelt.", + "gps_quality_excellent": "Stærk GPS-modtagelse (±{{accuracy}} m)", + "gps_quality_good": "God GPS-modtagelse (±{{accuracy}} m)", + "gps_quality_fair": "Middel GPS-modtagelse (±{{accuracy}} m) – gå udendørs for bedre signal.", + "gps_quality_poor": "Svag GPS-modtagelse (±{{accuracy}} m) – sandsynligvis få satellitter. Prøv udendørs igen eller kontroller positionen.", + "gps_quality_unknown": "GPS-position overtaget (nøjagtighed ikke rapporteret af enheden).", + "gps_live_intro_title": "Placering til live-log", + "gps_live_intro_body": "Appen har brug for din placering til automatiske positionsindlæg og GPS-knappen.\n\nTryk på „Tillad placering“ og bekræft i den næste dialog. Du kan altid indtaste position manuelt via „Position“.", + "gps_live_intro_allow": "Tillad placering", + "gps_live_intro_later": "Senere", + "gps_enable_in_settings_hint": "Adgang til placering er blokeret. Du kan tillade det senere i browser- eller enhedsindstillinger (websted / app → Placering).", "weather_btn": "OpenWeatherMap Kald vejret op", "weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.", "event_wind_pressure": "Lufttryk (hPa)", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index f0e1d82..8a204df 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -381,6 +381,24 @@ "event_location_placeholder": "z. B. Kiel", "event_remarks": "Bemerkungen / Vorkommnisse", "gps_btn": "GPS-Koordinaten abrufen", + "gps_permission_denied": "Standortzugriff wurde verweigert. Bitte in den Browser- oder Geräteeinstellungen erlauben und erneut versuchen.", + "gps_timeout": "GPS-Zeitüberschreitung. Bitte erneut versuchen – am besten im Freien mit gutem Empfang.", + "gps_position_unavailable": "Kein GPS-Signal verfügbar. Bitte warten oder Koordinaten manuell eingeben.", + "gps_unavailable": "GPS wird von diesem Browser oder Gerät nicht unterstützt.", + "gps_failed": "GPS-Position konnte nicht ermittelt werden.", + "gps_fallback_no_location": "GPS fehlgeschlagen. Bitte einen Ort unter „Ort / Hafen“, Start- oder Zielhafen eintragen, oder Koordinaten manuell eingeben.", + "gps_fallback_success": "Koordinaten für „{{location}}“ über den Ortsnamen ermittelt (nicht per GPS).", + "gps_fallback_failed": "GPS und Ortsnamen-Suche sind fehlgeschlagen. Bitte Koordinaten manuell eingeben.", + "gps_quality_excellent": "Starker GPS-Empfang (±{{accuracy}} m)", + "gps_quality_good": "Guter GPS-Empfang (±{{accuracy}} m)", + "gps_quality_fair": "Mäßiger GPS-Empfang (±{{accuracy}} m) – für besseren Empfang ins Freie gehen.", + "gps_quality_poor": "Schwacher GPS-Empfang (±{{accuracy}} m) – vermutlich wenig Satelliten. Im Freien erneut versuchen oder Position prüfen.", + "gps_quality_unknown": "GPS-Position übernommen (Genauigkeit vom Gerät nicht gemeldet).", + "gps_live_intro_title": "Standort für Live-Log", + "gps_live_intro_body": "Für automatische Positions-Einträge und den GPS-Knopf braucht die App Zugriff auf deinen Standort.\n\nTippe auf „Standort erlauben“ – im nächsten Dialog die Freigabe bestätigen. Du kannst jederzeit manuell unter „Position“ eintragen.", + "gps_live_intro_allow": "Standort erlauben", + "gps_live_intro_later": "Später", + "gps_enable_in_settings_hint": "Standortzugriff ist blockiert. In den Browser- oder Geräteeinstellungen (Website / App → Standort) kannst du die Freigabe nachträglich erlauben.", "weather_btn": "OpenWeatherMap Wetter abrufen", "weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.", "event_wind_pressure": "Luftdruck (hPa)", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 677be8f..b979c07 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -381,6 +381,24 @@ "event_location_placeholder": "e.g. Kiel", "event_remarks": "Remarks / Events", "gps_btn": "Get GPS Location", + "gps_permission_denied": "Location access was denied. Allow it in your browser or device settings and try again.", + "gps_timeout": "GPS timed out. Try again outdoors with a clear view of the sky.", + "gps_position_unavailable": "No GPS signal available. Wait and retry, or enter coordinates manually.", + "gps_unavailable": "GPS is not supported by this browser or device.", + "gps_failed": "Could not determine GPS position.", + "gps_fallback_no_location": "GPS failed. Enter a place under Location / harbour, departure, or destination, or type coordinates manually.", + "gps_fallback_success": "Coordinates for \"{{location}}\" resolved from place name (not GPS).", + "gps_fallback_failed": "GPS and place-name lookup both failed. Please enter coordinates manually.", + "gps_quality_excellent": "Strong GPS reception (±{{accuracy}} m)", + "gps_quality_good": "Good GPS reception (±{{accuracy}} m)", + "gps_quality_fair": "Fair GPS reception (±{{accuracy}} m) — move outdoors for a better fix.", + "gps_quality_poor": "Weak GPS reception (±{{accuracy}} m) — likely few satellites. Retry outdoors or verify the position.", + "gps_quality_unknown": "GPS position applied (accuracy not reported by device).", + "gps_live_intro_title": "Location for Live Log", + "gps_live_intro_body": "The app needs your location for automatic position entries and the GPS button.\n\nTap “Allow location” and confirm in the next dialog. You can always enter a position manually via “Position”.", + "gps_live_intro_allow": "Allow location", + "gps_live_intro_later": "Later", + "gps_enable_in_settings_hint": "Location access is blocked. You can allow it later in your browser or device settings (site / app → Location).", "weather_btn": "Fetch OpenWeatherMap Weather", "weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.", "event_wind_pressure": "Barometer (hPa)", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 053320e..efd789f 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -381,6 +381,24 @@ "event_location_placeholder": "z. f.eks. Kiel", "event_remarks": "Merknader / hendelser", "gps_btn": "Hent GPS-koordinater", + "gps_permission_denied": "Tilgang til posisjon ble nektet. Tillat det i nettleser- eller enhetsinnstillinger og prøv igjen.", + "gps_timeout": "GPS fikk tidsavbrudd. Prøv igjen utendørs med fri sikt mot himmelen.", + "gps_position_unavailable": "Ingen GPS-signal tilgjengelig. Vent og prøv igjen, eller skriv inn koordinater manuelt.", + "gps_unavailable": "GPS støttes ikke av denne nettleseren eller enheten.", + "gps_failed": "GPS-posisjon kunne ikke bestemmes.", + "gps_fallback_no_location": "GPS mislyktes. Skriv inn et sted under sted/havn, avreise eller destinasjon, eller koordinater manuelt.", + "gps_fallback_success": "Koordinater for «{{location}}» funnet via stedsnavn (ikke GPS).", + "gps_fallback_failed": "GPS og stedsnavnssøk mislyktes. Skriv inn koordinater manuelt.", + "gps_quality_excellent": "Sterk GPS-mottak (±{{accuracy}} m)", + "gps_quality_good": "God GPS-mottak (±{{accuracy}} m)", + "gps_quality_fair": "Middels GPS-mottak (±{{accuracy}} m) – gå utendørs for bedre signal.", + "gps_quality_poor": "Svakt GPS-mottak (±{{accuracy}} m) – sannsynligvis få satellitter. Prøv utendørs igjen eller kontroller posisjonen.", + "gps_quality_unknown": "GPS-posisjon tatt i bruk (nøyaktighet ikke rapportert av enheten).", + "gps_live_intro_title": "Posisjon for live-logg", + "gps_live_intro_body": "Appen trenger posisjonen din for automatiske posisjonsregistreringer og GPS-knappen.\n\nTrykk «Tillat posisjon» og bekreft i neste dialog. Du kan alltid legge inn posisjon manuelt via «Posisjon».", + "gps_live_intro_allow": "Tillat posisjon", + "gps_live_intro_later": "Senere", + "gps_enable_in_settings_hint": "Posisjonstilgang er blokkert. Du kan tillate det senere i nettleser- eller enhetsinnstillinger (nettsted / app → Posisjon).", "weather_btn": "OpenWeatherMap Ring opp været", "weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.", "event_wind_pressure": "Lufttrykk (hPa)", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index c0f6d87..81f8103 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -381,6 +381,24 @@ "event_location_placeholder": "z. t.ex. Kiel", "event_remarks": "Anmärkningar / incidenter", "gps_btn": "Hämta GPS-koordinater", + "gps_permission_denied": "Platstillgång nekades. Tillåt det i webbläsar- eller enhetsinställningar och försök igen.", + "gps_timeout": "GPS fick tidsgräns. Försök igen utomhus med fri sikt mot himlen.", + "gps_position_unavailable": "Ingen GPS-signal tillgänglig. Vänta och försök igen, eller ange koordinater manuellt.", + "gps_unavailable": "GPS stöds inte av denna webbläsare eller enhet.", + "gps_failed": "GPS-position kunde inte bestämmas.", + "gps_fallback_no_location": "GPS misslyckades. Ange en plats under ort/hamn, avresa eller destination, eller skriv koordinater manuellt.", + "gps_fallback_success": "Koordinater för \"{{location}}\" hittades via ortsnamn (inte GPS).", + "gps_fallback_failed": "GPS och ortnamnssökning misslyckades. Ange koordinater manuellt.", + "gps_quality_excellent": "Stark GPS-mottagning (±{{accuracy}} m)", + "gps_quality_good": "Bra GPS-mottagning (±{{accuracy}} m)", + "gps_quality_fair": "Måttlig GPS-mottagning (±{{accuracy}} m) – gå utomhus för bättre signal.", + "gps_quality_poor": "Svag GPS-mottagning (±{{accuracy}} m) – troligen få satelliter. Försök utomhus igen eller kontrollera positionen.", + "gps_quality_unknown": "GPS-position övertagen (noggrannhet ej rapporterad av enheten).", + "gps_live_intro_title": "Plats för live-logg", + "gps_live_intro_body": "Appen behöver din plats för automatiska positionsregistreringar och GPS-knappen.\n\nTryck på „Tillåt plats“ och bekräfta i nästa dialog. Du kan alltid ange position manuellt via „Position“.", + "gps_live_intro_allow": "Tillåt plats", + "gps_live_intro_later": "Senare", + "gps_enable_in_settings_hint": "Platstillgång är blockerad. Du kan tillåta det senare i webbläsar- eller enhetsinställningar (webbplats / app → Plats).", "weather_btn": "OpenWeatherMap Ring upp väder", "weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.", "event_wind_pressure": "Lufttryck (hPa)", diff --git a/client/src/utils/geolocation.test.ts b/client/src/utils/geolocation.test.ts index a3978ae..b50016c 100644 --- a/client/src/utils/geolocation.test.ts +++ b/client/src/utils/geolocation.test.ts @@ -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: {}, diff --git a/client/src/utils/geolocation.ts b/client/src/utils/geolocation.ts index ad05510..d260dd3 100644 --- a/client/src/utils/geolocation.ts +++ b/client/src/utils/geolocation.ts @@ -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 { 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) } }