mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
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:
@@ -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[] = []
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user