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
+78
View File
@@ -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;
+47
View File
@@ -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<GpsSignalQuality, number> = {
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 (
<p
className={`gps-signal-hint gps-signal-${quality} ${className}`.trim()}
role="status"
>
<span className="gps-signal-hint-label">
<Signal size={14} aria-hidden className="gps-signal-icon" />
<span className="gps-signal-bars" aria-hidden>
{[1, 2, 3, 4].map((level) => (
<span
key={level}
className={`gps-signal-bar ${level <= bars ? 'is-active' : ''}`}
/>
))}
</span>
<span>{t(gpsQualityI18nKey(quality), i18nParams)}</span>
</span>
</p>
)
}
+109 -20
View File
@@ -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<string | null>(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<GeolocationErrorReason | null>(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<boolean | void>,
@@ -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({
<h3>{t('logs.live_position')}</h3>
{positionGpsUnavailable && (
<>
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p>
{positionGpsErrorReason && (
<p className="live-log-modal-hint live-log-gps-error-modal" role="alert">
{t(geolocationErrorI18nKey(positionGpsErrorReason))}
</p>
)}
<p className="live-log-modal-hint">{t('logs.live_position_manual_hint')}</p>
</>
)}
@@ -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
/>
</label>
@@ -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() }}
/>
</label>
</div>
{positionGpsSignal && (
<GpsSignalHint
quality={positionGpsSignal.quality}
accuracyM={positionGpsSignal.accuracyM}
className="gps-signal-hint-modal"
/>
)}
<div className="live-log-position-gps-row">
<button
type="button"
+58 -21
View File
@@ -67,6 +67,14 @@ import {
} from '../services/nmeaArchive.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import GpsSignalHint from './GpsSignalHint.tsx'
import {
geolocationErrorI18nKey,
getCurrentPosition,
getGeolocationErrorReason,
queryGeolocationPermission,
type GpsSignalQuality
} from '../utils/geolocation.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
import TankLiterInput from './TankLiterInput.tsx'
import MetricRangeInput from './MetricRangeInput.tsx'
@@ -263,6 +271,10 @@ export default function LogEntryEditor({
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [gpsSignal, setGpsSignal] = useState<{
quality: GpsSignalQuality
accuracyM: number | null
} | null>(null)
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
// Track file upload
@@ -1006,12 +1018,16 @@ export default function LogEntryEditor({
}
}
const handleGetGps = () => {
const clearGpsSignal = () => setGpsSignal(null)
const handleGetGps = async () => {
if (readOnly) return
const lookupFallback = async () => {
clearGpsSignal()
const locationQuery = evLocationName.trim() || departure.trim() || destination.trim()
if (!locationQuery) {
showAlert('GPS capturing failed, and no location name is entered in "Ort / Hafen" or "Start-Hafen" to look up coordinates.')
showAlert(t('logs.gps_fallback_no_location'))
return
}
if (!isOnline) {
@@ -1028,7 +1044,9 @@ export default function LogEntryEditor({
if (coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
setEvGpsLng(Number(coord.lon).toFixed(6))
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
showAlert(t('logs.gps_fallback_success', { location: locationQuery }))
} else {
showAlert(t('logs.gps_fallback_failed'))
}
} catch (e) {
if (e instanceof WeatherApiError && e.code === 'OFFLINE') {
@@ -1039,25 +1057,37 @@ export default function LogEntryEditor({
showAlert(t('settings.no_key'))
return
}
showAlert('Failed to retrieve GPS location or look up coordinates by location name.')
showAlert(t('logs.gps_fallback_failed'))
}
}
if (!navigator.geolocation) {
lookupFallback()
return
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setEvGpsLat(pos.coords.latitude.toFixed(6))
setEvGpsLng(pos.coords.longitude.toFixed(6))
},
(err) => {
console.warn('GPS capturing failed, trying fallback:', err)
lookupFallback()
try {
const permission = await queryGeolocationPermission()
if (permission === 'denied' || permission === 'unsupported') {
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
showAlert(
`${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
)
await lookupFallback()
return
}
)
const coords = await getCurrentPosition({
timeoutMs: 15_000,
enableHighAccuracy: false,
maximumAge: 60_000
})
setEvGpsLat(coords.lat)
setEvGpsLng(coords.lng)
setGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
} catch (err) {
console.warn('GPS capture failed:', err)
const reason = getGeolocationErrorReason(err)
showAlert(
`${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
)
await lookupFallback()
}
}
const handleFetchWeather = async () => {
@@ -1938,7 +1968,7 @@ export default function LogEntryEditor({
placeholder="Lat"
className="input-text"
value={evGpsLat}
onChange={(e) => setEvGpsLat(e.target.value)}
onChange={(e) => { clearGpsSignal(); setEvGpsLat(e.target.value) }}
disabled={saving}
/>
<input
@@ -1946,13 +1976,13 @@ export default function LogEntryEditor({
placeholder="Lng"
className="input-text"
value={evGpsLng}
onChange={(e) => setEvGpsLng(e.target.value)}
onChange={(e) => { clearGpsSignal(); setEvGpsLng(e.target.value) }}
disabled={saving}
/>
<button
type="button"
className="btn secondary"
onClick={handleGetGps}
onClick={() => void handleGetGps()}
title={t('logs.gps_btn')}
style={{ width: 'auto', padding: '12px' }}
disabled={saving}
@@ -1975,6 +2005,13 @@ export default function LogEntryEditor({
<CloudSun size={16} />
</button>
</div>
{gpsSignal && (
<GpsSignalHint
quality={gpsSignal.quality}
accuracyM={gpsSignal.accuracyM}
className="gps-signal-hint-editor"
/>
)}
</div>
</div>
+18
View File
@@ -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)",
+18
View File
@@ -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)",
+18
View File
@@ -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)",
+18
View File
@@ -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)",
+18
View File
@@ -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)",
+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)
}
}