mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +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,387 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Modal from '../shared/Modal'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichBadge from './DawarichBadge'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
export interface AcceptTargetOption {
|
||||
id: number
|
||||
label: string
|
||||
/** Secondary half of the label — a day's date beside its number. */
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export interface DawarichAcceptDialogProps {
|
||||
suggestion: DawarichSuggestion | null
|
||||
target: DawarichSuggestionTarget
|
||||
/** Trips the user may write to, for the `place` target. */
|
||||
trips?: AcceptTargetOption[]
|
||||
/** Days of the chosen trip, resolved by the caller. */
|
||||
daysForTrip?: (tripId: number) => AcceptTargetOption[]
|
||||
/** Journeys the user may edit, for the `journal` target. */
|
||||
journals?: AcceptTargetOption[]
|
||||
busy?: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: (body: DawarichAccept) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The review step: see what was recorded, correct it, then decide where it goes.
|
||||
*
|
||||
* The whole reason the integration is not a one-click import. A visit detector
|
||||
* is right most of the time and confidently wrong the rest, and the difference
|
||||
* between the two is a judgement only the traveller can make — so the values
|
||||
* are prefilled from the recording and every one of them is editable before
|
||||
* anything is written.
|
||||
*
|
||||
* What it does NOT do is offer to edit the recording. Dawarich owns that, and
|
||||
* a correction made here would be lost the next time the detector ran.
|
||||
*
|
||||
* Three targets, because `DawarichSuggestionTarget` has three. The trip and
|
||||
* journal rails open the first two; `bucket_list` is reached through MCP, where
|
||||
* an assistant can accept a stay against a wish. The branch is kept for that and
|
||||
* for the day an Atlas-side entry point wants it — the server has always been
|
||||
* able to do it, and a dialog that could only handle two of three targets would
|
||||
* be the thing that had to be fixed then.
|
||||
*/
|
||||
export default function DawarichAcceptDialog({
|
||||
suggestion,
|
||||
target,
|
||||
trips = [],
|
||||
daysForTrip,
|
||||
journals = [],
|
||||
busy = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DawarichAcceptDialogProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const phone = useIsPhone()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [time, setTime] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
const [tripId, setTripId] = useState<number | ''>('')
|
||||
const [dayId, setDayId] = useState<number | ''>('')
|
||||
const [journalId, setJournalId] = useState<number | ''>('')
|
||||
// Re-seeded whenever a different suggestion opens the dialog, so the fields
|
||||
// always describe the row the user just pressed rather than the previous one.
|
||||
const [seededFor, setSeededFor] = useState<number | null>(null)
|
||||
|
||||
if (suggestion && seededFor !== suggestion.id) {
|
||||
setSeededFor(suggestion.id)
|
||||
setName(suggestion.name)
|
||||
setNotes('')
|
||||
setDate(suggestion.localDate)
|
||||
setTime(clockOf(suggestion.startedAt))
|
||||
setEndTime(clockOf(suggestion.endedAt))
|
||||
setTripId(suggestion.tripId ?? (trips[0]?.id ?? ''))
|
||||
setDayId('')
|
||||
setJournalId(journals[0]?.id ?? '')
|
||||
}
|
||||
|
||||
const days = useMemo(
|
||||
() => (typeof tripId === 'number' && daysForTrip ? daysForTrip(tripId) : []),
|
||||
[tripId, daysForTrip],
|
||||
)
|
||||
|
||||
if (!suggestion) return null
|
||||
|
||||
const missingTarget =
|
||||
(target === 'place' && typeof tripId !== 'number') ||
|
||||
(target === 'journal' && typeof journalId !== 'number') ||
|
||||
(target === 'bucket_list' && suggestion.matchedBucketListItemId === null)
|
||||
|
||||
const confirm = () => {
|
||||
const body: DawarichAccept = { target }
|
||||
if (target === 'place') {
|
||||
if (typeof tripId === 'number') body.tripId = tripId
|
||||
if (typeof dayId === 'number') body.dayId = dayId
|
||||
}
|
||||
if (target === 'journal' && typeof journalId === 'number') body.journalId = journalId
|
||||
if (target === 'bucket_list' && suggestion.matchedBucketListItemId !== null) {
|
||||
body.bucketListItemId = suggestion.matchedBucketListItemId
|
||||
}
|
||||
if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim()
|
||||
if (notes.trim()) body.notes = notes.trim()
|
||||
if (date && date !== suggestion.localDate) body.date = date
|
||||
if (time) body.time = time
|
||||
if (endTime) body.endTime = endTime
|
||||
onConfirm(body)
|
||||
}
|
||||
|
||||
// The same fields in both shells — only the frame around them differs.
|
||||
const fields = (
|
||||
<div className="space-y-4">
|
||||
{/* What was recorded, before anything the user changed. Kept visible so a
|
||||
corrected field can be compared against the source rather than
|
||||
replacing it invisibly. */}
|
||||
{/* What Dawarich recorded, as facts rather than as a sentence with
|
||||
separators in it: a line for when, and a row of the same badges the
|
||||
review list uses so the dialog and the list read as one thing. */}
|
||||
<div className="rounded-xl border p-3 border-edge bg-surface-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
|
||||
<DawarichIcon size={20} />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-caption text-content">
|
||||
{t('dawarich.accept.recorded', {
|
||||
from: new Date(suggestion.startedAt).toLocaleString(locale),
|
||||
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<DawarichBadge icon={Hourglass} size="sm">
|
||||
{t('dawarich.accept.duration', { minutes: suggestion.durationMinutes })}
|
||||
</DawarichBadge>
|
||||
{/* The source's own verdict, shown here rather than on every row of
|
||||
the list: since Dawarich 1.12.0 "unconfirmed" is the normal state
|
||||
of a visit, so it says nothing in a list and something here, where
|
||||
somebody is deciding whether to trust this one. */}
|
||||
{suggestion.sourceStatus === 'suggested' && (
|
||||
<DawarichBadge icon={CircleDashed} size="sm">{t('dawarich.sourceStatus.suggested')}</DawarichBadge>
|
||||
)}
|
||||
{KNOWN_BANDS.includes(suggestion.confidenceBand ?? '') && (
|
||||
<DawarichBadge
|
||||
icon={Gauge}
|
||||
size="sm"
|
||||
tone={suggestion.confidenceBand === 'low' ? 'warning' : 'neutral'}
|
||||
>
|
||||
{t('dawarich.confidence.' + suggestion.confidenceBand)}
|
||||
</DawarichBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why this stay is being offered against a wish at all. The row that
|
||||
led here shows the wish's name and nothing else, which leaves the
|
||||
reader to guess what a star beside a café has to do with a museum —
|
||||
it is the rule, and the rule is short enough to state. */}
|
||||
{target === 'bucket_list' && suggestion.matchedBucketListName && (
|
||||
<p className="flex items-start gap-1.5 text-caption text-content-muted">
|
||||
<Star className="w-3 h-3 flex-shrink-0 mt-0.5 text-accent-on" />
|
||||
<span>
|
||||
{t('dawarich.suggestions.matchesWish', { name: suggestion.matchedBucketListName })}
|
||||
{' — '}
|
||||
{t('dawarich.bucket.rule', {
|
||||
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
|
||||
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="dawarich-accept-name" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.name')}
|
||||
</label>
|
||||
<input
|
||||
id="dawarich-accept-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TREK's own pickers, not the browser's. A native `type="date"` paints
|
||||
itself from the OS locale and a native `type="time"` ignores the 12h/24h
|
||||
setting outright (#2067) — and neither follows the colour scheme. The
|
||||
date column is the wide one because a localized date is long and a
|
||||
clock never is. */}
|
||||
{target !== 'bucket_list' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_124px_124px] gap-2">
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.date')}
|
||||
</span>
|
||||
<CustomDatePicker value={date} onChange={setDate} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.from')}
|
||||
</span>
|
||||
<CustomTimePicker value={time} onChange={setTime} aria-label={t('dawarich.accept.from')} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.to')}
|
||||
</span>
|
||||
<CustomTimePicker value={endTime} onChange={setEndTime} aria-label={t('dawarich.accept.to')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stacked and full width, in the order the choice is actually made: the
|
||||
day is what somebody is deciding, the trip is usually already right.
|
||||
Side by side, both pickers were half as wide as their longest option
|
||||
and the menus opened narrower than the text in them. */}
|
||||
{target === 'place' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.day')}
|
||||
</span>
|
||||
{/* Unplanned is a real answer, not a missing one: half the places on
|
||||
a trip sit on no day, and forcing a choice here would invent one. */}
|
||||
<CustomSelect
|
||||
value={dayId === '' ? NO_DAY : dayId}
|
||||
onChange={next => setDayId(next === NO_DAY || typeof next !== 'number' ? '' : next)}
|
||||
options={[
|
||||
{ value: NO_DAY, label: t('dawarich.accept.noDay') },
|
||||
// The date rides as the option's badge rather than inside its
|
||||
// label: the day number is what people scan for, and the date
|
||||
// is the confirmation they read second.
|
||||
...days.map(day => ({ value: day.id, label: day.label, badge: day.badge })),
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.trip')}
|
||||
</span>
|
||||
<CustomSelect
|
||||
value={tripId}
|
||||
onChange={next => { setTripId(typeof next === 'number' ? next : ''); setDayId('') }}
|
||||
options={trips.map(trip => ({ value: trip.id, label: trip.label }))}
|
||||
placeholder={t('dawarich.accept.pickTrip')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target === 'journal' && (
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.journal')}
|
||||
</span>
|
||||
<CustomSelect
|
||||
value={journalId}
|
||||
onChange={next => setJournalId(typeof next === 'number' ? next : '')}
|
||||
options={journals.map(journal => ({ value: journal.id, label: journal.label }))}
|
||||
placeholder={t('dawarich.accept.pickJournal')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target !== 'bucket_list' && (
|
||||
<div>
|
||||
<label htmlFor="dawarich-accept-notes" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{target === 'journal' ? t('dawarich.accept.story') : t('dawarich.accept.notes')}
|
||||
</label>
|
||||
<textarea
|
||||
id="dawarich-accept-notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={target === 'journal' ? 5 : 3}
|
||||
placeholder={t('dawarich.accept.storyPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body resize-none focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
/>
|
||||
{target === 'journal' && (
|
||||
<p className="mt-1 text-caption text-content-muted">{t('dawarich.accept.photosHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const title = t(`dawarich.accept.title.${target}`)
|
||||
|
||||
// The phone shell has its own sheet, its own rounding and its own footer pill,
|
||||
// and every other form on it uses them. A desktop modal scaled down would be
|
||||
// the one dialog in the app that came from somewhere else.
|
||||
if (phone) {
|
||||
return (
|
||||
<MSheet open onClose={onCancel} variant="bottom" material="glass" ariaLabel={title}>
|
||||
<div className="flex max-h-[88dvh] min-h-0 flex-col">
|
||||
<FormSheetHeader
|
||||
// The mark fills the tile edge to edge, rounded like every other
|
||||
// avatar-sized badge in the shell — a small logo floating in a grey
|
||||
// square reads as a placeholder.
|
||||
leading={
|
||||
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
|
||||
<DawarichIcon size={40} />
|
||||
</span>
|
||||
}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{fields}</div>
|
||||
<FormSheetFooter
|
||||
onCancel={onCancel}
|
||||
cancelLabel={t('common.cancel')}
|
||||
onSubmit={confirm}
|
||||
submitLabel={t(`dawarich.accept.confirm.${target}`)}
|
||||
submitDisabled={busy || missingTarget}
|
||||
/>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
size="lg"
|
||||
title={title}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg text-body border border-edge text-content-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
disabled={busy || missingTarget}
|
||||
className="px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{t(`dawarich.accept.confirm.${target}`)}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{fields}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bands Dawarich actually emits (>=70 high, >=40 medium, else low).
|
||||
* A band a later version adds is shown as nothing rather than as a raw English
|
||||
* word or a missing translation key.
|
||||
*/
|
||||
const KNOWN_BANDS = ['high', 'medium', 'low']
|
||||
|
||||
/**
|
||||
* "No day" as a real option value.
|
||||
*
|
||||
* CustomSelect uses strict equality on its values and treats the empty string as
|
||||
* "nothing chosen", which would make the placeholder and the deliberate answer
|
||||
* indistinguishable — and unplanned is a deliberate answer here.
|
||||
*/
|
||||
const NO_DAY = '__none__'
|
||||
|
||||
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
|
||||
function clockOf(iso: string): string {
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// FE-DAWARICH-ATLASDLG-001 to FE-DAWARICH-ATLASDLG-009
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import DawarichAtlasDialog from './DawarichAtlasDialog'
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast, default: () => toast }))
|
||||
|
||||
const repo = {
|
||||
bucketScan: vi.fn(),
|
||||
atlasSuggestions: vi.fn(),
|
||||
}
|
||||
vi.mock('../../repo/dawarichRepo', () => ({
|
||||
dawarichRepo: {
|
||||
bucketScan: (...args: unknown[]) => repo.bucketScan(...args),
|
||||
atlasSuggestions: (...args: unknown[]) => repo.atlasSuggestions(...args),
|
||||
},
|
||||
DawarichOfflineError: class extends Error {},
|
||||
}))
|
||||
|
||||
const api = {
|
||||
confirmBucketVisits: vi.fn(),
|
||||
acceptAtlasCountries: vi.fn(),
|
||||
}
|
||||
vi.mock('../../api/dawarich', () => ({
|
||||
dawarichApi: {
|
||||
confirmBucketVisits: (...args: unknown[]) => api.confirmBucketVisits(...args),
|
||||
acceptAtlasCountries: (...args: unknown[]) => api.acceptAtlasCountries(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const SCAN = {
|
||||
matches: [
|
||||
{
|
||||
itemId: 11,
|
||||
name: 'Museum Ludwig',
|
||||
match: { at: '2026-09-10T14:00:00Z', minutes: 145, distanceMeters: 49, points: 60 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{
|
||||
itemId: 12,
|
||||
name: 'Kölner Dom',
|
||||
match: { at: '2026-09-09T14:00:00Z', minutes: 30, distanceMeters: 80, points: 12 },
|
||||
alreadyVisited: true,
|
||||
},
|
||||
{ itemId: 13, name: 'Rheinpark', match: null, alreadyVisited: false },
|
||||
],
|
||||
skippedWithoutCoordinates: 2,
|
||||
truncated: false,
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
const COUNTRIES = {
|
||||
countries: [
|
||||
{ countryCode: 'DE', sourceName: 'Germany', cities: [{ name: 'Cologne', minutes: 400, lastSeenAt: '2026-09-10T10:00:00Z' }], alreadyVisited: true },
|
||||
{ countryCode: 'NL', sourceName: 'Netherlands', cities: [{ name: 'Maastricht', minutes: 90, lastSeenAt: '2026-09-08T10:00:00Z' }], alreadyVisited: false },
|
||||
],
|
||||
unresolved: ['Freedonia'],
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
function open(props: Partial<React.ComponentProps<typeof DawarichAtlasDialog>> = {}) {
|
||||
return render(<DawarichAtlasDialog isOpen onClose={vi.fn()} {...props} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toast.success.mockReset()
|
||||
toast.error.mockReset()
|
||||
repo.bucketScan.mockReset().mockResolvedValue(SCAN)
|
||||
repo.atlasSuggestions.mockReset().mockResolvedValue(COUNTRIES)
|
||||
api.confirmBucketVisits.mockReset().mockResolvedValue({ updated: 1 })
|
||||
api.acceptAtlasCountries.mockReset().mockResolvedValue({ marked: 1 })
|
||||
})
|
||||
|
||||
describe('DawarichAtlasDialog', () => {
|
||||
it('FE-DAWARICH-ATLASDLG-001: asks nothing on its own — the scan runs when the reader says so', () => {
|
||||
open()
|
||||
expect(repo.bucketScan).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-002: offers no confirmation before there is anything to confirm', () => {
|
||||
open()
|
||||
// Hidden until a result is on screen rather than merely disabled: a greyed
|
||||
// out "Tick off 0" is a dead control asking to be understood, and `hidden`
|
||||
// keeps it out of the accessibility tree too.
|
||||
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-003: a scan lists what it found, with the distance, the time and the day', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
const row = screen.getByRole('checkbox', { name: 'Museum Ludwig' })
|
||||
expect(row.textContent).toContain('49 m away')
|
||||
expect(row.textContent).toContain('2 h 25 min')
|
||||
// A wish with no match at all is not a row — there is nothing to decide.
|
||||
expect(screen.queryByRole('checkbox', { name: 'Rheinpark' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-004: open wishes start selected, already ticked ones do not', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-005: unticking a row takes it out of what gets written', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /Tick off 0/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-006: confirming writes exactly the picked wishes and reports back', async () => {
|
||||
const onChanged = vi.fn()
|
||||
open({ onChanged })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
|
||||
// With the date the stay was actually reached on — a wish from last year
|
||||
// ticked off with today's date is a wrong entry in a list people keep.
|
||||
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z'))
|
||||
expect(onChanged).toHaveBeenCalled()
|
||||
// Marked in place rather than rescanned — one upstream request per wish is
|
||||
// not worth spending to tell the reader what they just did.
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeDisabled())
|
||||
expect(repo.bucketScan).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-007: the countries tab offers only what the Atlas does not already have', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
expect(screen.queryByRole('checkbox', { name: 'Germany' })).not.toBeInTheDocument()
|
||||
// A country Dawarich named but TREK could not code is said out loud, not dropped.
|
||||
expect(screen.getByText(/Freedonia/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-008: confirming countries sends their codes', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['NL']))
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-009: a scan that fails says so and leaves the dialog usable', async () => {
|
||||
repo.bucketScan.mockRejectedValue({ response: { data: { error: 'Dawarich rejected the API key.' } } })
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Dawarich rejected the API key.'))
|
||||
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,578 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Building2, Check, CalendarDays, Globe2, Hourglass, Info, Loader2, MapPin, RefreshCw, Star } from 'lucide-react'
|
||||
import type { DawarichAtlasSuggestions, DawarichBucketScan } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Skeleton } from '../shared/Skeleton'
|
||||
import { SlidingTabs } from '../shared/SlidingTabs'
|
||||
import Modal from '../shared/Modal'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichBadge from './DawarichBadge'
|
||||
import { dawarichApi } from '../../api/dawarich'
|
||||
import { dawarichRepo, DawarichOfflineError } from '../../repo/dawarichRepo'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { formatDuration } from './dawarichSuggestionModel'
|
||||
import { cityLine, countryLabel, countryWindow, formatDistance, newCountries, orderedMatches } from './dawarichAtlasModel'
|
||||
|
||||
type Tab = 'wishes' | 'countries'
|
||||
|
||||
/**
|
||||
* Everything Dawarich can answer about the Atlas, in a room of its own.
|
||||
*
|
||||
* It used to be a card wedged under the wishlist, which made two different
|
||||
* questions — "which wishes did I actually reach?" and "which countries was I
|
||||
* in?" — look like one paragraph of small print. Both are offers that need
|
||||
* reading before they are accepted, and reading needs space, so they get a
|
||||
* dialog with a tab each, one selection model and one confirm button.
|
||||
*
|
||||
* Nothing here runs on its own. The Atlas is curated — it carries tombstones
|
||||
* precisely so a country somebody removed stays removed — and a background job
|
||||
* that ticked countries off would be fighting its owner.
|
||||
*/
|
||||
export default function DawarichAtlasDialog({
|
||||
isOpen,
|
||||
initialTab = 'wishes',
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
/** Which question was asked to open it — the panel has a button per tab. */
|
||||
initialTab?: Tab
|
||||
onClose: () => void
|
||||
/** Called after something was written, so the Atlas can re-read itself. */
|
||||
onChanged?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const phone = useIsPhone()
|
||||
const [tab, setTab] = useState<Tab>(initialTab)
|
||||
|
||||
const [scan, setScan] = useState<DawarichBucketScan | null>(null)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set())
|
||||
|
||||
const [countries, setCountries] = useState<DawarichAtlasSuggestions | null>(null)
|
||||
const [loadingCountries, setLoadingCountries] = useState(false)
|
||||
const [pickedCountries, setPickedCountries] = useState<Set<string>>(new Set())
|
||||
// One write at a time: the button stays on screen while the request is in
|
||||
// flight, and a second press would send the same confirmation twice.
|
||||
const [writing, setWriting] = useState(false)
|
||||
|
||||
const matches = orderedMatches(scan?.matches ?? [])
|
||||
const openMatches = matches.filter(match => !match.alreadyVisited)
|
||||
const offered = newCountries(countries?.countries ?? [])
|
||||
|
||||
const runScan = async (): Promise<void> => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const result = await dawarichRepo.bucketScan()
|
||||
setScan(result)
|
||||
// Everything with a match and not already ticked starts selected: the
|
||||
// common case is "yes, all of those", and unticking the odd one is less
|
||||
// work than ticking twenty.
|
||||
setPicked(new Set(result.matches.filter(m => m.match && !m.alreadyVisited).map(m => m.itemId)))
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCountries = async (): Promise<void> => {
|
||||
setLoadingCountries(true)
|
||||
try {
|
||||
const { from, to } = countryWindow(new Date())
|
||||
const result = await dawarichRepo.atlasSuggestions(from, to)
|
||||
setCountries(result)
|
||||
setPickedCountries(new Set(newCountries(result.countries).map(c => c.countryCode)))
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setLoadingCountries(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmWishes = async (): Promise<void> => {
|
||||
if (picked.size === 0 || writing) return
|
||||
// Writes are online-only: the reads are gated in the repo, and firing a
|
||||
// confirmation into a dead connection would hang to a timeout and then
|
||||
// report a server error for something the server never saw.
|
||||
if (isEffectivelyOffline()) {
|
||||
toast.error(t('dawarich.error.offline'))
|
||||
return
|
||||
}
|
||||
setWriting(true)
|
||||
try {
|
||||
// Ticked off on the day each wish was reached, not on today. The dialog is
|
||||
// holding that date for every row it is about to write, so it says it;
|
||||
// grouping by date keeps that to one request per day rather than per wish.
|
||||
const byDate = new Map<string, number[]>()
|
||||
for (const m of matches) {
|
||||
if (!picked.has(m.itemId) || !m.match) continue
|
||||
const bucket = byDate.get(m.match.at)
|
||||
if (bucket) bucket.push(m.itemId)
|
||||
else byDate.set(m.match.at, [m.itemId])
|
||||
}
|
||||
const results = await Promise.all(
|
||||
[...byDate].map(([at, ids]) => dawarichApi.confirmBucketVisits(ids, at)),
|
||||
)
|
||||
const result = { updated: results.reduce((sum, r) => sum + r.updated, 0) }
|
||||
toast.success(t('dawarich.bucket.confirmed', { count: result.updated }))
|
||||
// Marked in place rather than rescanned: the scan costs one upstream
|
||||
// request per wish, and the reader already knows what they just ticked.
|
||||
setScan(prev =>
|
||||
prev === null
|
||||
? prev
|
||||
: { ...prev, matches: prev.matches.map(m => (picked.has(m.itemId) ? { ...m, alreadyVisited: true } : m)) },
|
||||
)
|
||||
setPicked(new Set())
|
||||
onChanged?.()
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setWriting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmCountries = async (): Promise<void> => {
|
||||
if (pickedCountries.size === 0 || writing) return
|
||||
if (isEffectivelyOffline()) {
|
||||
toast.error(t('dawarich.error.offline'))
|
||||
return
|
||||
}
|
||||
setWriting(true)
|
||||
try {
|
||||
const result = await dawarichApi.acceptAtlasCountries([...pickedCountries])
|
||||
toast.success(t('dawarich.atlas.accepted', { count: result.marked }))
|
||||
setCountries(prev =>
|
||||
prev === null
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
countries: prev.countries.map(c =>
|
||||
pickedCountries.has(c.countryCode) ? { ...c, alreadyVisited: true } : c,
|
||||
),
|
||||
},
|
||||
)
|
||||
setPickedCountries(new Set())
|
||||
onChanged?.()
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setWriting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = <T,>(set: Set<T>, value: T): Set<T> => {
|
||||
const next = new Set(set)
|
||||
if (next.has(value)) next.delete(value)
|
||||
else next.add(value)
|
||||
return next
|
||||
}
|
||||
|
||||
const busy = tab === 'wishes' ? scanning : loadingCountries
|
||||
const loaded = tab === 'wishes' ? scan !== null : countries !== null
|
||||
const selected = tab === 'wishes' ? picked.size : pickedCountries.size
|
||||
// Nothing was found, so there is nothing to confirm — the empty state says so
|
||||
// in a sentence, and a greyed-out "Add 0 countries" underneath it only asks to
|
||||
// be understood.
|
||||
const anything = tab === 'wishes' ? matches.length > 0 : offered.length > 0
|
||||
|
||||
// The same two questions in both shells — only the frame differs.
|
||||
const body = (
|
||||
<div className="space-y-4">
|
||||
<SlidingTabs<Tab>
|
||||
tabs={[
|
||||
{ id: 'wishes', label: t('dawarich.atlas.tab.wishes'), icon: Star, count: openMatches.length || undefined },
|
||||
{ id: 'countries', label: t('dawarich.atlas.tab.countries'), icon: Globe2, count: offered.length || undefined },
|
||||
]}
|
||||
activeTab={tab}
|
||||
onChange={setTab}
|
||||
size="sm"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{tab === 'wishes' ? (
|
||||
<Section
|
||||
icon={Star}
|
||||
title={t('dawarich.bucket.title')}
|
||||
description={t('dawarich.bucket.description')}
|
||||
action={t('dawarich.bucket.scan')}
|
||||
busyLabel={t('dawarich.bucket.scanning')}
|
||||
busy={scanning}
|
||||
loaded={scan !== null}
|
||||
onRun={() => { void runScan() }}
|
||||
empty={matches.length === 0}
|
||||
emptyLabel={t('dawarich.bucket.noMatches')}
|
||||
notes={[
|
||||
scan && scan.skippedWithoutCoordinates > 0
|
||||
? t('dawarich.bucket.skipped', { count: scan.skippedWithoutCoordinates })
|
||||
: null,
|
||||
scan?.truncated ? t('dawarich.bucket.truncated') : null,
|
||||
t('dawarich.bucket.rule', {
|
||||
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
|
||||
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{matches.map(match => (
|
||||
<Row
|
||||
key={match.itemId}
|
||||
name={match.name}
|
||||
checked={picked.has(match.itemId)}
|
||||
disabled={match.alreadyVisited}
|
||||
onToggle={() => setPicked(prev => toggle(prev, match.itemId))}
|
||||
leading={
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-surface-secondary">
|
||||
<Star className="w-4 h-4 text-accent-on" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{match.alreadyVisited ? (
|
||||
<DawarichBadge icon={Check} tone="success" size="sm">
|
||||
{t('dawarich.bucket.alreadyVisited')}
|
||||
</DawarichBadge>
|
||||
) : (
|
||||
<>
|
||||
<DawarichBadge icon={MapPin} size="sm">
|
||||
{formatDistance(match.match!.distanceMeters, t)}
|
||||
</DawarichBadge>
|
||||
<DawarichBadge icon={Hourglass} size="sm">
|
||||
{formatDuration(match.match!.minutes, t)}
|
||||
</DawarichBadge>
|
||||
<DawarichBadge icon={CalendarDays} size="sm">
|
||||
{new Date(match.match!.at).toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</DawarichBadge>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<Section
|
||||
icon={Globe2}
|
||||
title={t('dawarich.atlas.title')}
|
||||
description={t('dawarich.atlas.description')}
|
||||
action={t('dawarich.atlas.load')}
|
||||
busyLabel={t('dawarich.atlas.loading')}
|
||||
busy={loadingCountries}
|
||||
loaded={countries !== null}
|
||||
onRun={() => { void loadCountries() }}
|
||||
empty={offered.length === 0}
|
||||
emptyLabel={t('dawarich.atlas.empty')}
|
||||
notes={[
|
||||
countries && countries.unresolved.length > 0
|
||||
? t('dawarich.atlas.unresolved', { names: countries.unresolved.join(', ') })
|
||||
: null,
|
||||
countries ? t('dawarich.atlas.window') : null,
|
||||
]}
|
||||
>
|
||||
{offered.map(country => (
|
||||
<Row
|
||||
key={country.countryCode}
|
||||
name={countryLabel(country.countryCode, country.sourceName, locale)}
|
||||
checked={pickedCountries.has(country.countryCode)}
|
||||
onToggle={() => setPickedCountries(prev => toggle(prev, country.countryCode))}
|
||||
leading={
|
||||
// The Atlas page draws its countries with the same flags, and a
|
||||
// list of country names without them looks like a spreadsheet.
|
||||
<img
|
||||
src={`https://flagcdn.com/w40/${country.countryCode.toLowerCase()}.png`}
|
||||
alt=""
|
||||
className="h-6 w-8 flex-shrink-0 rounded object-cover"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{country.cities.length > 0 && (
|
||||
<>
|
||||
<DawarichBadge icon={Building2} size="sm">
|
||||
{country.cities.length === 1
|
||||
? t('dawarich.atlas.citiesOne')
|
||||
: t('dawarich.atlas.cities', { count: country.cities.length })}
|
||||
</DawarichBadge>
|
||||
<span className="min-w-0 truncate text-caption text-content-muted">
|
||||
{cityLine(country.cities)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Rendered or not, rather than `hidden`: the attribute loses to the display
|
||||
// utility on the class list, so the button stayed on screen greyed out.
|
||||
const confirmButton = !loaded || !anything ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void (tab === 'wishes' ? confirmWishes() : confirmCountries()) }}
|
||||
disabled={selected === 0 || writing}
|
||||
className={
|
||||
phone
|
||||
? 'inline-flex h-[38px] items-center gap-2 rounded-full bg-m-act px-4 text-[0.8125rem] font-bold text-m-actfg disabled:opacity-40'
|
||||
: 'inline-flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover disabled:opacity-40'
|
||||
}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{tab === 'wishes'
|
||||
? t('dawarich.bucket.confirm', { count: selected })
|
||||
: t('dawarich.atlas.accept', { count: selected })}
|
||||
</button>
|
||||
)
|
||||
|
||||
const againButton = loaded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void (tab === 'wishes' ? runScan() : loadCountries()) }}
|
||||
disabled={busy}
|
||||
className={
|
||||
phone
|
||||
? 'inline-flex h-[38px] items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[0.8125rem] font-bold text-m-muted disabled:opacity-50'
|
||||
: 'inline-flex items-center gap-2 rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary hover:bg-surface-hover disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
{t('dawarich.again')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
// The phone shell has its own sheet, its own rounding and its own pill
|
||||
// buttons, and every other panel on it uses them.
|
||||
if (phone) {
|
||||
return (
|
||||
<MSheet open={isOpen} onClose={onClose} variant="bottom" material="glass" ariaLabel={t('dawarich.title')}>
|
||||
<div className="flex max-h-[88dvh] min-h-0 flex-col">
|
||||
<FormSheetHeader
|
||||
leading={
|
||||
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
|
||||
<DawarichIcon size={40} />
|
||||
</span>
|
||||
}
|
||||
title={t('dawarich.title')}
|
||||
subtitle={t('dawarich.atlas.dialogSubtitle')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{body}</div>
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.71875rem] text-m-muted">
|
||||
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
|
||||
</span>
|
||||
{againButton}
|
||||
{confirmButton}
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={
|
||||
<span className="flex items-center gap-2.5">
|
||||
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
|
||||
square badge and a hard corner beside a rounded dialog reads as a
|
||||
pasted-in image. */}
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-lg">
|
||||
<DawarichIcon size={26} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-subtitle font-semibold text-content">{t('dawarich.title')}</span>
|
||||
<span className="block truncate text-caption font-normal text-content-muted">
|
||||
{t('dawarich.atlas.dialogSubtitle')}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-caption text-content-muted">
|
||||
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{againButton}
|
||||
{confirmButton}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One half of the dialog: the pitch before anything has been asked, the wait
|
||||
* while it is being asked, and the answer.
|
||||
*
|
||||
* Both halves behave identically, which is the point — the two questions are
|
||||
* different, the shape of answering them is not.
|
||||
*/
|
||||
function Section({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
busyLabel,
|
||||
busy,
|
||||
loaded,
|
||||
onRun,
|
||||
empty,
|
||||
emptyLabel,
|
||||
notes,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description: string
|
||||
action: string
|
||||
busyLabel: string
|
||||
busy: boolean
|
||||
loaded: boolean
|
||||
onRun: () => void
|
||||
empty: boolean
|
||||
emptyLabel: string
|
||||
notes: (string | null)[]
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
if (busy) {
|
||||
return (
|
||||
<div className="space-y-2" aria-busy>
|
||||
<p className="text-caption text-content-muted">{busyLabel}</p>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border border-edge p-3">
|
||||
<Skeleton width={32} height={32} radius={8} />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton width="45%" height={13} radius={4} />
|
||||
<Skeleton width="70%" height={11} radius={4} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-xl border border-dashed border-edge px-6 py-8 text-center">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-surface-secondary">
|
||||
<Icon className="w-5 h-5 text-content-secondary" />
|
||||
</span>
|
||||
<p className="text-body font-semibold text-content">{title}</p>
|
||||
<p className="mt-1.5 max-w-md text-caption text-content-muted">{description}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{action}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{empty ? (
|
||||
<div className="rounded-xl border border-dashed border-edge px-6 py-8 text-center text-caption text-content-muted">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
)}
|
||||
{notes.filter(Boolean).length > 0 && (
|
||||
<div className="space-y-1 border-t border-edge-faint pt-2.5">
|
||||
{notes.filter(Boolean).map(note => (
|
||||
<p key={note} className="flex items-start gap-1.5 text-caption text-content-faint">
|
||||
<Info className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A selectable answer: what it is on the left, why it is being offered
|
||||
* underneath, and a tick that says whether it will be applied.
|
||||
*
|
||||
* The whole row is the control — a 16px checkbox is a hard target on a phone,
|
||||
* and there is nothing else in the row to click.
|
||||
*/
|
||||
function Row({
|
||||
name,
|
||||
checked,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
leading,
|
||||
children,
|
||||
}: {
|
||||
name: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
onToggle: () => void
|
||||
leading: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-label={name}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`flex w-full items-center gap-3 rounded-xl border p-2.5 text-left transition-colors ${
|
||||
checked ? 'border-accent bg-accent-subtle' : 'border-edge hover:bg-surface-hover'
|
||||
} ${disabled ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded ${
|
||||
checked ? 'bg-accent' : 'border-[1.5px] border-edge'
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="w-2.5 h-2.5 text-accent-text" strokeWidth={3} />}
|
||||
</span>
|
||||
{leading}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body font-medium text-content">{name}</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">{children}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What to tell the reader when a question could not be asked.
|
||||
*
|
||||
* Being offline is not an error the server reported — nothing was sent — and
|
||||
* "something went wrong" for a plane journey is the kind of message that sends
|
||||
* somebody looking for a bug in their Dawarich instance.
|
||||
*/
|
||||
function failureText(err: unknown, t: (key: string) => string): string {
|
||||
if (err instanceof DawarichOfflineError) return t('dawarich.error.offline')
|
||||
return errorText(err) ?? t('dawarich.error.unknown')
|
||||
}
|
||||
|
||||
/** The server's `{ error }` envelope, when there is one. */
|
||||
function errorText(err: unknown): string | null {
|
||||
const data = (err as { response?: { data?: { error?: unknown } } })?.response?.data
|
||||
return typeof data?.error === 'string' ? data.error : null
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Globe2, Star } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useGlassGlare } from '../Atlas/useGlassGlare'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichAtlasDialog from './DawarichAtlasDialog'
|
||||
|
||||
type Question = 'wishes' | 'countries'
|
||||
|
||||
/**
|
||||
* Dawarich's own panel on the Atlas, beside the stats.
|
||||
*
|
||||
* The Atlas is a map with a single glass panel floating over it, and everything
|
||||
* it offers lives in that panel. Dawarich answers two questions about the same
|
||||
* map — which wishes were actually reached, and which countries the recordings
|
||||
* say you were in — but they are *its* answers, not the Atlas's own numbers, and
|
||||
* squeezed under the wishlist they read as fine print nobody asked for.
|
||||
*
|
||||
* So it gets a panel of its own, in the same glass and stretched to the same
|
||||
* height, and the two questions are two tiles built like the stat blocks beside
|
||||
* them: a mark, then a small capitalised label. The lists they produce open in a
|
||||
* dialog — they are offers to read and confirm, and a bar over a world map is no
|
||||
* place to read anything.
|
||||
*
|
||||
* Renders nothing when the addon is off, so an Atlas without Dawarich looks
|
||||
* exactly as it did before.
|
||||
*/
|
||||
export default function DawarichAtlasSidePanel({
|
||||
style,
|
||||
dark,
|
||||
onChanged,
|
||||
}: {
|
||||
/** The glass the Atlas panels are made of, handed down so the two cannot drift. */
|
||||
style: React.CSSProperties
|
||||
dark: boolean
|
||||
/** Called after something was written, so the Atlas can re-read itself. */
|
||||
onChanged?: () => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const addonEnabled = useAddonStore(state => state.isEnabled)
|
||||
const [asked, setAsked] = useState<Question | null>(null)
|
||||
const glare = useGlassGlare(dark)
|
||||
|
||||
if (!addonEnabled('dawarich')) return null
|
||||
|
||||
// theme-lint-disable — the panel floats on a blurred map: a solid surface
|
||||
// token would punch an opaque hole in the glass, so the tiles are a tint of
|
||||
// the same light the panel is made of.
|
||||
const tile = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)'
|
||||
const tileHover = dark ? 'rgba(255,255,255,0.13)' : 'rgba(0,0,0,0.09)'
|
||||
|
||||
const question = (id: Question, Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAsked(id)}
|
||||
className="group flex flex-col items-center justify-center text-content-secondary hover:text-content"
|
||||
style={{
|
||||
background: tile,
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
gap: 6,
|
||||
minWidth: 74,
|
||||
padding: '10px 12px',
|
||||
transition: 'background 0.15s ease, color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = tileHover }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = tile }}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2} />
|
||||
<span
|
||||
className="font-bold uppercase tracking-wide whitespace-nowrap"
|
||||
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={glare.panelRef}
|
||||
onMouseMove={glare.onMouseMove}
|
||||
onMouseLeave={glare.onMouseLeave}
|
||||
className="relative hidden md:flex flex-col justify-center gap-3 overflow-hidden px-5 py-4"
|
||||
style={style}
|
||||
>
|
||||
{/* Liquid glass glare, and the border glow that follows the cursor —
|
||||
the same two layers the Atlas panel carries. */}
|
||||
<div ref={glare.glareRef} className="absolute inset-0 pointer-events-none" style={{ opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20 }} />
|
||||
<div ref={glare.borderGlareRef} className="absolute inset-0 pointer-events-none" style={{
|
||||
opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20,
|
||||
border: dark ? '1.5px solid rgba(255,255,255,0.5)' : '2px solid rgba(0,0,0,0.15)',
|
||||
}} />
|
||||
{/* The long explanation moved into the tooltip: on a panel this size it
|
||||
was three lines of grey that said what the two tiles already say. */}
|
||||
<Tooltip label={t('dawarich.atlas.trigger')} placement="top">
|
||||
<span className="relative flex items-center justify-center gap-2.5">
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[9px]">
|
||||
<DawarichIcon size={30} />
|
||||
</span>
|
||||
<span
|
||||
className="truncate font-black tracking-tight text-content"
|
||||
style={{ fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))' }}
|
||||
>
|
||||
{t('dawarich.title')}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div className="relative flex items-stretch gap-2">
|
||||
{question('wishes', Star, t('dawarich.atlas.tab.wishes'))}
|
||||
{question('countries', Globe2, t('dawarich.atlas.tab.countries'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mounted on open so every visit starts from a clean question rather than
|
||||
from a scan somebody ran an hour ago. */}
|
||||
{asked !== null && (
|
||||
<DawarichAtlasDialog
|
||||
isOpen
|
||||
initialTab={asked}
|
||||
onClose={() => setAsked(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
/**
|
||||
* One badge shape for everything Dawarich shows.
|
||||
*
|
||||
* Fixed height and a single radius, so a row of them lines up however many there
|
||||
* are; the tone is the only thing that varies, and it carries the meaning rather
|
||||
* than decorating it. `quiet` is for a fact worth stating and not worth looking
|
||||
* at twice.
|
||||
*
|
||||
* It lives in its own file because the review list, the accept dialog and the
|
||||
* Atlas dialog all use it, and three copies of a pill is how two of them end up
|
||||
* a pixel apart.
|
||||
*/
|
||||
export default function DawarichBadge({
|
||||
icon: Icon,
|
||||
tone = 'neutral',
|
||||
size = 'xs',
|
||||
title,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
tone?: 'neutral' | 'quiet' | 'accent' | 'success' | 'warning'
|
||||
/**
|
||||
* `xs` matches the places rail, where the panel sits beside rows of that size;
|
||||
* `sm` is for the dialogs, which are read at arm's length rather than scanned.
|
||||
*/
|
||||
size?: 'xs' | 'sm'
|
||||
/** Hover text. Defaults to `label` when the badge is icon-only. */
|
||||
title?: string
|
||||
/**
|
||||
* The accessible name for an icon-only badge. Passing it instead of children
|
||||
* is what makes a flag a round dot of colour without turning a screen reader
|
||||
* into someone staring at an unlabelled icon.
|
||||
*/
|
||||
label?: string
|
||||
children?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
const tones: Record<string, string> = {
|
||||
neutral: 'bg-surface-secondary text-content-secondary',
|
||||
quiet: 'bg-surface-secondary text-content-faint',
|
||||
accent: 'bg-accent-subtle text-accent-on',
|
||||
success: 'bg-success-soft text-success',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
}
|
||||
const metrics =
|
||||
size === 'sm'
|
||||
? { style: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }, box: 'h-[20px]', icon: 'w-3 h-3' }
|
||||
: { style: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }, box: 'h-[18px]', icon: 'w-2.5 h-2.5' }
|
||||
const iconOnly = children === undefined
|
||||
const hint = title ?? label
|
||||
const badge = (
|
||||
<span
|
||||
aria-label={iconOnly ? label : undefined}
|
||||
role={iconOnly ? 'img' : undefined}
|
||||
style={metrics.style}
|
||||
className={`inline-flex ${metrics.box} max-w-full flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-full font-semibold ${
|
||||
iconOnly ? (size === 'sm' ? 'w-[20px] justify-center' : 'w-[18px] justify-center') : 'px-1.5'
|
||||
} ${tones[tone]}`}
|
||||
>
|
||||
{Icon && <Icon className={`${metrics.icon} flex-shrink-0`} />}
|
||||
{!iconOnly && <span className="truncate">{children}</span>}
|
||||
</span>
|
||||
)
|
||||
// TREK's own tooltip rather than the browser's: the native one ignores the
|
||||
// colour scheme, waits a second and a half, and cannot be read on a touch
|
||||
// device at all. A badge with nothing to add gets none.
|
||||
return hint ? <Tooltip label={hint} placement="top">{badge}</Tooltip> : badge
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, MapPin, BookOpen, Check, X, RotateCcw, AlertTriangle, Clock, Hourglass, CalendarDays, Briefcase, Gauge, History, Unplug } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { relativeTime } from '../../utils/relativeTime'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import Badge from './DawarichBadge'
|
||||
import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
|
||||
import { formatDayHeading, formatDuration, groupByDay, timeRange } from './dawarichSuggestionModel'
|
||||
|
||||
/**
|
||||
* The panel sits inside the places rail, and it has to read at the same size as
|
||||
* the rows below it — `text-body` (14px) and `text-caption` (12px) made every
|
||||
* line of it a size larger than the trip's own places, which is the one thing a
|
||||
* panel about somebody else's data must not be.
|
||||
*
|
||||
* These are the rail's own figures (PlacesSidebarRow uses exactly 13 / 11 / 10),
|
||||
* kept as calc() against the per-tier multipliers so the appearance text-size
|
||||
* control still reaches them — a bare number would not.
|
||||
*/
|
||||
const TYPE = {
|
||||
name: { fontSize: 'calc(13px * var(--fs-scale-body, 1))' },
|
||||
meta: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' },
|
||||
micro: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' },
|
||||
} as const
|
||||
|
||||
export interface DawarichSuggestionsPanelProps {
|
||||
/** Only stays that fall inside this trip. */
|
||||
tripId?: number
|
||||
/** Trips the user may write a place into. */
|
||||
trips?: AcceptTargetOption[]
|
||||
daysForTrip?: (tripId: number) => AcceptTargetOption[]
|
||||
/** Journeys the user may add an entry to. */
|
||||
journals?: AcceptTargetOption[]
|
||||
/** Called after something was written, so the host can refresh its own list. */
|
||||
onAccepted?: (suggestion: DawarichSuggestion) => void
|
||||
/** Start collapsed. The planner does; the journal does not. */
|
||||
initiallyCollapsed?: boolean
|
||||
/**
|
||||
* Drop the panel's own card and header.
|
||||
*
|
||||
* For a sheet that already carries both — the phone's journey opens this list
|
||||
* in one, and a second "From Dawarich" under the first is a stutter.
|
||||
*/
|
||||
bare?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The stays Dawarich recorded, waiting to be reviewed.
|
||||
*
|
||||
* One component for both shells. The phone and the desktop place it
|
||||
* differently, but the rows are the same rows, and a second copy of them is a
|
||||
* second thing to keep in step — the same reasoning as `TripRouteOverviewPanel`
|
||||
* next door, and the same reason SonarCloud's duplication budget exists.
|
||||
*
|
||||
* **Grouped by day, and coloured like the map.** A day of travel produces four
|
||||
* or five stays; repeating the full date on each of them buries the one thing
|
||||
* that tells them apart, which is the time. The day's dot carries the colour its
|
||||
* line is drawn in on the trip map, so "the purple day" means the same thing in
|
||||
* both places.
|
||||
*
|
||||
* The panel renders **nothing at all** when there is nothing to review. An
|
||||
* integration that is connected and quiet should be invisible, not a permanent
|
||||
* empty card explaining that it found nothing.
|
||||
*/
|
||||
export default function DawarichSuggestionsPanel({
|
||||
tripId,
|
||||
trips = [],
|
||||
daysForTrip,
|
||||
journals = [],
|
||||
onAccepted,
|
||||
initiallyCollapsed = false,
|
||||
bare = false,
|
||||
}: DawarichSuggestionsPanelProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const S = useDawarichSuggestions({ tripId })
|
||||
// The phone shell has its own palette (`--m-*`): a card in the desktop surface
|
||||
// tokens glares next to it. Same component, the shell's own colours.
|
||||
const phone = useIsPhone()
|
||||
const [collapsed, setCollapsed] = useState(initiallyCollapsed)
|
||||
const [showHandled, setShowHandled] = useState(false)
|
||||
const [pending, setPending] = useState<{ suggestion: DawarichSuggestion; target: DawarichSuggestionTarget } | null>(null)
|
||||
|
||||
// Deleting the place an acceptance created is how somebody says "not that
|
||||
// one" after the fact: the server puts the stay back into review, and a
|
||||
// shrinking place count is what tells us to go and ask. Only a shrink — a
|
||||
// place being added is usually this panel's own doing.
|
||||
const placeCount = useTripStore(state => (tripId === undefined ? 0 : state.places.length))
|
||||
const lastPlaceCount = useRef(placeCount)
|
||||
const reload = S.reload
|
||||
useEffect(() => {
|
||||
const shrank = placeCount < lastPlaceCount.current
|
||||
lastPlaceCount.current = placeCount
|
||||
if (shrank) reload()
|
||||
}, [placeCount, reload])
|
||||
|
||||
const open = useMemo(() => S.suggestions.filter(item => item.state === 'new'), [S.suggestions])
|
||||
const handled = useMemo(() => S.suggestions.filter(item => item.state !== 'new'), [S.suggestions])
|
||||
const openDays = useMemo(() => groupByDay(open), [open])
|
||||
|
||||
// Nothing to review and nothing to report: stay out of the way entirely.
|
||||
if (S.status === 'idle') return null
|
||||
if (S.status === 'ready' && open.length === 0 && handled.length === 0) return null
|
||||
|
||||
const confirm = async (body: DawarichAccept) => {
|
||||
if (!pending) return
|
||||
const ok = await S.accept(pending.suggestion.id, body)
|
||||
if (ok) {
|
||||
onAccepted?.(pending.suggestion)
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
const rowProps = {
|
||||
phone,
|
||||
// Offered only where there is somewhere to put it: the planner has no
|
||||
// journey list, and a button that opens an empty picker is worse than none.
|
||||
allowJournal: journals.length > 0,
|
||||
allowPlace: trips.length > 0,
|
||||
// The trip name is noise when every row is from the same trip.
|
||||
showTrip: tripId === undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
bare
|
||||
? ''
|
||||
: `rounded-2xl overflow-hidden ${phone ? 'bg-[color:var(--m-ic)]' : 'border border-edge bg-surface-secondary'}`
|
||||
}
|
||||
>
|
||||
{bare ? (
|
||||
<p className={`px-1 pb-2 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(value => !value)}
|
||||
aria-expanded={!collapsed}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 text-left ${
|
||||
phone ? 'active:bg-[color:var(--m-inner)]' : 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
|
||||
square badge and a hard corner beside a rounded card reads as a
|
||||
pasted-in image. */}
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
|
||||
<DawarichIcon size={22} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className={`block truncate font-semibold leading-tight ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
|
||||
{t('dawarich.suggestions.title')}
|
||||
</span>
|
||||
<span className={`block truncate ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</span>
|
||||
</span>
|
||||
{open.length > 0 && (
|
||||
<span
|
||||
className={`flex-shrink-0 min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full font-bold ${
|
||||
phone ? 'bg-m-act text-m-actfg' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
style={TYPE.micro}
|
||||
>
|
||||
{open.length}
|
||||
</span>
|
||||
)}
|
||||
{collapsed
|
||||
? <ChevronRight className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />
|
||||
: <ChevronDown className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(bare || !collapsed) && (
|
||||
<div className="border-t border-edge-secondary">
|
||||
{S.status !== 'ready' && (
|
||||
<p className={`px-3.5 py-2.5 ${S.status === 'unavailable' ? 'text-danger' : 'text-content-secondary'}`} style={TYPE.meta}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{S.lastSyncError && S.status === 'ready' && (
|
||||
<p className="px-3.5 pt-2.5 text-danger" style={TYPE.meta}>{S.lastSyncError}</p>
|
||||
)}
|
||||
|
||||
{open.length === 0 && S.status === 'ready' && (
|
||||
<p className={`px-3.5 py-2.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>{t('dawarich.suggestions.allHandled')}</p>
|
||||
)}
|
||||
|
||||
{openDays.map(day => (
|
||||
<div key={day.date}>
|
||||
<div className="flex items-center gap-2 px-3.5 pt-2.5 pb-1">
|
||||
{/* The colour this day's line is drawn in on the map. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ background: day.color }}
|
||||
/>
|
||||
<span className={`font-bold uppercase tracking-wider whitespace-nowrap ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{formatDayHeading(day.date, locale)}
|
||||
</span>
|
||||
<span className="flex-1 h-px bg-edge-faint" />
|
||||
<Badge tone="quiet">{day.stays.length}</Badge>
|
||||
</div>
|
||||
{day.stays.map(suggestion => (
|
||||
<SuggestionRow
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
busy={S.busyId === suggestion.id}
|
||||
{...rowProps}
|
||||
onAccept={target => setPending({ suggestion, target })}
|
||||
onDismiss={() => S.dismiss(suggestion.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{handled.length > 0 && (
|
||||
<div className="border-t border-edge-faint">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHandled(value => !value)}
|
||||
className={`w-full px-3.5 py-2 text-left ${
|
||||
phone ? 'text-m-muted active:bg-[color:var(--m-inner)]' : 'text-content-muted hover:bg-surface-hover'
|
||||
}`}
|
||||
style={TYPE.meta}
|
||||
>
|
||||
{showHandled
|
||||
? t('dawarich.suggestions.hideHandled')
|
||||
: t('dawarich.suggestions.showHandled', { count: handled.length })}
|
||||
</button>
|
||||
{showHandled &&
|
||||
handled.map(suggestion => (
|
||||
<SuggestionRow
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
busy={S.busyId === suggestion.id}
|
||||
showTrip={rowProps.showTrip}
|
||||
showDate
|
||||
onRestore={suggestion.state === 'dismissed' ? () => S.restore(suggestion.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DawarichAcceptDialog
|
||||
suggestion={pending?.suggestion ?? null}
|
||||
target={pending?.target ?? 'journal'}
|
||||
trips={trips}
|
||||
daysForTrip={daysForTrip}
|
||||
journals={journals}
|
||||
busy={S.busyId !== null}
|
||||
onCancel={() => setPending(null)}
|
||||
onConfirm={confirm}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One stay: a name, when it was, and how to act on it.
|
||||
*
|
||||
* Two lines and no chip soup. The meta line carries only what varies — how long,
|
||||
* and the two things worth interrupting for: a wish this fulfils, and a
|
||||
* detection the source itself was unsure about. The "detected, unconfirmed"
|
||||
* label that used to sit on nearly every row is gone: since Dawarich 1.12.0
|
||||
* unconfirmed is the normal state of a visit, so saying it everywhere said
|
||||
* nothing. Reviewing it here *is* the confirmation.
|
||||
*
|
||||
* The actions fade in on hover on a pointer device and stay visible on touch,
|
||||
* where there is no hover to reveal them.
|
||||
*/
|
||||
function SuggestionRow({
|
||||
suggestion,
|
||||
busy,
|
||||
allowJournal = false,
|
||||
allowPlace = false,
|
||||
showTrip = false,
|
||||
showDate = false,
|
||||
phone = false,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onRestore,
|
||||
}: {
|
||||
suggestion: DawarichSuggestion
|
||||
busy: boolean
|
||||
allowJournal?: boolean
|
||||
allowPlace?: boolean
|
||||
showTrip?: boolean
|
||||
showDate?: boolean
|
||||
/** Rendered inside the phone shell, which has its own palette and its own reach. */
|
||||
phone?: boolean
|
||||
onAccept?: (target: DawarichSuggestionTarget) => void
|
||||
onDismiss?: () => void
|
||||
onRestore?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch gap-[6px] px-3 py-[5px]">
|
||||
{/* A stay is a card with its actions clipped to the side — the shape an
|
||||
expense already has in Costs. One layout in both shells; only the
|
||||
palette differs, because the phone paints from `--m-*` and the desktop
|
||||
from the semantic tokens, and neither set exists outside its own. */}
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 flex-col justify-center rounded-2xl border px-[11px] py-[9px] ${
|
||||
phone ? 'border-[color:var(--m-gbr)] bg-m-card' : 'border-edge bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
{/* The name gets the line, and the one badge that is about the name
|
||||
itself rides with it: "Unsure" qualifies what this place is called,
|
||||
not how long somebody stayed, and two lines below the name it read as
|
||||
one more fact among the times. It never pushes the name out — the
|
||||
name truncates, the flag does not. */}
|
||||
<p className={`flex items-center gap-1.5 font-medium ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
|
||||
<span className="truncate">{suggestion.name}</span>
|
||||
{/* The mark, never the word: the name is what the line is for, and
|
||||
"Unsure" beside it is a caption competing with a title. The tooltip
|
||||
and the accessible name still say it in full. */}
|
||||
{suggestion.confidenceBand === 'low' && (
|
||||
<Badge icon={Gauge} tone="warning" label={t('dawarich.badge.lowConfidence')} title={t('dawarich.confidence.low')} />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* One badge row, one badge shape, colour carrying the meaning: neutral
|
||||
for a fact, accent for a wish this fulfils, success for what was
|
||||
already taken, amber for the two things worth interrupting for.
|
||||
Nothing wraps — a row that reflows every time a name is long reads as
|
||||
a mess, so it stays one line and the badges that do not fit are cut
|
||||
off rather than pushed onto a second. */}
|
||||
{/* Wraps, and only here: each badge is a fixed-height pill whose own text
|
||||
never breaks, so a second line of them reads as a second line of
|
||||
badges rather than as a paragraph coming apart. Cutting them off
|
||||
instead would hide the wish a stay fulfils, which is the one thing in
|
||||
the row worth the space. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{/* Ordered by what a reader needs first, because the rail is narrow and
|
||||
the row does not wrap: how long, then what it would fulfil, then the
|
||||
flags. The flags are icon-only — the word costs half the row and the
|
||||
sentence under the badges says it properly anyway. */}
|
||||
{showDate && (
|
||||
<Badge icon={CalendarDays}>{formatDayHeading(suggestion.localDate, locale)}</Badge>
|
||||
)}
|
||||
<Badge icon={Clock}>{timeRange(suggestion.startedAt, suggestion.endedAt)}</Badge>
|
||||
<Badge icon={Hourglass}>{formatDuration(suggestion.durationMinutes, t)}</Badge>
|
||||
|
||||
{suggestion.state === 'accepted' && (
|
||||
<Badge icon={Check} tone="success">
|
||||
{t('dawarich.suggestions.acceptedAs.' + (suggestion.target ?? 'journal'))}
|
||||
</Badge>
|
||||
)}
|
||||
{suggestion.state === 'dismissed' && (
|
||||
<Badge icon={X} tone="quiet">{t('dawarich.suggestions.dismissed')}</Badge>
|
||||
)}
|
||||
|
||||
{showTrip && suggestion.tripTitle && (
|
||||
<Badge icon={Briefcase}>{suggestion.tripTitle}</Badge>
|
||||
)}
|
||||
|
||||
{suggestion.sourceMissing && (
|
||||
<Badge icon={Unplug} tone="warning" label={t('dawarich.badge.sourceMissing')} />
|
||||
)}
|
||||
{!suggestion.sourceMissing && suggestion.sourceChanged && (
|
||||
<Badge icon={History} tone="warning" label={t('dawarich.badge.sourceChanged')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The long form of the two warnings, under the badges: the badge is the
|
||||
flag, this is what it means, and somebody reading the panel for the
|
||||
first time should not have to hover to find out. */}
|
||||
{(suggestion.sourceChanged || suggestion.sourceMissing) && (
|
||||
<p className={`mt-1 flex items-start gap-1.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>
|
||||
<AlertTriangle className="w-3 h-3 flex-shrink-0 mt-px text-warning" />
|
||||
{suggestion.sourceMissing
|
||||
? t('dawarich.suggestions.sourceMissing')
|
||||
: t('dawarich.suggestions.sourceChanged')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Always on screen. Hiding them until hover made the only two things a row
|
||||
is for invisible to anyone who had not discovered that hovering did
|
||||
something — and on a touch screen there is no hover at all.
|
||||
|
||||
A column beside the text in the desktop rail, which is narrow and tall;
|
||||
a row on a phone, where there is width to spare and a stack of small
|
||||
squares reads as a scrollbar. Centred either way, against the name and
|
||||
the time it belongs to. */}
|
||||
{onAccept && (
|
||||
<div className={ACTIONS(phone)}>
|
||||
{allowJournal && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.asJournal')}
|
||||
icon={<BookOpen className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={() => onAccept('journal')}
|
||||
/>
|
||||
)}
|
||||
{allowPlace && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.asPlace')}
|
||||
icon={<MapPin className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={() => onAccept('place')}
|
||||
/>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.dismiss')}
|
||||
icon={<X className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onRestore && (
|
||||
<div className={ACTIONS(phone)}>
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.restore')}
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={onRestore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a row keeps its actions: a pill clipped to the side of the card, the
|
||||
* same in both shells, painted from whichever palette the shell has.
|
||||
*/
|
||||
const ACTIONS = (phone: boolean): string =>
|
||||
'flex flex-none flex-col gap-1 rounded-full border p-[5px] ' +
|
||||
(phone ? 'border-[color:var(--m-gbr)] bg-[color:var(--m-glass)]' : 'border-edge bg-surface')
|
||||
|
||||
/**
|
||||
* One action on a row: a real target with a real accessible name.
|
||||
*
|
||||
* A square with a border rather than a bare glyph, because a bare glyph on a
|
||||
* white rail reads as decoration; this reads as a button, which is what it is.
|
||||
* The label is a tooltip and the accessible name — spelling it out beside the
|
||||
* icon would cost the row the width its name needs.
|
||||
*
|
||||
* All of them look the same. Marking one as the primary action would be TREK
|
||||
* deciding for the reader which of "put it on the map" and "write about it" they
|
||||
* meant, and the whole review step exists because that is theirs to decide.
|
||||
*
|
||||
* Hover fills the square with the inverse surface — near-black in the light
|
||||
* scheme, near-white in the dark one — so the button somebody is about to press
|
||||
* is unmistakable without inventing a colour the theme does not have.
|
||||
*/
|
||||
function RowAction({
|
||||
label,
|
||||
icon,
|
||||
disabled,
|
||||
phone = false,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
disabled?: boolean
|
||||
phone?: boolean
|
||||
onClick: () => void
|
||||
}): React.ReactElement {
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className={`flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full transition-all disabled:opacity-40 ${
|
||||
phone
|
||||
? 'text-m-muted active:scale-95'
|
||||
: 'text-content-secondary hover:bg-inverse hover:text-inverse-text'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
// No tooltip on a phone: there is no hover to open it with, and the label is
|
||||
// already the button's accessible name.
|
||||
return phone ? button : <Tooltip label={label} placement="left">{button}</Tooltip>
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the data came from and how fresh it is, in one line under the title.
|
||||
*
|
||||
* Relative rather than a stamp: "checked 2 minutes ago" answers the question
|
||||
* somebody actually has, and "9/12/2026, 6:40:19 PM" makes them do the
|
||||
* subtraction themselves — on two lines, seconds included.
|
||||
*/
|
||||
function statusLine(
|
||||
state: ReturnType<typeof useDawarichSuggestions>,
|
||||
openCount: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
locale: string,
|
||||
): string {
|
||||
if (state.status === 'loading') return t('dawarich.suggestions.loading')
|
||||
if (state.status === 'disconnected') return t('dawarich.suggestions.notConnected')
|
||||
if (state.status === 'offline') return t('dawarich.trail.offline')
|
||||
if (state.status === 'unavailable') return t('dawarich.suggestions.unavailable')
|
||||
|
||||
// Just the freshness. The count sits in the badge beside this line, and
|
||||
// gluing the two together with a separator said the same thing twice.
|
||||
return state.lastSyncAt
|
||||
? t('dawarich.checkedAgo', { ago: relativeTime(Date.parse(state.lastSyncAt), locale) })
|
||||
: t('dawarich.neverSynced')
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// FE-DAWARICH-ATLASMODEL-001 to FE-DAWARICH-ATLASMODEL-010
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
|
||||
import {
|
||||
cityLine,
|
||||
countryLabel,
|
||||
countryWindow,
|
||||
formatDistance,
|
||||
newCountries,
|
||||
orderedMatches,
|
||||
} from './dawarichAtlasModel'
|
||||
|
||||
function match(over: Partial<DawarichBucketMatch> & { itemId: number }): DawarichBucketMatch {
|
||||
return {
|
||||
itemId: over.itemId,
|
||||
name: over.name ?? `Wish ${over.itemId}`,
|
||||
match: over.match === undefined ? { at: '2026-09-01T10:00:00Z', minutes: 90, distanceMeters: 40, points: 12 } : over.match,
|
||||
alreadyVisited: over.alreadyVisited ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
function country(over: Partial<DawarichAtlasCountry> & { countryCode: string }): DawarichAtlasCountry {
|
||||
return {
|
||||
countryCode: over.countryCode,
|
||||
sourceName: over.sourceName ?? over.countryCode,
|
||||
cities: over.cities ?? [],
|
||||
alreadyVisited: over.alreadyVisited ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>): string =>
|
||||
`${key}:${JSON.stringify(params ?? {})}`
|
||||
|
||||
describe('countryWindow', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-001: asks for exactly the year before the given moment', () => {
|
||||
const now = new Date('2026-09-12T18:00:00Z')
|
||||
const { from, to } = countryWindow(now)
|
||||
expect(to).toBe('2026-09-12T18:00:00.000Z')
|
||||
expect(from).toBe('2025-09-12T18:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderedMatches', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-002: drops wishes the recordings found nothing for', () => {
|
||||
const ordered = orderedMatches([match({ itemId: 1, match: null }), match({ itemId: 2 })])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-003: open wishes lead, already ticked ones follow', () => {
|
||||
const ordered = orderedMatches([
|
||||
match({ itemId: 1, alreadyVisited: true }),
|
||||
match({ itemId: 2 }),
|
||||
])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-004: within a group the most recent visit comes first', () => {
|
||||
const ordered = orderedMatches([
|
||||
match({ itemId: 1, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
|
||||
match({ itemId: 2, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
|
||||
])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-005: leaves the caller\'s array untouched', () => {
|
||||
const input = [match({ itemId: 1, alreadyVisited: true }), match({ itemId: 2 })]
|
||||
orderedMatches(input)
|
||||
expect(input.map(m => m.itemId)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('newCountries', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-006: only the ones a confirmation would actually change', () => {
|
||||
const offered = newCountries([
|
||||
country({ countryCode: 'DE', alreadyVisited: true }),
|
||||
country({ countryCode: 'NL' }),
|
||||
])
|
||||
expect(offered.map(c => c.countryCode)).toEqual(['NL'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countryLabel', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-007: names the country in the reader\'s language', () => {
|
||||
expect(countryLabel('NL', 'Netherlands', 'de')).toBe('Niederlande')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-008: falls back to Dawarich\'s own spelling for a code it cannot name', () => {
|
||||
// Not an ISO region: Intl either throws or echoes it back, and neither is a
|
||||
// name a reader would recognise.
|
||||
expect(countryLabel('ZZZZ', 'Freedonia', 'en')).toBe('Freedonia')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cityLine', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-009: lists the first few cities and stops', () => {
|
||||
const cities = ['Cologne', 'Bonn', 'Aachen', 'Trier'].map(name => ({
|
||||
name,
|
||||
minutes: 60,
|
||||
lastSeenAt: '2026-09-01T10:00:00Z',
|
||||
}))
|
||||
expect(cityLine(cities)).toBe('Cologne, Bonn, Aachen')
|
||||
expect(cityLine(cities, 1)).toBe('Cologne')
|
||||
expect(cityLine([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-010: metres up to a kilometre, then kilometres with one decimal', () => {
|
||||
expect(formatDistance(49.4, t)).toBe('dawarich.bucket.metersAway:{"meters":49}')
|
||||
expect(formatDistance(999, t)).toBe('dawarich.bucket.metersAway:{"meters":999}')
|
||||
expect(formatDistance(1400, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.4"}')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Pure shaping for the Atlas dialog — no React, so the ordering, the naming and
|
||||
* the window arithmetic can be tested without rendering anything.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A year back from the given moment.
|
||||
*
|
||||
* Long enough to cover the trips someone would be filling in, short enough that
|
||||
* a self-hosted instance is not asked to aggregate a decade of points for a card
|
||||
* nobody was looking at. Takes `now` rather than reading the clock so the window
|
||||
* is testable.
|
||||
*/
|
||||
export function countryWindow(now: Date): { from: string; to: string } {
|
||||
const from = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
|
||||
return { from: from.toISOString(), to: now.toISOString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The wishes worth showing, best first.
|
||||
*
|
||||
* Only entries the recordings actually found; the ones still open lead, because
|
||||
* they are the ones the reader can act on, and within each group the most recent
|
||||
* visit comes first — a wish reached last week is easier to confirm than one
|
||||
* reached ten months ago.
|
||||
*/
|
||||
export function orderedMatches(matches: DawarichBucketMatch[]): DawarichBucketMatch[] {
|
||||
return matches
|
||||
.filter(match => match.match !== null)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.alreadyVisited !== b.alreadyVisited) return a.alreadyVisited ? 1 : -1
|
||||
return (b.match?.at ?? '') < (a.match?.at ?? '') ? -1 : 1
|
||||
})
|
||||
}
|
||||
|
||||
/** The countries a confirmation would actually change. */
|
||||
export function newCountries(countries: DawarichAtlasCountry[]): DawarichAtlasCountry[] {
|
||||
return countries.filter(country => !country.alreadyVisited)
|
||||
}
|
||||
|
||||
/**
|
||||
* A country's name in the reader's language, falling back to Dawarich's own
|
||||
* spelling.
|
||||
*
|
||||
* `Intl.DisplayNames` is missing on older WebViews and throws on a code it does
|
||||
* not know, so both are caught — the Atlas page's own resolver does the same.
|
||||
*/
|
||||
export function countryLabel(code: string, sourceName: string, locale: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) || sourceName
|
||||
} catch {
|
||||
return sourceName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first few cities, as one line.
|
||||
*
|
||||
* Three is what fits beside a flag at the width the dialog has on a phone, and
|
||||
* the count badge beside it already says how many there were in total.
|
||||
*/
|
||||
export function cityLine(cities: DawarichAtlasCountry['cities'], limit = 3): string {
|
||||
return cities
|
||||
.slice(0, limit)
|
||||
.map(city => city.name)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* A distance a person would say out loud: metres up to a kilometre, then one
|
||||
* decimal of a kilometre. `1400 m away` is a measurement; `1.4 km away` is how
|
||||
* far it was.
|
||||
*/
|
||||
export function formatDistance(
|
||||
meters: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
return meters < 1000
|
||||
? t('dawarich.bucket.metersAway', { meters: Math.round(meters) })
|
||||
: t('dawarich.bucket.kilometersAway', { km: (meters / 1000).toFixed(1) })
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-012
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import {
|
||||
clockOf,
|
||||
formatDayHeading,
|
||||
formatDayOption,
|
||||
formatDuration,
|
||||
groupByDay,
|
||||
timeRange,
|
||||
} from './dawarichSuggestionModel'
|
||||
|
||||
function stay(over: Partial<DawarichSuggestion> & { id: number; localDate: string }): DawarichSuggestion {
|
||||
return {
|
||||
id: over.id,
|
||||
sourceVisitId: String(over.id),
|
||||
tripId: over.tripId ?? null,
|
||||
tripTitle: over.tripTitle ?? null,
|
||||
name: over.name ?? 'Cafe Reichard',
|
||||
lat: over.lat ?? 50.94,
|
||||
lng: over.lng ?? 6.96,
|
||||
startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
|
||||
endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
|
||||
durationMinutes: over.durationMinutes ?? 145,
|
||||
localDate: over.localDate,
|
||||
sourceStatus: over.sourceStatus ?? 'suggested',
|
||||
confidence: over.confidence ?? null,
|
||||
confidenceBand: over.confidenceBand ?? null,
|
||||
state: over.state ?? 'new',
|
||||
target: over.target ?? null,
|
||||
acceptedPlaceId: over.acceptedPlaceId ?? null,
|
||||
acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
|
||||
acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
|
||||
sourceChanged: over.sourceChanged ?? false,
|
||||
sourceMissing: over.sourceMissing ?? false,
|
||||
matchedBucketListItemId: over.matchedBucketListItemId ?? null,
|
||||
matchedBucketListName: over.matchedBucketListName ?? null,
|
||||
countryCode: over.countryCode ?? null,
|
||||
firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>): string =>
|
||||
`${key}:${JSON.stringify(params ?? {})}`
|
||||
|
||||
describe('groupByDay', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-001: newest day first, earliest stay first within a day', () => {
|
||||
const days = groupByDay([
|
||||
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
|
||||
])
|
||||
expect(days.map(d => d.date)).toEqual(['2026-09-11', '2026-09-10'])
|
||||
expect(days[1].stays.map(s => s.id)).toEqual([3, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-002: the colour counts from the oldest day, like the map does', () => {
|
||||
const days = groupByDay([
|
||||
stay({ id: 1, localDate: '2026-09-10' }),
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
])
|
||||
const oldest = days.find(d => d.date === '2026-09-10')!
|
||||
const newest = days.find(d => d.date === '2026-09-11')!
|
||||
expect(oldest.color).not.toBe(newest.color)
|
||||
// Same input in a different order paints the same day the same colour.
|
||||
const again = groupByDay([
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
stay({ id: 1, localDate: '2026-09-10' }),
|
||||
])
|
||||
expect(again.find(d => d.date === '2026-09-10')!.color).toBe(oldest.color)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-003: nothing in, nothing out', () => {
|
||||
expect(groupByDay([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clockOf / timeRange', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-004: reads the clock out of the timestamp\'s own offset', () => {
|
||||
expect(clockOf('2026-09-10T10:15:00+02:00')).toBe('10:15')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-005: an unreadable timestamp has no clock', () => {
|
||||
expect(clockOf('yesterday')).toBe('')
|
||||
expect(clockOf('')).toBe('')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-006: a range when both ends read, the arrival alone when the end does not', () => {
|
||||
expect(timeRange('2026-09-10T10:15:00+02:00', '2026-09-10T12:40:00+02:00')).toBe('10:15 – 12:40')
|
||||
expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense')).toBe('10:15')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => {
|
||||
expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-008: minutes under an hour, hours above it, both when there is a remainder', () => {
|
||||
expect(formatDuration(45, t)).toBe('dawarich.duration.minutes:{"minutes":45}')
|
||||
expect(formatDuration(120, t)).toBe('dawarich.duration.hours:{"hours":2}')
|
||||
expect(formatDuration(145, t)).toBe('dawarich.duration.hoursMinutes:{"hours":2,"minutes":25}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDayHeading', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-009: reads as the local date, not as yesterday', () => {
|
||||
// Built from the date parts rather than from a Date at midnight UTC, which
|
||||
// is the difference between "Thu, 10 Sept" and "Wed, 9 Sept" west of UTC.
|
||||
expect(formatDayHeading('2026-09-10', 'en-GB')).toContain('10')
|
||||
expect(formatDayHeading('2026-09-10', 'en-GB')).toMatch(/Thu/)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-010: an unparseable date is shown as it came', () => {
|
||||
expect(formatDayHeading('not-a-date', 'en-GB')).toBe('not-a-date')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDayOption', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-011: the day number is the label, the date rides along as a badge', () => {
|
||||
const option = formatDayOption(3, '2026-09-10', 'en-GB', t)
|
||||
expect(option.label).toBe('planner.dayN:{"n":3}')
|
||||
expect(option.badge).toMatch(/10/)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-012: a day without a date gets no badge rather than an empty one', () => {
|
||||
expect(formatDayOption(1, null, 'en-GB', t)).toEqual({ label: 'planner.dayN:{"n":1}', badge: undefined })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import { dayColor } from '../Map/dawarichTrail'
|
||||
|
||||
/**
|
||||
* Pure shaping for the suggestions panel — no React, so the grouping and the
|
||||
* formatting can be tested without rendering anything.
|
||||
*/
|
||||
|
||||
export interface SuggestionDay {
|
||||
/** `YYYY-MM-DD`, the stay's own local date. */
|
||||
date: string
|
||||
/** The colour this day is drawn in on the map, so the panel and the line agree. */
|
||||
color: string
|
||||
stays: DawarichSuggestion[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Group stays by the local day they happened on, newest day first, and give each
|
||||
* day the colour the map draws it in.
|
||||
*
|
||||
* The grouping is what turns a flat list into something readable: a day of
|
||||
* travel produces four or five stays, and repeating the full date on every row
|
||||
* buries the one thing that distinguishes them — the time.
|
||||
*
|
||||
* The colour index counts days from the OLDEST, matching how the track overlay
|
||||
* numbers them, so "the purple line" and "the purple day" are the same day.
|
||||
*/
|
||||
export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] {
|
||||
const byDate = new Map<string, DawarichSuggestion[]>()
|
||||
for (const stay of suggestions) {
|
||||
const bucket = byDate.get(stay.localDate)
|
||||
if (bucket) bucket.push(stay)
|
||||
else byDate.set(stay.localDate, [stay])
|
||||
}
|
||||
|
||||
const oldestFirst = [...byDate.keys()].sort()
|
||||
return oldestFirst
|
||||
.map((date, index) => ({
|
||||
date,
|
||||
color: dayColor(index),
|
||||
// Within a day, earliest first: that is the order they were lived in.
|
||||
stays: byDate.get(date)!.slice().sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)),
|
||||
}))
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
|
||||
export function clockOf(iso: string): string {
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* `10:15 – 12:40`, or just the arrival when the end is unreadable.
|
||||
*
|
||||
* An en dash with hair spaces rather than a hyphen: this is a range, and it sits
|
||||
* next to times often enough that the difference is visible.
|
||||
*/
|
||||
export function timeRange(startedAt: string, endedAt: string): string {
|
||||
const from = clockOf(startedAt)
|
||||
const to = clockOf(endedAt)
|
||||
if (!from) return ''
|
||||
return to ? `${from} – ${to}` : from
|
||||
}
|
||||
|
||||
/**
|
||||
* A duration a person would say out loud.
|
||||
*
|
||||
* "285 min" is a number from a database; "4 h 45 min" is how long somebody was
|
||||
* somewhere. The two label keys carry the units so a locale can put them where
|
||||
* it wants them.
|
||||
*/
|
||||
export function formatDuration(
|
||||
minutes: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (minutes < 60) return t('dawarich.duration.minutes', { minutes })
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
return rest === 0
|
||||
? t('dawarich.duration.hours', { hours })
|
||||
: t('dawarich.duration.hoursMinutes', { hours, minutes: rest })
|
||||
}
|
||||
|
||||
/**
|
||||
* A stay's day header, in the reader's language: `Thu, 10 Sept`.
|
||||
*
|
||||
* Built from the date string rather than from a Date at midnight UTC, so a
|
||||
* reader east or west of UTC does not see yesterday.
|
||||
*/
|
||||
export function formatDayHeading(date: string, locale: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
if (!year || !month || !day) return date
|
||||
return new Date(year, month - 1, day).toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* How a trip day reads in the accept dialog's picker.
|
||||
*
|
||||
* `1. 2026-09-09` is a row out of a database. What a person recognises is the
|
||||
* number the planner gives the day and the weekday it fell on, which is exactly
|
||||
* what the day rail shows them — so the picker says the same thing.
|
||||
*/
|
||||
export function formatDayOption(
|
||||
dayNumber: number,
|
||||
date: string | null | undefined,
|
||||
locale: string,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): { label: string; badge?: string } {
|
||||
return {
|
||||
label: t('planner.dayN', { n: dayNumber }),
|
||||
badge: date ? formatDayHeading(date, locale) : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
/**
|
||||
* Pull the trip back in after a stay was accepted into it.
|
||||
*
|
||||
* The acceptance is written server-side, so the planner knows nothing about the
|
||||
* place it produced until somebody reloads the page — which is exactly what a
|
||||
* person should never have to do after pressing a button. Both trip shells call
|
||||
* this, which is also why it is a plain function rather than a hook: one line at
|
||||
* each call site keeps the desktop and phone copies from drifting.
|
||||
*
|
||||
* Places and days both, because a stay accepted onto a day creates an assignment
|
||||
* as well, and the day rail would otherwise stay one place short.
|
||||
*/
|
||||
export async function refreshTripAfterAccept(tripId: number | string): Promise<void> {
|
||||
const store = useTripStore.getState()
|
||||
await Promise.all([store.refreshPlaces(tripId), store.refreshDays(tripId)])
|
||||
}
|
||||
Reference in New Issue
Block a user