feat(client): show Dawarich stays, routes and marks where they belong

The trip planner and the journal get a "From Dawarich" list: the stays
recorded for those dates, each one a row that can be dismissed or turned
into a place or an entry after a review step that shows time, name, note
and day. A place that came from a recording carries the Dawarich mark
next to its name so its origin stays visible afterwards.

The recorded route lies over the trip and journal maps as an overlay,
coloured per day and cut along the reader's own timezone rather than
UTC. It is fetched for the window on screen and never stored.

The Atlas gets its own panel beside the map for the countries and cities
the recordings prove, and the wish list is ticked off there, on the day
of the visit, undoably. The phone has its own take on all of it, in the
mobile shell's colours and with native sheets.
This commit is contained in:
Maurice
2026-09-12 23:35:43 +02:00
parent 5678eed55c
commit 5b4a7e82ff
108 changed files with 8945 additions and 295 deletions
+265
View File
@@ -0,0 +1,265 @@
import { useCallback, useEffect, useState } from 'react'
import type { DawarichCapabilities, DawarichErrorCode, DawarichSyncState } from '@trek/shared'
import { dawarichApi } from '../api/dawarich'
import { useTranslation } from '../i18n'
import { useToast } from '../components/shared/Toast'
/**
* Everything the Dawarich connection card does, in one hook.
*
* Shared by the desktop section and its phone twin on purpose. The two shells
* render very different markup and identical logic, and SonarCloud's 3%
* duplication budget on new code is not big enough for a second copy of this —
* `useInstanceSettings` under `components/Admin/` is the same pattern for the
* same reason.
*/
export interface DawarichConnectionState {
url: string
setUrl: (value: string) => void
apiKey: string
setApiKey: (value: string) => void
allowInsecureTls: boolean
toggleInsecureTls: () => void
syncEnabled: boolean
toggleSync: () => void
connected: boolean
loading: boolean
saving: boolean
testing: boolean
syncing: boolean
lastSyncAt: string | null
lastSyncState: DawarichSyncState
/** Already translated; null when the last sync was clean. */
lastSyncError: string | null
capabilities: DawarichCapabilities | null
/** Set after a test run, so the card can show what the probe found. */
probeMessage: string | null
canSave: boolean
save: () => Promise<void>
test: () => Promise<void>
syncNow: () => Promise<void>
disconnect: () => Promise<void>
}
export function useDawarichConnection(): DawarichConnectionState {
const { t } = useTranslation()
const toast = useToast()
const [url, setUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
const [syncEnabled, setSyncEnabled] = useState(true)
const [connected, setConnected] = useState(false)
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null)
const [lastSyncState, setLastSyncState] = useState<DawarichSyncState>('never')
const [lastSyncErrorCode, setLastSyncErrorCode] = useState<string | null>(null)
const [capabilities, setCapabilities] = useState<DawarichCapabilities | null>(null)
const [probeMessage, setProbeMessage] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [syncing, setSyncing] = useState(false)
/**
* Reason codes rather than the upstream sentence: Dawarich answers in English
* and occasionally quotes the URL back, and neither belongs on a German
* install. An unknown code still gets a sentence rather than nothing.
*/
const translateError = useCallback(
(code: string | null): string | null => {
if (!code) return null
const key = `dawarich.error.${code}`
const text = t(key)
return text === key ? t('dawarich.error.unknown') : text
},
[t],
)
useEffect(() => {
let cancelled = false
dawarichApi
.getSettings()
.then(data => {
if (cancelled) return
setUrl(data.url || '')
setAllowInsecureTls(!!data.allowInsecureTls)
setSyncEnabled(data.syncEnabled !== false)
setConnected(!!data.connected)
setLastSyncAt(data.lastSyncAt)
setLastSyncState(data.lastSyncState)
setLastSyncErrorCode(data.lastSyncError)
setCapabilities(data.capabilities)
})
.catch(() => {
// A failed read of our own settings is a TREK problem, not a Dawarich
// one — the card stays in its empty state rather than claiming the
// instance is unreachable.
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
/** The key field is never prefilled, so blank means "keep the stored one". */
const keyPayload = useCallback((): { apiKey?: string } => {
const typed = apiKey.trim()
return typed ? { apiKey: typed } : {}
}, [apiKey])
const save = useCallback(async () => {
setSaving(true)
try {
const result = await dawarichApi.saveSettings({
url: url.trim(),
allowInsecureTls,
syncEnabled,
...keyPayload(),
})
const fresh = await dawarichApi.getSettings().catch(() => null)
if (fresh) {
setConnected(!!fresh.connected)
setCapabilities(fresh.capabilities)
setLastSyncState(fresh.lastSyncState)
setLastSyncErrorCode(fresh.lastSyncError)
}
setApiKey('')
// The warning the server attaches when the address resolves to a private
// IP. Rendered from its code so it arrives in the reader's language; the
// server's own sentence is the fallback for a code this build predates.
if (result?.warning) {
const key = result.warningCode ? `dawarich.warning.${result.warningCode}` : ''
const translated = key ? t(key, { ip: result.warningIp ?? '' }) : ''
toast.warning(translated && translated !== key ? translated : result.warning)
}
else toast.success(t('dawarich.toast.saved'))
} catch (err) {
toast.error(errorText(err, t) || t('dawarich.toast.saveError'))
} finally {
setSaving(false)
}
}, [url, allowInsecureTls, syncEnabled, keyPayload, toast, t])
const test = useCallback(async () => {
setTesting(true)
setProbeMessage(null)
try {
const result = await dawarichApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() })
setConnected(!!result.connected)
if (result.capabilities) setCapabilities(result.capabilities)
if (result.connected) {
setProbeMessage(t('dawarich.test.success', { count: result.visitCount ?? 0 }))
toast.success(t('dawarich.test.success', { count: result.visitCount ?? 0 }))
} else {
const message = translateError(result.error ?? null) ?? t('dawarich.test.failed')
setProbeMessage(message)
toast.error(message)
}
} catch (err) {
const message = errorText(err, t) || t('dawarich.test.failed')
setProbeMessage(message)
toast.error(message)
} finally {
setTesting(false)
}
}, [url, allowInsecureTls, keyPayload, toast, t, translateError])
const syncNow = useCallback(async () => {
setSyncing(true)
try {
const result = await dawarichApi.syncNow()
if (result.alreadyRunning) {
// Somebody (or the cron) is mid-sync. Saying "0 new stays" here would be
// a result, and there is no result yet.
toast.info(t('dawarich.toast.syncRunning'))
return
}
setLastSyncAt(new Date().toISOString())
setLastSyncState((result.state as DawarichSyncState) ?? 'ok')
setLastSyncErrorCode(null)
// Said as a count rather than "done": a sync that found nothing and one
// that failed silently look identical otherwise.
toast.success(t('dawarich.toast.synced', { count: result.created ?? 0 }))
} catch (err) {
setLastSyncState('failed')
toast.error(errorText(err, t) || t('dawarich.toast.syncError'))
} finally {
setSyncing(false)
}
}, [toast, t])
const disconnect = useCallback(async () => {
setSaving(true)
try {
await dawarichApi.disconnect()
setUrl('')
setApiKey('')
setConnected(false)
setCapabilities(null)
setLastSyncAt(null)
setLastSyncState('never')
setLastSyncErrorCode(null)
setProbeMessage(null)
toast.success(t('dawarich.toast.disconnected'))
} catch (err) {
toast.error(errorText(err, t) || t('dawarich.toast.saveError'))
} finally {
setSaving(false)
}
}, [toast, t])
return {
url,
setUrl,
apiKey,
setApiKey,
allowInsecureTls,
toggleInsecureTls: () => setAllowInsecureTls(v => !v),
syncEnabled,
toggleSync: () => setSyncEnabled(v => !v),
connected,
loading,
saving,
testing,
syncing,
lastSyncAt,
lastSyncState,
lastSyncError: translateError(lastSyncErrorCode),
capabilities,
probeMessage,
// An address alone is not a connection, and the key field is blank once a
// key is stored — so saving needs either an existing connection or a typed key.
canSave: !!url.trim() && (connected || !!apiKey.trim()),
save,
test,
syncNow,
disconnect,
}
}
/**
* The server's `{ code, error }` envelope, as a sentence the reader can read.
*
* The code comes first and the prose is the fallback: the server's own sentence
* is written in English, and a German install should not be told what went
* wrong in a language it does not speak. A code this version has no string for
* falls back to the sentence rather than to nothing.
*/
function errorText(err: unknown, t?: (key: string) => string): string | null {
const data = (err as { response?: { data?: { error?: unknown; code?: unknown } } })?.response?.data
if (t && typeof data?.code === 'string') {
const key = `dawarich.error.${data.code}`
const text = t(key)
if (text !== key) return text
}
return typeof data?.error === 'string' ? data.error : null
}
/** Exported for the error-code type so consumers do not re-declare it. */
export type { DawarichErrorCode }
@@ -0,0 +1,93 @@
import { useEffect, useMemo, useState } from 'react'
import type { JourneyTrack } from '@trek/shared'
import { dawarichRepo } from '../repo/dawarichRepo'
import { useAddonStore } from '../store/addonStore'
import { trailSegments } from '../components/Map/dawarichTrail'
import { isEffectivelyOffline } from '../sync/networkMode'
/**
* The recorded route for a journal, shaped as the journey map's own tracks.
*
* The journey map already draws `JourneyTrack[]` — several separate polylines,
* each with its own colour and a white casing, described in that file as
* reading "as a recorded route rather than as the dashed line that merely
* connects entries in time order". That is exactly what this is, so it rides
* the existing layer instead of adding a third one.
*
* It is deliberately NOT the map's `trail` prop: that draws a single polyline,
* and a day's recording is several disconnected pieces — joining them would
* paint straight lines across gaps the traveller never travelled.
*
* The synthetic `place_id` is negative so it can never collide with a real
* place's, which is what the map keys its lines on.
*/
export function useDawarichJournalTrail(
/** Dates the journal covers, from its trips. */
dates: Set<string>,
enabled: boolean,
): { tracks: JourneyTrack[]; fetchedAt: string | null; status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'offline' } {
const addonEnabled = useAddonStore(s => s.isEnabled)
const active = enabled && addonEnabled('dawarich') && dates.size > 0
const [tracks, setTracks] = useState<JourneyTrack[]>(EMPTY)
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'unavailable' | 'offline'>('idle')
// The window is the journal's own span. A Set has no order, so it is sorted
// here rather than trusting insertion order.
const window = useMemo(() => {
if (dates.size === 0) return null
const sorted = [...dates].sort()
return { from: `${sorted[0]}T00:00:00Z`, to: `${sorted[sorted.length - 1]}T23:59:59Z` }
}, [dates])
useEffect(() => {
if (!active || !window) {
setTracks(EMPTY)
setStatus('idle')
return
}
if (isEffectivelyOffline()) {
setTracks(EMPTY)
setStatus('offline')
return
}
const controller = new AbortController()
let cancelled = false
setStatus('loading')
dawarichRepo
.windowTrack(window.from, window.to, controller.signal)
.then(track => {
if (cancelled) return
const segments = trailSegments(track)
setTracks(
segments.map((segment, index): JourneyTrack => ({
place_id: -(index + 1),
trip_id: 0,
name: segment.date,
color: segment.color,
points: segment.points,
})),
)
setFetchedAt(track.fetchedAt)
setStatus('ready')
})
.catch(() => {
if (cancelled) return
setTracks(EMPTY)
setStatus(isEffectivelyOffline() ? 'offline' : 'unavailable')
})
return () => {
cancelled = true
controller.abort()
}
}, [active, window])
return { tracks: active ? tracks : EMPTY, fetchedAt, status }
}
/** Stable empty identity: a fresh literal would re-render the map on every pass. */
const EMPTY: JourneyTrack[] = []
+175
View File
@@ -0,0 +1,175 @@
import { useCallback, useEffect, useState } from 'react'
import type { DawarichAccept, DawarichSuggestion, DawarichSyncState } from '@trek/shared'
import { dawarichApi } from '../api/dawarich'
import { dawarichRepo, DawarichOfflineError } from '../repo/dawarichRepo'
import { isEffectivelyOffline } from '../sync/networkMode'
import { useAddonStore } from '../store/addonStore'
import { useTranslation } from '../i18n'
import { useToast } from '../components/shared/Toast'
export type DawarichSuggestionsStatus =
| 'idle'
| 'loading'
| 'ready'
| 'disconnected'
| 'offline'
| 'unavailable'
export interface DawarichSuggestionsState {
suggestions: DawarichSuggestion[]
status: DawarichSuggestionsStatus
lastSyncAt: string | null
lastSyncState: DawarichSyncState
/** Already translated; null when the last sync was clean. */
lastSyncError: string | null
busyId: number | null
reload: () => void
accept: (id: number, body: DawarichAccept) => Promise<boolean>
dismiss: (id: number) => Promise<void>
restore: (id: number) => Promise<void>
}
/**
* The stays waiting for review, and what a user can do with them.
*
* Shared by the journal and the trip planner so the two never disagree about
* what is still pending — and so the review logic exists once rather than in
* each shell.
*
* Every mutation updates the row in place rather than refetching the list. A
* refetch would reorder under the cursor, and someone working down a backlog
* would lose their place every time they pressed something.
*/
export function useDawarichSuggestions(filter?: {
tripId?: number
state?: 'new' | 'accepted' | 'dismissed'
}): DawarichSuggestionsState {
const { t } = useTranslation()
const toast = useToast()
const addonEnabled = useAddonStore(s => s.isEnabled)
const active = addonEnabled('dawarich')
const [suggestions, setSuggestions] = useState<DawarichSuggestion[]>([])
const [status, setStatus] = useState<DawarichSuggestionsStatus>('idle')
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null)
const [lastSyncState, setLastSyncState] = useState<DawarichSyncState>('never')
const [lastSyncErrorCode, setLastSyncErrorCode] = useState<string | null>(null)
const [busyId, setBusyId] = useState<number | null>(null)
const [reloadToken, setReloadToken] = useState(0)
const tripId = filter?.tripId
const stateFilter = filter?.state
const reload = useCallback(() => setReloadToken(token => token + 1), [])
useEffect(() => {
if (!active) {
setStatus('idle')
return
}
let cancelled = false
setStatus('loading')
dawarichRepo
.suggestions({ tripId, state: stateFilter })
.then(result => {
if (cancelled) return
setSuggestions(result.suggestions)
setLastSyncAt(result.lastSyncAt)
setLastSyncState(result.lastSyncState)
setLastSyncErrorCode(result.lastSyncError)
setStatus(result.connected ? 'ready' : 'disconnected')
})
.catch((err: unknown) => {
if (cancelled) return
setStatus(err instanceof DawarichOfflineError ? 'offline' : 'unavailable')
})
return () => {
cancelled = true
}
}, [active, tripId, stateFilter, reloadToken])
const replace = useCallback((updated: DawarichSuggestion) => {
setSuggestions(prev => prev.map(item => (item.id === updated.id ? updated : item)))
}, [])
const accept = useCallback(
async (id: number, body: DawarichAccept): Promise<boolean> => {
// Accepting writes into a trip or a journal, which needs the server. The
// reads go through the repo, which is offline-aware; this one says so
// rather than hanging until the request times out.
if (isEffectivelyOffline()) {
toast.error(t('dawarich.error.offline'))
return false
}
setBusyId(id)
try {
const result = await dawarichApi.accept(id, body)
replace(result.suggestion)
toast.success(t(`dawarich.toast.accepted.${body.target}`))
return true
} catch (err) {
toast.error(serverError(err, t) ?? t('dawarich.toast.acceptError'))
return false
} finally {
setBusyId(null)
}
},
[replace, toast, t],
)
const setRowState = useCallback(
async (id: number, next: 'new' | 'dismissed') => {
if (isEffectivelyOffline()) {
toast.error(t('dawarich.error.offline'))
return
}
setBusyId(id)
try {
replace(await dawarichApi.setState(id, next))
} catch (err) {
toast.error(serverError(err, t) ?? t('dawarich.toast.updateError'))
} finally {
setBusyId(null)
}
},
[replace, toast, t],
)
return {
suggestions,
status,
lastSyncAt,
lastSyncState,
lastSyncError: translateCode(lastSyncErrorCode, t),
busyId,
reload,
accept,
dismiss: (id: number) => setRowState(id, 'dismissed'),
restore: (id: number) => setRowState(id, 'new'),
}
}
/**
* The server's `{ code, error }` envelope, as a sentence the reader can read.
*
* The controller ships a code beside every message precisely so nothing reaches
* a non-English install in English; the prose is the fallback for a code this
* build has no string for.
*/
function serverError(err: unknown, t: (key: string) => string): string | null {
const data = (err as { response?: { data?: { error?: unknown; code?: unknown } } })?.response?.data
if (typeof data?.code === 'string') {
const key = `dawarich.error.${data.code}`
const text = t(key)
if (text !== key) return text
}
return typeof data?.error === 'string' ? data.error : null
}
/** A reason code turned into the reader's language, with a fallback for new codes. */
function translateCode(code: string | null, t: (key: string) => string): string | null {
if (!code) return null
const key = `dawarich.error.${code}`
const text = t(key)
return text === key ? t('dawarich.error.unknown') : text
}