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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
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>
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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