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);
|
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 {
|
.live-log-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(148px, 200px) 1fr;
|
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 { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||||
import {
|
import {
|
||||||
|
geolocationErrorI18nKey,
|
||||||
getCurrentPosition,
|
getCurrentPosition,
|
||||||
|
getGeolocationErrorReason,
|
||||||
|
hasSeenGeolocationLiveIntro,
|
||||||
|
markGeolocationLiveIntroSeen,
|
||||||
normalizeGpsCoordinates,
|
normalizeGpsCoordinates,
|
||||||
queryGeolocationPermission
|
queryGeolocationPermission,
|
||||||
|
type GeolocationErrorReason,
|
||||||
|
type GpsSignalQuality
|
||||||
} from '../utils/geolocation.js'
|
} from '../utils/geolocation.js'
|
||||||
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +72,7 @@ import {
|
|||||||
} from '../utils/sailSelection.js'
|
} from '../utils/sailSelection.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import CourseDialInput from './CourseDialInput.tsx'
|
import CourseDialInput from './CourseDialInput.tsx'
|
||||||
|
import GpsSignalHint from './GpsSignalHint.tsx'
|
||||||
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
||||||
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
||||||
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
|
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
|
||||||
@@ -142,13 +149,21 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
|
|||||||
return ''
|
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({
|
export default function LiveLogView({
|
||||||
logbookId,
|
logbookId,
|
||||||
onOpenEditor,
|
onOpenEditor,
|
||||||
onSwitchToList
|
onSwitchToList
|
||||||
}: LiveLogViewProps) {
|
}: LiveLogViewProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showAlert } = useDialog()
|
const { showAlert, showConfirm } = useDialog()
|
||||||
|
const [geolocationAccessEpoch, setGeolocationAccessEpoch] = useState(0)
|
||||||
|
|
||||||
const [entryId, setEntryId] = useState<string | null>(null)
|
const [entryId, setEntryId] = useState<string | null>(null)
|
||||||
const [dayOfTravel, setDayOfTravel] = useState('')
|
const [dayOfTravel, setDayOfTravel] = useState('')
|
||||||
@@ -171,6 +186,11 @@ export default function LiveLogView({
|
|||||||
const [positionLng, setPositionLng] = useState('')
|
const [positionLng, setPositionLng] = useState('')
|
||||||
const [positionGpsLoading, setPositionGpsLoading] = useState(false)
|
const [positionGpsLoading, setPositionGpsLoading] = useState(false)
|
||||||
const [positionGpsUnavailable, setPositionGpsUnavailable] = 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 [photoCaption, setPhotoCaption] = useState('')
|
||||||
const [photoSaving, setPhotoSaving] = useState(false)
|
const [photoSaving, setPhotoSaving] = useState(false)
|
||||||
const [voiceCaption, setVoiceCaption] = useState('')
|
const [voiceCaption, setVoiceCaption] = useState('')
|
||||||
@@ -310,6 +330,56 @@ export default function LiveLogView({
|
|||||||
}
|
}
|
||||||
}, [loading, entryId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [events.length])
|
}, [events.length])
|
||||||
@@ -377,7 +447,7 @@ export default function LiveLogView({
|
|||||||
if (startTimer !== undefined) window.clearTimeout(startTimer)
|
if (startTimer !== undefined) window.clearTimeout(startTimer)
|
||||||
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
||||||
}
|
}
|
||||||
}, [entryId, loading, logbookId, refreshEntry])
|
}, [entryId, loading, logbookId, refreshEntry, geolocationAccessEpoch])
|
||||||
|
|
||||||
const runQuickAction = async (
|
const runQuickAction = async (
|
||||||
action: () => Promise<boolean | void>,
|
action: () => Promise<boolean | void>,
|
||||||
@@ -453,16 +523,26 @@ export default function LiveLogView({
|
|||||||
}, 'moor')
|
}, 'moor')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reportPositionGpsFailure = async (reason: GeolocationErrorReason) => {
|
||||||
|
setPositionGpsUnavailable(true)
|
||||||
|
setPositionGpsErrorReason(reason)
|
||||||
|
setPositionGpsSignal(null)
|
||||||
|
await showAlert(gpsFailureAlertBody(t, reason), t('logs.live_position'))
|
||||||
|
}
|
||||||
|
|
||||||
const openPositionModal = async () => {
|
const openPositionModal = async () => {
|
||||||
setPositionLat('')
|
setPositionLat('')
|
||||||
setPositionLng('')
|
setPositionLng('')
|
||||||
setPositionGpsUnavailable(false)
|
setPositionGpsUnavailable(false)
|
||||||
|
setPositionGpsErrorReason(null)
|
||||||
|
setPositionGpsSignal(null)
|
||||||
setPositionGpsLoading(true)
|
setPositionGpsLoading(true)
|
||||||
setModal('position')
|
setModal('position')
|
||||||
try {
|
try {
|
||||||
const permission = await queryGeolocationPermission()
|
const permission = await queryGeolocationPermission()
|
||||||
if (permission !== 'granted') {
|
if (permission !== 'granted') {
|
||||||
setPositionGpsUnavailable(true)
|
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
|
||||||
|
await reportPositionGpsFailure(reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const coords = await getCurrentPosition({
|
const coords = await getCurrentPosition({
|
||||||
@@ -472,8 +552,9 @@ export default function LiveLogView({
|
|||||||
})
|
})
|
||||||
setPositionLat(coords.lat)
|
setPositionLat(coords.lat)
|
||||||
setPositionLng(coords.lng)
|
setPositionLng(coords.lng)
|
||||||
} catch {
|
setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
|
||||||
setPositionGpsUnavailable(true)
|
} catch (err) {
|
||||||
|
await reportPositionGpsFailure(getGeolocationErrorReason(err))
|
||||||
} finally {
|
} finally {
|
||||||
setPositionGpsLoading(false)
|
setPositionGpsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -482,14 +563,13 @@ export default function LiveLogView({
|
|||||||
const retryPositionGps = async () => {
|
const retryPositionGps = async () => {
|
||||||
setPositionGpsLoading(true)
|
setPositionGpsLoading(true)
|
||||||
setPositionGpsUnavailable(false)
|
setPositionGpsUnavailable(false)
|
||||||
|
setPositionGpsErrorReason(null)
|
||||||
|
setPositionGpsSignal(null)
|
||||||
try {
|
try {
|
||||||
const permission = await queryGeolocationPermission()
|
const permission = await queryGeolocationPermission()
|
||||||
if (permission !== 'granted') {
|
if (permission !== 'granted') {
|
||||||
setPositionGpsUnavailable(true)
|
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
|
||||||
await showAlert(
|
await reportPositionGpsFailure(reason)
|
||||||
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
|
||||||
t('logs.live_position')
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const coords = await getCurrentPosition({
|
const coords = await getCurrentPosition({
|
||||||
@@ -499,12 +579,10 @@ export default function LiveLogView({
|
|||||||
})
|
})
|
||||||
setPositionLat(coords.lat)
|
setPositionLat(coords.lat)
|
||||||
setPositionLng(coords.lng)
|
setPositionLng(coords.lng)
|
||||||
} catch {
|
setPositionGpsUnavailable(false)
|
||||||
setPositionGpsUnavailable(true)
|
setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
|
||||||
await showAlert(
|
} catch (err) {
|
||||||
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
await reportPositionGpsFailure(getGeolocationErrorReason(err))
|
||||||
t('logs.live_position')
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
setPositionGpsLoading(false)
|
setPositionGpsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -1170,7 +1248,11 @@ export default function LiveLogView({
|
|||||||
<h3>{t('logs.live_position')}</h3>
|
<h3>{t('logs.live_position')}</h3>
|
||||||
{positionGpsUnavailable && (
|
{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>
|
<p className="live-log-modal-hint">{t('logs.live_position_manual_hint')}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1185,7 +1267,7 @@ export default function LiveLogView({
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
placeholder="54.123456"
|
placeholder="54.123456"
|
||||||
value={positionLat}
|
value={positionLat}
|
||||||
onChange={(e) => setPositionLat(e.target.value)}
|
onChange={(e) => { setPositionGpsSignal(null); setPositionLat(e.target.value) }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -1197,11 +1279,18 @@ export default function LiveLogView({
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
placeholder="10.654321"
|
placeholder="10.654321"
|
||||||
value={positionLng}
|
value={positionLng}
|
||||||
onChange={(e) => setPositionLng(e.target.value)}
|
onChange={(e) => { setPositionGpsSignal(null); setPositionLng(e.target.value) }}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') confirmPosition() }}
|
onKeyDown={(e) => { if (e.key === 'Enter') confirmPosition() }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{positionGpsSignal && (
|
||||||
|
<GpsSignalHint
|
||||||
|
quality={positionGpsSignal.quality}
|
||||||
|
accuracyM={positionGpsSignal.accuracyM}
|
||||||
|
className="gps-signal-hint-modal"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="live-log-position-gps-row">
|
<div className="live-log-position-gps-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ import {
|
|||||||
} from '../services/nmeaArchive.js'
|
} from '../services/nmeaArchive.js'
|
||||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.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 { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||||
import TankLiterInput from './TankLiterInput.tsx'
|
import TankLiterInput from './TankLiterInput.tsx'
|
||||||
import MetricRangeInput from './MetricRangeInput.tsx'
|
import MetricRangeInput from './MetricRangeInput.tsx'
|
||||||
@@ -263,6 +271,10 @@ export default function LogEntryEditor({
|
|||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
const [weatherLoading, setWeatherLoading] = useState(false)
|
||||||
|
const [gpsSignal, setGpsSignal] = useState<{
|
||||||
|
quality: GpsSignalQuality
|
||||||
|
accuracyM: number | null
|
||||||
|
} | null>(null)
|
||||||
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
|
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
|
||||||
|
|
||||||
// Track file upload
|
// Track file upload
|
||||||
@@ -1006,12 +1018,16 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGetGps = () => {
|
const clearGpsSignal = () => setGpsSignal(null)
|
||||||
|
|
||||||
|
const handleGetGps = async () => {
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
|
|
||||||
const lookupFallback = async () => {
|
const lookupFallback = async () => {
|
||||||
|
clearGpsSignal()
|
||||||
const locationQuery = evLocationName.trim() || departure.trim() || destination.trim()
|
const locationQuery = evLocationName.trim() || departure.trim() || destination.trim()
|
||||||
if (!locationQuery) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
@@ -1028,7 +1044,9 @@ export default function LogEntryEditor({
|
|||||||
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||||
setEvGpsLng(Number(coord.lon).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) {
|
} catch (e) {
|
||||||
if (e instanceof WeatherApiError && e.code === 'OFFLINE') {
|
if (e instanceof WeatherApiError && e.code === 'OFFLINE') {
|
||||||
@@ -1039,25 +1057,37 @@ export default function LogEntryEditor({
|
|||||||
showAlert(t('settings.no_key'))
|
showAlert(t('settings.no_key'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showAlert('Failed to retrieve GPS location or look up coordinates by location name.')
|
showAlert(t('logs.gps_fallback_failed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!navigator.geolocation) {
|
try {
|
||||||
lookupFallback()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
const coords = await getCurrentPosition({
|
||||||
(pos) => {
|
timeoutMs: 15_000,
|
||||||
setEvGpsLat(pos.coords.latitude.toFixed(6))
|
enableHighAccuracy: false,
|
||||||
setEvGpsLng(pos.coords.longitude.toFixed(6))
|
maximumAge: 60_000
|
||||||
},
|
})
|
||||||
(err) => {
|
setEvGpsLat(coords.lat)
|
||||||
console.warn('GPS capturing failed, trying fallback:', err)
|
setEvGpsLng(coords.lng)
|
||||||
lookupFallback()
|
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 () => {
|
const handleFetchWeather = async () => {
|
||||||
@@ -1938,7 +1968,7 @@ export default function LogEntryEditor({
|
|||||||
placeholder="Lat"
|
placeholder="Lat"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={evGpsLat}
|
value={evGpsLat}
|
||||||
onChange={(e) => setEvGpsLat(e.target.value)}
|
onChange={(e) => { clearGpsSignal(); setEvGpsLat(e.target.value) }}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -1946,13 +1976,13 @@ export default function LogEntryEditor({
|
|||||||
placeholder="Lng"
|
placeholder="Lng"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={evGpsLng}
|
value={evGpsLng}
|
||||||
onChange={(e) => setEvGpsLng(e.target.value)}
|
onChange={(e) => { clearGpsSignal(); setEvGpsLng(e.target.value) }}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={handleGetGps}
|
onClick={() => void handleGetGps()}
|
||||||
title={t('logs.gps_btn')}
|
title={t('logs.gps_btn')}
|
||||||
style={{ width: 'auto', padding: '12px' }}
|
style={{ width: 'auto', padding: '12px' }}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -1975,6 +2005,13 @@ export default function LogEntryEditor({
|
|||||||
<CloudSun size={16} />
|
<CloudSun size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{gpsSignal && (
|
||||||
|
<GpsSignalHint
|
||||||
|
quality={gpsSignal.quality}
|
||||||
|
accuracyM={gpsSignal.accuracyM}
|
||||||
|
className="gps-signal-hint-editor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -381,6 +381,24 @@
|
|||||||
"event_location_placeholder": "z. f.eks. Kiel",
|
"event_location_placeholder": "z. f.eks. Kiel",
|
||||||
"event_remarks": "Bemærkninger / hændelser",
|
"event_remarks": "Bemærkninger / hændelser",
|
||||||
"gps_btn": "Hent GPS-koordinater",
|
"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_btn": "OpenWeatherMap Kald vejret op",
|
||||||
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
|
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
|
||||||
"event_wind_pressure": "Lufttryk (hPa)",
|
"event_wind_pressure": "Lufttryk (hPa)",
|
||||||
|
|||||||
@@ -381,6 +381,24 @@
|
|||||||
"event_location_placeholder": "z. B. Kiel",
|
"event_location_placeholder": "z. B. Kiel",
|
||||||
"event_remarks": "Bemerkungen / Vorkommnisse",
|
"event_remarks": "Bemerkungen / Vorkommnisse",
|
||||||
"gps_btn": "GPS-Koordinaten abrufen",
|
"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_btn": "OpenWeatherMap Wetter abrufen",
|
||||||
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
|
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"event_wind_pressure": "Luftdruck (hPa)",
|
"event_wind_pressure": "Luftdruck (hPa)",
|
||||||
|
|||||||
@@ -381,6 +381,24 @@
|
|||||||
"event_location_placeholder": "e.g. Kiel",
|
"event_location_placeholder": "e.g. Kiel",
|
||||||
"event_remarks": "Remarks / Events",
|
"event_remarks": "Remarks / Events",
|
||||||
"gps_btn": "Get GPS Location",
|
"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_btn": "Fetch OpenWeatherMap Weather",
|
||||||
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
|
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
|
||||||
"event_wind_pressure": "Barometer (hPa)",
|
"event_wind_pressure": "Barometer (hPa)",
|
||||||
|
|||||||
@@ -381,6 +381,24 @@
|
|||||||
"event_location_placeholder": "z. f.eks. Kiel",
|
"event_location_placeholder": "z. f.eks. Kiel",
|
||||||
"event_remarks": "Merknader / hendelser",
|
"event_remarks": "Merknader / hendelser",
|
||||||
"gps_btn": "Hent GPS-koordinater",
|
"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_btn": "OpenWeatherMap Ring opp været",
|
||||||
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
|
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
|
||||||
"event_wind_pressure": "Lufttrykk (hPa)",
|
"event_wind_pressure": "Lufttrykk (hPa)",
|
||||||
|
|||||||
@@ -381,6 +381,24 @@
|
|||||||
"event_location_placeholder": "z. t.ex. Kiel",
|
"event_location_placeholder": "z. t.ex. Kiel",
|
||||||
"event_remarks": "Anmärkningar / incidenter",
|
"event_remarks": "Anmärkningar / incidenter",
|
||||||
"gps_btn": "Hämta GPS-koordinater",
|
"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_btn": "OpenWeatherMap Ring upp väder",
|
||||||
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
|
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
|
||||||
"event_wind_pressure": "Lufttryck (hPa)",
|
"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 {
|
import {
|
||||||
|
classifyGpsAccuracyMeters,
|
||||||
|
geolocationErrorI18nKey,
|
||||||
|
GEOLOCATION_LIVE_INTRO_STORAGE_KEY,
|
||||||
getCurrentPosition,
|
getCurrentPosition,
|
||||||
|
getGeolocationErrorReason,
|
||||||
|
hasSeenGeolocationLiveIntro,
|
||||||
|
markGeolocationLiveIntroSeen,
|
||||||
normalizeGpsCoordinates,
|
normalizeGpsCoordinates,
|
||||||
parseGpsCoordinate,
|
parseGpsCoordinate,
|
||||||
queryGeolocationPermission
|
queryGeolocationPermission
|
||||||
} from './geolocation.js'
|
} from './geolocation.js'
|
||||||
|
|
||||||
describe('geolocation helpers', () => {
|
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', () => {
|
it('parses coordinates with comma decimals', () => {
|
||||||
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
|
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
|
||||||
})
|
})
|
||||||
@@ -50,7 +66,7 @@ describe('geolocation helpers', () => {
|
|||||||
geolocation: {
|
geolocation: {
|
||||||
getCurrentPosition: (success: PositionCallback) => {
|
getCurrentPosition: (success: PositionCallback) => {
|
||||||
success({
|
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)
|
} as GeolocationPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,10 +75,29 @@ describe('geolocation helpers', () => {
|
|||||||
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
|
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
|
||||||
lat: '59.910000',
|
lat: '59.910000',
|
||||||
lng: '10.750000',
|
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 () => {
|
it('reads permission state when supported', async () => {
|
||||||
vi.stubGlobal('navigator', {
|
vi.stubGlobal('navigator', {
|
||||||
geolocation: {},
|
geolocation: {},
|
||||||
|
|||||||
@@ -3,15 +3,75 @@ const MPS_TO_KNOTS = 1.9438444924406
|
|||||||
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
||||||
const TIMEOUT_GRACE_MS = 750
|
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 {
|
export interface GeoCoordinates {
|
||||||
lat: string
|
lat: string
|
||||||
lng: string
|
lng: string
|
||||||
/** SOG from GPS when available (kn), otherwise null. */
|
/** SOG from GPS when available (kn), otherwise null. */
|
||||||
speedKn: number | 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 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 {
|
export interface GetPositionOptions {
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
/** Manual fixes may use high accuracy; background auto-position should not. */
|
/** 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) }
|
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> {
|
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
|
||||||
if (!navigator.geolocation) return 'unsupported'
|
if (!navigator.geolocation) return 'unsupported'
|
||||||
if (!navigator.permissions?.query) return 'prompt'
|
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)
|
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||||
: null
|
: null
|
||||||
|
const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy)
|
||||||
|
? pos.coords.accuracy
|
||||||
|
: null
|
||||||
return {
|
return {
|
||||||
lat: pos.coords.latitude.toFixed(6),
|
lat: pos.coords.latitude.toFixed(6),
|
||||||
lng: pos.coords.longitude.toFixed(6),
|
lng: pos.coords.longitude.toFixed(6),
|
||||||
speedKn
|
speedKn,
|
||||||
|
accuracyM,
|
||||||
|
signalQuality: classifyGpsAccuracyMeters(accuracyM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user