mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
5b4a7e82ff
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.
176 lines
5.9 KiB
TypeScript
176 lines
5.9 KiB
TypeScript
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
|
|
}
|