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
+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"