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

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

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

The Atlas gets its own panel beside the map for the countries and cities
the recordings prove, and the wish list is ticked off there, on the day
of the visit, undoably. The phone has its own take on all of it, in the
mobile shell's colours and with native sheets.
This commit is contained in:
Maurice
2026-09-12 23:35:43 +02:00
parent 5678eed55c
commit 5b4a7e82ff
108 changed files with 8945 additions and 295 deletions
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="#3c83f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-tower-control-icon lucide-tower-control">
<path d="M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z" />
<path d="M8 13v9" />
<path d="M16 22v-9" />
<path d="m9 6 1 7" />
<path d="m15 6-1 7" />
<path d="M12 6V2" />
<path d="M13 2h-2" />
</svg>

After

Width:  |  Height:  |  Size: 495 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

+83
View File
@@ -0,0 +1,83 @@
import apiClient from './client'
import type {
DawarichAccept,
DawarichAcceptResult,
DawarichAtlasSuggestions,
DawarichBucketScan,
DawarichConnection,
DawarichSettings,
DawarichStatus,
DawarichSuggestion,
DawarichSuggestionList,
DawarichTrack,
} from '@trek/shared'
/**
* The Dawarich surface, in its own file rather than in the `client.ts` god
* module. Types come from the shared contracts, so a server change that the
* client has not caught up with is a typecheck failure rather than a runtime
* surprise.
*/
export const dawarichApi = {
// ── Connection ─────────────────────────────────────────────────────────────
getSettings: (): Promise<DawarichConnection> =>
apiClient.get('/integrations/dawarich/settings').then(r => r.data),
saveSettings: (data: DawarichSettings): Promise<{
success: boolean
/** The server's own sentence, English — the fallback when the code below is unknown here. */
warning?: string
/** What the warning is about, so the UI can say it in the reader's language. */
warningCode?: string
/** The address it resolved to, for the private-IP warning. */
warningIp?: string
}> =>
apiClient.put('/integrations/dawarich/settings', data).then(r => r.data),
disconnect: (): Promise<{ success: boolean }> =>
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
test: (data: Partial<DawarichSettings>): Promise<DawarichStatus> =>
apiClient.post('/integrations/dawarich/test', data).then(r => r.data),
syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
apiClient.post('/integrations/dawarich/sync').then(r => r.data),
// ── Suggestions ────────────────────────────────────────────────────────────
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data),
accept: (id: number, body: DawarichAccept): Promise<DawarichAcceptResult> =>
apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data),
setState: (id: number, state: 'new' | 'dismissed'): Promise<DawarichSuggestion> =>
apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data),
// ── Bucket list ────────────────────────────────────────────────────────────
scanBucketList: (): Promise<DawarichBucketScan> =>
apiClient.post('/integrations/dawarich/bucket-list/scan').then(r => r.data),
confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
apiClient.delete(`/integrations/dawarich/bucket-list/${itemId}/visit`).then(r => r.data),
// ── Atlas ──────────────────────────────────────────────────────────────────
atlasSuggestions: (from: string, to: string): Promise<DawarichAtlasSuggestions> =>
apiClient.get('/integrations/dawarich/atlas/suggestions', { params: { from, to } }).then(r => r.data),
acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
// ── Track overlay ──────────────────────────────────────────────────────────
//
// Recorded points are bare instants, so the server cannot know which day they
// belong to without being told whose day is meant. The browser is the only
// party that knows, so it says so — otherwise an evening east of UTC lands on
// the wrong day of the trip.
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
apiClient
.get(`/integrations/dawarich/trips/${tripId}/track`, { params: { ...params, offset: localOffsetMinutes() }, signal })
.then(r => r.data),
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
apiClient
.get('/integrations/dawarich/track', { params: { from, to, offset: localOffsetMinutes() }, signal })
.then(r => r.data),
}
/** The reader's own UTC offset in minutes — +120 for Berlin in summer. */
function localOffsetMinutes(): number {
return -new Date().getTimezoneOffset()
}
+18 -2
View File
@@ -7,6 +7,8 @@ import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
@@ -15,6 +17,9 @@ import AddonSubRow from './AddonSubRow'
// used to land on the fallback.
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
// providers below do. Keyed by the string in the addons table.
Dawarich: DawarichIcon,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -59,9 +64,20 @@ interface ProviderOption {
interface AddonIconProps {
name: string
size?: number
/** A switched-off addon greys its icon out, brand marks included. */
enabled?: boolean
}
function AddonIcon({ name, size = 20 }: AddonIconProps) {
function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
// The brand marks are badges, not glyphs: they fill the framed slot instead of
// sitting in the middle of it at glyph size, and they grey out when the addon
// is off — the lucide glyphs get that from the slot's text colour, an <img>
// has to be told.
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
// means every existing install still carries it — so the brand is keyed on
// that rather than on a new name no row would ever have.
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
const Icon = ICON_MAP[name] || Puzzle
return <Icon size={size} />
}
@@ -212,7 +228,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return (
<AddonTile
key={addon.id}
icon={<AddonIcon name={addon.icon} size={18} />}
icon={<AddonIcon name={addon.icon} size={18} enabled={addon.enabled} />}
name={label.name}
description={label.description}
enabled={addon.enabled}
+3 -1
View File
@@ -44,7 +44,9 @@ export default function AddonTile({
>
<div className="flex items-start gap-3 p-4">
<div
className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl ${
// overflow-hidden so a brand mark that fills the slot keeps the slot's
// rounded corners instead of squaring them off.
className={`grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl ${
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
}`}
>
@@ -0,0 +1,48 @@
import { useRef, type RefObject } from 'react'
export interface GlassGlare {
/** Goes on the panel itself — the glare is measured against its box. */
panelRef: RefObject<HTMLDivElement | null>
/** The soft light under the cursor. */
glareRef: RefObject<HTMLDivElement | null>
/** The lit stretch of border around it. */
borderGlareRef: RefObject<HTMLDivElement | null>
onMouseMove: (e: { clientX: number; clientY: number }) => void
onMouseLeave: () => void
}
/**
* The Atlas panels' liquid-glass hover: a pool of light under the cursor and the
* piece of border nearest to it lighting up.
*
* Extracted so a second panel beside the first one behaves identically. Two
* hand-copied versions of a gradient are two versions that drift, and the whole
* point of the effect is that the panels look like one material.
*
* Written straight to the DOM rather than through state: this runs on every
* mousemove, and a re-render per frame would cost more than the effect is worth.
*/
export function useGlassGlare(dark: boolean): GlassGlare {
const panelRef = useRef<HTMLDivElement>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const onMouseMove = (e: { clientX: number; clientY: number }): void => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
const rect = panelRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
glareRef.current.style.opacity = '1'
borderGlareRef.current.style.opacity = '1'
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
}
const onMouseLeave = (): void => {
if (glareRef.current) glareRef.current.style.opacity = '0'
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
}
return { panelRef, glareRef, borderGlareRef, onMouseMove, onMouseLeave }
}
@@ -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)])
}
@@ -0,0 +1,78 @@
import { useMemo } from 'react'
import { Polyline } from 'react-leaflet'
import type { DawarichTrack } from '@trek/shared'
import {
DAWARICH_TRAIL_CASING,
trailSegments,
type DawarichTrailSegment,
} from './dawarichTrail'
/**
* The recorded route on the Leaflet renderer.
*
* Three lines per segment, the same construction the GPX tracks next door use:
* a white casing so the line survives a satellite tile, the coloured line
* itself, and nothing on top — this overlay is not clickable. That last part is
* deliberate: a fat invisible hit-line would sit over the places and the
* planned route, and swallow the clicks that select them, in exchange for a
* popup nobody asked for.
*
* Dashed rather than solid. The planned route is already a solid line in the
* user's accent, and the whole point of this layer is to be visibly the other
* thing — what actually happened, next to what was planned. Telling them apart
* has to survive being printed, screenshotted and looked at by someone who
* cannot distinguish the two hues.
*/
export default function DawarichTrailLayer({
track,
selectedDate,
casingPane,
}: {
track: DawarichTrack | null
/** When set, only that local day is drawn. */
selectedDate?: string | null
/** The `trek-track-casing` pane, when the renderer supports panes. */
casingPane?: string
}) {
const segments = useMemo(() => trailSegments(track, selectedDate), [track, selectedDate])
if (segments.length === 0) return null
return (
<>
{segments.map((segment: DawarichTrailSegment) => (
<Polyline
key={`dawarich-casing-${segment.id}`}
positions={segment.points}
pane={casingPane}
// theme-lint-disable — map paint
pathOptions={{
color: DAWARICH_TRAIL_CASING,
weight: 6,
opacity: 0.55,
lineCap: 'round',
lineJoin: 'round',
}}
interactive={false}
/>
))}
{/* pathOptions rather than bare props: react-leaflet only calls setStyle
when the pathOptions reference changes, so a bare `color` would stick
at its mount-time value and a day recolour would never arrive. */}
{segments.map((segment: DawarichTrailSegment) => (
<Polyline
key={`dawarich-line-${segment.id}`}
positions={segment.points}
pathOptions={{
color: segment.color,
weight: 3,
opacity: 0.85,
dashArray: '6 5',
lineCap: 'round',
lineJoin: 'round',
}}
interactive={false}
/>
))}
</>
)
}
@@ -0,0 +1,93 @@
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import DawarichIcon from '../shared/DawarichIcon'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { DawarichTrailStatus } from './useDawarichTrail'
/**
* Toggle for the recorded-route overlay (#2279), in the same frosted shell as
* the compass, the layer switcher and the trip-route overview so the map's
* controls stay one family.
*
* The status rides on the button rather than in a banner: the states that
* matter — loading, nothing recorded for these dates, instance unreachable,
* device offline — are all answers to "why is there no line", and the place
* someone looks for that answer is the control they just pressed.
*/
export function DawarichTrailPill({
active,
status,
onToggle,
}: {
active: boolean
status: DawarichTrailStatus
onToggle: () => void
}) {
const { t } = useTranslation()
const label = !active
? t('dawarich.trail.show')
: status === 'loading'
? t('dawarich.trail.loading')
: status === 'empty'
? t('dawarich.trail.empty')
: status === 'offline'
? t('dawarich.trail.offline')
: status === 'unavailable'
? t('dawarich.trail.unavailable')
: t('dawarich.trail.hide')
// A problem worth seeing without reading the tooltip: the layer is on and
// there is nothing to show.
const muted = active && (status === 'empty' || status === 'offline' || status === 'unavailable')
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
padding: 4,
borderRadius: 999,
pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}
>
{/* TREK's own tooltip: the native one is unreadable in dark mode and never
appears on a touch device, and this control's whole job is to explain
why there is no line. */}
<Tooltip label={label} placement="left">
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
data-testid="dawarich-trail-pill"
style={{
display: 'block',
width: 34,
height: 34,
borderRadius: 999,
border: 'none',
cursor: 'pointer',
background: 'transparent',
padding: 0,
// The mark fills the circle and is clipped by it, so the control reads
// as the Dawarich button rather than as a frame with a logo in it.
overflow: 'hidden',
// Its own colours stay; on/off is carried by opacity, because a
// recoloured logo is a different logo.
opacity: muted ? 0.35 : active ? 1 : 0.6,
transition: 'opacity 0.14s, transform 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'none' }}
>
<DawarichIcon fill />
</button>
</Tooltip>
</div>
)
}
+12
View File
@@ -53,6 +53,7 @@ import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
@@ -669,6 +670,8 @@ export const MapView = memo(function MapView({
routeVias = [],
dayBoundaryControls,
hazards,
dawarichTrack = null,
dawarichSelectedDate = null,
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
@@ -1179,6 +1182,15 @@ export const MapView = memo(function MapView({
/>
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
{/* The route as it was actually recorded (#2279). Drawn in the casing
pane's sibling order so it sits under the planned route rather than
over it — the plan is what the user is editing. */}
<DawarichTrailLayer
track={dawarichTrack}
selectedDate={dawarichSelectedDate}
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
/>
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
{/* Charging stops / rest areas a plugin route places on the drawn day route.
Host-vetted data (server-normalized), rendered as plain tone dots. */}
@@ -20,6 +20,9 @@ import { useTranslation } from '../../i18n/TranslationContext'
export function MapViewAuto(props: any) {
const { t } = useTranslation()
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
// `dawarichTrack` arrives as a prop rather than being fetched here: the pill
// that switches it on lives at page level and needs the load status, so the
// fetch sits in useTripPlanner and both shells read the same one.
const mapProps = { ...props, hazards: hazards.feed?.hazards }
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
@@ -34,6 +34,11 @@ const glMap = vi.hoisted(() => ({
addSource: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addLayer: vi.fn(),
// Every overlay that adds a layer takes it away again on cleanup; a real
// MapLibre/Mapbox map has both halves, and a double with only the adding half
// fails the teardown rather than the behaviour under test.
removeLayer: vi.fn(),
removeSource: vi.fn(),
setLayoutProperty: vi.fn(),
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
+10 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import type { RoadtripVia } from '@trek/shared'
import type { DawarichTrack, RoadtripVia } from '@trek/shared'
import { useStableVias } from './viaMarkerState'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
@@ -30,6 +30,7 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import type { RoadtripHazard } from '@trek/shared'
import { useHazardLayerGL } from './useHazardLayerGL'
import { useDawarichTrailGL } from './useDawarichTrailGL'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import NightPauseTooltip from './NightPauseTooltip'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
@@ -190,6 +191,10 @@ interface Props {
*/
clusterLoosely?: boolean
hazards?: RoadtripHazard[]
/** The route recorded in Dawarich, already fetched by MapViewAuto (#2279). */
dawarichTrack?: DawarichTrack | null
/** Draw only this local day of the recording. */
dawarichSelectedDate?: string | null
/** Via points to draw as draggable handles, keyed by day (#1797). */
roadtripVias?: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
@@ -635,6 +640,8 @@ export function MapViewGL({
focusPoints,
clusterLoosely = false,
hazards,
dawarichTrack = null,
dawarichSelectedDate = null,
dayOrderMap = NO_DAY_ORDER,
leftWidth = 0,
rightWidth = 0,
@@ -712,6 +719,8 @@ export function MapViewGL({
const mapRef = useRef<any | null>(null)
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, PlacePin>>(new Map())
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
+25 -19
View File
@@ -1,5 +1,6 @@
import { Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
import { formatDistance } from '../../utils/units'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
@@ -21,25 +22,30 @@ export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; o
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
onClick={onToggle}
aria-label={label}
title={label}
aria-pressed={active}
data-testid="trip-overview-pill"
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Route size={17} strokeWidth={2} />
</button>
{/* TREK's own tooltip, not 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. `left`, because these controls hug the right edge of the
map and a tooltip to the right would hang off it. */}
<Tooltip label={label} placement="left">
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
data-testid="trip-overview-pill"
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Route size={17} strokeWidth={2} />
</button>
</Tooltip>
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
import type { DawarichTrack, DawarichTrackDay } from '@trek/shared'
/**
* Everything both map renderers need to agree on for the recorded-route
* overlay, in one module.
*
* TREK draws no line twice the same way — Leaflet takes `<Polyline>` elements
* and GL takes a GeoJSON source — so the *shape* cannot be shared. The colour,
* the day ordering and the identity of a segment can, and they are exactly what
* would drift between the two renderers if each carried its own copy.
*/
/**
* The palette for the recorded route.
*
* Deliberately not derived from `var(--accent)`: the planned route already uses
* the accent, and the whole point of this overlay is that it reads as *the
* other thing* — what happened, next to what was planned. These are the same
* hues Dawarich itself uses for its track modes, which makes the two products
* agree about a line the user has seen in both.
*
* theme-lint-disable — map paint: Leaflet path options and GL paint
* expressions cannot read a CSS variable, and the map is the one surface where
* a fixed palette is the documented exception (client/src/theme/README.md).
*/
export const DAWARICH_TRAIL_COLOR = '#2563EB' // theme-lint-disable — map paint
/** The white casing under the line, so it stays visible over a satellite tile. */
export const DAWARICH_TRAIL_CASING = '#FFFFFF' // theme-lint-disable — map paint
/**
* Per-day hues, cycled.
*
* A multi-day trip drawn in one colour is a scribble; per-day colours make "we
* were here on Tuesday" readable at a glance. Six is enough to tell adjacent
* days apart and few enough that the map does not turn into a rainbow.
*
* theme-lint-disable — map paint, same reason as above.
*/
export const DAWARICH_DAY_COLORS = [
'#2563EB', // theme-lint-disable — map paint
'#7C3AED', // theme-lint-disable — map paint
'#0891B2', // theme-lint-disable — map paint
'#DB2777', // theme-lint-disable — map paint
'#EA580C', // theme-lint-disable — map paint
'#059669', // theme-lint-disable — map paint
] as const
export function dayColor(index: number): string {
return DAWARICH_DAY_COLORS[index % DAWARICH_DAY_COLORS.length]!
}
/** One drawable piece of the overlay, flattened out of the day/segment nesting. */
export interface DawarichTrailSegment {
/** Stable across refetches, so a renderer can key on it instead of on array order. */
id: string
date: string
color: string
/** `[lat, lng]`, TREK's internal order. GeoJSON flips it at the boundary. */
points: Array<[number, number]>
mode: string | null
startedAt: string
endedAt: string
distanceMeters: number | null
}
/**
* Flatten a fetched track into drawable segments, optionally limited to one day.
*
* The day filter is what the timeline scrubber drives: selecting a day on the
* planner should dim the rest of the trip rather than reload it, so the filter
* happens here over data that is already in memory.
*/
export function trailSegments(
track: DawarichTrack | null,
onlyDate?: string | null,
): DawarichTrailSegment[] {
if (!track) return []
const days: DawarichTrackDay[] = onlyDate
? track.days.filter(day => day.date === onlyDate)
: track.days
const out: DawarichTrailSegment[] = []
for (const day of days) {
// The colour index comes from the day's position in the WHOLE track, not in
// the filtered list — otherwise picking a day would repaint it.
const index = track.days.findIndex(candidate => candidate.date === day.date)
day.segments.forEach((segment, i) => {
// Zod's tuple infers as "maybe two numbers, maybe more", so the pairs are
// narrowed at runtime rather than cast. In practice the schema has already
// guaranteed both are numbers; this is what makes the type honest without
// an `as` at the boundary.
const points = segment.points.flatMap((pair): Array<[number, number]> =>
typeof pair[0] === 'number' && typeof pair[1] === 'number' ? [[pair[0], pair[1]]] : [],
)
if (points.length < 2) return
out.push({
id: `${day.date}-${i}`,
date: day.date,
color: dayColor(index < 0 ? 0 : index),
points,
mode: segment.mode,
startedAt: segment.startedAt,
endedAt: segment.endedAt,
distanceMeters: segment.distanceMeters,
})
})
}
return out
}
/** The overlay as a GeoJSON FeatureCollection, for the GL renderer. */
export function trailGeoJson(segments: DawarichTrailSegment[]): {
type: 'FeatureCollection'
features: Array<{
type: 'Feature'
properties: { id: string; color: string; date: string; mode: string | null }
geometry: { type: 'LineString'; coordinates: Array<[number, number]> }
}>
} {
return {
type: 'FeatureCollection',
features: segments.map(segment => ({
type: 'Feature' as const,
properties: {
id: segment.id,
color: segment.color,
date: segment.date,
mode: segment.mode,
},
geometry: {
type: 'LineString' as const,
// Internal order is [lat, lng] everywhere in TREK; GeoJSON wants the
// other one, and this is the single place the overlay flips it.
coordinates: segment.points.map((point): [number, number] => [point[1], point[0]]),
},
})),
}
}
/** Total kilometres the overlay covers, for the panel that reports it. */
export function trailDistanceKm(segments: DawarichTrailSegment[]): number | null {
const known = segments.filter(segment => segment.distanceMeters !== null)
if (known.length === 0) return null
return known.reduce((sum, segment) => sum + (segment.distanceMeters ?? 0), 0) / 1000
}
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { DawarichTrack } from '@trek/shared'
import { dawarichRepo } from '../../repo/dawarichRepo'
import { useAddonStore } from '../../store/addonStore'
import { isEffectivelyOffline, onNetworkModeChange } from '../../sync/networkMode'
export type DawarichTrailStatus = 'idle' | 'loading' | 'ready' | 'empty' | 'unavailable' | 'offline'
export interface DawarichTrailState {
track: DawarichTrack | null
status: DawarichTrailStatus
/** ISO-8601 of the last fetch that actually returned something. */
fetchedAt: string | null
reload: () => void
}
/**
* The recorded route for a trip, fetched while the overlay is on.
*
* Called **once** in `MapViewAuto` and mirrored into both renderers as a prop —
* the same shape `useRoadtripHazards` uses, and for the same reason: two
* renderers each fetching would double the load on somebody's self-hosted
* instance and could draw two different answers.
*
* Refreshes on a timer while the layer is on, so a trip in progress catches up
* without anyone reloading the page. Two minutes rather than seconds: this
* reaches across the network to a server the user runs, and the server caches
* for a minute anyway, so a tighter loop would buy nothing and cost them.
*/
const REFRESH_MS = 120_000
export function useDawarichTrail(
tripId: number | undefined,
enabled: boolean,
range?: { from?: string; to?: string },
): DawarichTrailState {
const addonEnabled = useAddonStore(s => s.isEnabled)
const active = enabled && !!tripId && addonEnabled('dawarich')
const [track, setTrack] = useState<DawarichTrack | null>(null)
const [status, setStatus] = useState<DawarichTrailStatus>('idle')
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [reloadToken, setReloadToken] = useState(0)
// Kept in a ref so changing the window does not re-create the effect's
// identity on every render of the parent — the object literal would.
const rangeRef = useRef(range)
rangeRef.current = range
const rangeKey = `${range?.from ?? ''}|${range?.to ?? ''}`
const reload = useCallback(() => setReloadToken(token => token + 1), [])
useEffect(() => {
if (!active || !tripId) {
setTrack(null)
setStatus('idle')
return
}
let controller: AbortController | null = null
let cancelled = false
const load = () => {
controller?.abort()
controller = new AbortController()
const signal = controller.signal
if (isEffectivelyOffline()) {
setStatus('offline')
return
}
// The previous line stays on screen while the next one loads: blanking it
// makes a refresh look like the recording disappeared.
setStatus(current => (current === 'ready' ? 'ready' : 'loading'))
dawarichRepo
.tripTrack(tripId, rangeRef.current, signal)
.then(result => {
if (cancelled || signal.aborted) return
setTrack(result)
setFetchedAt(result.fetchedAt)
setStatus(result.days.length > 0 ? 'ready' : 'empty')
})
.catch(() => {
if (cancelled || signal.aborted) return
// Keep whatever was drawn. A line from two minutes ago is more useful
// than an empty map, and the status is what says it may be stale.
setStatus(isEffectivelyOffline() ? 'offline' : 'unavailable')
})
}
load()
const timer = window.setInterval(load, REFRESH_MS)
const unsubscribe = onNetworkModeChange(load)
return () => {
cancelled = true
controller?.abort()
window.clearInterval(timer)
unsubscribe()
}
}, [active, tripId, rangeKey, reloadToken])
return { track: active ? track : null, status: active ? status : 'idle', fetchedAt, reload }
}
@@ -0,0 +1,90 @@
import { useEffect } from 'react'
import type { Map } from 'mapbox-gl'
import type { GeoJSONSource } from 'mapbox-gl'
import type { DawarichTrack } from '@trek/shared'
import { DAWARICH_TRAIL_CASING, trailGeoJson, trailSegments } from './dawarichTrail'
/**
* The recorded route on the GL renderer — the imperative twin of
* `DawarichTrailLayer`.
*
* The ids start with `trek-` because `applySatellite` finds the first layer
* whose id carries one of TREK's own prefixes and inserts the satellite tile
* beneath it. A layer named `dawarich-trail` would end up *under* the
* satellite image and simply not be there when someone switched basemap on.
*
* Everything is rebuilt on `style.load`, because the whole GL map is torn down
* and reassembled when the provider, the style or the token changes, and a
* source added outside that effect does not survive it.
*/
const SOURCE = 'trek-dawarich-trail'
const CASING_LAYER = 'trek-dawarich-trail-casing'
const LINE_LAYER = 'trek-dawarich-trail-line'
export function useDawarichTrailGL(
map: Map | null,
ready: boolean,
track: DawarichTrack | null | undefined,
selectedDate?: string | null,
/** Insert beneath this layer, so the planned route stays on top. */
beforeId?: string,
): void {
useEffect(() => {
if (!map || !ready) return
const data = trailGeoJson(trailSegments(track ?? null, selectedDate))
const draw = () => {
if (!map.isStyleLoaded()) return
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined
if (existing) {
// setData rather than remove-and-add: a rebuilt source flickers, and a
// refresh every two minutes would flicker every two minutes.
existing.setData(data as never)
return
}
map.addSource(SOURCE, { type: 'geojson', data: data as never })
const anchor = beforeId && map.getLayer(beforeId) ? beforeId : undefined
map.addLayer(
{
id: CASING_LAYER,
type: 'line',
source: SOURCE,
layout: { 'line-cap': 'round', 'line-join': 'round' },
// theme-lint-disable — map paint
paint: { 'line-color': DAWARICH_TRAIL_CASING, 'line-width': 6, 'line-opacity': 0.55 },
},
anchor,
)
map.addLayer(
{
id: LINE_LAYER,
type: 'line',
source: SOURCE,
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: {
// Per-day colour off the feature, with the same fallback the GPX
// layer uses when a feature somehow carries none.
'line-color': ['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING],
'line-width': 3,
'line-opacity': 0.85,
// Dashed, so the recording is distinguishable from the planned
// route without relying on colour alone.
'line-dasharray': [2, 1.6],
},
},
anchor,
)
}
draw()
map.on('style.load', draw)
return () => {
map.off('style.load', draw)
for (const layer of [LINE_LAYER, CASING_LAYER]) {
if (map.getLayer(layer)) map.removeLayer(layer)
}
if (map.getSource(SOURCE)) map.removeSource(SOURCE)
}
}, [map, ready, track, selectedDate, beforeId])
}
@@ -512,13 +512,16 @@ describe('DayPlanSidebar', () => {
const onToggleConnection = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onOpenTransit: vi.fn(), onToggleConnection, visibleConnectionIds: [] })} />)
// No map-connections toggle on transit rows — the expander replaces it.
expect(screen.queryByTitle(/connections/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/connections/i)).not.toBeInTheDocument()
// Collapsed: no stop names beyond the chips.
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
await user.click(screen.getByLabelText('Expand'))
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
await user.click(screen.getByLabelText('Collapse'))
// The day's own chevron carries the same accessible name, so pick the one
// that is not in the day's action block.
const rowCollapse = screen.getAllByLabelText('Collapse').find(el => !el.closest('.dp-day-actions'))!
await user.click(rowCollapse)
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
})
@@ -2159,7 +2162,7 @@ describe('DayPlanSidebar', () => {
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
const pencil = screen.getByTitle(/edit/i)
const pencil = screen.getByLabelText(/edit/i)
await user.click(pencil)
expect(onEditReservation).toHaveBeenCalledWith(res)
expect(onEditTransport).not.toHaveBeenCalled()
@@ -2177,7 +2180,7 @@ describe('DayPlanSidebar', () => {
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
const pencil = screen.getByTitle(/edit/i)
const pencil = screen.getByLabelText(/edit/i)
await user.click(pencil)
expect(onEditTransport).toHaveBeenCalledWith(res)
expect(onEditReservation).not.toHaveBeenCalled()
@@ -3435,7 +3438,7 @@ describe('DayPlanSidebar', () => {
const { rerender } = render(<DayPlanSidebar {...makeDefaultProps({
days: [day], reservations: [flight], onToggleConnection,
})} />)
const show = screen.getByTitle('Show booking routes')
const show = screen.getByLabelText('Show booking routes')
await user.click(show)
expect(onToggleConnection).toHaveBeenCalledWith(510)
fireEvent.mouseEnter(show)
@@ -3444,7 +3447,7 @@ describe('DayPlanSidebar', () => {
rerender(<DayPlanSidebar {...makeDefaultProps({
days: [day], reservations: [flight], onToggleConnection, visibleConnectionIds: [510],
})} />)
expect(screen.getByTitle('Hide booking routes')).toBeInTheDocument()
expect(screen.getByLabelText('Hide booking routes')).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-157: a non-transport booking row opens the reservation editor, unless read-only', async () => {
@@ -3560,9 +3563,9 @@ describe('DayPlanSidebar', () => {
})} />)
expect(screen.getByText('08:30 – 10:05')).toBeInTheDocument()
expect(screen.getByText('Air France AF1235')).toBeInTheDocument()
await user.click(screen.getByTitle('Show booking routes'))
await user.click(screen.getByLabelText('Show booking routes'))
expect(onToggleConnection).toHaveBeenCalledWith(520)
const edit = screen.getByTitle('Edit')
const edit = screen.getByLabelText('Edit')
fireEvent.mouseEnter(edit)
fireEvent.mouseLeave(edit)
await user.click(edit)
@@ -3608,7 +3611,7 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText(/Reservation confirmed/)).toBeInTheDocument()
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
// Earliest first, so the first pencil belongs to the parking pass.
await user.click(screen.getAllByTitle('Edit')[0])
await user.click(screen.getAllByLabelText('Edit')[0])
expect(onEditReservation).toHaveBeenCalledWith(parking)
})
@@ -3636,12 +3639,12 @@ describe('DayPlanSidebar', () => {
days: [day], places: [place], assignments: { '10': [a] }, onAddBookingToAssignment,
})} />)
const row = dragRow(screen.getByText('Hover place'))
expect(screen.queryByTitle('Add booking')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
fireEvent.mouseEnter(row)
await user.click(screen.getByTitle('Add booking'))
await user.click(screen.getByLabelText('Add booking'))
expect(onAddBookingToAssignment).toHaveBeenCalledWith(10, 11)
fireEvent.mouseLeave(row)
expect(screen.queryByTitle('Add booking')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-166: the lock tooltip switches once a stop is pinned, and unlocking clears it', async () => {
@@ -3797,7 +3800,7 @@ describe('DayPlanSidebar', () => {
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
const connector = await screen.findByTitle('Change travel mode')
const connector = await screen.findByLabelText('Change travel mode')
await user.click(connector)
await user.click(contextMenu().getByRole('button', { name: 'Walking' }))
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenCalledWith(1, 11, 'walking')
@@ -3805,7 +3808,7 @@ describe('DayPlanSidebar', () => {
'10': [expect.objectContaining({ id: 11, leg_transport_mode: 'walking' }), expect.objectContaining({ id: 12 })],
})
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Use day default' }))
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenLastCalledWith(1, 11, null)
})
@@ -3821,7 +3824,7 @@ describe('DayPlanSidebar', () => {
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true, onPlanTransitLeg })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
// Origin/destination + this stop's departure time, resolved back from the leg coords.
expect(onPlanTransitLeg).toHaveBeenCalledWith({
@@ -3842,7 +3845,7 @@ describe('DayPlanSidebar', () => {
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
expect(contextMenu().queryByRole('button', { name: 'Public transit' })).not.toBeInTheDocument()
})
@@ -3866,7 +3869,7 @@ describe('DayPlanSidebar', () => {
}
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
// The morning bookend (hotel -> first stop) is the first connector in the list.
const connectors = await screen.findAllByTitle('Change travel mode')
const connectors = await screen.findAllByLabelText('Change travel mode')
await user.click(connectors[0])
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({
@@ -3898,7 +3901,7 @@ describe('DayPlanSidebar', () => {
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
// Day 11's own 16:30, not day 10's 09:00 (a trip-wide coord index would leak it).
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({ dayId: 11, time: '16:30' }))
@@ -3918,7 +3921,7 @@ describe('DayPlanSidebar', () => {
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Driving' }))
await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('nope'))
expect(refreshDays).toHaveBeenCalledWith(1)
@@ -3942,7 +3945,7 @@ describe('DayPlanSidebar', () => {
})} />)
await user.click(screen.getByRole('button', { name: 'EV eco' }))
expect(onSetRouteProfile).toHaveBeenCalledWith('plugin:ev/eco')
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
expect(contextMenu().getByRole('button', { name: 'EV eco' })).toBeInTheDocument()
})
@@ -4025,7 +4028,7 @@ describe('DayPlanSidebar', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const onAddTransport = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onAddTransport })} />)
await user.click(screen.getByTitle('Add transport'))
await user.click(screen.getByLabelText('Add transport'))
expect(onAddTransport).toHaveBeenCalledWith(10)
})
@@ -4244,7 +4247,7 @@ describe('DayPlanSidebar', () => {
fireEvent.mouseEnter(expand)
fireEvent.mouseLeave(expand)
await user.click(expand)
expect(screen.getByLabelText('Collapse')).toBeInTheDocument()
expect(screen.getAllByLabelText('Collapse').some(el => !el.closest('.dp-day-actions'))).toBe(true)
await user.click(cardRow(screen.getByText('U2 to Zoo')))
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
})
@@ -4490,10 +4493,13 @@ describe('the day route-tools row', () => {
it('keeps the optimize action reachable by name without printing it', () => {
render(<DayPlanSidebar {...makeDefaultProps(dayWithTwoStops())} />)
const btn = screen.getByRole('button', { name: /optimize/i })
// The assertion that pins the change: named, but no visible label.
// The assertion that pins the change: named, but no visible label. The name
// is `aria-label` rather than `title` — the hover text is TREK's own tooltip
// now, and a browser tooltip is not an accessible name anyway.
expect(btn).toBeInTheDocument()
expect(btn.textContent?.trim()).toBe('')
expect(btn.getAttribute('title')).toBeTruthy()
expect(btn.getAttribute('aria-label')).toBeTruthy()
expect(btn.getAttribute('title')).toBeNull()
})
/* The click itself is already pinned by FE-PLANNER-DAYPLAN-038 above, which
+199 -152
View File
@@ -29,6 +29,7 @@ import { usePluginStore } from '../../store/pluginStore'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { placeToSaveTarget } from '../Collections/saveTarget'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
@@ -1189,8 +1190,34 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
}
/**
* Below this the plan sidebar has no room for a word on the route button.
*
* The word goes early on purpose: it shares its row with two export buttons and
* an optimise button, and a "Route" squeezed to four letters and an ellipsis is
* worse than the icon that means the same thing. The icon keeps the accessible
* name, so nothing is lost but the letters.
*/
const NARROW_PLAN_PX = 320
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
const S = useDayPlanSidebar(props)
// The plan sidebar is resizable. Below this the route button's own word is the
// first thing that stops fitting beside the two export buttons — the icon says
// the same thing, and the accessible name still spells it out.
//
// A callback ref rather than `useRef`, so the measurement happens when the
// element exists rather than on a mount that may render nothing yet.
const [panel, setPanel] = useState<HTMLElement | null>(null)
const [narrowPanel, setNarrowPanel] = useState(false)
useEffect(() => {
if (!panel || typeof ResizeObserver === 'undefined') return
const measure = (): void => setNarrowPanel(panel.clientWidth < NARROW_PLAN_PX)
measure()
const ro = new ResizeObserver(measure)
ro.observe(panel)
return () => ro.disconnect()
}, [panel])
// A stable key for the current selection. A multi-day place renders one row per
// day (same place_id, different assignment ids); selecting it by place_id alone
// (e.g. clicking an accommodation) marks every one of those rows selected, so a
@@ -1504,7 +1531,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}
return (
<div data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
<div ref={setPanel} data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
{/* Toolbar */}
<DayPlanSidebarToolbar
tripId={tripId}
@@ -1790,19 +1817,23 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* Public transit search (#1065) — replaced the rename pencil,
which moved next to the day name in the day detail view. */}
{onPlanTransit ? (
<button type="button" onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} title={t('transit.title')} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<TramFront size={14} strokeWidth={1.8} />
</button>
<Tooltip label={t('transit.title')} placement="top">
<button type="button" onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<TramFront size={14} strokeWidth={1.8} />
</button>
</Tooltip>
) : <div style={{ borderRight: div, borderBottom: div }} />}
{onAddTransport ? (
<button type="button" onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
<Plus size={14} strokeWidth={1.8} />
</button>
<Tooltip label={t('transport.addTransport')} placement="top">
<button type="button" onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} style={{ ...cell, border: 'none', borderBottom: div }} aria-label={t('transport.addTransport')}>
<Plus size={14} strokeWidth={1.8} />
</button>
</Tooltip>
) : <div style={{ borderBottom: div }} />}
<button type="button" onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
<FileText size={14} strokeWidth={1.8} />
</button>
<button type="button" onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
<button type="button" onClick={e => toggleDay(day.id, e)} style={{ ...cell, border: 'none' }} aria-label={isExpanded ? t('common.collapse') : t('common.expand')}>
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
</button>
</div>
@@ -1885,9 +1916,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const targetId = hotelLegs[day.id]?.top?.targetId
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.top!.seg} name={hotelLegs[day.id]!.top!.name} profile={routeProfile} placement="top" />
return canEditDays && targetId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.top!.seg))} style={{ cursor: 'pointer' }}>
{connector}
</div>
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
<div role="button" tabIndex={0} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.top!.seg))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}>
{connector}
</div>
</Tooltip>
) : connector
})()}
{daySchedule.byPosition[day.id]?.start.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
@@ -2221,34 +2254,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
{hasEndpoints && (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={11} />
</button>
)}
{canEditDays && (() => {
const isTransport = TRANSPORT_TYPES.has(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
return (
<button
<Tooltip label={t(active ? 'map.hideConnections' : 'map.showConnections')} placement="top">
<button aria-label={t(active ? 'map.hideConnections' : 'map.showConnections')}
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
title={t('common.edit')}
className="bg-transparent text-content-faint"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
@@ -2256,11 +2266,36 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
<RouteIcon size={11} />
</button>
</Tooltip>
)}
{canEditDays && (() => {
const isTransport = TRANSPORT_TYPES.has(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
return (
<Tooltip label={t('common.edit')} placement="top">
<button aria-label={t('common.edit')}
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
className="bg-transparent text-content-faint"
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
</button>
</Tooltip>
)
})()}
</div>
@@ -2296,37 +2331,40 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</button>
</div>}
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
<button type="button"
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
}}
title={t('reservations.addBooking')}
style={{
flexShrink: 0,
background: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 5,
padding: '2px 6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 500,
color: 'var(--text-muted)',
fontFamily: 'inherit',
}}
>
<Plus size={11} strokeWidth={2} />
</button>
<Tooltip label={t('reservations.addBooking')} placement="top">
<button type="button" aria-label={t('reservations.addBooking')}
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
}}
style={{
flexShrink: 0,
background: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 5,
padding: '2px 6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 500,
color: 'var(--text-muted)',
fontFamily: 'inherit',
}}
>
<Plus size={11} strokeWidth={2} />
</button>
</Tooltip>
)}
</div>
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
{routeLegs[day.id]?.[assignment.id] && (canEditDays ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, assignment.id, day.id, routeLegs[day.id]![assignment.id]))} style={{ cursor: 'pointer' }}>
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
</div>
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
<div role="button" tabIndex={0} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, assignment.id, day.id, routeLegs[day.id]![assignment.id]))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}>
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
</div>
</Tooltip>
) : (
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
))}
@@ -2532,7 +2570,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
return next
})
}}
title={t(expanded ? 'common.collapse' : 'common.expand')}
aria-label={t(expanded ? 'common.collapse' : 'common.expand')}
style={{
flexShrink: 0, appearance: 'none', width: 26, height: 26, borderRadius: 6,
@@ -2549,24 +2586,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{!transitMeta && onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
const active = visibleConnectionIds.includes(res.id)
return (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
style={{
flexShrink: 0, appearance: 'none',
width: 26, height: 26, borderRadius: 6,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
background: active ? color : 'transparent',
color: active ? '#fff' : 'var(--text-faint)',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={13} />
</button>
<Tooltip label={t(active ? 'map.hideConnections' : 'map.showConnections')} placement="top">
<button aria-label={t(active ? 'map.hideConnections' : 'map.showConnections')}
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
style={{
flexShrink: 0, appearance: 'none',
width: 26, height: 26, borderRadius: 6,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
background: active ? color : 'transparent',
color: active ? '#fff' : 'var(--text-faint)',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={13} />
</button>
</Tooltip>
)
})()}
</div>
@@ -2580,9 +2618,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const nextPlaceId = merged.slice(idx + 1).find(i => i.type === 'place' && i.data.place?.lat && i.data.place?.lng)?.data.id
const connector = <RouteConnector seg={routeLegs[day.id]![res.id]} profile={routeProfile} />
return canEditDays && nextPlaceId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id]))} style={{ cursor: 'pointer' }}>
{connector}
</div>
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
<div role="button" tabIndex={0} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id]))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}>
{connector}
</div>
</Tooltip>
) : connector
})()}
</React.Fragment>
@@ -2717,9 +2757,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const targetId = hotelLegs[day.id]?.bottom?.targetId
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.bottom!.seg} name={hotelLegs[day.id]!.bottom!.name} profile={routeProfile} placement="bottom" />
return canEditDays && targetId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.bottom!.seg))} style={{ cursor: 'pointer' }}>
{connector}
</div>
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
<div role="button" tabIndex={0} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.bottom!.seg))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}>
{connector}
</div>
</Tooltip>
) : connector
})()}
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
@@ -2786,6 +2828,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onToggleRoute?.()
}
}}
aria-label={t('dayplan.route')}
className={routeActive ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
@@ -2795,81 +2838,85 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}}
>
<RouteIcon size={12} strokeWidth={2} />
{t('dayplan.route')}
{!narrowPanel && t('dayplan.route')}
</button>
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button type="button"
onClick={() => {
const url = generateGoogleMapsUrl(dayExportStops())
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
title={t('planner.openGoogleMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<GoogleMapsIcon size={14} />
</button>
<Tooltip label={t('planner.openGoogleMaps')} placement="top">
<button type="button"
onClick={() => {
const url = generateGoogleMapsUrl(dayExportStops())
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<GoogleMapsIcon size={14} />
</button>
</Tooltip>
{/* The same day, handed to CoMaps for offline navigation (#1904). The
day's own travel mode rides along, so the route it builds walks
when the plan walks. */}
<button type="button"
onClick={() => {
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openCoMaps')}
title={t('planner.openCoMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<Compass size={14} strokeWidth={2} />
</button>
<Tooltip label={t('planner.openCoMaps')} placement="top">
<button type="button"
onClick={() => {
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openCoMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<Compass size={14} strokeWidth={2} />
</button>
</Tooltip>
{/* Icon-only, like the two map hand-offs beside it (#1981). It
was the one button here carrying a label with no room for
it: `flex: 1` alongside `padding: '6px 0'` meant the text
sat against both edges as soon as the row filled up, and
the row gained a button when CoMaps arrived. The label
stays as the accessible name and the tooltip. */}
<button type="button"
onClick={() => handleOptimize(day.id)}
aria-label={t('dayplan.optimize')}
title={t('dayplan.optimize')}
className="bg-surface-hover text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<RotateCcw size={14} strokeWidth={2} />
</button>
<Tooltip label={t('dayplan.optimize')} placement="top">
<button type="button"
onClick={() => handleOptimize(day.id)}
aria-label={t('dayplan.optimize')}
className="bg-surface-hover text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<RotateCcw size={14} strokeWidth={2} />
</button>
</Tooltip>
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
{routeProfileOptions.map(p => {
const ModeIcon = p.key === 'driving' ? Car : p.key === 'walking' ? Footprints : Zap
const active = (day.default_transport_mode ?? routeProfile) === p.key
return (
<button type="button"
key={p.key}
onClick={() => setDayDefaultMode(day.id, p.key)}
aria-label={p.label}
title={p.label}
className={active ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', border: 'none', cursor: 'pointer',
}}
>
<ModeIcon size={13} strokeWidth={2} />
</button>
<Tooltip label={p.label} placement="top">
<button type="button"
key={p.key}
onClick={() => setDayDefaultMode(day.id, p.key)}
aria-label={p.label}
className={active ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', border: 'none', cursor: 'pointer',
}}
>
<ModeIcon size={13} strokeWidth={2} />
</button>
</Tooltip>
)
})}
</div>
@@ -22,6 +22,8 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { getCategoryIcon } from '../shared/categoryIcons'
import DawarichIcon from '../shared/DawarichIcon'
import { Tooltip } from '../shared/Tooltip'
import { useToast } from '../shared/Toast'
import { useTranslation, translateApiError } from '../../i18n'
import { usePluginStore } from '../../store/pluginStore'
@@ -796,6 +798,17 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn
style={{ fontWeight: 600, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
>{place.name}</span>
)}
{/* Where the place came from, when it did not come from somebody
typing it: a stay accepted out of their own recordings. The
mark alone — the name beside it is already the place's name,
and a word here would only repeat the tooltip. */}
{place.source === 'dawarich' && (
<Tooltip label={t('dawarich.place.fromDawarich')} placement="top">
<span style={{ display: 'inline-flex', flexShrink: 0, overflow: 'hidden', borderRadius: 5 }}>
<DawarichIcon size={16} />
</span>
</Tooltip>
)}
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (
@@ -10,6 +10,10 @@ import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker'
import { ListImportModal } from './PlacesSidebarListImportModal'
import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal'
import SaveTripPlacesToListModal from '../Collections/SaveTripPlacesToListModal'
import DawarichSuggestionsPanel from '../Dawarich/DawarichSuggestionsPanel'
import { formatDayOption } from '../Dawarich/dawarichSuggestionModel'
import { refreshTripAfterAccept } from '../Dawarich/dawarichTripRefresh'
import { useTranslation } from '../../i18n'
const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProps) {
const S = usePlacesSidebar(props)
@@ -19,8 +23,10 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
collectionsEnabled, saveToListOpen, setSaveToListOpen,
collectionsEnabled, saveToListOpen, setSaveToListOpen, days,
} = S
// The sidebar hook carries `t` but not the locale; day labels need both.
const { locale } = useTranslation()
// Below lg the places sit in their own tab with no plan beside them to drag
// into. A coarse pointer no longer disables the drag on its own — tablets
// reach it through a long press (#1616).
@@ -59,6 +65,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
</div>
)}
{/* Stays Dawarich recorded on these dates, waiting to be reviewed (#2279).
Above the list because that is what they become, and the panel renders
nothing at all when there is nothing pending — an integration that is
connected and quiet should be invisible rather than a permanent empty
card. */}
<div style={{ padding: '0 12px 8px', flexShrink: 0 }}>
<DawarichSuggestionsPanel
tripId={tripId}
trips={[{ id: tripId, label: t('dawarich.accept.thisTrip') }]}
daysForTrip={() => days.map(day => ({
id: day.id,
...formatDayOption(day.day_number, day.date, locale, t),
}))}
// The place it just created belongs on the map and in the list
// now, not after a reload.
onAccepted={() => { void refreshTripAfterAccept(tripId) }}
initiallyCollapsed
/>
</div>
{/* Liste */}
<PlacesList {...S} />
@@ -3,6 +3,7 @@ import { Plane, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { airtrailApi } from '../../api/client'
import AirTrailIcon from '../shared/AirTrailIcon'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
@@ -78,7 +79,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
const canSave = !!url.trim() && (connected || !!apiKey.trim())
return (
<Section title={t('settings.airtrail.title')} icon={Plane}>
<Section title={t('settings.airtrail.title')} icon={AirTrailIcon}>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
@@ -0,0 +1,193 @@
import React from 'react'
import { Save, Plug, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
import DawarichIcon from '../shared/DawarichIcon'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
/**
* Settings → Integrations → Dawarich.
*
* All of the behaviour is in `useDawarichConnection`, shared with the phone
* twin; this file is markup. The layout follows the AirTrail and LLM sections
* so the three read as one shelf, but the classes are the semantic tokens
* rather than the raw `slate-*` those two still carry — a new surface has to
* follow the user's accent and colour scheme.
*
* The status block below the fields is the part that earns its space: a
* connection to somebody else's server fails in ways they can act on — a wrong
* key, a certificate, an instance that is simply off — and "not connected" with
* no reason is the version of this card that generates support questions.
*/
export default function DawarichConnectionSection(): React.ReactElement {
const { t, locale } = useTranslation()
const S = useDawarichConnection()
return (
<Section title={t('dawarich.title')} icon={DawarichIcon}>
<div className="space-y-3">
<p className="text-caption text-content-secondary">{t('dawarich.intro')}</p>
<div>
<label htmlFor="dawarich-url" className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.url')}
</label>
<input
id="dawarich-url"
type="url"
value={S.url}
onChange={e => S.setUrl(e.target.value)}
placeholder="https://dawarich.example.com"
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>
<div>
<label htmlFor="dawarich-key" className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.apiKey')}
</label>
<input
id="dawarich-key"
type="password"
value={S.apiKey}
onChange={e => S.setApiKey(e.target.value)}
autoComplete="off"
placeholder={S.connected && !S.apiKey ? '••••••••' : t('dawarich.apiKeyPlaceholder')}
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"
/>
<p className="mt-1 text-caption text-content-muted">{t('dawarich.apiKeyHint')}</p>
</div>
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={S.syncEnabled} onToggle={S.toggleSync} label={t('dawarich.syncEnabled')} />
<span className="text-body font-medium text-content-secondary">{t('dawarich.syncEnabled')}</span>
</div>
<p className="mt-1 text-caption text-content-muted">{t('dawarich.syncEnabledHint')}</p>
</div>
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={S.allowInsecureTls} onToggle={S.toggleInsecureTls} label={t('dawarich.allowInsecureTls')} />
<span className="text-body font-medium text-content-secondary">{t('dawarich.allowInsecureTls')}</span>
</div>
<p className="mt-1 text-caption text-content-muted">{t('dawarich.allowInsecureTlsHint')}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={S.save}
disabled={S.saving || S.loading || !S.canSave}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button
type="button"
onClick={S.test}
disabled={S.testing || S.loading || !S.url.trim()}
className="flex items-center gap-2 px-4 py-2 border rounded-lg text-body border-edge text-content-secondary hover:bg-surface-hover disabled:opacity-50"
>
{S.testing
? <span className="w-4 h-4 border-2 rounded-full animate-spin border-edge border-t-transparent" />
: <Plug className="w-4 h-4" />}
{t('dawarich.test.button')}
</button>
{S.connected && (
<button
type="button"
onClick={S.syncNow}
disabled={S.syncing}
className="flex items-center gap-2 px-4 py-2 border rounded-lg text-body border-edge text-content-secondary hover:bg-surface-hover disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${S.syncing ? 'animate-spin' : ''}`} />
{t('dawarich.syncNow')}
</button>
)}
<span className="basis-full sm:basis-auto text-caption font-medium flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${S.connected ? 'bg-success' : 'bg-surface-tertiary'}`} />
<span className={S.connected ? 'text-success' : 'text-content-muted'}>
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
</span>
</span>
{S.connected && (
<button
type="button"
onClick={S.disconnect}
disabled={S.saving}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-caption text-danger hover:bg-danger-soft disabled:opacity-50"
>
<Unplug className="w-3.5 h-3.5" /> {t('dawarich.disconnect')}
</button>
)}
</div>
<DawarichConnectionStatus state={S} locale={locale} />
</div>
</Section>
)
}
/**
* Where the connection stands, in the place where it helps someone decide what
* to do next: what the last sync did, and which parts of their instance TREK
* could actually reach.
*
* Its own component because the phone twin renders the same three facts in a
* different frame, and because it is the bit that keeps growing.
*/
function DawarichConnectionStatus({
state,
locale,
}: {
state: ReturnType<typeof useDawarichConnection>
locale: string
}): React.ReactElement | null {
const { t } = useTranslation()
if (!state.connected && !state.probeMessage) return null
const missing: string[] = []
if (state.capabilities) {
if (!state.capabilities.visits) missing.push(t('dawarich.capability.visits'))
if (!state.capabilities.tracks && !state.capabilities.points) missing.push(t('dawarich.capability.track'))
if (!state.capabilities.locations) missing.push(t('dawarich.capability.locations'))
if (!state.capabilities.visitedCities) missing.push(t('dawarich.capability.visitedCities'))
}
return (
<div className="rounded-lg border p-3 space-y-1.5 border-edge bg-surface-secondary">
{state.probeMessage && <p className="text-caption text-content">{state.probeMessage}</p>}
{state.connected && (
<p className="text-caption text-content-secondary">
{state.lastSyncAt
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
: t('dawarich.neverSynced')}
{state.lastSyncState === 'partial' && ` · ${t('dawarich.syncPartial')}`}
</p>
)}
{state.lastSyncError && (
<p className="text-caption text-danger">{state.lastSyncError}</p>
)}
{state.capabilities?.serverVersion && (
<p className="text-caption text-content-muted">
{t('dawarich.serverVersion', { version: state.capabilities.serverVersion })}
</p>
)}
{missing.length > 0 && (
<p className="text-caption text-content-muted">
{t('dawarich.capability.missing', { features: missing.join(', ') })}
</p>
)}
</div>
)
}
@@ -7,6 +7,7 @@ import { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection'
import AirTrailConnectionSection from './AirTrailConnectionSection'
import DawarichConnectionSection from './DawarichConnectionSection'
import LlmConnectionSection from './LlmConnectionSection'
import ApiKeysSection from './ApiKeysSection'
import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes'
@@ -106,6 +107,7 @@ export default function IntegrationsTab(): React.ReactElement {
these would ask for an address that cannot be reached from here. */}
{!managed && <PhotoProvidersSection />}
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
@@ -127,6 +129,7 @@ function useIntegrations() {
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
const dawarichEnabled = addonEnabled('dawarich')
useEffect(() => {
loadAddons()
@@ -307,7 +310,7 @@ function useIntegrations() {
return {
t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
}
}
+5 -1
View File
@@ -3,7 +3,11 @@ import type { LucideIcon } from 'lucide-react'
interface SectionProps {
title: string
icon: LucideIcon
/**
* A lucide icon, or anything that takes the same `className` — a brand mark
* standing in for a glyph gets sized by the same utilities.
*/
icon: LucideIcon | React.ComponentType<{ className?: string }>
badge?: React.ReactNode
children: React.ReactNode
}
@@ -0,0 +1,14 @@
import React from 'react'
import BrandIcon, { type BrandIconProps } from './BrandIcon'
/**
* AirTrail's own mark, unmodified: the control tower in its blue.
*
* The file is `static/favicon.svg` from johanohly/AirTrail, served from
* `public/brands/`. Rendered as a file rather than as lucide's `TowerControl`
* recoloured: the two happen to be the same shape today, and the day AirTrail
* changes its logo TREK should follow the logo, not the coincidence.
*/
export default function AirTrailIcon(props: BrandIconProps): React.ReactElement {
return <BrandIcon src="/brands/airtrail.svg" {...props} />
}
@@ -0,0 +1,65 @@
import React from 'react'
export interface BrandIconProps {
size?: number
/**
* Fill the box it sits in, corner to corner.
*
* A brand mark that is itself a badge should BE the framed slot rather than
* float in the middle of one — which is what the lucide line icons beside it
* need, and it does not.
*/
fill?: boolean
/**
* Drain the colour, for a switched-off addon. Every other tile greys its glyph
* out; a full-colour badge among them reads as the one thing that is on.
*/
muted?: boolean
/**
* Sizing classes, so the mark can stand in wherever a lucide icon is expected
* (`w-5 h-5 …`). When set it wins over `size`.
*/
className?: string
}
/**
* A third party's own logo, served as a file and rendered unmodified.
*
* The files under `public/brands/` are the marks as each project ships them.
* They are deliberately NOT recoloured to `currentColor` the way the Immich and
* Synology glyphs in the addon manager are: those are single-colour shapes that
* happen to be brands, while these are the pictures people recognise, and
* redrawing one in the user's accent makes it a different logo.
*
* Served rather than inlined because one of them is 56 paths — inline it would
* be a kilobyte of markup at every call site, and this way the browser caches it.
*/
export default function BrandIcon({
src,
size = 20,
fill = false,
muted = false,
className,
}: BrandIconProps & { src: string }): React.ReactElement {
return (
<img
src={src}
alt=""
aria-hidden="true"
width={size}
height={size}
className={className}
// The files carry their own width/height attributes; the explicit style is
// what keeps them at icon size next to the lucide glyphs.
style={{
...(fill
? { width: '100%', height: '100%', objectFit: 'contain' as const }
: className
? { flexShrink: 0 }
: { width: size, height: size, flexShrink: 0 }),
display: 'block',
...(muted ? { filter: 'grayscale(1)', opacity: 0.55 } : {}),
}}
/>
)
}
@@ -0,0 +1,14 @@
import React from 'react'
import BrandIcon, { type BrandIconProps } from './BrandIcon'
/**
* Dawarich's own mark, unmodified: green and grey map blocks behind the blue
* road that loops back as a D, with the white roundabout at the junction.
*
* The file is `app/assets/images/logo.svg` from Freika/dawarich, served from
* `public/brands/`. See `BrandIcon` for why it is a file rather than inline
* markup, and why it keeps its own colours.
*/
export default function DawarichIcon(props: BrandIconProps): React.ReactElement {
return <BrandIcon src="/brands/dawarich.svg" {...props} />
}
+265
View File
@@ -0,0 +1,265 @@
import { useCallback, useEffect, useState } from 'react'
import type { DawarichCapabilities, DawarichErrorCode, DawarichSyncState } from '@trek/shared'
import { dawarichApi } from '../api/dawarich'
import { useTranslation } from '../i18n'
import { useToast } from '../components/shared/Toast'
/**
* Everything the Dawarich connection card does, in one hook.
*
* Shared by the desktop section and its phone twin on purpose. The two shells
* render very different markup and identical logic, and SonarCloud's 3%
* duplication budget on new code is not big enough for a second copy of this —
* `useInstanceSettings` under `components/Admin/` is the same pattern for the
* same reason.
*/
export interface DawarichConnectionState {
url: string
setUrl: (value: string) => void
apiKey: string
setApiKey: (value: string) => void
allowInsecureTls: boolean
toggleInsecureTls: () => void
syncEnabled: boolean
toggleSync: () => void
connected: boolean
loading: boolean
saving: boolean
testing: boolean
syncing: boolean
lastSyncAt: string | null
lastSyncState: DawarichSyncState
/** Already translated; null when the last sync was clean. */
lastSyncError: string | null
capabilities: DawarichCapabilities | null
/** Set after a test run, so the card can show what the probe found. */
probeMessage: string | null
canSave: boolean
save: () => Promise<void>
test: () => Promise<void>
syncNow: () => Promise<void>
disconnect: () => Promise<void>
}
export function useDawarichConnection(): DawarichConnectionState {
const { t } = useTranslation()
const toast = useToast()
const [url, setUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
const [syncEnabled, setSyncEnabled] = useState(true)
const [connected, setConnected] = useState(false)
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null)
const [lastSyncState, setLastSyncState] = useState<DawarichSyncState>('never')
const [lastSyncErrorCode, setLastSyncErrorCode] = useState<string | null>(null)
const [capabilities, setCapabilities] = useState<DawarichCapabilities | null>(null)
const [probeMessage, setProbeMessage] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [syncing, setSyncing] = useState(false)
/**
* Reason codes rather than the upstream sentence: Dawarich answers in English
* and occasionally quotes the URL back, and neither belongs on a German
* install. An unknown code still gets a sentence rather than nothing.
*/
const translateError = useCallback(
(code: string | null): string | null => {
if (!code) return null
const key = `dawarich.error.${code}`
const text = t(key)
return text === key ? t('dawarich.error.unknown') : text
},
[t],
)
useEffect(() => {
let cancelled = false
dawarichApi
.getSettings()
.then(data => {
if (cancelled) return
setUrl(data.url || '')
setAllowInsecureTls(!!data.allowInsecureTls)
setSyncEnabled(data.syncEnabled !== false)
setConnected(!!data.connected)
setLastSyncAt(data.lastSyncAt)
setLastSyncState(data.lastSyncState)
setLastSyncErrorCode(data.lastSyncError)
setCapabilities(data.capabilities)
})
.catch(() => {
// A failed read of our own settings is a TREK problem, not a Dawarich
// one — the card stays in its empty state rather than claiming the
// instance is unreachable.
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
/** The key field is never prefilled, so blank means "keep the stored one". */
const keyPayload = useCallback((): { apiKey?: string } => {
const typed = apiKey.trim()
return typed ? { apiKey: typed } : {}
}, [apiKey])
const save = useCallback(async () => {
setSaving(true)
try {
const result = await dawarichApi.saveSettings({
url: url.trim(),
allowInsecureTls,
syncEnabled,
...keyPayload(),
})
const fresh = await dawarichApi.getSettings().catch(() => null)
if (fresh) {
setConnected(!!fresh.connected)
setCapabilities(fresh.capabilities)
setLastSyncState(fresh.lastSyncState)
setLastSyncErrorCode(fresh.lastSyncError)
}
setApiKey('')
// The warning the server attaches when the address resolves to a private
// IP. Rendered from its code so it arrives in the reader's language; the
// server's own sentence is the fallback for a code this build predates.
if (result?.warning) {
const key = result.warningCode ? `dawarich.warning.${result.warningCode}` : ''
const translated = key ? t(key, { ip: result.warningIp ?? '' }) : ''
toast.warning(translated && translated !== key ? translated : result.warning)
}
else toast.success(t('dawarich.toast.saved'))
} catch (err) {
toast.error(errorText(err, t) || t('dawarich.toast.saveError'))
} finally {
setSaving(false)
}
}, [url, allowInsecureTls, syncEnabled, keyPayload, toast, t])
const test = useCallback(async () => {
setTesting(true)
setProbeMessage(null)
try {
const result = await dawarichApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() })
setConnected(!!result.connected)
if (result.capabilities) setCapabilities(result.capabilities)
if (result.connected) {
setProbeMessage(t('dawarich.test.success', { count: result.visitCount ?? 0 }))
toast.success(t('dawarich.test.success', { count: result.visitCount ?? 0 }))
} else {
const message = translateError(result.error ?? null) ?? t('dawarich.test.failed')
setProbeMessage(message)
toast.error(message)
}
} catch (err) {
const message = errorText(err, t) || t('dawarich.test.failed')
setProbeMessage(message)
toast.error(message)
} finally {
setTesting(false)
}
}, [url, allowInsecureTls, keyPayload, toast, t, translateError])
const syncNow = useCallback(async () => {
setSyncing(true)
try {
const result = await dawarichApi.syncNow()
if (result.alreadyRunning) {
// Somebody (or the cron) is mid-sync. Saying "0 new stays" here would be
// a result, and there is no result yet.
toast.info(t('dawarich.toast.syncRunning'))
return
}
setLastSyncAt(new Date().toISOString())
setLastSyncState((result.state as DawarichSyncState) ?? 'ok')
setLastSyncErrorCode(null)
// Said as a count rather than "done": a sync that found nothing and one
// that failed silently look identical otherwise.
toast.success(t('dawarich.toast.synced', { count: result.created ?? 0 }))
} catch (err) {
setLastSyncState('failed')
toast.error(errorText(err, t) || t('dawarich.toast.syncError'))
} finally {
setSyncing(false)
}
}, [toast, t])
const disconnect = useCallback(async () => {
setSaving(true)
try {
await dawarichApi.disconnect()
setUrl('')
setApiKey('')
setConnected(false)
setCapabilities(null)
setLastSyncAt(null)
setLastSyncState('never')
setLastSyncErrorCode(null)
setProbeMessage(null)
toast.success(t('dawarich.toast.disconnected'))
} catch (err) {
toast.error(errorText(err, t) || t('dawarich.toast.saveError'))
} finally {
setSaving(false)
}
}, [toast, t])
return {
url,
setUrl,
apiKey,
setApiKey,
allowInsecureTls,
toggleInsecureTls: () => setAllowInsecureTls(v => !v),
syncEnabled,
toggleSync: () => setSyncEnabled(v => !v),
connected,
loading,
saving,
testing,
syncing,
lastSyncAt,
lastSyncState,
lastSyncError: translateError(lastSyncErrorCode),
capabilities,
probeMessage,
// An address alone is not a connection, and the key field is blank once a
// key is stored — so saving needs either an existing connection or a typed key.
canSave: !!url.trim() && (connected || !!apiKey.trim()),
save,
test,
syncNow,
disconnect,
}
}
/**
* The server's `{ code, error }` envelope, as a sentence the reader can read.
*
* The code comes first and the prose is the fallback: the server's own sentence
* is written in English, and a German install should not be told what went
* wrong in a language it does not speak. A code this version has no string for
* falls back to the sentence rather than to nothing.
*/
function errorText(err: unknown, t?: (key: string) => string): string | null {
const data = (err as { response?: { data?: { error?: unknown; code?: unknown } } })?.response?.data
if (t && typeof data?.code === 'string') {
const key = `dawarich.error.${data.code}`
const text = t(key)
if (text !== key) return text
}
return typeof data?.error === 'string' ? data.error : null
}
/** Exported for the error-code type so consumers do not re-declare it. */
export type { DawarichErrorCode }
@@ -0,0 +1,93 @@
import { useEffect, useMemo, useState } from 'react'
import type { JourneyTrack } from '@trek/shared'
import { dawarichRepo } from '../repo/dawarichRepo'
import { useAddonStore } from '../store/addonStore'
import { trailSegments } from '../components/Map/dawarichTrail'
import { isEffectivelyOffline } from '../sync/networkMode'
/**
* The recorded route for a journal, shaped as the journey map's own tracks.
*
* The journey map already draws `JourneyTrack[]` — several separate polylines,
* each with its own colour and a white casing, described in that file as
* reading "as a recorded route rather than as the dashed line that merely
* connects entries in time order". That is exactly what this is, so it rides
* the existing layer instead of adding a third one.
*
* It is deliberately NOT the map's `trail` prop: that draws a single polyline,
* and a day's recording is several disconnected pieces — joining them would
* paint straight lines across gaps the traveller never travelled.
*
* The synthetic `place_id` is negative so it can never collide with a real
* place's, which is what the map keys its lines on.
*/
export function useDawarichJournalTrail(
/** Dates the journal covers, from its trips. */
dates: Set<string>,
enabled: boolean,
): { tracks: JourneyTrack[]; fetchedAt: string | null; status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'offline' } {
const addonEnabled = useAddonStore(s => s.isEnabled)
const active = enabled && addonEnabled('dawarich') && dates.size > 0
const [tracks, setTracks] = useState<JourneyTrack[]>(EMPTY)
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'unavailable' | 'offline'>('idle')
// The window is the journal's own span. A Set has no order, so it is sorted
// here rather than trusting insertion order.
const window = useMemo(() => {
if (dates.size === 0) return null
const sorted = [...dates].sort()
return { from: `${sorted[0]}T00:00:00Z`, to: `${sorted[sorted.length - 1]}T23:59:59Z` }
}, [dates])
useEffect(() => {
if (!active || !window) {
setTracks(EMPTY)
setStatus('idle')
return
}
if (isEffectivelyOffline()) {
setTracks(EMPTY)
setStatus('offline')
return
}
const controller = new AbortController()
let cancelled = false
setStatus('loading')
dawarichRepo
.windowTrack(window.from, window.to, controller.signal)
.then(track => {
if (cancelled) return
const segments = trailSegments(track)
setTracks(
segments.map((segment, index): JourneyTrack => ({
place_id: -(index + 1),
trip_id: 0,
name: segment.date,
color: segment.color,
points: segment.points,
})),
)
setFetchedAt(track.fetchedAt)
setStatus('ready')
})
.catch(() => {
if (cancelled) return
setTracks(EMPTY)
setStatus(isEffectivelyOffline() ? 'offline' : 'unavailable')
})
return () => {
cancelled = true
controller.abort()
}
}, [active, window])
return { tracks: active ? tracks : EMPTY, fetchedAt, status }
}
/** Stable empty identity: a fresh literal would re-render the map on every pass. */
const EMPTY: JourneyTrack[] = []
+175
View File
@@ -0,0 +1,175 @@
import { useCallback, useEffect, useState } from 'react'
import type { DawarichAccept, DawarichSuggestion, DawarichSyncState } from '@trek/shared'
import { dawarichApi } from '../api/dawarich'
import { dawarichRepo, DawarichOfflineError } from '../repo/dawarichRepo'
import { isEffectivelyOffline } from '../sync/networkMode'
import { useAddonStore } from '../store/addonStore'
import { useTranslation } from '../i18n'
import { useToast } from '../components/shared/Toast'
export type DawarichSuggestionsStatus =
| 'idle'
| 'loading'
| 'ready'
| 'disconnected'
| 'offline'
| 'unavailable'
export interface DawarichSuggestionsState {
suggestions: DawarichSuggestion[]
status: DawarichSuggestionsStatus
lastSyncAt: string | null
lastSyncState: DawarichSyncState
/** Already translated; null when the last sync was clean. */
lastSyncError: string | null
busyId: number | null
reload: () => void
accept: (id: number, body: DawarichAccept) => Promise<boolean>
dismiss: (id: number) => Promise<void>
restore: (id: number) => Promise<void>
}
/**
* The stays waiting for review, and what a user can do with them.
*
* Shared by the journal and the trip planner so the two never disagree about
* what is still pending — and so the review logic exists once rather than in
* each shell.
*
* Every mutation updates the row in place rather than refetching the list. A
* refetch would reorder under the cursor, and someone working down a backlog
* would lose their place every time they pressed something.
*/
export function useDawarichSuggestions(filter?: {
tripId?: number
state?: 'new' | 'accepted' | 'dismissed'
}): DawarichSuggestionsState {
const { t } = useTranslation()
const toast = useToast()
const addonEnabled = useAddonStore(s => s.isEnabled)
const active = addonEnabled('dawarich')
const [suggestions, setSuggestions] = useState<DawarichSuggestion[]>([])
const [status, setStatus] = useState<DawarichSuggestionsStatus>('idle')
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null)
const [lastSyncState, setLastSyncState] = useState<DawarichSyncState>('never')
const [lastSyncErrorCode, setLastSyncErrorCode] = useState<string | null>(null)
const [busyId, setBusyId] = useState<number | null>(null)
const [reloadToken, setReloadToken] = useState(0)
const tripId = filter?.tripId
const stateFilter = filter?.state
const reload = useCallback(() => setReloadToken(token => token + 1), [])
useEffect(() => {
if (!active) {
setStatus('idle')
return
}
let cancelled = false
setStatus('loading')
dawarichRepo
.suggestions({ tripId, state: stateFilter })
.then(result => {
if (cancelled) return
setSuggestions(result.suggestions)
setLastSyncAt(result.lastSyncAt)
setLastSyncState(result.lastSyncState)
setLastSyncErrorCode(result.lastSyncError)
setStatus(result.connected ? 'ready' : 'disconnected')
})
.catch((err: unknown) => {
if (cancelled) return
setStatus(err instanceof DawarichOfflineError ? 'offline' : 'unavailable')
})
return () => {
cancelled = true
}
}, [active, tripId, stateFilter, reloadToken])
const replace = useCallback((updated: DawarichSuggestion) => {
setSuggestions(prev => prev.map(item => (item.id === updated.id ? updated : item)))
}, [])
const accept = useCallback(
async (id: number, body: DawarichAccept): Promise<boolean> => {
// Accepting writes into a trip or a journal, which needs the server. The
// reads go through the repo, which is offline-aware; this one says so
// rather than hanging until the request times out.
if (isEffectivelyOffline()) {
toast.error(t('dawarich.error.offline'))
return false
}
setBusyId(id)
try {
const result = await dawarichApi.accept(id, body)
replace(result.suggestion)
toast.success(t(`dawarich.toast.accepted.${body.target}`))
return true
} catch (err) {
toast.error(serverError(err, t) ?? t('dawarich.toast.acceptError'))
return false
} finally {
setBusyId(null)
}
},
[replace, toast, t],
)
const setRowState = useCallback(
async (id: number, next: 'new' | 'dismissed') => {
if (isEffectivelyOffline()) {
toast.error(t('dawarich.error.offline'))
return
}
setBusyId(id)
try {
replace(await dawarichApi.setState(id, next))
} catch (err) {
toast.error(serverError(err, t) ?? t('dawarich.toast.updateError'))
} finally {
setBusyId(null)
}
},
[replace, toast, t],
)
return {
suggestions,
status,
lastSyncAt,
lastSyncState,
lastSyncError: translateCode(lastSyncErrorCode, t),
busyId,
reload,
accept,
dismiss: (id: number) => setRowState(id, 'dismissed'),
restore: (id: number) => setRowState(id, 'new'),
}
}
/**
* The server's `{ code, error }` envelope, as a sentence the reader can read.
*
* The controller ships a code beside every message precisely so nothing reaches
* a non-English install in English; the prose is the fallback for a code this
* build has no string for.
*/
function serverError(err: unknown, t: (key: string) => string): string | null {
const data = (err as { response?: { data?: { error?: unknown; code?: unknown } } })?.response?.data
if (typeof data?.code === 'string') {
const key = `dawarich.error.${data.code}`
const text = t(key)
if (text !== key) return text
}
return typeof data?.error === 'string' ? data.error : null
}
/** A reason code turned into the reader's language, with a fallback for new codes. */
function translateCode(code: string | null, t: (key: string) => string): string | null {
if (!code) return null
const key = `dawarich.error.${code}`
const text = t(key)
return text === key ? t('dawarich.error.unknown') : text
}
@@ -8,11 +8,14 @@ import {
Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Check, Loader2,
} from 'lucide-react'
import DawarichIcon from '../../../components/shared/DawarichIcon'
import AirTrailIcon from '../../../components/shared/AirTrailIcon'
import MToggle from '../../components/MToggle'
import { MAdminButton, MAdminCard, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
Dawarich: DawarichIcon,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -57,9 +60,16 @@ interface ProviderOption {
interface AddonIconProps {
name: string
size?: number
/** A switched-off addon greys its icon out, brand marks included. */
enabled?: boolean
}
function AddonIcon({ name, size = 18 }: AddonIconProps) {
function AddonIcon({ name, size = 18, enabled = true }: AddonIconProps) {
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
// means every existing install still carries it — so the brand is keyed on
// that rather than on a new name no row would ever have.
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
const Icon = ICON_MAP[name] || Puzzle
return <Icon size={size} />
}
@@ -304,8 +314,9 @@ function MAddonRow({ addon, onToggle, t, first }: MAddonRowProps) {
addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')
return (
<div className={`flex items-center gap-3 py-[11px] ${first ? '' : 'border-t border-[color:var(--m-rowbr)]'}`}>
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
<AddonIcon name={addon.icon} size={18} />
{/* overflow-hidden so a brand mark that fills the slot keeps its rounded corners. */}
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center overflow-hidden rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
<AddonIcon name={addon.icon} size={18} enabled={addon.enabled} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
+31 -1
View File
@@ -10,6 +10,9 @@ import MAtlasSearch from './MAtlasSearch'
import MAtlasCountryPopup from './MAtlasCountryPopup'
import MAtlasBucketSheet from './MAtlasBucketSheet'
import MToggle from '../../components/MToggle'
import DawarichIcon from '../../../components/shared/DawarichIcon'
import DawarichAtlasDialog from '../../../components/Dawarich/DawarichAtlasDialog'
import { useAddonStore } from '../../../store/addonStore'
import { countryStatus } from '../../../pages/atlas/atlasModel'
const removeBtnCls = 'mt-4 w-full rounded-full bg-[rgba(214,39,59,.12)] py-[11px] text-center text-[0.8125rem] font-bold text-[color:var(--m-st-danger)]' // theme-lint-disable — fixed status-danger tint
@@ -44,6 +47,8 @@ export default function MAtlas() {
const plannedCount = stats.totalCountriesPlanned || 0
const [searchOpen, setSearchOpen] = useState(false)
const [bucketOpen, setBucketOpen] = useState(false)
const [dawarichOpen, setDawarichOpen] = useState(false)
const dawarichEnabled = useAddonStore(state => state.isEnabled)('dawarich')
const [detailOpen, setDetailOpen] = useState(false)
// The dock FAB is the search button on this screen (demo Z. 1099) and hands
@@ -115,10 +120,27 @@ export default function MAtlas() {
<button
type="button"
onClick={() => setBucketOpen(true)}
className="flex h-[38px] flex-1 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-4 text-[0.78125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
className="flex h-[38px] min-w-0 flex-1 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-4 text-[0.78125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
>
{t('atlas.bucketTab')}
</button>
{/* Dawarich answers two questions about this map, so it opens from the
map — not from inside the wishlist sheet, where it was one card deep
and looked like part of the list it is only asking about. Renders
nothing when the addon is off. */}
{dawarichEnabled && (
<button
type="button"
onClick={() => setDawarichOpen(true)}
aria-label={t('dawarich.title')}
className="flex h-[38px] min-w-0 flex-1 items-center justify-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-3 shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
>
<span className="flex h-[26px] w-[26px] flex-none overflow-hidden rounded-full">
<DawarichIcon size={26} />
</span>
<span className="truncate text-[0.78125rem] font-bold text-m-ink">{t('dawarich.title')}</span>
</button>
)}
{/* Only worth the space once there is something planned to reveal. */}
{plannedCount > 0 && (
<div className="flex h-[38px] shrink-0 items-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-3 shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]">
@@ -128,6 +150,14 @@ export default function MAtlas() {
)}
</div>
{dawarichOpen && (
<DawarichAtlasDialog
isOpen
onClose={() => setDawarichOpen(false)}
onChanged={atlas.reloadAfterDawarich}
/>
)}
<MAtlasStatsCard stats={stats} />
<MAtlasSearch
@@ -236,6 +236,7 @@ export default function MAtlasBucketSheet({ atlas, open, onClose }: MAtlasBucket
{t('atlas.addPoi')}
</button>
)}
</div>
</MSheet>
)
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2 } from 'lucide-react'
import { useSearchParams } from 'react-router'
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2 } from 'lucide-react'
import JourneyMap from '../../../components/Journey/JourneyMapAuto'
import type { JourneyMapAutoHandle } from '../../../components/Journey/JourneyMapAuto'
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
@@ -9,6 +10,10 @@ import { ProviderPicker } from '../../../components/Journey/JourneyDetailPagePro
import { photoUrl } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import DawarichIcon from '../../../components/shared/DawarichIcon'
import DawarichSuggestionsPanel from '../../../components/Dawarich/DawarichSuggestionsPanel'
import { FormSheetHeader } from '../trip/sheets/PlSheetChrome'
import type { JourneyEntry, GalleryPhoto } from '../../../store/journeyStore'
import { useAuthStore } from '../../../store/authStore'
import { journeyApi, addonsApi, memoriesApi } from '../../../api/client'
@@ -43,6 +48,15 @@ export default function MJourneyDetail() {
loadJourney, updateEntry, deleteEntry, uploadPhotos,
} = useJourneyDetail()
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
// upload button, so it has to know which tab is open — and it stops knowing
// when this screen goes away.
const setMobileGalleryOpen = useJourneyStore(state => state.setMobileGalleryOpen)
useEffect(() => {
setMobileGalleryOpen(view === 'gallery')
return () => setMobileGalleryOpen(false)
}, [view, setMobileGalleryOpen])
const mapRef = useRef<JourneyMapAutoHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null)
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
@@ -129,11 +143,45 @@ export default function MJourneyDetail() {
// Gallery upload — device files plus the connected photo providers (Immich/Synology).
const galleryFileRef = useRef<HTMLInputElement>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
// Whether the probe below has finished — not whether it found anything.
const [providersReady, setProvidersReady] = useState(false)
const [showUploadMenu, setShowUploadMenu] = useState(false)
const [showActionMenu, setShowActionMenu] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const dawarichEnabled = useAddonStore(state => state.isEnabled)('dawarich')
const [dawarichOpen, setDawarichOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const openUpload = useCallback(() => {
if (availableProviders.length > 0) setShowUploadMenu(true)
else galleryFileRef.current?.click()
}, [availableProviders.length])
// The dock's FAB asks through the URL, the way every other "+" in the shell
// does. The parameter is cleared straight away so going back does not reopen
// the picker; the intent is held in state until the provider probe has
// answered, because "device or Immich?" cannot be asked before we know
// whether there is an Immich.
const [params, setParams] = useSearchParams()
const [pendingUpload, setPendingUpload] = useState(false)
useEffect(() => {
if (params.get('create') !== 'photo') return
setParams(prev => {
const next = new URLSearchParams(prev)
next.delete('create')
return next
}, { replace: true })
if (!canEditEntries) return
setView('gallery')
setPendingUpload(true)
}, [params, setParams, canEditEntries, setView])
useEffect(() => {
if (!pendingUpload || !providersReady) return
setPendingUpload(false)
openUpload()
}, [pendingUpload, providersReady, openUpload])
useEffect(() => {
let active = true
;(async () => {
@@ -155,6 +203,7 @@ export default function MJourneyDetail() {
}
if (active) setAvailableProviders(connected)
} catch { /* no providers */ }
finally { if (active) setProvidersReady(true) }
})()
return () => { active = false }
}, [])
@@ -314,20 +363,18 @@ export default function MJourneyDetail() {
</button>
</div>
<span className="ml-auto flex flex-none items-center gap-2">
{view === 'gallery' && canEditEntries && (
<button
type="button"
onClick={() => (availableProviders.length > 0 ? setShowUploadMenu(true) : galleryFileRef.current?.click())}
disabled={uploading}
{/* Uploading lives on the dock's FAB while the Gallery is open — the
one big action on the screen. A second button up here would be the
same thing twice, so it only appears while an upload is running,
as its progress. */}
{view === 'gallery' && canEditEntries && uploading && (
<span
role="status"
aria-label={t('common.upload')}
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-m-act text-m-actfg shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)] disabled:opacity-60"
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-m-act text-m-actfg shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
{uploading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Upload size={16} strokeWidth={2.2} />
)}
</button>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
</span>
)}
<button
type="button"
@@ -340,6 +387,47 @@ export default function MJourneyDetail() {
</span>
</div>
{/* The stays Dawarich recorded for these dates, as candidate entries. Over
the map rather than in the header: it belongs to what is on the map,
and the header is already three controls wide on a phone. Sits above
the card rail so it never covers the card somebody is reading. */}
{dawarichEnabled && canEditEntries && view === 'timeline' && (
<button
type="button"
onClick={() => setDawarichOpen(true)}
aria-label={t('dawarich.suggestions.title')}
className="absolute right-4 z-[9] flex h-[46px] w-[46px] items-center justify-center overflow-hidden rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] shadow-[0_6px_18px_-8px_rgba(0,0,0,.35)] bottom-[calc(var(--bottom-nav-h,84px)+16px+128px)]"
>
<DawarichIcon size={46} />
</button>
)}
{dawarichOpen && current && (
<MSheet open onClose={() => setDawarichOpen(false)} variant="bottom" material="glass" ariaLabel={t('dawarich.suggestions.title')}>
<div className="flex max-h-[82dvh] 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.suggestions.title')}
onClose={() => setDawarichOpen(false)}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[14px] pb-4">
{/* Journal entries and nothing else: a journey is for writing, and
the trip planner already offers the same stay as a place. */}
<DawarichSuggestionsPanel
bare
journals={[{ id: current.id, label: current.title }]}
onAccepted={() => { void loadJourney(current.id) }}
/>
</div>
</div>
</MSheet>
)}
{/* Horizontal card timeline */}
{view === 'timeline' && entries.length > 0 && (
<div
@@ -0,0 +1,120 @@
import React from 'react'
import { Plug, Save, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useDawarichConnection } from '../../../hooks/useDawarichConnection'
import { MSetCard, MSetRow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MToggle from '../../components/MToggle'
/**
* Phone twin of components/Settings/DawarichConnectionSection.
*
* Presentation only: every piece of behaviour lives in `useDawarichConnection`,
* which both shells share. That is not tidiness — SonarCloud allows 3%
* duplicated lines on a PR's new code, and a second copy of the connect / test /
* sync logic would spend the whole budget on its own.
*/
export default function MDawarichConnectionSection(): React.ReactElement {
const { t, locale } = useTranslation()
const S = useDawarichConnection()
return (
<MSetCard title={t('dawarich.title')} icon={Plug} className="mt-3">
<MSetHint className="mt-0">{t('dawarich.intro')}</MSetHint>
<div className="mt-3 space-y-3">
<div>
<MSetInput
type="url"
inputMode="url"
value={S.url}
onChange={e => S.setUrl(e.target.value)}
placeholder="https://dawarich.example.com"
aria-label={t('dawarich.url')}
/>
</div>
<div>
<MSetInput
type="password"
value={S.apiKey}
onChange={e => S.setApiKey(e.target.value)}
autoComplete="off"
placeholder={S.connected && !S.apiKey ? '••••••••' : t('dawarich.apiKeyPlaceholder')}
aria-label={t('dawarich.apiKey')}
/>
<MSetHint>{t('dawarich.apiKeyHint')}</MSetHint>
</div>
</div>
<div className="mt-1">
<MSetRow
first
label={t('dawarich.syncEnabled')}
sub={t('dawarich.syncEnabledHint')}
trailing={
<MToggle checked={S.syncEnabled} onChange={S.toggleSync} ariaLabel={t('dawarich.syncEnabled')} />
}
/>
<MSetRow
label={t('dawarich.allowInsecureTls')}
sub={t('dawarich.allowInsecureTlsHint')}
trailing={
<MToggle
checked={S.allowInsecureTls}
onChange={S.toggleInsecureTls}
ariaLabel={t('dawarich.allowInsecureTls')}
/>
}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<MSetButton onClick={S.save} disabled={S.saving || S.loading || !S.canSave}>
<Save size={14} /> {t('common.save')}
</MSetButton>
<MSetButton variant="ghost" onClick={S.test} disabled={S.testing || S.loading || !S.url.trim()}>
<Plug size={14} /> {t('dawarich.test.button')}
</MSetButton>
{S.connected && (
<MSetButton variant="ghost" onClick={S.syncNow} disabled={S.syncing}>
<RefreshCw size={14} className={S.syncing ? 'animate-spin' : ''} /> {t('dawarich.syncNow')}
</MSetButton>
)}
</div>
<div className="mt-3 space-y-[4px]">
<p className="font-geist text-[0.6875rem] font-semibold text-m-ink">
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
</p>
{S.probeMessage && (
<p className="font-geist text-[0.625rem] leading-relaxed text-m-muted">{S.probeMessage}</p>
)}
{S.connected && (
<p className="font-geist text-[0.625rem] leading-relaxed text-m-muted">
{S.lastSyncAt
? t('dawarich.lastSync', { when: new Date(S.lastSyncAt).toLocaleString(locale) })
: t('dawarich.neverSynced')}
</p>
)}
{S.lastSyncError && (
<p className="font-geist text-[0.625rem] leading-relaxed text-[color:var(--m-st-danger)]">
{S.lastSyncError}
</p>
)}
{S.capabilities?.serverVersion && (
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">
{t('dawarich.serverVersion', { version: S.capabilities.serverVersion })}
</p>
)}
</div>
{S.connected && (
<div className="mt-3">
<MSetButton variant="danger" onClick={S.disconnect} disabled={S.saving}>
<Unplug size={14} /> {t('dawarich.disconnect')}
</MSetButton>
</div>
)}
</MSetCard>
)
}
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { useAddonStore } from '../../../store/addonStore'
import MPhotoProvidersSection from './MPhotoProvidersSection'
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
import MDawarichConnectionSection from './MDawarichConnectionSection'
import MLlmConnectionSection from './MLlmConnectionSection'
import MSettingsMcp from './MSettingsMcp'
import { useAuthStore } from '../../../store/authStore'
@@ -17,6 +18,7 @@ export default function MSettingsIntegrations() {
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
const dawarichEnabled = addonEnabled('dawarich')
const managed = useAuthStore((s) => s.managed)
useEffect(() => {
@@ -27,6 +29,9 @@ export default function MSettingsIntegrations() {
<>
<MPhotoProvidersSection />
{airtrailEnabled && <MAirTrailConnectionSection />}
{/* Reaches a server the reader runs themselves, which a managed install has
no route to — the same gate the desktop shell puts on AirTrail. */}
{dawarichEnabled && !managed && <MDawarichConnectionSection />}
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { MapViewAuto } from '../../../../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapCompassPill'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../components/Map/TripRouteOverview'
import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill'
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
import { usePoiExplore } from '../../../../components/Map/usePoiExplore'
import { useSettingsStore } from '../../../../store/settingsStore'
@@ -44,6 +45,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
<div className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px)]">
<MapViewAuto
tripId={planner.tripId}
dawarichTrack={planner.dawarichTrail.track}
places={planner.mapPlaces}
dayPlaces={planner.dayPlaces}
route={planner.overviewActive ? planner.tripOverview.lines : planner.route}
@@ -115,9 +117,9 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
Tailwind rather than inline because the compass band is identified by being
the one element with an inline --bottom-nav-h, and a second would make that
ambiguous. */}
{mapActive && !planner.roadtripActive && (
{mapActive && (!planner.roadtripActive || planner.dawarichEnabled) && (
<div className="pointer-events-none absolute left-3 right-3 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+58px)]">
{planner.overviewActive && (
{!planner.roadtripActive && planner.overviewActive && (
<TripRouteOverviewPanel
overview={planner.tripOverview}
unit={distanceUnit}
@@ -128,7 +130,18 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
maxWidth={240}
/>
)}
<TripRouteOverviewPill active={planner.overviewShown} onToggle={planner.toggleOverview} />
{!planner.roadtripActive && (
<TripRouteOverviewPill active={planner.overviewShown} onToggle={planner.toggleOverview} />
)}
{/* Kept in road-trip mode: the recorded route beside the planned one is
exactly the comparison a drive invites. */}
{planner.dawarichEnabled && (
<DawarichTrailPill
active={planner.dawarichTrailShown}
status={planner.dawarichTrail.status}
onToggle={planner.toggleDawarichTrail}
/>
)}
</div>
)}
</div>
@@ -13,6 +13,10 @@ import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import { resolveTrackColor } from '../../../../components/Map/trackColors'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { MPlacesBrowserProps } from '../MTripShell'
import DawarichSuggestionsPanel from '../../../../components/Dawarich/DawarichSuggestionsPanel'
import { formatDayOption } from '../../../../components/Dawarich/dawarichSuggestionModel'
import { refreshTripAfterAccept } from '../../../../components/Dawarich/dawarichTripRefresh'
import { useTranslation } from '../../../../i18n'
import type { Place } from '../../../../types'
import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet'
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet'
@@ -31,6 +35,8 @@ import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBro
*/
export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) {
const { t, places, categories, assignments, days, trip } = planner
// The planner hook carries `t` but not the locale; day labels need both.
const { locale } = useTranslation()
const canEditPlaces = planner.can('place_edit', trip)
const collectionsEnabled = useAddonStore(s => s.isEnabled('collections'))
@@ -231,6 +237,24 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
)}
</div>
{/* Stays Dawarich recorded on these dates (#2279). Same component as the
desktop rail — the rows are the same rows, and the panel renders
nothing when there is nothing pending. */}
<div className="mt-3">
<DawarichSuggestionsPanel
tripId={planner.tripId}
trips={[{ id: planner.tripId, label: t('dawarich.accept.thisTrip') }]}
daysForTrip={() => days.map(day => ({
id: day.id,
...formatDayOption(day.day_number, day.date, locale, t),
}))}
// The place it just created belongs on the map and in the list
// now, not after a reload.
onAccepted={() => { void refreshTripAfterAccept(planner.tripId) }}
initiallyCollapsed
/>
</div>
{/* ── Selection toolbar ── */}
{selectMode && (
<div className="mt-2 flex items-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] py-[6px] pl-[14px] pr-[6px] backdrop-blur-[20px]">
@@ -1,4 +1,5 @@
import { useMemo, useRef, useState } from 'react'
import DawarichIcon from '../../../../components/shared/DawarichIcon'
import {
Bookmark, Camera, ChevronRight, ExternalLink, Loader2, Map as MapIcon, Navigation, Paperclip,
Pencil, Phone, Plus, Route, Trash2, Upload, X,
@@ -301,7 +302,16 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-[1rem] font-bold leading-snug">{place.name}</div>
<div className="flex items-center gap-1.5">
<span className="min-w-0 truncate text-[1rem] font-bold leading-snug">{place.name}</span>
{/* Accepted out of the traveller's own recordings — the same
mark the desktop inspector carries. */}
{place.source === 'dawarich' && (
<span className="flex-none overflow-hidden rounded-[5px]">
<DawarichIcon size={15} />
</span>
)}
</div>
{place.address && (
<div className="mt-[2px] font-geist text-[0.6875rem] leading-[1.4] text-m-muted">{place.address}</div>
)}
+80 -18
View File
@@ -5,8 +5,10 @@ import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client'
import CustomSelect from '../components/shared/CustomSelect'
import EmptyState from '../components/shared/EmptyState'
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search, Check } from 'lucide-react'
import type { TranslationFn } from '../types'
import { Tooltip } from '../components/shared/Tooltip'
import DawarichAtlasSidePanel from '../components/Dawarich/DawarichAtlasSidePanel'
import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { continentForCountry } from '@trek/shared'
import { useAtlas } from './atlas/useAtlas'
@@ -41,10 +43,10 @@ function AtlasPageDesktop(): React.ReactElement {
bucketMonth, setBucketMonth, bucketYear, setBucketYear,
bucketList, setBucketList, bucketTab, setBucketTab,
showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm,
handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi,
handleAddBucketItem, handleDeleteBucketItem, handleClearBucketVisit, handleBucketPoiSearch, handleSelectBucketPoi,
bucketSearchResults, setBucketSearchResults,
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
bucketSearching, bucketSearch, setBucketSearch,
bucketSearching, bucketSearch, setBucketSearch, reloadAfterDawarich,
} = useAtlas()
const toast = useToast()
// Solid surfaces when the user disabled transparency (read at render — the
@@ -62,6 +64,17 @@ function AtlasPageDesktop(): React.ReactElement {
)
}
// The glass both bottom panels are cut from. One object, because two panels
// side by side that differ by a blur radius look like a mistake.
const glassPanel: React.CSSProperties = {
background: noTransparency ? (dark ? '#15151c' : '#ffffff') : (dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)'),
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'),
borderRadius: 20,
boxShadow: dark ? '0 8px 32px rgba(0,0,0,0.3)' : '0 8px 32px rgba(0,0,0,0.08)',
}
return (
<div className="h-screen overflow-hidden bg-surface">
<Navbar />
@@ -121,26 +134,36 @@ function AtlasPageDesktop(): React.ReactElement {
</div>
</div>
{/* Desktop Panel — bottom center, glass effect */}
{/* Desktop Panels — bottom center, glass effect.
Three columns so the Atlas panel stays exactly centred over the map
however wide its neighbour is: Dawarich sits in the left column,
right-aligned against the middle one, and stretches to its height. */}
<div
className="hidden md:grid absolute z-10 items-stretch"
style={{
bottom: 16,
left: 20,
right: 20,
gridTemplateColumns: '1fr auto 1fr',
alignItems: 'stretch',
gap: 12,
pointerEvents: 'none',
}}
>
<div className="flex justify-end" style={{ pointerEvents: 'auto', minWidth: 0 }}>
<DawarichAtlasSidePanel style={glassPanel} dark={dark} onChanged={reloadAfterDawarich} />
</div>
<div
ref={panelRef}
onMouseMove={handlePanelMouseMove}
onMouseLeave={handlePanelMouseLeave}
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-[width,height,transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
className="hidden md:flex flex-col overflow-hidden transition-[width,height,transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
...glassPanel,
width: 'fit-content',
maxWidth: 'calc(100vw - 40px)',
background: noTransparency ? (dark ? '#15151c' : '#ffffff') : (dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)'),
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'),
borderRadius: 20,
boxShadow: dark
? '0 8px 32px rgba(0,0,0,0.3)'
: '0 8px 32px rgba(0,0,0,0.08)',
maxWidth: '100%',
pointerEvents: 'auto',
position: 'relative',
}}
>
{/* Liquid glass glare effect */}
@@ -158,6 +181,7 @@ function AtlasPageDesktop(): React.ReactElement {
showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd}
bucketForm={bucketForm} setBucketForm={setBucketForm}
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
onClearBucketVisit={handleClearBucketVisit}
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
bucketSearchResults={bucketSearchResults} setBucketSearchResults={setBucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
@@ -165,6 +189,8 @@ function AtlasPageDesktop(): React.ReactElement {
t={t} dark={dark}
/>
</div>
<div aria-hidden />
</div>
</div>
@@ -463,6 +489,7 @@ interface SidebarContentProps {
setBucketForm: (f: { name: string; notes: string; lat: string; lng: string; target_date: string }) => void
onAddBucket: () => Promise<void>
onDeleteBucket: (id: number) => Promise<void>
onClearBucketVisit: (id: number) => void | Promise<void>
onSearchBucket: () => Promise<void>
onSelectBucketPoi: (result: any) => void
bucketSearchResults: any[]
@@ -478,7 +505,7 @@ interface SidebarContentProps {
dark: boolean
}
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onClearBucketVisit, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation()
const statsContentRef = useRef<HTMLDivElement>(null)
const bucketSearchRowRef = useRef<HTMLDivElement>(null)
@@ -552,6 +579,41 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
return <span className="text-[9px] mt-0.5 text-center" style={{ color: tf }}>{label}</span>
})()}
{!item.target_date && item.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</span>}
{/* Reached (#2279). A tick rather than removing the entry: a wish that
came true is the part of the list worth keeping, and the source is
named so a recording-derived tick is not mistaken for a manual one. */}
{item.visited_at && (
<Tooltip
label={item.visited_source === 'dawarich'
? `${t('dawarich.bucket.visitedFrom')} — ${t('dawarich.bucket.clearVisit')}`
: ''}
placement="top"
>
{/* A tick that came from a recording can be taken back here: the
wishlist is curated by hand, and a suggestion nobody can undo
is not a suggestion. A tick somebody set themselves is text. */}
{item.visited_source === 'dawarich' ? (
<button
type="button"
onClick={() => { void onClearBucketVisit(item.id) }}
aria-label={t('dawarich.bucket.clearVisit')}
className="text-success mt-0.5 flex items-center gap-1 hover:opacity-70"
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', background: 'none', border: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit' }}
>
<Check size={9} />
{new Date(item.visited_at).toLocaleDateString(language)}
</button>
) : (
<span
className="text-success mt-0.5 flex items-center gap-1"
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
>
<Check size={9} />
{new Date(item.visited_at).toLocaleDateString(language)}
</span>
)}
</Tooltip>
)}
<button type="button" onClick={() => onDeleteBucket(item.id)}
className="opacity-0 group-hover:opacity-100"
style={{ position: 'absolute', top: 4, right: 4, background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: tf, display: 'flex', transition: 'opacity 0.15s' }}>
+14
View File
@@ -13,6 +13,7 @@ import {
Plus, ChevronUp, ChevronDown, Eye, EyeOff, BookOpen, Image,
} from 'lucide-react'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import DawarichSuggestionsPanel from '../components/Dawarich/DawarichSuggestionsPanel'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useJourneyStore } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
@@ -379,6 +380,19 @@ function JourneyDetailPageDesktop() {
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
{!isMobile && (
<div className={`flex flex-col gap-6 pb-24 md:pb-6${view === 'timeline' ? '' : ' hidden'}`}>
{/* Stays Dawarich recorded over this journal's dates, waiting to
be turned into entries (#2279). Above the timeline because
that is what they become; renders nothing when there is
nothing pending. */}
{canEditEntries && (
<DawarichSuggestionsPanel
journals={[{ id: current.id, label: current.title }]}
// The entry the acceptance created belongs on the timeline
// immediately; re-reading the journey is how it gets there.
onAccepted={() => { void loadJourney(current.id) }}
/>
)}
{sortedDates.length === 0 && (
<EmptyState scene="journey" title={t('journey.detail.noEntries')} />
)}
+19 -3
View File
@@ -7,6 +7,7 @@ import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../components/Map/TripRouteOverview'
import { DawarichTrailPill } from '../components/Map/DawarichTrailPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
@@ -255,6 +256,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewActive, tripOverview, toggleOverview, overviewShown,
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
@@ -406,6 +408,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<div style={{ position: 'absolute', inset: 0 }}>
<MapView
tripId={tripId}
dawarichTrack={dawarichTrail.track}
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
dayPlaces={dayPlaces}
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
@@ -484,12 +487,12 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
bottom-left corner, where Leaflet's base-layer switcher sits at
z-index 1000 and would cover this. The right corner is free on both
renderers — the locate button that lives there is phone-only. */}
{!roadtripActive && (
{(!roadtripActive || dawarichEnabled) && (
<div className="hidden md:flex" style={{
position: 'absolute', bottom: 18, right: mapInsetRight + 14, zIndex: 26,
pointerEvents: 'none', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
}}>
{overviewActive && (
{!roadtripActive && overviewActive && (
<TripRouteOverviewPanel
overview={tripOverview}
unit={distanceUnit}
@@ -497,7 +500,20 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onSelectDay={handleSelectDay}
/>
)}
<TripRouteOverviewPill active={overviewShown} onToggle={toggleOverview} />
{!roadtripActive && (
<TripRouteOverviewPill active={overviewShown} onToggle={toggleOverview} />
)}
{/* Stays in road-trip mode, unlike the overview: the route that was
actually driven is the thing you most want beside the planned
one. It is drawn, never applied — correcting the plan from the
recording is a different feature and deliberately not this one. */}
{dawarichEnabled && (
<DawarichTrailPill
active={dawarichTrailShown}
status={dawarichTrail.status}
onToggle={toggleDawarichTrail}
/>
)}
</div>
)}
@@ -154,6 +154,13 @@ function baseState(): HookState {
askRefuel: vi.fn(),
acceptRefuel: vi.fn(),
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
// The recorded-route overlay (#2279). Off and empty: the page reads
// `dawarichTrail.track` unconditionally, so the fixture has to carry the
// shape the hook returns even when the addon is not in play.
dawarichEnabled: false,
dawarichTrailShown: false,
toggleDawarichTrail: vi.fn(),
dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
roadtripViaCounts: {},
stopDraft: null,
setStopDraft: vi.fn(),
+4
View File
@@ -105,6 +105,10 @@ export interface BucketItem {
country_code: string | null
notes: string | null
target_date: string | null
/** ISO-8601 of the stay that fulfilled this wish, or null while it is still a wish. */
visited_at?: string | null
/** Who decided it was reached: 'manual' or 'dawarich' (#2279). */
visited_source?: string | null
}
// Normalize a region name for matching: strip diacritics (the geocoder and the
+32 -24
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState, useRef } from 'react'
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate } from 'react-router'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
@@ -11,6 +11,8 @@ import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
import { A2_TO_A3, countryStatus, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, regionCacheEvictions, withCountryMarkedVisited, wishlistA3Codes, countryColor, REGION_CACHE_MAX, bucketTooltipWidth, bucketTooltipPlacement, bucketTooltipNeedsScroll, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
import { continentForCountry, escapeHtml, type VisitStatus } from '@trek/shared'
import { useGlassGlare } from '../../components/Atlas/useGlassGlare'
import { dawarichApi } from '../../api/dawarich'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
@@ -105,29 +107,11 @@ export function useAtlas() {
// kept redrawing itself on every pan for the rest of the session (#1950).
const countryRendererRef = useRef<L.Canvas | null>(null)
const regionRendererRef = useRef<L.SVG | null>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
// The panel's hover light, shared with the Dawarich panel beside it.
const { panelRef, glareRef, borderGlareRef, onMouseMove: handlePanelMouseMove, onMouseLeave: handlePanelMouseLeave } =
useGlassGlare(dark)
const country_layer_by_a2_ref = useRef<Record<string, any>>({})
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
const rect = panelRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// Subtle inner glow
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
glareRef.current.style.opacity = '1'
// Border glow that follows cursor
borderGlareRef.current.style.opacity = '1'
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
}
const handlePanelMouseLeave = () => {
if (glareRef.current) glareRef.current.style.opacity = '0'
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
}
const [data, setData] = useState<AtlasData | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
@@ -224,6 +208,30 @@ export function useAtlas() {
}, [geoData, resolveName])
// Load atlas data + bucket list
//
// Re-run on `atlasEpoch` so a confirmation made elsewhere on the page — the
// Dawarich card ticking wishes off or marking countries (#2279) — lands in the
// same numbers the map is drawing, rather than only after a reload.
const [atlasEpoch, setAtlasEpoch] = useState(0)
const reloadAfterDawarich = useCallback(() => setAtlasEpoch(epoch => epoch + 1), [])
/**
* Undo a wish that a recording ticked off.
*
* A suggestion that cannot be taken back is not a suggestion, and this one
* writes into the wishlist — the one list on this page somebody curates by
* hand. The row is kept; only the visit is cleared.
*/
const handleClearBucketVisit = useCallback(async (itemId: number) => {
try {
await dawarichApi.clearBucketVisit(itemId)
setBucketList(prev => prev.map(item => (
item.id === itemId ? { ...item, visited_at: null, visited_source: null } : item
)))
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}, [t, toast])
useEffect(() => {
Promise.all([
apiClient.get('/addons/atlas/stats'),
@@ -233,7 +241,7 @@ export function useAtlas() {
setBucketList(bucketRes.data.items || [])
setLoading(false)
}).catch(() => setLoading(false))
}, [])
}, [atlasEpoch])
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
@@ -1176,6 +1184,6 @@ export function useAtlas() {
handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi,
bucketSearchResults, setBucketSearchResults,
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
bucketSearching, bucketSearch, setBucketSearch,
bucketSearching, bucketSearch, setBucketSearch, reloadAfterDawarich, handleClearBucketVisit,
}
}
@@ -13,6 +13,8 @@ import { lockBodyScroll } from '../../utils/bodyScrollLock'
import type { JourneyEntry } from '../../store/journeyStore'
import { createDraftJourneyEntry } from './JourneyDetailPage.helpers'
import { useDawarichJournalTrail } from '../../hooks/useDawarichJournalTrail'
/** Stable identity for "this journey draws no trip tracks" (#2194). */
const NO_TRACKS: JourneyTrack[] = []
@@ -371,6 +373,17 @@ export function useJourneyDetail() {
return dates
}, [current?.trips])
// The route actually recorded over the journal's own dates (#2279), drawn on
// the same layer as the GPX tracks because it is the same kind of thing: a
// recording, not a line connecting entries. Gated on `show_trip_tracks` for
// the same reason that switch exists — it gates the REQUEST, and a map nobody
// asked for should not reach across the network for it.
const dawarichTrail = useDawarichJournalTrail(tripDates, showTripTracks)
const mapTracks = useMemo(
() => (dawarichTrail.tracks.length > 0 ? [...tracks, ...dawarichTrail.tracks] : tracks),
[tracks, dawarichTrail.tracks],
)
/** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */
const STUDIO_INSET = 16
@@ -412,7 +425,7 @@ export function useJourneyDetail() {
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
}
+2 -1
View File
@@ -20,7 +20,8 @@ export function useSettings() {
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled
const dawarichEnabled = addonEnabled('dawarich')
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || dawarichEnabled
const [appVersion, setAppVersion] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState('display')
+21 -2
View File
@@ -31,6 +31,7 @@ import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
@@ -117,7 +118,7 @@ export function useTripPlanner() {
toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast])
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false })
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false, dawarich: false })
// The values above are an optimistic guess until the addon feed answers. The
// tab guard below waits for this before evicting anything, so a tab we were
// asked to open ('collab' in particular, guessed off) survives the gap.
@@ -164,7 +165,7 @@ export function useTripPlanner() {
addonsApi.enabled().then(data => {
const map: Record<string, boolean> = {}
data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip, dawarich: !!map.dawarich })
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
}).catch(() => {}).finally(() => setAddonsLoaded(true))
authApi.getAppConfig().then(config => {
@@ -391,6 +392,23 @@ export function useTripPlanner() {
return next
})
}, [tripId])
// The recorded route from Dawarich (#2279), per trip and per session for the
// same reason as the overview above: it answers "what actually happened on
// this trip", which is a question about one trip rather than a preference.
const [dawarichTrailShown, setDawarichTrailShown] = useState<boolean>(
() => sessionStorage.getItem(`trip-dawarich-${tripId}`) === '1',
)
const toggleDawarichTrail = useCallback(() => {
setDawarichTrailShown(prev => {
const next = !prev
sessionStorage.setItem(`trip-dawarich-${tripId}`, next ? '1' : '0')
return next
})
}, [tripId])
// Fetched here rather than in MapViewAuto so the desktop and the phone share
// one request, and so the pill that toggles it can show why there is no line.
const dawarichTrail = useDawarichTrail(tripId, dawarichTrailShown)
const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
@@ -2177,6 +2195,7 @@ export function useTripPlanner() {
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewShown, toggleOverview, overviewActive, tripOverview,
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichEnabled: !!enabledAddons.dawarich,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
+67
View File
@@ -0,0 +1,67 @@
import {
dawarichAtlasSuggestionsSchema,
dawarichBucketScanSchema,
dawarichSuggestionListSchema,
dawarichTrackSchema,
} from '@trek/shared'
import { dawarichApi } from '../api/dawarich'
import { isEffectivelyOffline } from '../sync/networkMode'
/**
* Reads from the Dawarich integration.
*
* **Deliberately online-only, and no Dexie table.** Everything here is a live
* read of somebody's location history on a server TREK does not own: a cached
* recording is a stale one, and a cached copy of a GPS archive is the thing
* this integration exists not to keep. The precedent in this directory is
* `roadtripHazardsRepo` / `chargingRepo` / `schoolHolidayCatalogRepo` — same
* shape, same reason.
*
* The offline check is `isEffectivelyOffline()` rather than `navigator.onLine`,
* so the manual offline switch counts: someone who turned it on before a flight
* has said they do not want requests fired, and firing them anyway would block
* the UI on a timeout.
*
* Responses are parsed against the shared schema rather than cast. A server
* that answered something unexpected should fail here, loudly, not three
* components later where the cause is invisible.
*/
/** Thrown before a request is made when the device is offline by choice or by circumstance. */
export class DawarichOfflineError extends Error {
constructor() {
super('Dawarich needs a connection')
this.name = 'DawarichOfflineError'
}
}
function requireOnline(): void {
if (isEffectivelyOffline()) throw new DawarichOfflineError()
}
export const dawarichRepo = {
async suggestions(params?: { tripId?: number; state?: string }) {
requireOnline()
return dawarichSuggestionListSchema.parse(await dawarichApi.listSuggestions(params))
},
async tripTrack(tripId: number, range?: { from?: string; to?: string }, signal?: AbortSignal) {
requireOnline()
return dawarichTrackSchema.parse(await dawarichApi.tripTrack(tripId, range, signal))
},
async windowTrack(from: string, to: string, signal?: AbortSignal) {
requireOnline()
return dawarichTrackSchema.parse(await dawarichApi.windowTrack(from, to, signal))
},
async bucketScan() {
requireOnline()
return dawarichBucketScanSchema.parse(await dawarichApi.scanBucketList())
},
async atlasSuggestions(from: string, to: string) {
requireOnline()
return dawarichAtlasSuggestionsSchema.parse(await dawarichApi.atlasSuggestions(from, to))
},
}
+7
View File
@@ -193,6 +193,13 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
expandedDayIds: new Set<number>(),
setExpandedDayIds: vi.fn(),
mapPlaces: [],
// The recorded-route overlay (#2279): off and empty. MMapArea reads
// `dawarichTrail.track` unconditionally, so the shape has to be here even
// when the addon is not in play.
dawarichEnabled: false,
dawarichTrailShown: false,
toggleDawarichTrail: vi.fn(),
dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
route: null,
routeSegments: [],
@@ -180,12 +180,19 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
};
}
function setup(over: Record<string, unknown> = {}) {
function setup(over: Record<string, unknown> = {}, entries?: string[]) {
mocks.detail = buildHook(over);
const view = render(<MJourneyDetail />);
const view = render(<MJourneyDetail />, entries ? { initialEntries: entries } : undefined);
return { ...view, hook: mocks.detail };
}
/**
* Opening the upload the way the shell does since the Gallery took over the
* dock's FAB: the button asks through `?create=photo`, the screen answers. The
* header no longer carries an upload button of its own — one screen, one upload.
*/
const UPLOAD_URL = ['/journey/1?create=photo'];
const journeyStoreInitial = useJourneyStore.getState();
let uploadGalleryPhotos: ReturnType<typeof vi.fn>;
let createEntry: ReturnType<typeof vi.fn>;
@@ -355,9 +362,8 @@ describe('MJourneyDetail', () => {
],
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
setup({ view: 'gallery' });
setup({ view: 'gallery' }, UPLOAD_URL);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
expect(await screen.findByText('mobileJourney.uploadFromDevice')).toBeInTheDocument();
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
expect(await screen.findByTestId('provider-picker')).toBeInTheDocument();
@@ -370,10 +376,9 @@ describe('MJourneyDetail', () => {
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
const toGallery = vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockResolvedValue({ added: 3 });
const toEntry = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 });
const { hook } = setup({ view: 'gallery' });
const { hook } = setup({ view: 'gallery' }, UPLOAD_URL);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
await screen.findByTestId('provider-picker');
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise<void>;
@@ -392,10 +397,9 @@ describe('MJourneyDetail', () => {
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockRejectedValue(new Error('boom'));
setup({ view: 'gallery' });
setup({ view: 'gallery' }, UPLOAD_URL);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
await screen.findByTestId('provider-picker');
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise<void>;
@@ -509,11 +513,11 @@ describe('MJourneyDetail', () => {
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
const { container } = setup({ view: 'gallery' });
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
const click = vi.spyOn(input, 'click').mockImplementation(() => {});
// The upload opens on mount now (the dock asks through the URL), so the spy
// goes on the prototype rather than on an input that does not exist yet.
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {});
setup({ view: 'gallery' }, UPLOAD_URL);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
fireEvent.click(await screen.findByText('mobileJourney.uploadFromDevice'));
expect(click).toHaveBeenCalled();
await waitFor(() => expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument());
@@ -524,12 +528,10 @@ describe('MJourneyDetail', () => {
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: false });
const { container } = setup({ view: 'gallery' });
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
const click = vi.spyOn(input, 'click').mockImplementation(() => {});
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {});
setup({ view: 'gallery' }, UPLOAD_URL);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
expect(click).toHaveBeenCalled();
await waitFor(() => expect(click).toHaveBeenCalled());
expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument();
});
@@ -545,7 +547,7 @@ describe('MJourneyDetail', () => {
editingEntry: buildEntry({ id: 4 }),
showSettings: true,
showInvite: true,
});
}, UPLOAD_URL);
(mocks.captured.entrySheet.onClose as () => void)();
expect(hook.setEditingEntry).toHaveBeenCalledWith(null);
@@ -554,8 +556,7 @@ describe('MJourneyDetail', () => {
(mocks.captured.invite.onClose as () => void)();
expect(hook.setShowInvite).toHaveBeenCalledWith(false);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
await screen.findByTestId('provider-picker');
expect([...(mocks.captured.picker.existingAssetIds as Set<string>)]).toEqual(['asset-1']);