Merge pull request #2349 from liketrek/feat/dawarich-integration

feat: Dawarich integration
This commit is contained in:
Maurice
2026-09-13 00:05:05 +02:00
committed by GitHub
209 changed files with 18743 additions and 403 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

+5 -1
View File
@@ -21,6 +21,7 @@ import {
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
type ApiTokenCreateRequest, type PublicApiScope,
type TripAddMemberRequest, type TripTransferOwnershipRequest,
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
@@ -342,7 +343,10 @@ export const authApi = {
// and a key of the wrong kind is refused like one that does not exist.
apiKeys: {
list: () => apiClient.get('/auth/api-tokens').then(r => r.data),
create: (name: string) => apiClient.post('/auth/api-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
// `scopes` narrows what the key may read (#2279). Omitted means everything,
// which is what every key minted before scopes existed still does.
create: (name: string, scopes?: PublicApiScope[]) =>
apiClient.post('/auth/api-tokens', { name, ...(scopes?.length ? { scopes } : {}) } satisfies ApiTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data),
},
passkey: {
+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)])
}
@@ -606,13 +606,17 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* The date needs the room, not the clock: a long localized date
("12. Sept. 2026") wrapped onto a second line while the time field
sat half empty beside it. The row is split in the date's favour and
the location, which is a free-text search, gives some back. */}
<div className="grid grid-cols-1 sm:grid-cols-[1.3fr_1fr] gap-3">
{/* Time sat in state and went to the server, it just had no input here — so a
draft's auto-stamped clock time showed up in the timeline, the map, the PDF
and the public share, and the desktop had no way to correct it (#1614). */}
{/* 136px, not 104: the custom picker adds a clock button and, in 12h,
shows "2:30 PM" where the native input showed a fixed-width HH:MM. */}
<div className="grid grid-cols-[1fr_136px] gap-2">
{/* 112px: enough for the clock button plus "2:30 PM" in 12h, and no
more — the date column is what needed the space back. */}
<div className="grid grid-cols-[minmax(0,1fr)_112px] gap-2">
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
@@ -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>
)
}
+24 -18
View File
@@ -1,5 +1,6 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
@@ -21,24 +22,29 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
onClick={onToggle}
aria-label={label}
title={label}
aria-pressed={isSatellite}
className="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' }}
>
<Icon size={17} strokeWidth={2} />
</button>
{/* Same tooltip as its neighbours on the map, for the same reasons — see
TripRouteOverviewPill. To the right, though: this one sits at the
bottom left of the map, where a tooltip to the left would land on the
day sidebar and one below would land off the screen. */}
<Tooltip label={label} placement="right">
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={isSatellite}
className="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' }}
>
<Icon size={17} strokeWidth={2} />
</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>
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { KeyRound, Plus, Trash2, Copy, Check } from 'lucide-react'
import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared'
import Section from './Section'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -24,6 +25,9 @@ interface ApiKey {
token_prefix: string
created_at: string
last_used_at: string | null
/** 'all' for every key minted before scopes existed, and for any key created without narrowing. */
scope_mode?: 'all' | 'limited'
scopes?: PublicApiScope[]
}
export default function ApiKeysSection(): React.ReactElement {
@@ -37,6 +41,12 @@ export default function ApiKeysSection(): React.ReactElement {
const [created, setCreated] = useState<string | null>(null)
const [deleteId, setDeleteId] = useState<number | null>(null)
const [copied, setCopied] = useState(false)
/**
* What the new key may read. Starts as everything, because that is what a key
* did before this existed and what most integrations want — narrowing is a
* deliberate act, and the dialog should not make the common case work harder.
*/
const [newScopes, setNewScopes] = useState<Set<PublicApiScope>>(new Set(PUBLIC_API_SCOPES))
useEffect(() => {
authApi.apiKeys.list().then(d => setKeys(d.tokens || [])).catch(() => {})
@@ -46,10 +56,23 @@ export default function ApiKeysSection(): React.ReactElement {
if (!newName.trim() || creating) return
setCreating(true)
try {
const d = await authApi.apiKeys.create(newName.trim())
// All of them selected means "no narrowing", which is what the server
// stores as `all` — sending the full list would record it as a limited key
// that happens to allow everything, and a section added in a later version
// would then be refused for a key nobody meant to restrict.
const narrowed = newScopes.size < PUBLIC_API_SCOPES.length ? [...newScopes] : undefined
const d = await authApi.apiKeys.create(newName.trim(), narrowed)
setCreated(d.token.raw_token)
setKeys(prev => [
{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null },
{
id: d.token.id,
name: d.token.name,
token_prefix: d.token.token_prefix,
created_at: d.token.created_at,
last_used_at: null,
scope_mode: d.token.scope_mode,
scopes: d.token.scopes,
},
...prev,
])
setNewName('')
@@ -83,6 +106,16 @@ export default function ApiKeysSection(): React.ReactElement {
setModalOpen(false)
setCreated(null)
setNewName('')
setNewScopes(new Set(PUBLIC_API_SCOPES))
}
const toggleScope = (scope: PublicApiScope) => {
setNewScopes(prev => {
const next = new Set(prev)
if (next.has(scope)) next.delete(scope)
else next.add(scope)
return next
})
}
return (
@@ -114,6 +147,14 @@ export default function ApiKeysSection(): React.ReactElement {
<span className="ml-2">· {t('settings.apiKeys.usedAt')} {new Date(key.last_used_at).toLocaleDateString(locale)}</span>
)}
</p>
{/* What the key may read. Shown on the row rather than behind a
detail view: the whole reason to narrow a key is to be able
to see later that you did. */}
<p className="text-xs mt-1 text-content-muted">
{key.scope_mode === 'limited' && key.scopes
? key.scopes.map(scope => t(`settings.apiScopes.${scope}`)).join(', ')
: t('settings.apiScopes.all')}
</p>
</div>
<button type="button" onClick={() => setDeleteId(key.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@@ -144,12 +185,31 @@ export default function ApiKeysSection(): React.ReactElement {
autoFocus />
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.apiKeys.modal.nameHint')}</p>
</div>
<div>
<span className="block text-sm font-medium mb-1 text-content-secondary">
{t('settings.apiScopes.title')}
</span>
<p className="text-xs mb-2 text-content-muted">{t('settings.apiScopes.hint')}</p>
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{PUBLIC_API_SCOPES.map(scope => (
<label key={scope} className="flex items-center gap-2 text-sm text-content-secondary cursor-pointer">
<input
type="checkbox"
checked={newScopes.has(scope)}
onChange={() => toggleScope(scope)}
/>
{t(`settings.apiScopes.${scope}`)}
</label>
))}
</div>
</div>
<div className="flex gap-2 justify-end pt-1">
<button type="button" onClick={closeModal}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button type="button" onClick={handleCreate} disabled={!newName.trim() || creating}
<button type="button" onClick={handleCreate} disabled={!newName.trim() || creating || newScopes.size === 0}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{creating ? t('settings.apiKeys.modal.creating') : t('settings.apiKeys.modal.create')}
</button>
@@ -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 } : {}),
}}
/>
)
}
@@ -21,6 +21,8 @@ interface CustomDatePickerProps {
style?: React.CSSProperties;
compact?: boolean;
borderless?: boolean;
/** Read-only contexts (a shared journey, a locked form) — same as CustomTimePicker. */
disabled?: boolean;
// Optional inclusive ISO (YYYY-MM-DD) bounds. Dates outside the range are
// disabled in the calendar and rejected on manual entry.
min?: string;
@@ -34,6 +36,7 @@ export function CustomDatePicker({
style = {},
compact = false,
borderless = false,
disabled = false,
min,
max,
}: CustomDatePickerProps) {
@@ -299,6 +302,7 @@ export function CustomDatePicker({
<button
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
aria-label={displayValue || placeholder || t('common.date')}
aria-expanded={open}
aria-haspopup="dialog"
@@ -320,7 +324,8 @@ export function CustomDatePicker({
color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
cursor: 'pointer',
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
outline: 'none',
transition: 'border-color 0.15s',
}}
@@ -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
}
+18 -4
View File
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
import { useNavigate, useLocation, useMatch } from 'react-router'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { ChevronRight, MoreHorizontal, Plus, Search } from 'lucide-react'
import { useJourneyStore } from '../../store/journeyStore'
import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { normalizeAppearance } from '@trek/shared'
import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
@@ -18,9 +19,10 @@ interface NavItem { to: string; label: string; icon: LucideIcon }
// everywhere else it creates a new trip. Pages pick the intent up from the
// query params. The result is unused on /vacay: that screen draws its own centre
// FAB, so the dock yields the slot instead (see screenFabSlot below, #1811).
function useCreateAction(): { label: string; run: () => void } {
function useCreateAction(): { label: string; run: () => void; upload?: boolean } {
const navigate = useNavigate()
const { t } = useTranslation()
const galleryOpen = useJourneyStore(state => state.mobileGalleryOpen)
const inTrip = useMatch('/trips/:id')
const inJourney = useMatch('/journey/:id')
const onJourneyList = useMatch('/journey')
@@ -40,7 +42,15 @@ function useCreateAction(): { label: string; run: () => void } {
return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) }
}
if (inJourney) {
return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) }
// Context-aware per tab, like the trip's "+": the Gallery holds photos, so
// there the one big action is uploading one. Read from sessionStorage
// because the tab is the screen's own state and the dock is a sibling —
// exactly how the trip tabs hand theirs over.
const journeyId = inJourney.params.id
if (galleryOpen) {
return { label: t('common.upload'), run: () => navigate(`/journey/${journeyId}?create=photo`), upload: true }
}
return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${journeyId}?create=entry`) }
}
if (onJourneyList) {
return { label: t('journey.new'), run: () => navigate('/journey?create=1') }
@@ -174,7 +184,11 @@ export default function MBottomNav() {
<span aria-hidden="true" className="mx-2 h-14 w-14 flex-none" />
) : (
<MFab onClick={create.run} ariaLabel={create.label} className="mx-2">
{searchFab ? <Search size={24} strokeWidth={2.4} /> : <Plus size={26} strokeWidth={2.4} />}
{searchFab
? <Search size={24} strokeWidth={2.4} />
: create.upload
? <Upload size={23} strokeWidth={2.4} />
: <Plus size={26} strokeWidth={2.4} />}
</MFab>
)}
@@ -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
@@ -7,6 +7,7 @@ import MToggle from '../../components/MToggle'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
import { journeyApi, mapsApi, weatherApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types'
import { normalizeImageFiles } from '../../../utils/convertHeic'
@@ -721,21 +722,17 @@ export default function MJourneyEntrySheet({
)}
</>}
{/* Date + Time */}
<div className="mt-3 flex gap-2">
<div className="min-w-0 flex-1">
{/* Date + Time. Split in the date's favour: a localized date needs the
room ("10. Sept. 2026"), a clock never does. */}
<div className="mt-3 grid grid-cols-[minmax(0,1fr)_104px] gap-2">
<div className="min-w-0">
<div className={`${eyebrow} mb-[5px]`}>{t('journey.editor.date')}</div>
<div className={`${fieldShell} overflow-hidden`}>
<input
type="date"
value={entryDate}
disabled={readOnly}
onChange={e => setEntryDate(e.target.value)}
className="block min-w-0 w-full box-border border-0 bg-transparent px-3 py-[10px] text-center text-[0.78125rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums]"
/>
</div>
{/* TREK's own picker, like the time field beside it: a native
`<input type="date">` paints itself from the OS locale and takes
no theme, so the two sat in one row looking like two apps. */}
<CustomDatePicker value={entryDate} onChange={setEntryDate} disabled={readOnly} />
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className={`${eyebrow} mb-[5px]`}>{t('mobileJourney.time')}</div>
{/* A native <input type="time"> paints 12h or 24h from the browser
locale, whatever the user picked in settings (#2067). The picker
@@ -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))
},
}
+11
View File
@@ -131,6 +131,15 @@ interface JourneyState {
current: JourneyDetail | null
loading: boolean
notFound: boolean
/**
* The phone's journey screen is showing its Gallery tab.
*
* Lives here because the dock's FAB is a sibling of that screen: on the
* Gallery the one big action is uploading a photo, not adding an entry, and a
* sibling cannot read another component's state any other way.
*/
mobileGalleryOpen: boolean
setMobileGalleryOpen: (open: boolean) => void
loadJourneys: () => Promise<void>
loadJourney: (id: number) => Promise<void>
@@ -180,6 +189,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
current: null,
loading: false,
notFound: false,
mobileGalleryOpen: false,
setMobileGalleryOpen: (open: boolean) => set({ mobileGalleryOpen: open }),
loadJourneys: async () => {
set({ loading: true })
+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']);
@@ -284,7 +284,20 @@ function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) {
}
const multiFileInput = () => document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
const dateField = () => document.querySelector('input[type="date"]') as HTMLInputElement;
/**
* The date is TREK's own picker (#2067 follow-up): a trigger button whose
* accessible name is the date it shows, plus a keyboard button that swaps it for
* a DD.MM.YYYY input. `input[type="date"]` is gone — it painted itself from the
* OS locale and took no theme.
*/
const dateTrigger = () =>
document.querySelector('[aria-haspopup="dialog"]') as HTMLButtonElement;
/** What the trigger shows, as the picker formats it: "18 Mar 2026". */
const shownDate = (iso: string) =>
new Date(iso + 'T00:00:00Z').toLocaleDateString('en', {
day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC',
});
// The time field is CustomTimePicker's text input, not a native time input — a
// native one paints 12h/24h from the browser locale and ignored the setting (#2067).
const timeField = () => document.querySelector('input[placeholder="00:00"], input[placeholder="2:30 PM"]') as HTMLInputElement;
@@ -330,7 +343,7 @@ describe('MJourneyEntrySheet full editor', () => {
expect(screen.getByText('Weather')).toBeInTheDocument();
expect(screen.getByText('Tags')).toBeInTheDocument();
// An empty entry_date falls back to today (the LOCAL date), an empty gallery locks the picker.
expect(dateField().value).toBe(localIsoDate());
expect(dateTrigger()).toHaveAccessibleName(shownDate(localIsoDate()));
expect(timeField()).toHaveValue('');
expect(screen.getByRole('button', { name: 'From Gallery' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Upload photos' })).toBeEnabled();
@@ -372,7 +385,14 @@ describe('MJourneyEntrySheet full editor', () => {
await user.type(screen.getByPlaceholderText('Give this moment a name...'), 'Rome');
await user.type(screen.getByPlaceholderText('Write your story...'), 'Great day');
fireEvent.change(dateField(), { target: { value: '2026-03-18' } });
// Through the picker's manual entry — the calendar's own path would depend
// on which month it opens on.
// The keyboard button and the input it opens share a name — they never
// exist at the same time, so the same query reaches first one, then the other.
fireEvent.click(screen.getByLabelText('Enter date manually'));
const manual = screen.getByLabelText('Enter date manually');
fireEvent.change(manual, { target: { value: '18.03.2026' } });
fireEvent.keyDown(manual, { key: 'Enter' });
fireEvent.change(timeField(), { target: { value: '18:45' } });
const [addPro, addCon] = screen.getAllByRole('button', { name: /Add another/ });
@@ -905,7 +925,7 @@ describe('MJourneyEntrySheet read-only', () => {
expect(screen.getByDisplayValue('Gelato')).toHaveAttribute('readonly');
expect(screen.getByDisplayValue('Queues')).toHaveAttribute('readonly');
expect(screen.getByDisplayValue('Rome')).toHaveAttribute('readonly');
expect(dateField()).toBeDisabled();
expect(dateTrigger()).toBeDisabled();
expect(timeField()).toBeDisabled();
expect(screen.getByRole('button', { name: 'Good' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Sunny' })).toBeDisabled();
+16
View File
@@ -530,6 +530,22 @@ async function main() {
}
}
// --- dawarich_connections: api_key ---
// Its own table rather than a users column, so it needs its own block. The
// table arrived with a later migration, hence the existence check: an older
// database simply has nothing to rotate here.
if (tableExists('dawarich_connections')) {
const connections = db
.prepare('SELECT user_id, api_key FROM dawarich_connections WHERE api_key IS NOT NULL')
.all() as { user_id: number; api_key: string }[];
for (const row of connections) {
const newVal = migrateApiKeyValue(row.api_key, `dawarich_connections[${row.user_id}].api_key`);
if (newVal !== null) {
db.prepare('UPDATE dawarich_connections SET api_key = ? WHERE user_id = ?').run(newVal, row.user_id);
}
}
}
// --- trek_photos: passphrase ---
const photos = db.prepare('SELECT id, passphrase FROM trek_photos WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
for (const row of photos) {
+2
View File
@@ -8,6 +8,7 @@ export const ADDON_IDS = {
COLLAB: 'collab',
JOURNEY: 'journey',
AIRTRAIL: 'airtrail',
DAWARICH: 'dawarich',
LLM_PARSING: 'llm_parsing',
COLLECTIONS: 'collections',
ROADTRIP: 'roadtrip',
@@ -40,6 +41,7 @@ export const MCP_GATED_ADDON_IDS: readonly AddonId[] = [
ADDON_IDS.BUDGET,
ADDON_IDS.COLLAB,
ADDON_IDS.COLLECTIONS,
ADDON_IDS.DAWARICH,
ADDON_IDS.JOURNEY,
ADDON_IDS.PACKING,
ADDON_IDS.ROADTRIP,
+111
View File
@@ -4620,6 +4620,117 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE places ADD COLUMN amap_poi_id TEXT');
}
},
// Dawarich connection (#2279). Its own table rather than more `users` columns,
// which is where the AirTrail connection lives: that was the right call while
// AirTrail was the only integration of this shape, and this is the second. It
// also carries sync state (cursor, last error, probed capabilities) that has no
// business sitting on the identity row. Credentials for a personal location
// archive stay strictly apart from anything shared on a trip.
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS dawarich_connections (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
url TEXT,
api_key TEXT,
allow_insecure_tls INTEGER NOT NULL DEFAULT 0,
sync_enabled INTEGER NOT NULL DEFAULT 1,
last_sync_at TEXT,
last_sync_state TEXT NOT NULL DEFAULT 'never',
last_sync_error TEXT,
capabilities TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
},
// Reviewed visit suggestions (#2279). One row per (user, Dawarich visit id) —
// the UNIQUE is what makes a repeated sync idempotent instead of duplicating
// everything it already imported.
//
// `source_hash` exists because a Dawarich visit carries no `updated_at`: a
// rename or a re-detection is only visible as a different hash of the fields
// TREK shows. `source_missing_at` exists because deleting a visit removes it
// from the API rather than tombstoning it, so absence from a full re-read of
// the same window is the only signal — and it is recorded, not acted on,
// because an entry the user already accepted and edited is theirs now.
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS dawarich_visit_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
source_visit_id TEXT NOT NULL,
trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL,
name TEXT NOT NULL,
lat REAL,
lng REAL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
duration_minutes INTEGER NOT NULL DEFAULT 0,
local_date TEXT NOT NULL,
source_status TEXT NOT NULL DEFAULT 'suggested',
confidence REAL,
confidence_band TEXT,
country_code TEXT,
state TEXT NOT NULL DEFAULT 'new',
target TEXT,
accepted_place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
accepted_journal_entry_id INTEGER,
accepted_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL,
matched_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL,
source_hash TEXT NOT NULL,
accepted_hash TEXT,
source_missing_at TEXT,
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, source_visit_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_user_state ON dawarich_visit_suggestions(user_id, state)');
db.exec('CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_trip ON dawarich_visit_suggestions(trip_id)');
},
// Bucket-list entries can now be ticked off (#2279). `visited_source` records
// who decided — a hand-ticked wish and one confirmed from a recording read the
// same on the map otherwise, and re-running a scan must not touch the first.
() => {
const hasVisitedAt = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'visited_at'").get();
if (!hasVisitedAt) db.exec('ALTER TABLE bucket_list ADD COLUMN visited_at TEXT');
const hasVisitedSource = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'visited_source'").get();
if (!hasVisitedSource) db.exec('ALTER TABLE bucket_list ADD COLUMN visited_source TEXT');
},
// Where an Atlas country came from (#2279). Everything that exists today was
// marked by hand, so 'manual' is the correct backfill rather than a guess;
// countries confirmed out of a recording are written as 'dawarich' and the
// Atlas can say so. Unmarking still deletes the row either way.
() => {
const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('visited_countries') WHERE name = 'source'").get();
if (!hasSource) db.exec("ALTER TABLE visited_countries ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'");
},
// Per-key read scopes for /api/v1 (#2279). The `include` parameter picks which
// sections a *response* carries; it has never restricted what a key may read,
// and handing an integration a key that reads every trip because it wanted day
// notes is the thing that needed fixing.
//
// `scope_mode` is an explicit flag rather than "NULL means everything": a
// sentinel here would mean a bug that drops the scopes column silently grants
// full access. Every existing key is 'all', so nothing that works today stops
// working — the restriction is opt-in at mint time.
() => {
const hasMode = db.prepare("SELECT 1 FROM pragma_table_info('mcp_tokens') WHERE name = 'scope_mode'").get();
if (!hasMode) db.exec("ALTER TABLE mcp_tokens ADD COLUMN scope_mode TEXT NOT NULL DEFAULT 'all'");
const hasScopes = db.prepare("SELECT 1 FROM pragma_table_info('mcp_tokens') WHERE name = 'api_scopes'").get();
if (!hasScopes) db.exec('ALTER TABLE mcp_tokens ADD COLUMN api_scopes TEXT');
},
// Where a place came from, when it did not come from a person typing it
// (#2279). Only 'dawarich' writes it today; NULL is every place anyone has
// ever added by hand, which is what it should stay.
//
// A column rather than a lookup through dawarich_visit_suggestions: the mark
// has to survive the suggestion being deleted, and a place list would
// otherwise join an integration's table to render a name.
() => {
const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('places') WHERE name = 'source'").get();
if (!hasSource) db.exec('ALTER TABLE places ADD COLUMN source TEXT');
},
];
if (currentVersion < migrations.length) {
+1
View File
@@ -123,6 +123,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'roadtrip', name: 'Road trip', description: 'Drives with stops along the route, driving times, and arrival times that update themselves', type: 'trip', icon: 'Route', enabled: 0, sort_order: 7 },
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
{ id: 'dawarich', name: 'Dawarich', description: 'Read visits and recorded routes from your Dawarich instance — suggested journal entries, places and countries you confirm yourself', type: 'integration', icon: 'Dawarich', enabled: 0, sort_order: 17 },
{ id: 'llm_parsing', name: 'AI Parsing', description: 'LLM fallback for booking imports kitinerary cannot read', type: 'integration', icon: 'Sparkles', enabled: 0, sort_order: 15 },
{ id: 'collections', name: 'Collections', description: 'Personal place library — save places across trips into named lists, copy into any trip, share with others', type: 'global', icon: 'Bookmark', enabled: 0, sort_order: 16 },
];
+5 -3
View File
@@ -548,13 +548,15 @@ export class AdminService {
// to the operator, so there is no instance decision left to make — not even
// whether it runs.
//
// AirTrail: the addon exists to reach a server the admin runs themselves,
// and a managed instance has no route to one.
// AirTrail and Dawarich: both addons exist to reach a server the admin
// runs themselves, and a managed instance has no route to one.
.filter(
(a) =>
!(
readEnv().managed.enabled &&
(a.id === ADDON_IDS.LLM_PARSING || a.id === ADDON_IDS.AIRTRAIL)
(a.id === ADDON_IDS.LLM_PARSING ||
a.id === ADDON_IDS.AIRTRAIL ||
a.id === ADDON_IDS.DAWARICH)
),
);
const providers = this.db.all<{
+2 -1
View File
@@ -44,6 +44,7 @@ import { FilesModule } from './files/files.module';
import { PhotosModule } from './photos/photos.module';
import { MemoriesModule } from './memories/memories.module';
import { AirtrailModule } from './integrations/airtrail.module';
import { DawarichModule } from './integrations/dawarich.module';
import { JourneyModule } from './journey/journey.module';
import { CollectionsModule } from './collections/collections.module';
import { ShareModule } from './share/share.module';
@@ -80,7 +81,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
+57 -14
View File
@@ -82,6 +82,19 @@ function blankToNull(value: string | null | undefined): string | null {
return value === undefined || value === null || value === '' ? null : value;
}
/**
* Who ticked a country off, for the detail sheet.
*
* Null when there is no row at all — a country derived from a trip's places was
* never marked by anyone, and reporting 'manual' for it would claim a decision
* nobody made. A row written before the column existed defaults to 'manual',
* which is exactly what it was.
*/
function markedSource(row: { source?: string | null } | undefined): string | null {
if (!row) return null;
return row.source || 'manual';
}
/**
* DI-native atlas domain service — the legacy services/atlasService SQL
* (stats aggregation, visited countries/regions with the #1490 tombstones,
@@ -469,10 +482,17 @@ export class AtlasService {
if (tripIds.length === 0) {
// Post-fold quirk fix: the legacy early return hardcoded manually_marked
// false, so a trip-less user's manually marked country read as unmarked.
const marked = !!this.db
.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?')
.get(userId, code);
return { places: [], trips: [], manually_marked: marked, status: marked ? 'visited' : 'idea' };
const row = this.db
.prepare('SELECT source FROM visited_countries WHERE user_id = ? AND country_code = ?')
.get(userId, code) as { source?: string | null } | undefined;
const marked = !!row;
return {
places: [],
trips: [],
manually_marked: marked,
marked_source: markedSource(row),
status: marked ? 'visited' : 'idea',
};
}
const places = this.getPlacesForTrips(tripIds);
@@ -506,9 +526,10 @@ export class AtlasService {
.filter((t) => matchingTripIds.has(t.id))
.map((t) => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date }));
const isManuallyMarked = !!this.db
.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?')
.get(userId, code);
const markRow = this.db
.prepare('SELECT source FROM visited_countries WHERE user_id = ? AND country_code = ?')
.get(userId, code) as { source?: string | null } | undefined;
const isManuallyMarked = !!markRow;
// Take the status from the same trip classification stats() uses rather than deriving
// it again here — the detail sheet and the map must agree on what this country is.
@@ -517,15 +538,24 @@ export class AtlasService {
for (const id of matchingTripIds) status = strongerVisitStatus(status, tripStatus.get(id) ?? 'idea');
if (isManuallyMarked) status = 'visited';
return { places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked, status };
return {
places: matchingPlaces,
trips: matchingTrips,
manually_marked: isManuallyMarked,
// Where the tick came from, so the sheet can say "confirmed from your
// Dawarich recordings" instead of implying the user typed it in. Null when
// the country is only derived from trips and carries no row at all.
marked_source: markedSource(markRow),
status,
};
}
// ── Mark / unmark country ─────────────────────────────────────────────────
listVisitedCountries(userId: number): { country_code: string; created_at: string }[] {
listVisitedCountries(userId: number): { country_code: string; created_at: string; source: string }[] {
return this.db
.prepare('SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC')
.all(userId) as { country_code: string; created_at: string }[];
.prepare('SELECT country_code, created_at, source FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC')
.all(userId) as { country_code: string; created_at: string; source: string }[];
}
/** Countries the user explicitly removed, which stats() must not re-derive (#1490). */
@@ -536,11 +566,24 @@ export class AtlasService {
return new Set(rows.map((r) => r.country_code));
}
markCountry(userId: number, code: string): void {
this.db.transaction(() => {
this.db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code);
/**
* `source` records who decided. It defaults to 'manual' because that is what
* every mark was until an integration could make one, and `INSERT OR IGNORE`
* means a country already ticked by hand keeps that provenance — confirming
* it again from a recording does not relabel somebody's own work as imported.
*/
/**
* Returns whether this actually added the country, so a caller reporting
* "3 countries added" is not counting the ones that were already there.
*/
markCountry(userId: number, code: string, source: 'manual' | 'dawarich' = 'manual'): boolean {
return this.db.transaction(() => {
const inserted = this.db
.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code, source) VALUES (?, ?, ?)')
.run(userId, code, source).changes > 0;
// Marking it visited again lifts a previous removal.
this.db.prepare('DELETE FROM hidden_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
return inserted;
});
}
@@ -3,7 +3,7 @@ import type { Request } from 'express';
import type { PublicApiStats } from '@trek/shared';
import { AtlasService } from './atlas.service';
import { ApiTokenGuard } from '../public-api/api-token.guard';
import { enforcePublicApiRateLimit, requireUserId } from '../public-api/public-api-request';
import { enforcePublicApiRateLimit, requireScope, requireUserId } from '../public-api/public-api-request';
import { RateLimitService } from '../common/rate-limit.service';
/**
@@ -30,9 +30,12 @@ import { RateLimitService } from '../common/rate-limit.service';
* instead. TravelStatsController next door already does exactly this for
* `/api/auth/travel-stats`, and for the same reason.
*
* What crosses the boundary is only the request handling: the guard and the shared
* rate-limit bucket, so an integration polling `/trips` and `/stats` spends one
* budget rather than two.
* What crosses the boundary is only the request handling: the guard, the shared
* rate-limit bucket (so an integration polling `/trips` and `/stats` spends one
* budget rather than two) and the key's read scopes. The scope check is not
* optional here: this route aggregates trips, countries, cities and flown
* distance, which is precisely the reach a narrowed key exists to withhold, and
* enforcing scopes only in public-api/ would leave the widest answer open.
*/
@Controller('api/v1')
@UseGuards(ApiTokenGuard)
@@ -45,6 +48,7 @@ export class PublicStatsController {
@Get('stats')
stats(@Req() req: Request): PublicApiStats {
enforcePublicApiRateLimit(this.rl, req);
requireScope(req, 'stats');
const userId = requireUserId(req);
const travel = this.atlas.getTravelStats(userId);
+6 -2
View File
@@ -33,6 +33,7 @@ import {
MfaEnableDto,
MfaDisableDto,
McpTokenCreateDto,
ApiTokenCreateDto,
ResourceTokenDto,
} from './auth.dto';
import { RateLimitService } from '../common/rate-limit.service';
@@ -340,9 +341,12 @@ export class AuthController {
@Post('api-tokens')
@HttpCode(201)
createApiToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) {
this.limit('login', req, 5);
const result = this.tokens.createApiToken(user.id, body.name);
// No `scopes` means the key reads everything, which is what every key minted
// before this field existed does. Narrowing stays opt-in so the change
// cannot break an integration that is already running.
const result = this.tokens.createApiToken(user.id, body.name, body.scopes);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
+7
View File
@@ -9,6 +9,7 @@ import {
mfaEnableRequestSchema,
mfaDisableRequestSchema,
mcpTokenCreateRequestSchema,
apiTokenCreateRequestSchema,
mapsKeyUpdateRequestSchema,
apiKeysUpdateRequestSchema,
settingsUpdateRequestSchema,
@@ -38,6 +39,12 @@ export class MfaVerifyLoginDto extends createZodDto(mfaVerifyLoginRequestSchema)
export class MfaEnableDto extends createZodDto(mfaEnableRequestSchema) {}
export class MfaDisableDto extends createZodDto(mfaDisableRequestSchema) {}
export class McpTokenCreateDto extends createZodDto(mcpTokenCreateRequestSchema) {}
/**
* Its own DTO rather than a field added to the one above: both create routes
* share that schema, and widening it would make POST /api/auth/mcp-tokens
* quietly accept read scopes that an MCP token has no way to honour.
*/
export class ApiTokenCreateDto extends createZodDto(apiTokenCreateRequestSchema) {}
export class MapsKeyUpdateDto extends createZodDto(mapsKeyUpdateRequestSchema) {}
export class ApiKeysUpdateDto extends createZodDto(apiKeysUpdateRequestSchema) {}
export class SettingsUpdateDto extends createZodDto(settingsUpdateRequestSchema) {}
@@ -0,0 +1,734 @@
import { Injectable } from '@nestjs/common';
import {
DAWARICH_BUCKET_MATCH_MIN_MINUTES,
DAWARICH_BUCKET_MATCH_RADIUS_M,
DAWARICH_BUCKET_SCAN_LIMIT,
type DawarichAccept,
type DawarichAcceptResult,
type DawarichAtlasCountry,
type DawarichAtlasSuggestions,
type DawarichBucketMatch,
type DawarichBucketScan,
type DawarichSuggestion,
type DawarichSuggestionList,
} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { AtlasService } from '../atlas/atlas.service';
import { PlacesService } from '../places/places.service';
import { AssignmentsService } from '../assignments/assignments.service';
import { PermissionsService } from '../permissions/permissions.service';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { NAME_TO_CODE } from '../atlas/atlas-geo';
import { DawarichClient, type DawarichCreds } from './dawarich.client';
import { DawarichService } from './dawarich.service';
import { minutesBetween, toNumber } from './dawarich.helpers';
/**
* What a user does with the stays a sync brought in.
*
* Everything here is an explicit act by the person the suggestion belongs to.
* That is the whole point of the split: the sync may look at a shared trip, but
* only this service is ever allowed to write to one, and only because somebody
* pressed something.
*
* Three destinations, deliberately not one:
* - **a place on the trip**, for a stay that was a stop worth planning around;
* - **a journal entry**, for one that was a moment worth writing about;
* - **a bucket-list tick**, for one that fulfilled a wish.
* They are separate because accepting into the wrong one is a mess to undo, and
* a single "import" button would have to guess which was meant.
*
* The trip UI offers the first two. A wish belongs to the Atlas — it is reached
* once, and it is confirmed there, against the whole wishlist at once — so the
* third is reached from the Atlas and from MCP, not from a row in a trip's rail.
*/
@Injectable()
export class DawarichSuggestionsService {
constructor(
private readonly db: DatabaseService,
private readonly dawarich: DawarichService,
private readonly client: DawarichClient,
private readonly atlas: AtlasService,
private readonly places: PlacesService,
private readonly assignments: AssignmentsService,
private readonly permissions: PermissionsService,
private readonly journey: JourneyDomainService,
) {}
/**
* The review list, newest stay first.
*
* Scoped by `user_id` in SQL rather than filtered afterwards: these rows are
* someone's location history, and a forgotten filter over a wider read is how
* that leaks.
*/
list(userId: number, filter: { tripId?: number; state?: string }): DawarichSuggestionList {
this.reopenOrphaned(userId);
const clauses = ['s.user_id = ?'];
const params: unknown[] = [userId];
if (filter.tripId !== undefined) {
clauses.push('s.trip_id = ?');
params.push(filter.tripId);
}
if (filter.state) {
clauses.push('s.state = ?');
params.push(filter.state);
}
const rows = this.db.all<SuggestionJoinRow>(
`SELECT s.*, t.title AS trip_title, b.name AS matched_bucket_name
FROM dawarich_visit_suggestions s
LEFT JOIN trips t ON t.id = s.trip_id
LEFT JOIN bucket_list b ON b.id = s.matched_bucket_list_item_id
WHERE ${clauses.join(' AND ')}
ORDER BY s.started_at DESC, s.id DESC`,
...params,
);
const connection = this.dawarich.getConnection(userId);
return {
suggestions: rows.map(toWire),
connected: connection.connected,
lastSyncAt: connection.lastSyncAt,
lastSyncState: connection.lastSyncState,
lastSyncError: connection.lastSyncError,
};
}
/**
* Put back into review every acceptance whose result no longer exists.
*
* Deleting the place an acceptance created is how somebody says "not that
* one" after the fact, and the stay then has nothing to show for itself: it
* is unhandled again, and leaving it in the handled list is a dead end — the
* row points at a place that is gone and accepting it again is refused as a
* duplicate.
*
* The place and bucket columns are `ON DELETE SET NULL`, so a NULL beside a
* target that names them IS the deletion. Journey entries are written by
* another domain without a foreign key, so that one is asked directly.
*
* A write on a read path, deliberately: the alternative is computing the state
* at read time, which would make the list say "back in review" while `accept`
* still refused the row as already accepted.
*/
private reopenOrphaned(userId: number): void {
this.db.run(
`UPDATE dawarich_visit_suggestions
SET state = 'new', target = NULL, accepted_hash = NULL,
accepted_place_id = NULL, accepted_journal_entry_id = NULL,
accepted_bucket_list_item_id = NULL
WHERE user_id = ? AND state = 'accepted'
AND (
(target = 'place' AND accepted_place_id IS NULL)
OR (target = 'bucket_list' AND accepted_bucket_list_item_id IS NULL)
OR (target = 'journal' AND (
accepted_journal_entry_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM journey_entries je WHERE je.id = accepted_journal_entry_id
)
))
)`,
userId,
);
}
getOne(userId: number, id: number): DawarichSuggestion | null {
const row = this.db.get<SuggestionJoinRow>(
`SELECT s.*, t.title AS trip_title, b.name AS matched_bucket_name
FROM dawarich_visit_suggestions s
LEFT JOIN trips t ON t.id = s.trip_id
LEFT JOIN bucket_list b ON b.id = s.matched_bucket_list_item_id
WHERE s.id = ? AND s.user_id = ?`,
id,
userId,
);
return row ? toWire(row) : null;
}
/**
* Put a suggestion back into review, or take it out of the list.
*
* Reversible on purpose: dismissing is the cheap action people take to clear
* a backlog, and the one they regret. Restoring an *accepted* suggestion is
* not offered — the place or entry it produced is a separate thing now, and
* "undo" would have to guess whether to delete it.
*/
setState(userId: number, id: number, state: 'new' | 'dismissed'): DawarichSuggestion | null {
const row = this.db.get<{ id: number; state: string }>(
'SELECT id, state FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?',
id,
userId,
);
if (!row) return null;
if (row.state === 'accepted') return this.getOne(userId, id);
this.db.run('UPDATE dawarich_visit_suggestions SET state = ? WHERE id = ?', state, id);
return this.getOne(userId, id);
}
/**
* Accept a suggestion into TREK.
*
* Throws `AcceptError` rather than an HttpException so the controller, the
* MCP tool and any future caller each shape the failure their own way — the
* atlas domain's `BucketItemExistsError` is the precedent.
*/
accept(userId: number, id: number, body: DawarichAccept, sid?: string): DawarichAcceptResult {
const row = this.db.get<SuggestionRow>(
'SELECT * FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?',
id,
userId,
);
if (!row) throw new AcceptError('not_found', 'Suggestion not found', 404);
if (row.state === 'accepted') throw new AcceptError('already_accepted', 'Suggestion has already been accepted', 409);
switch (body.target) {
case 'place':
return this.acceptAsPlace(userId, row, body, sid);
case 'journal':
return this.acceptAsJournalEntry(userId, row, body, sid);
case 'bucket_list':
return this.acceptAsBucketTick(userId, row, body);
}
}
/**
* A stay becomes a place on a trip, optionally pinned to one of its days.
*
* Every referenced id is verified against the trip before anything is
* written: permission to touch the trip is not permission to attach a place
* to somebody else's day.
*/
private acceptAsPlace(
userId: number,
row: SuggestionRow,
body: DawarichAccept,
sid?: string,
): DawarichAcceptResult {
const tripId = body.tripId ?? row.trip_id;
if (!tripId) throw new AcceptError('trip_required', 'A trip is required to create a place', 400);
const access = this.db.canAccessTrip(tripId, userId);
if (!access) throw new AcceptError('not_found', 'Trip not found', 404);
// Being on the roster is not permission to write to it. Every other way of
// putting a place on a trip asks `place_edit` (and `day_edit` for the
// assignment), and on an instance where an admin narrowed either to the
// owner, a member accepting a stay must be refused exactly as they would be
// in the planner. Asked here rather than in the controller so the MCP tool
// cannot take a different route to the same write.
this.requirePermission('place_edit', access.user_id, userId);
if (body.dayId !== undefined) {
this.requirePermission('day_edit', access.user_id, userId);
if (!this.assignments.dayExists(body.dayId, tripId)) {
throw new AcceptError('day_not_on_trip', 'Day does not belong to this trip', 400);
}
}
const lat = body.lat ?? row.lat ?? undefined;
const lng = body.lng ?? row.lng ?? undefined;
// One transaction for the four writes an acceptance makes. Half of it —
// a place with no assignment, or a place nobody recorded as accepted — is
// worse than none: the stay would come back as unhandled while the place it
// already produced sat on the trip.
const { created, placeId, assignment } = this.db.transaction(() => {
const place = this.places.create(String(tripId), {
name: body.name?.trim() || row.name,
lat,
lng,
notes: body.notes ?? null,
place_time: body.time ?? timeOf(row.started_at),
end_time: body.endTime ?? timeOf(row.ended_at),
duration_minutes: row.duration_minutes > 0 ? row.duration_minutes : undefined,
} as Parameters<PlacesService['create']>[1]);
const id = Number((place as { id: number }).id);
// Stamped here rather than passed through PlacesService.create: `source`
// is not part of the create contract and should not become something a
// request body can set — a place claims to come from a recording only
// because this path made it.
this.db.run("UPDATE places SET source = 'dawarich' WHERE id = ?", id);
const day = body.dayId !== undefined ? this.assignments.createAssignment(body.dayId, id, null) : undefined;
this.markAccepted(row.id, 'place', { placeId: id });
// Re-read, so what goes out on the wire carries the source: everyone else
// on the trip should see the Dawarich mark on it too, not only the person
// who accepted it.
return { created: this.db.getPlaceWithTags(id) ?? place, placeId: id, assignment: day };
});
// Outside the transaction, because both reach past the database: a
// broadcast of a write that then rolls back is a lie every other client
// believes, and the journey mirror writes on its own.
//
// The same two side effects the places controller performs, because this IS
// a place somebody added to the trip. Skipping them is why an accepted stay
// used to need a page reload to show up.
this.places.broadcast(String(tripId), 'place:created', { place: created }, sid);
this.places.onCreated(String(tripId), placeId);
if (assignment) {
this.assignments.broadcast(String(tripId), 'assignment:created', { assignment }, sid);
}
return {
suggestion: this.getOne(userId, row.id)!,
createdPlaceId: placeId,
createdJournalEntryId: null,
bucketListItemId: null,
};
}
/**
* A stay becomes a dated journal entry.
*
* The date comes from the stay's own local date unless corrected, and the
* arrival time fills the entry's time — the two fields a person would
* otherwise copy off the suggestion by hand.
*/
private acceptAsJournalEntry(
userId: number,
row: SuggestionRow,
body: DawarichAccept,
sid?: string,
): DawarichAcceptResult {
if (body.journalId === undefined) {
throw new AcceptError('journal_required', 'A journey is required to create an entry', 400);
}
// canEdit is the journey domain's own answer, and createEntry asks it again.
// Asking here too is what turns "silently did nothing" into a 403.
if (!this.journey.canEdit(body.journalId, userId)) {
throw new AcceptError('journal_forbidden', 'Journey not found', 404);
}
// The entry and the record that it was written commit together. Apart, a
// failure between them leaves an entry in the journal with the stay still
// listed as unhandled — and the next acceptance writes a second copy of it.
//
// The journey domain broadcasts the new entry from inside `createEntry`, so
// that one notification can still outrun a rollback. Moving it out means
// changing that domain's contract, which this integration has no business
// doing; the write itself is what had to stop being two.
const entry = this.db.transaction(() => {
const created = this.journey.createEntry(
body.journalId,
userId,
{
type: 'entry',
title: body.name?.trim() || row.name,
story: body.notes ?? undefined,
entry_date: body.date ?? row.local_date,
entry_time: body.time ?? timeOf(row.started_at) ?? undefined,
location_name: body.name?.trim() || row.name,
location_lat: body.lat ?? row.lat ?? undefined,
location_lng: body.lng ?? row.lng ?? undefined,
},
sid,
);
if (!created) throw new AcceptError('journal_forbidden', 'Journey not found', 404);
this.markAccepted(row.id, 'journal', { journalEntryId: created.id });
return created;
});
return {
suggestion: this.getOne(userId, row.id)!,
createdPlaceId: null,
createdJournalEntryId: entry.id,
bucketListItemId: null,
};
}
/** A stay ticks off a wish. The wish must be the caller's own. */
private acceptAsBucketTick(userId: number, row: SuggestionRow, body: DawarichAccept): DawarichAcceptResult {
const itemId = body.bucketListItemId ?? row.matched_bucket_list_item_id;
if (!itemId) {
throw new AcceptError('bucket_required', 'A bucket-list entry is required', 400);
}
const item = this.db.get<{ id: number }>(
'SELECT id FROM bucket_list WHERE id = ? AND user_id = ?',
itemId,
userId,
);
if (!item) throw new AcceptError('not_found', 'Bucket-list entry not found', 404);
this.db.transaction(() => {
this.db.run(
"UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ?",
row.started_at,
itemId,
userId,
);
this.markAccepted(row.id, 'bucket_list', { bucketItemId: itemId });
});
return {
suggestion: this.getOne(userId, row.id)!,
createdPlaceId: null,
createdJournalEntryId: null,
bucketListItemId: itemId,
};
}
/**
* The instance's answer to "may this person do this on this trip".
*
* The role comes out of the users table because the only thing every caller
* has in common is a user id: the REST controller holds the full user, the MCP
* tool holds `ctx.userId`, and a permission that depended on which door
* somebody came through would not be a permission.
*/
private requirePermission(action: 'place_edit' | 'day_edit', tripOwnerId: number, userId: number): void {
const actor = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
const allowed = this.permissions.checkPermission(
action,
actor?.role ?? 'user',
tripOwnerId,
userId,
tripOwnerId !== userId,
);
if (!allowed) throw new AcceptError('forbidden', 'No permission', 403);
}
/**
* Record what the acceptance produced, and freeze the hash it was accepted at.
*
* `accepted_hash` is what makes "the source changed since you accepted this"
* answerable at all: without it there is nothing to compare the current hash
* against, and the flag would either never fire or fire forever.
*/
private markAccepted(
id: number,
target: 'place' | 'journal' | 'bucket_list',
ids: { placeId?: number; journalEntryId?: number; bucketItemId?: number },
): void {
this.db.run(
`UPDATE dawarich_visit_suggestions
SET state = 'accepted', target = ?, accepted_place_id = ?,
accepted_journal_entry_id = ?, accepted_bucket_list_item_id = ?,
accepted_hash = source_hash
WHERE id = ?`,
target,
ids.placeId ?? null,
ids.journalEntryId ?? null,
ids.bucketItemId ?? null,
id,
);
}
// ── Bucket-list scan ───────────────────────────────────────────────────────
/**
* Ask Dawarich, wish by wish, whether the user ever actually got there.
*
* This is the thorough half of the bucket-list match. The sync already links
* a wish to a stay when Dawarich's own detector happened to produce one
* nearby; this asks the recordings directly, which finds the places the
* detector never called a visit — the viewpoint you stopped at for half an
* hour on the way somewhere else.
*
* One upstream request per wish, so it is capped and explicitly triggered
* rather than run on a timer. The cap is reported, never silent: a user with
* eighty wishes must not be told their other thirty had no match.
*/
async scanBucketList(userId: number): Promise<DawarichBucketScan> {
const creds = this.dawarich.getCredentials(userId);
if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400);
const items = this.db.all<BucketRow>(
`SELECT id, name, lat, lng, visited_at
FROM bucket_list WHERE user_id = ?
ORDER BY (visited_at IS NOT NULL), created_at DESC, id DESC`,
userId,
);
const withCoords = items.filter((item) => item.lat !== null && item.lng !== null);
const skipped = items.length - withCoords.length;
const batch = withCoords.slice(0, DAWARICH_BUCKET_SCAN_LIMIT);
const matches: DawarichBucketMatch[] = [];
for (const item of batch) {
matches.push({
itemId: item.id,
name: item.name,
match: await this.bestStayNear(creds, item.lat!, item.lng!),
alreadyVisited: item.visited_at !== null,
});
}
return {
matches,
skippedWithoutCoordinates: skipped,
truncated: withCoords.length > batch.length,
fetchedAt: new Date().toISOString(),
};
}
/**
* The longest recorded stay near a coordinate that clears both thresholds.
*
* Longest rather than nearest: of two stays at the same landmark, the one
* that lasted two hours is the visit and the one that lasted four minutes is
* the bus stopping outside.
*/
private async bestStayNear(
creds: DawarichCreds,
lat: number,
lng: number,
): Promise<DawarichBucketMatch['match']> {
let stays;
try {
stays = await this.client.findVisitsNear(creds, lat, lng, DAWARICH_BUCKET_MATCH_RADIUS_M, 20);
} catch {
// One wish that could not be checked is not a failed scan; it reports as
// "no match found" and the user can run it again.
return null;
}
let best: DawarichBucketMatch['match'] = null;
for (const stay of stays) {
const details = stay.visit_details ?? null;
const minutes =
toNumber(details?.duration_minutes) ??
(details?.start_time && details?.end_time ? minutesBetween(details.start_time, details.end_time) : 0);
if (minutes < DAWARICH_BUCKET_MATCH_MIN_MINUTES) continue;
const distance = toNumber(stay.distance_meters) ?? DAWARICH_BUCKET_MATCH_RADIUS_M;
if (distance > DAWARICH_BUCKET_MATCH_RADIUS_M) continue;
// A stay with no readable timestamp at either end cannot be dated, and a
// wish ticked off on 1970-01-01 is worse than one that is not offered.
const at = details?.start_time ?? isoFromUnix(toNumber(stay.timestamp));
if (!at) continue;
if (!best || minutes > best.minutes) {
best = {
at,
minutes,
distanceMeters: Math.round(distance),
points: toNumber(stay.points_count) ?? 0,
};
}
}
return best;
}
/** Tick off wishes the user confirmed after reading a scan. */
confirmBucketVisits(userId: number, itemIds: number[], visitedAt?: string): number {
const now = new Date().toISOString();
let updated = 0;
this.db.transaction(() => {
for (const itemId of itemIds) {
// The date is the one the wish was actually reached on. A wish somebody
// got to in 2023 ticked off with today's date is a wrong entry in a list
// people keep for years, so "now" is only the answer when nothing else
// knows better: the caller's own value first, then the stay this wish
// was matched to, then the clock.
const stamp = visitedAt ?? this.matchedStayStart(userId, itemId) ?? now;
const result = this.db.run(
"UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ? AND visited_at IS NULL",
stamp,
itemId,
userId,
);
updated += result.changes;
}
});
return updated;
}
/** When the stay that claimed this wish began, if one did. */
private matchedStayStart(userId: number, itemId: number): string | null {
const row = this.db.get<{ started_at: string }>(
`SELECT started_at FROM dawarich_visit_suggestions
WHERE user_id = ? AND matched_bucket_list_item_id = ?
ORDER BY started_at DESC LIMIT 1`,
userId,
itemId,
);
return row?.started_at ?? null;
}
/** Undo a tick. Clears the source with it, so the entry reads as untouched again. */
clearBucketVisit(userId: number, itemId: number): boolean {
const result = this.db.run(
'UPDATE bucket_list SET visited_at = NULL, visited_source = NULL WHERE id = ? AND user_id = ?',
itemId,
userId,
);
return result.changes > 0;
}
// ── Atlas ──────────────────────────────────────────────────────────────────
/**
* Countries and cities the recordings put the user in, offered to the Atlas.
*
* Offered, never applied. The Atlas is a thing people curate — it has
* tombstones precisely so a country somebody removed stays removed — and a
* background job that ticked countries off would fight that. `alreadyVisited`
* marks the ones TREK already counts, so the list reads as "these are new"
* rather than as a wall of things the user has known for years.
*
* Dawarich names countries rather than coding them, so the name is mapped
* through the Atlas's own table. Anything that does not resolve is listed by
* name instead of being dropped: a country TREK cannot code is still a
* country the user went to, and silently losing it would be the worse bug.
*/
async atlasSuggestions(userId: number, from: Date, to: Date): Promise<DawarichAtlasSuggestions> {
const creds = this.dawarich.getCredentials(userId);
if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400);
const countries = await this.client.listVisitedCities(creds, from, to);
const visited = new Set(
this.db
.all<{ country_code: string }>('SELECT country_code FROM visited_countries WHERE user_id = ?', userId)
.map((r) => r.country_code.toUpperCase()),
);
const resolved: DawarichAtlasCountry[] = [];
const unresolved: string[] = [];
for (const entry of countries) {
const name = (entry?.country ?? '').trim();
if (!name) continue;
const code = NAME_TO_CODE[name.toLowerCase()];
if (!code) {
unresolved.push(name);
continue;
}
resolved.push({
countryCode: code,
sourceName: name,
cities: (entry.cities ?? []).map((city) => ({
name: city?.city ?? '',
minutes: toNumber(city?.stayed_for) ?? 0,
lastSeenAt: isoFromUnix(toNumber(city?.timestamp)),
})).filter((city) => city.name !== ''),
alreadyVisited: visited.has(code),
});
}
return { countries: resolved, unresolved, fetchedAt: new Date().toISOString() };
}
/**
* Confirm a batch of countries into the Atlas.
*
* Goes through `AtlasService.markCountry`, which is what lifts the tombstone
* a previous removal left behind and keeps the two write paths from drifting.
* `INSERT OR IGNORE` underneath means a country already marked by hand keeps
* its own provenance — confirming it again does not relabel it as imported.
*/
acceptAtlasCountries(userId: number, codes: string[]): number {
let marked = 0;
this.db.transaction(() => {
for (const raw of codes) {
const code = raw.trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) continue;
// Counted only when it was actually added: "3 countries added" must not
// include ones that were already on the map.
if (this.atlas.markCountry(userId, code, 'dawarich')) marked++;
}
});
return marked;
}
}
/** A refusal with a code the controller and the MCP tool each render their own way. */
export class AcceptError extends Error {
readonly code: string;
readonly status: number;
constructor(code: string, message: string, status: number) {
super(message);
this.name = 'AcceptError';
this.code = code;
this.status = status;
}
}
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
function timeOf(iso: string): string | null {
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : null;
}
function toWire(row: SuggestionJoinRow): DawarichSuggestion {
return {
id: row.id,
sourceVisitId: row.source_visit_id,
tripId: row.trip_id,
tripTitle: row.trip_title ?? null,
name: row.name,
lat: row.lat,
lng: row.lng,
startedAt: row.started_at,
endedAt: row.ended_at,
durationMinutes: row.duration_minutes,
localDate: row.local_date,
sourceStatus: row.source_status === 'confirmed' ? 'confirmed' : 'suggested',
confidence: row.confidence,
confidenceBand: row.confidence_band,
state: row.state === 'accepted' || row.state === 'dismissed' ? row.state : 'new',
target:
row.target === 'place' || row.target === 'journal' || row.target === 'bucket_list' ? row.target : null,
acceptedPlaceId: row.accepted_place_id,
acceptedJournalEntryId: row.accepted_journal_entry_id,
acceptedBucketListItemId: row.accepted_bucket_list_item_id,
// Only meaningful once something was accepted: before that there is no
// "what you agreed to" to have diverged from.
sourceChanged: row.accepted_hash !== null && row.accepted_hash !== row.source_hash,
sourceMissing: row.source_missing_at !== null,
matchedBucketListItemId: row.matched_bucket_list_item_id,
matchedBucketListName: row.matched_bucket_name ?? null,
countryCode: row.country_code,
firstSeenAt: row.first_seen_at,
lastSeenAt: row.last_seen_at,
};
}
/** A Unix timestamp as ISO-8601, or null when there was none to read. */
function isoFromUnix(seconds: number | null | undefined): string | null {
if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) return null;
return new Date(seconds * 1000).toISOString();
}
interface SuggestionRow {
id: number;
user_id: number;
source_visit_id: string;
trip_id: number | null;
name: string;
lat: number | null;
lng: number | null;
started_at: string;
ended_at: string;
duration_minutes: number;
local_date: string;
source_status: string;
confidence: number | null;
confidence_band: string | null;
country_code: string | null;
state: string;
target: string | null;
accepted_place_id: number | null;
accepted_journal_entry_id: number | null;
accepted_bucket_list_item_id: number | null;
matched_bucket_list_item_id: number | null;
source_hash: string;
accepted_hash: string | null;
source_missing_at: string | null;
first_seen_at: string;
last_seen_at: string;
}
interface SuggestionJoinRow extends SuggestionRow {
trip_title: string | null;
matched_bucket_name: string | null;
}
interface BucketRow {
id: number;
name: string;
lat: number | null;
lng: number | null;
visited_at: string | null;
}
@@ -0,0 +1,47 @@
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import { logInfo, logError } from '../audit/audit-log.logger';
import { DatabaseService } from '../database/database.service';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { DawarichSyncService } from './dawarich-sync.service';
/**
* Polls every connected Dawarich instance for new visits (#2279).
*
* Slower than the AirTrail job by an order of magnitude, and on purpose: a
* flight lands once, while a visit detector produces stays continuously, and
* nobody needs a stay from four minutes ago on their planning screen. Fifteen
* minutes keeps a whole afternoon's suggestions arriving in time to be useful
* without turning a self-hosted instance into a busy server.
*
* The addon gate is re-read per tick inside the service, so switching the addon
* off takes effect without a restart; the interval is read once at bootstrap,
* matching how AirTrail behaves.
*/
@Injectable()
export class DawarichSyncJob implements OnApplicationBootstrap {
constructor(
private readonly db: DatabaseService,
private readonly sync: DawarichSyncService,
private readonly registrar: CronRegistrarService,
) {}
onApplicationBootstrap(): void {
if (!this.registrar.isEnabled()) return;
const value = this.db.get<{ value: string }>(
'SELECT value FROM app_settings WHERE key = ?',
'dawarich_poll_interval_minutes',
)?.value;
const raw = Number.parseInt(value || '15', 10);
const minutes = Number.isFinite(raw) && raw >= 5 && raw <= 59 ? raw : 15;
logInfo(`Dawarich sync: scheduled every ${minutes}m`);
this.registrar.register('dawarich-sync', `*/${minutes} * * * *`, () => this.tick());
}
async tick(): Promise<void> {
try {
await this.sync.runSync();
} catch (err: unknown) {
logError(`Dawarich sync tick failed: ${err instanceof Error ? err.message : err}`);
}
}
}
@@ -0,0 +1,525 @@
import { Injectable } from '@nestjs/common';
import {
DAWARICH_SYNC_LOOKAHEAD_DAYS,
DAWARICH_SYNC_LOOKBACK_DAYS,
DAWARICH_BUCKET_MATCH_RADIUS_M,
DAWARICH_BUCKET_MATCH_MIN_MINUTES,
type DawarichSyncState,
} from '@trek/shared';
import { ADDON_IDS } from '../../addons';
import { DatabaseService } from '../database/database.service';
import { AddonsService } from '../addons/addons.service';
import { logError, logInfo } from '../audit/audit-log.logger';
import { getCountryFromCoords } from '../atlas/atlas-geo';
import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client';
import { DawarichService } from './dawarich.service';
import { distanceMeters, localDateOf, normalizeVisit, syncWindow, visitHash } from './dawarich.helpers';
/** What one user's sync produced — plus whether it ran at all. */
export interface DawarichSyncOutcome {
state: DawarichSyncState;
created: number;
updated: number;
missing: number;
/** True when a sync for this user was already in flight, so nothing was asked. */
alreadyRunning?: boolean;
}
/**
* Pulls Dawarich visits into TREK as reviewable suggestions.
*
* Three rules run through everything here, and they are the whole reason this
* is a sync rather than an import:
*
* 1. **A sync never changes shared trip content.** It writes rows into
* `dawarich_visit_suggestions`, which belong to the person whose credentials
* fetched them. A place or a journal entry appears only when that person
* accepts one, in `DawarichSuggestionsService`.
* 2. **Repeating a sync is free.** Identity is (user, Dawarich visit id) with a
* UNIQUE behind it, so the tenth run over the same window produces the same
* rows as the first.
* 3. **The source is allowed to change its mind; TREK is not allowed to act on
* that alone.** A visit whose content hash moved is flagged `source_changed`
* and one that vanished from a full re-read is flagged `source_missing` —
* both visible, neither applied. A suggestion the user already accepted and
* then edited is their text now; rewriting or deleting it because a detector
* ran again would be the integration overwriting someone's journal.
*/
@Injectable()
export class DawarichSyncService {
/**
* Module-level rather than per-call: a cron tick that overlaps the previous
* one would ask the same instance for the same window twice and race on the
* same rows.
*/
private running = false;
/**
* Who is being synced right now, cron or button.
*
* The module-level flag only guards the cron against itself. "Check now" calls
* `syncUser` directly, so without this a held-down button — or a button pressed
* while the cron is working — would ask the same instance for the same windows
* several times over, each walking up to 20 pages, and let two passes race on
* the same rows. A second request is answered with the truth: one is running.
*/
private readonly inFlight = new Set<number>();
constructor(
private readonly db: DatabaseService,
private readonly addons: AddonsService,
private readonly client: DawarichClient,
private readonly dawarich: DawarichService,
) {}
/** The addon gate, evaluated per tick so an admin toggle lands without a restart. */
syncGloballyEnabled(): boolean {
return this.addons.isAddonEnabled(ADDON_IDS.DAWARICH);
}
/** Every connected user, one after another. The cron's entry point. */
async runSync(): Promise<void> {
if (!this.syncGloballyEnabled()) return;
if (this.running) return;
this.running = true;
try {
const userIds = this.dawarich.listSyncableUserIds();
for (const userId of userIds) {
try {
await this.syncUser(userId);
} catch (err) {
// One unreachable instance must not stop the others.
logError(`Dawarich sync failed for user ${userId}: ${err instanceof Error ? err.message : err}`);
}
}
} finally {
this.running = false;
}
}
/**
* One user's trips.
*
* The result is deliberately three-valued. `partial` is the interesting one:
* some windows came back and some did not, which is what a flaky instance or
* a very long trip actually looks like, and calling that either "ok" or
* "failed" would mislead the person reading the connection card.
*/
async syncUser(userId: number): Promise<DawarichSyncOutcome> {
if (this.inFlight.has(userId)) {
// Not a failure and not a fresh result: the run that is already going will
// record its own. Reporting the state the connection currently holds keeps
// the card honest, and `alreadyRunning` lets the caller say so.
const current = this.db.get<{ last_sync_state: string }>(
'SELECT last_sync_state FROM dawarich_connections WHERE user_id = ?',
userId,
);
return {
state: (current?.last_sync_state as DawarichSyncState) ?? 'never',
created: 0,
updated: 0,
missing: 0,
alreadyRunning: true,
};
}
this.inFlight.add(userId);
try {
return await this.syncUserOnce(userId);
} finally {
this.inFlight.delete(userId);
}
}
/** The body of a sync, with the guard above already held. */
private async syncUserOnce(userId: number): Promise<DawarichSyncOutcome> {
if (!this.syncGloballyEnabled()) {
this.dawarich.recordSyncResult(userId, 'failed', 'addon_disabled');
return { state: 'failed', created: 0, updated: 0, missing: 0 };
}
const creds = this.dawarich.getCredentials(userId);
if (!creds) {
this.dawarich.recordSyncResult(userId, 'failed', 'not_connected');
return { state: 'failed', created: 0, updated: 0, missing: 0 };
}
const trips = this.listTripsToSync(userId);
if (trips.length === 0) {
// Nothing to ask about is a successful sync, not a failure — otherwise a
// user with no dated trips sees a permanent red badge for doing nothing wrong.
this.dawarich.recordSyncResult(userId, 'ok', null);
return { state: 'ok', created: 0, updated: 0, missing: 0 };
}
let created = 0;
let updated = 0;
let missing = 0;
let failures = 0;
let lastError: string | null = null;
const now = new Date();
for (const trip of trips) {
const window = syncWindow(
trip.start_date,
trip.end_date,
now,
DAWARICH_SYNC_LOOKBACK_DAYS,
DAWARICH_SYNC_LOOKAHEAD_DAYS,
);
if (!window) continue;
try {
const result = await this.syncTripWindow(userId, trip.id, creds, window.from, window.to);
created += result.created;
updated += result.updated;
missing += result.missing;
} catch (err) {
failures++;
lastError = err instanceof DawarichError ? err.code : 'unreachable';
}
}
const state: DawarichSyncState =
failures === 0 ? 'ok' : failures === trips.length ? 'failed' : 'partial';
this.dawarich.recordSyncResult(userId, state, state === 'ok' ? null : lastError);
// The probe is cheap next to the windows just fetched, and re-running it is
// how a Dawarich upgrade that finally ships `updated_at` starts being used
// without anyone reconnecting.
if (state !== 'failed') {
try {
this.dawarich.storeCapabilities(userId, await this.dawarich.probeCapabilities(creds));
} catch {
// Capabilities are an optimisation; a failed probe is not a failed sync.
}
}
if (created || updated || missing) {
logInfo(`Dawarich sync for user ${userId}: ${created} new, ${updated} changed, ${missing} gone`);
}
return { state, created, updated, missing };
}
/**
* Trips worth asking about: the ones the user owns or is on, not archived,
* with a start date, and either still running or recent enough that a
* recording could still arrive.
*
* A trip five years past is not re-polled every five minutes. The cut-off is
* generous rather than tight, because someone who connects Dawarich for the
* first time wants their last holiday filled in, not just today's.
*/
private listTripsToSync(userId: number): TripRow[] {
return this.db.all<TripRow>(
`SELECT DISTINCT t.id, t.start_date, t.end_date
FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE (t.user_id = ? OR m.user_id IS NOT NULL)
AND COALESCE(t.is_archived, 0) = 0
AND t.start_date IS NOT NULL
AND (t.end_date IS NULL OR t.end_date >= date('now', '-400 days'))
AND t.start_date <= date('now', '+1 day')
ORDER BY t.start_date DESC`,
userId,
userId,
);
}
/**
* One trip's window: fetch, reconcile, flag what disappeared.
*
* The reconciliation is a full re-read of the window rather than a delta,
* and that is not laziness. Dawarich sends no `updated_at` to build a delta
* from, and deleting a visit removes it from the list rather than tombstoning
* it — so a changed-since filter, even if one existed, could never see a
* deletion. Comparing the whole window against what TREK already holds is the
* only thing that can.
*/
async syncTripWindow(
userId: number,
tripId: number,
creds: DawarichCreds,
from: Date,
to: Date,
): Promise<{ created: number; updated: number; missing: number }> {
const { visits } = await this.client.listVisits(creds, from, to);
const seenIds = new Set<string>();
let created = 0;
let updated = 0;
for (const raw of visits) {
const visit = normalizeVisit(raw);
if (!visit) continue;
seenIds.add(visit.sourceVisitId);
const hash = visitHash(raw);
const existing = this.db.get<SuggestionRow>(
'SELECT * FROM dawarich_visit_suggestions WHERE user_id = ? AND source_visit_id = ?',
userId,
visit.sourceVisitId,
);
// Dawarich has no country code on a visit's place, so it is resolved here
// from the coordinates against the same borders the Atlas draws. A code
// the source does start sending one day wins, because it is the source's
// own answer.
const countryCode =
visit.countryCodeFromSource ??
(visit.lat !== null && visit.lng !== null ? getCountryFromCoords(visit.lat, visit.lng) : null);
if (!existing) {
this.db.run(
`INSERT INTO dawarich_visit_suggestions
(user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at,
duration_minutes, local_date, source_status, confidence, confidence_band,
country_code, state, source_hash, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)`,
userId,
visit.sourceVisitId,
tripId,
visit.name,
visit.lat,
visit.lng,
visit.startedAt,
visit.endedAt,
visit.durationMinutes,
visit.localDate,
visit.status,
visit.confidence,
visit.confidenceBand,
countryCode,
hash,
new Date().toISOString(),
new Date().toISOString(),
);
created++;
this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes);
continue;
}
const changed = existing.source_hash !== hash;
if (existing.state === 'new') {
// Nobody has acted on it yet, so the newest version of the source is
// simply the better suggestion. Overwriting here loses nothing.
this.db.run(
`UPDATE dawarich_visit_suggestions
SET name = ?, lat = ?, lng = ?, started_at = ?, ended_at = ?, duration_minutes = ?,
local_date = ?, source_status = ?, confidence = ?, confidence_band = ?,
country_code = ?, source_hash = ?, source_missing_at = NULL,
trip_id = COALESCE(trip_id, ?), last_seen_at = ?
WHERE id = ?`,
visit.name,
visit.lat,
visit.lng,
visit.startedAt,
visit.endedAt,
visit.durationMinutes,
visit.localDate,
visit.status,
visit.confidence,
visit.confidenceBand,
countryCode,
hash,
tripId,
new Date().toISOString(),
existing.id,
);
if (changed) updated++;
this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes);
continue;
}
// Accepted or dismissed: the hash is refreshed so the flag is raised once
// rather than on every tick, but nothing the user can see is rewritten.
// `accepted_hash` is what they said yes to; the difference between that
// and the current hash is what the UI reports.
this.db.run(
`UPDATE dawarich_visit_suggestions
SET source_hash = ?, source_missing_at = NULL, last_seen_at = ?
WHERE id = ?`,
hash,
new Date().toISOString(),
existing.id,
);
if (changed) updated++;
}
const missing = this.flagMissing(userId, tripId, from, to, seenIds);
return { created, updated, missing };
}
/**
* Mark everything TREK holds for this window that the source no longer lists.
*
* Untouched suggestions are removed outright — nobody has seen them, and a
* list of stays that no longer exist is worse than an empty list. Anything the
* user acted on keeps its row and gets a timestamp instead, so the panel can
* say "this is gone in Dawarich" next to an entry they wrote. Deleting that
* would delete their work.
*/
private flagMissing(
userId: number,
tripId: number,
from: Date,
to: Date,
seenIds: Set<string>,
): number {
// Bounded on `local_date`, not on `started_at`. Dawarich renders a visit's
// start as local wall-clock plus an offset (`2026-09-01T14:23:00+02:00`)
// while the window is UTC (`…Z`), and comparing those two shapes as strings
// is only accurate to the date — on a statement that DELETES rows, "only
// accurate to the date" is not good enough. `local_date` is a plain
// YYYY-MM-DD on both sides of the comparison, and a day of slack at each
// end is deliberate: a row just outside the window is left alone rather
// than deleted for not having been seen.
const candidates = this.db.all<{ id: number; source_visit_id: string; state: string }>(
`SELECT id, source_visit_id, state
FROM dawarich_visit_suggestions
WHERE user_id = ? AND trip_id = ?
AND local_date >= ? AND local_date <= ?`,
userId,
tripId,
localDateOf(from.toISOString()),
localDateOf(to.toISOString()),
);
const gone = candidates.filter((row) => !seenIds.has(row.source_visit_id));
if (gone.length === 0) return 0;
const stamp = new Date().toISOString();
this.db.transaction(() => {
for (const row of gone) {
if (row.state === 'new') {
this.db.run('DELETE FROM dawarich_visit_suggestions WHERE id = ?', row.id);
} else {
this.db.run(
'UPDATE dawarich_visit_suggestions SET source_missing_at = COALESCE(source_missing_at, ?) WHERE id = ?',
stamp,
row.id,
);
}
}
});
return gone.length;
}
/**
* Link a stay to a bucket-list wish it sits on top of.
*
* Both tests have to pass: close enough, and long enough. Proximity alone is
* what makes a detector untrustworthy — driving past a cathedral puts you
* within 250 m of it — and the dwell time is what separates being somewhere
* from going past it. The link is only a hint; ticking the wish off still
* needs the user.
*/
private matchBucketList(
userId: number,
sourceVisitId: string,
lat: number | null,
lng: number | null,
durationMinutes: number,
): void {
if (lat === null || lng === null) return;
if (durationMinutes < DAWARICH_BUCKET_MATCH_MIN_MINUTES) return;
// A rough box first so SQLite does not measure every wish in the world. One
// degree of latitude is ~111 km, and longitude shrinks with latitude, so the
// box is deliberately generous and the real test is the distance below.
const degrees = (DAWARICH_BUCKET_MATCH_RADIUS_M / 111_000) * 2 + 0.01;
const nearby = this.db.all<{ id: number; lat: number; lng: number }>(
`SELECT id, lat, lng FROM bucket_list
WHERE user_id = ? AND lat IS NOT NULL AND lng IS NOT NULL
AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`,
userId,
lat - degrees,
lat + degrees,
lng - degrees,
lng + degrees,
);
let best: { id: number; lat: number; lng: number; distance: number } | null = null;
for (const item of nearby) {
const distance = distanceMeters(lat, lng, item.lat, item.lng);
if (distance > DAWARICH_BUCKET_MATCH_RADIUS_M) continue;
if (!best || distance < best.distance) best = { id: item.id, lat: item.lat, lng: item.lng, distance };
}
if (!best) return;
this.claimWish(userId, sourceVisitId, best, lat, lng, durationMinutes);
}
/**
* Give a wish to the one stay that best answers it.
*
* 250 m is a city block, and a block in a city centre holds a dozen stays: a
* coffee across the square from the museum satisfies the radius exactly as
* the museum does. Attaching the wish to each of them turns one achievement
* into five claims and invites ticking it off from the wrong one, so the
* closest stay keeps it — and the longest wins a tie, because standing
* somewhere for two hours is a better answer to "were you there" than passing
* within the same few metres.
*
* Every previous claim on the same wish is cleared, so re-running a sync after
* the recordings change cannot leave two stays holding it.
*/
private claimWish(
userId: number,
sourceVisitId: string,
wish: { id: number; lat: number; lng: number; distance: number },
lat: number,
lng: number,
durationMinutes: number,
): void {
const holders = this.db.all<{
id: number;
source_visit_id: string;
lat: number | null;
lng: number | null;
duration_minutes: number;
}>(
`SELECT id, source_visit_id, lat, lng, duration_minutes
FROM dawarich_visit_suggestions
WHERE user_id = ? AND matched_bucket_list_item_id = ? AND source_visit_id <> ?`,
userId,
wish.id,
sourceVisitId,
);
for (const holder of holders) {
if (holder.lat === null || holder.lng === null) continue;
const distance = distanceMeters(holder.lat, holder.lng, wish.lat, wish.lng);
const holderWins =
distance < wish.distance ||
(distance === wish.distance && holder.duration_minutes > durationMinutes);
if (holderWins) return;
}
this.db.transaction(() => {
this.db.run(
'UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = NULL WHERE user_id = ? AND matched_bucket_list_item_id = ?',
userId,
wish.id,
);
this.db.run(
'UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = ? WHERE user_id = ? AND source_visit_id = ?',
wish.id,
userId,
sourceVisitId,
);
});
}
}
interface TripRow {
id: number;
start_date: string | null;
end_date: string | null;
}
interface SuggestionRow {
id: number;
state: string;
source_hash: string;
}
@@ -0,0 +1,194 @@
import { Injectable } from '@nestjs/common';
import { DAWARICH_TRACK_POINTS_PER_DAY, type DawarichTrack } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { DawarichClient, type DawarichCreds } from './dawarich.client';
import { DawarichService } from './dawarich.service';
import {
bucketPointsByDay,
bucketTracksByDay,
countPoints,
offsetMinutesOf,
} from './dawarich.helpers';
/**
* The recorded route for a window, fetched on demand and never stored.
*
* That is the point of the whole feature and the one rule it must not break:
* Dawarich owns the location archive. TREK draws the part of it somebody is
* looking at right now and forgets it again. There is no table here, no
* `route_geometry` written to a place, no background import.
*
* What there is instead is a short in-memory cache, because "update the line
* while the map is open" and "ask an instance for a month of GPS every few
* seconds" are the same request otherwise. It lives for a minute, it is keyed
* per user and window, and losing it on restart costs one extra fetch.
*/
@Injectable()
export class DawarichTracksService {
private readonly cache = new Map<string, { at: number; value: DawarichTrack }>();
/**
* Long enough that panning and toggling the layer do not re-fetch, short
* enough that a live recording visibly catches up.
*/
private static readonly TTL_MS = 60_000;
/**
* A ceiling, not a sizing estimate: the cache holds whole days of geometry
* and a user flipping through a year of trips would otherwise keep every one
* of them alive. Oldest entry out first.
*/
private static readonly MAX_ENTRIES = 64;
constructor(
private readonly db: DatabaseService,
private readonly dawarich: DawarichService,
private readonly client: DawarichClient,
) {}
/**
* The recorded route for a trip, grouped by local day.
*
* `from`/`to` narrow it further so the planner can ask for one day without
* pulling the whole trip; without them the trip's own dates are the window.
* Returns null when the caller cannot read the trip — the controller turns
* that into the same 404 a missing trip gets.
*/
async forTrip(
userId: number,
tripId: number,
from?: string,
to?: string,
offsetMinutes = 0,
): Promise<DawarichTrack | null> {
if (!this.db.canAccessTrip(tripId, userId)) return null;
const trip = this.db.get<{ start_date: string | null; end_date: string | null }>(
'SELECT start_date, end_date FROM trips WHERE id = ?',
tripId,
);
if (!trip) return null;
const start = from ?? trip.start_date;
const end = to ?? trip.end_date ?? trip.start_date;
if (!start || !end) {
// A trip without dates has no window to ask about. An empty result says
// so honestly; an error would suggest the connection is broken.
return emptyTrack();
}
return this.forWindow(userId, `${start}T00:00:00Z`, `${end}T23:59:59Z`, offsetMinutes);
}
/**
* The recorded route for an explicit instant range.
*
* Used by the journal, which has dated entries rather than a trip window.
*/
async forWindow(
userId: number,
fromIso: string,
toIso: string,
offsetMinutes = offsetMinutesOf(fromIso),
): Promise<DawarichTrack> {
const creds = this.dawarich.getCredentials(userId);
if (!creds) return emptyTrack();
const from = new Date(fromIso);
const to = new Date(toIso);
if (!Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || to <= from) {
return emptyTrack();
}
// The offset is part of the key: the same window grouped by two different
// days is two different answers, and one traveller can ask for both across
// a timezone change.
const key = `${userId}:${from.toISOString()}:${to.toISOString()}:${offsetMinutes}`;
const hit = this.cache.get(key);
if (hit && Date.now() - hit.at < DawarichTracksService.TTL_MS) return hit.value;
const value = await this.fetch(creds, userId, from, to, offsetMinutes);
this.remember(key, value);
return value;
}
/**
* Tracks first, points as the fallback.
*
* `/tracks` returns lines Dawarich has already segmented and labelled with a
* travel mode, which is both a smaller payload and a better line than raw
* points. But tracks are produced by a background job, so a fresh import —
* or an instance old enough not to have the endpoint — has points long before
* it has tracks. Falling back keeps the overlay working on both, and the
* result says which one answered so the UI can be honest about it.
*/
private async fetch(
creds: DawarichCreds,
userId: number,
from: Date,
to: Date,
offsetMinutes: number,
): Promise<DawarichTrack> {
const capabilities = this.dawarich.getCapabilities(userId);
if (capabilities?.tracks !== false) {
try {
const { features, truncated } = await this.client.listTracks(creds, from, to);
if (features.length > 0) {
const days = bucketTracksByDay(features, { maxPointsPerDay: DAWARICH_TRACK_POINTS_PER_DAY });
return {
days,
source: 'tracks',
fetchedAt: new Date().toISOString(),
pointCount: countPoints(days),
truncated,
};
}
} catch {
// Fall through to points: an instance that cannot serve tracks can
// usually still serve the points they would have been built from.
}
}
const { points, truncated } = await this.client.listPoints(creds, from, to);
// Points carry a Unix timestamp and no zone, so the local day has to come
// from somewhere. It comes from the caller: the browser knows the offset the
// reader is living in, and "which day was that" is their question, not the
// server's. Zero — plain UTC — is the answer for a caller that says nothing,
// which is what a Date can ever tell us (`toISOString` always ends in `Z`,
// so deriving it from the window here only ever produced UTC).
const days = bucketPointsByDay(points, offsetMinutes, { maxPointsPerDay: DAWARICH_TRACK_POINTS_PER_DAY });
return {
days,
source: 'points',
fetchedAt: new Date().toISOString(),
pointCount: countPoints(days),
truncated,
};
}
private remember(key: string, value: DawarichTrack): void {
if (this.cache.size >= DawarichTracksService.MAX_ENTRIES) {
const oldest = this.cache.keys().next().value;
if (oldest !== undefined) this.cache.delete(oldest);
}
this.cache.set(key, { at: Date.now(), value });
}
/** Drop everything cached for one user — used when a connection changes. */
forget(userId: number): void {
for (const key of [...this.cache.keys()]) {
if (key.startsWith(`${userId}:`)) this.cache.delete(key);
}
}
}
function emptyTrack(): DawarichTrack {
return {
days: [],
source: 'tracks',
fetchedAt: new Date().toISOString(),
pointCount: 0,
truncated: false,
};
}
@@ -0,0 +1,426 @@
import { Injectable } from '@nestjs/common';
import type { DawarichErrorCode } from '@trek/shared';
import { safeFetch } from '../../utils/ssrfGuard';
import { readCappedJson } from '../../utils/cappedFetch';
/**
* Thin HTTP client for the Dawarich REST API (github.com/Freika/dawarich).
* This is the ONLY place that talks to a user's Dawarich instance.
*
* Verified against the Dawarich source at 1.14.4 (2026-09-06), not against its
* prose documentation — the two disagree in the places that matter here:
*
* - **Auth**: `Authorization: Bearer <key>` (or `?api_key=`, which this client
* never uses — a key in a query string ends up in every access log between
* here and there). `User.find_by(api_key:)` means one key per user and no
* scopes: the key reads that user's whole archive. Granular Dawarich tokens
* were discussed and have not shipped, so TREK must not depend on them.
* - **Version**: every response carries `X-Dawarich-Version`. That is the
* instance's own word on what it is, which beats guessing from behaviour.
* - `GET /api/v1/health` skips authentication, so it proves reachability and
* nothing else. `GET /api/v1/users/me` is the key test.
* - `GET /api/v1/visits?start_at&end_at` filters on `started_at` ONLY. A stay
* that began before the window is not returned even if it ran well into it,
* so callers widen the window rather than trusting its edges. Without a
* `page` parameter it returns the whole range in one response; with one, the
* page size caps at 500 and the count arrives in `X-Total-Pages`.
* - A visit carries **no `updated_at`** and its `place` carries **no country
* code**. Change detection is therefore hash-based and the country is
* resolved from the coordinates; see dawarich-sync.service.ts.
* - Deleting a visit is a soft delete that removes it from the index. Absence
* from a full re-read is the only deletion signal there is.
* - `GET /api/v1/tracks` answers GeoJSON with one LineString per track and
* `start_at`/`end_at` in the properties — a far better overlay source than
* raw points. `GET /api/v1/points?slim=true` is the fallback, and its
* `latitude`/`longitude` arrive as **strings**.
*/
const TIMEOUT_MS = 15000;
/** A page of visits is a few hundred small objects; a megabyte is misbehaviour. */
const MAX_JSON_BYTES = 8 * 1024 * 1024;
/** Dawarich's own ceiling for `per_page` on visits. Asking for more is silently capped. */
export const VISITS_PAGE_SIZE = 500;
/** Tracks default to 500 per page and carry whole geometries, so we ask for fewer. */
export const TRACKS_PAGE_SIZE = 200;
/** Points cap at 10000 upstream. A day of tracking is a few thousand. */
export const POINTS_PAGE_SIZE = 5000;
/**
* How many pages one call will walk before giving up and saying so.
*
* A cap rather than a loop: a misconfigured instance answering the same page
* forever would otherwise hold a request open until the timeout, and a caller
* that is told the result is incomplete can do something about it. Every
* paginated method reports `truncated` rather than pretending it saw everything.
*/
const MAX_PAGES = 20;
export interface DawarichCreds {
/** Instance origin without a trailing /api. */
baseUrl: string;
apiKey: string;
allowInsecureTls: boolean;
}
/**
* A failed Dawarich call, carrying the reason as a code.
*
* The code is what reaches the user (through i18n on the client) because the
* upstream message is English, occasionally quotes the URL back, and on a
* misconfigured reverse proxy is an HTML login page. `detail` keeps the
* original for the places where a human is debugging their own setup.
*/
export class DawarichError extends Error {
readonly code: DawarichErrorCode;
readonly status?: number;
readonly detail?: string;
constructor(code: DawarichErrorCode, message: string, status?: number, detail?: string) {
super(message);
this.name = 'DawarichError';
this.code = code;
this.status = status;
this.detail = detail;
}
}
/** A visit exactly as `Api::VisitSerializer` emits it. Nothing is assumed optional-but-present. */
export interface DawarichVisitRaw {
id: number | string;
area_id: number | null;
user_id?: number;
started_at: string;
ended_at: string;
/** Seconds in current builds; TREK normalises rather than trusting the unit. */
duration: number | string | null;
name: string | null;
status: string | null;
confidence: number | string | null;
confidence_band: string | null;
place: {
latitude: number | string | null;
longitude: number | string | null;
id: number | null;
/** Requested upstream, absent at 1.14.4. Read defensively so it lights up on its own. */
country_code?: string | null;
} | null;
/** Requested upstream, absent at 1.14.4. */
updated_at?: string | null;
}
/** One GeoJSON feature from `GET /api/v1/tracks`. */
export interface DawarichTrackFeature {
type?: string;
geometry: { type?: string; coordinates?: unknown } | null;
properties: {
id?: number | string;
start_at?: string;
end_at?: string;
distance?: number | null;
duration?: number | null;
dominant_mode?: string | null;
} | null;
}
/** One entry of `GET /api/v1/points?slim=true`. lat/lng are strings upstream. */
export interface DawarichSlimPoint {
id?: number;
latitude: string | number;
longitude: string | number;
/** Unix seconds. */
timestamp: number;
country_name?: string | null;
}
/** One country of `GET /api/v1/countries/visited_cities`. */
export interface DawarichVisitedCountry {
country: string;
cities: Array<{
city: string;
points?: number;
/** Unix seconds of the last point in that city. */
timestamp?: number;
/** Minutes. */
stayed_for?: number;
}>;
}
/** One stay near a coordinate from `GET /api/v1/locations`. */
export interface DawarichLocationVisit {
timestamp: number;
date?: string;
distance_meters?: number;
points_count?: number;
visit_details?: {
start_time?: string;
end_time?: string;
duration_minutes?: number;
city?: string | null;
country?: string | null;
} | null;
}
export interface DawarichProbe {
/** From `X-Dawarich-Version`, null on an instance too old to send it. */
version: string | null;
}
/**
* `https://host/extra` → `https://host/extra/api/v1`, tolerating what people
* actually paste: a trailing slash, a trailing `/api`, or the full `/api/v1`.
* Without this, half the support load is `/api/api/v1` 404s.
*/
function apiBase(baseUrl: string): string {
const origin = baseUrl
.trim()
.replace(/(?<!\/)\/+$/, '')
.replace(/\/api\/v1$/i, '')
.replace(/\/api$/i, '');
return `${origin}/api/v1`;
}
/** ISO-8601 in UTC. `Time.zone.parse` on the Dawarich side reads this unambiguously. */
export function isoUtc(value: Date): string {
return value.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
/**
* Unix seconds as a bare digit string.
*
* The points and visited-cities endpoints run their input through
* `safe_timestamp`, which falls back to **the current time** for anything it
* cannot parse — silently. A digit string takes the one branch that cannot
* misread, so a formatting slip becomes an empty result instead of a plausible
* wrong one.
*/
export function unixSeconds(value: Date): string {
return String(Math.floor(value.getTime() / 1000));
}
function classify(status: number): DawarichErrorCode {
if (status === 401) return 'unauthorized';
if (status === 403) return 'forbidden';
if (status === 404) return 'not_found';
if (status === 429) return 'rate_limited';
return 'server_error';
}
@Injectable()
export class DawarichClient {
/**
* One authenticated GET, size-capped and parsed.
*
* Returns the headers alongside the body because two things TREK needs are
* only in them: the version banner and the page count.
*/
private async get<T>(
creds: DawarichCreds,
path: string,
query: Record<string, string | undefined>,
): Promise<{ data: T; headers: Headers }> {
const url = new URL(apiBase(creds.baseUrl) + path);
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) url.searchParams.set(key, value);
}
let response: Response;
try {
response = await safeFetch(
url.toString(),
{
method: 'GET',
headers: {
Authorization: `Bearer ${creds.apiKey}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(TIMEOUT_MS) as AbortSignal,
},
{ rejectUnauthorized: !creds.allowInsecureTls },
);
} catch (err: unknown) {
throw new DawarichError(
'unreachable',
'Could not reach Dawarich',
undefined,
err instanceof Error ? err.message : undefined,
);
}
if (!response.ok) {
throw new DawarichError(
classify(response.status),
`Dawarich answered HTTP ${response.status}`,
response.status,
);
}
// Read through the cap rather than calling .json(): Dawarich answers chunked
// over large ranges, so there is no content-length to check first, and an
// auth proxy in front of it answers with an HTML login page that would
// otherwise surface as "Unexpected token '<'".
const data = await readCappedJson<T>(response, MAX_JSON_BYTES);
if (data === undefined) {
throw new DawarichError(
'invalid_response',
'Dawarich returned something that is not JSON. Check the URL points at Dawarich itself and that it is reachable without a separate login.',
);
}
return { data, headers: response.headers };
}
/**
* Authenticate and read the version banner.
*
* `/users/me` rather than `/health`, because health skips authentication
* upstream: a wrong key would pass it, and "connected" would mean nothing.
*/
async probe(creds: DawarichCreds): Promise<DawarichProbe> {
const { headers } = await this.get<unknown>(creds, '/users/me', {});
return { version: headers.get('x-dawarich-version') };
}
/**
* Visits whose stay STARTED inside the window.
*
* Paginated explicitly even though the endpoint returns everything without a
* `page` parameter: a multi-year archive in one response is exactly the shape
* the size cap exists to refuse, and the caller would get an error instead of
* a long answer.
*/
async listVisits(
creds: DawarichCreds,
from: Date,
to: Date,
): Promise<{ visits: DawarichVisitRaw[]; truncated: boolean; version: string | null }> {
const visits: DawarichVisitRaw[] = [];
let version: string | null = null;
let page = 1;
let truncated = false;
for (; page <= MAX_PAGES; page++) {
const { data, headers } = await this.get<DawarichVisitRaw[]>(creds, '/visits', {
start_at: isoUtc(from),
end_at: isoUtc(to),
page: String(page),
per_page: String(VISITS_PAGE_SIZE),
});
version ??= headers.get('x-dawarich-version');
if (!Array.isArray(data)) {
throw new DawarichError('invalid_response', 'Dawarich returned a visits payload that is not a list');
}
visits.push(...data);
const totalPages = Number(headers.get('x-total-pages') ?? '1');
if (!Number.isFinite(totalPages) || page >= totalPages) break;
if (page === MAX_PAGES) truncated = true;
}
return { visits, truncated, version };
}
/**
* Pre-segmented recorded routes overlapping the window, as GeoJSON features.
*
* Unlike visits, the upstream filter is a real overlap test
* (`end_at >= from AND start_at <= to`), so a drive that crosses the window's
* edge is included rather than dropped.
*/
async listTracks(
creds: DawarichCreds,
from: Date,
to: Date,
): Promise<{ features: DawarichTrackFeature[]; truncated: boolean }> {
const features: DawarichTrackFeature[] = [];
let truncated = false;
for (let page = 1; page <= MAX_PAGES; page++) {
const { data, headers } = await this.get<{ features?: DawarichTrackFeature[] }>(creds, '/tracks', {
start_at: isoUtc(from),
end_at: isoUtc(to),
page: String(page),
per_page: String(TRACKS_PAGE_SIZE),
});
const batch = Array.isArray(data?.features) ? data.features : [];
features.push(...batch);
const totalPages = Number(headers.get('x-total-pages') ?? '1');
if (!Number.isFinite(totalPages) || page >= totalPages) break;
if (page === MAX_PAGES) truncated = true;
}
return { features, truncated };
}
/**
* Raw recorded points, oldest first — the fallback when an instance has not
* generated tracks (they are produced by a background job, so a fresh import
* has points long before it has tracks).
*/
async listPoints(
creds: DawarichCreds,
from: Date,
to: Date,
): Promise<{ points: DawarichSlimPoint[]; truncated: boolean }> {
const points: DawarichSlimPoint[] = [];
let truncated = false;
for (let page = 1; page <= MAX_PAGES; page++) {
const { data, headers } = await this.get<DawarichSlimPoint[]>(creds, '/points', {
start_at: unixSeconds(from),
end_at: unixSeconds(to),
order: 'asc',
slim: 'true',
page: String(page),
per_page: String(POINTS_PAGE_SIZE),
});
if (!Array.isArray(data)) {
throw new DawarichError('invalid_response', 'Dawarich returned a points payload that is not a list');
}
points.push(...data);
const totalPages = Number(headers.get('x-total-pages') ?? '1');
if (!Number.isFinite(totalPages) || page >= totalPages) break;
if (page === MAX_PAGES) truncated = true;
}
return { points, truncated };
}
/** Countries and cities the recordings put the user in, for the Atlas. */
async listVisitedCities(
creds: DawarichCreds,
from: Date,
to: Date,
): Promise<DawarichVisitedCountry[]> {
const { data } = await this.get<{ data?: DawarichVisitedCountry[] }>(creds, '/countries/visited_cities', {
start_at: unixSeconds(from),
end_at: unixSeconds(to),
});
return Array.isArray(data?.data) ? data.data : [];
}
/**
* "Was I ever here, and for how long?" for one coordinate.
*
* `radius_override` is passed explicitly: without it Dawarich picks a radius
* from the place TYPE, and a coordinate search has no type, so it falls back
* to 500 m — wide enough to call a drive past a landmark a visit.
*/
async findVisitsNear(
creds: DawarichCreds,
lat: number,
lng: number,
radiusMeters: number,
limit: number,
): Promise<DawarichLocationVisit[]> {
const { data } = await this.get<{
locations?: Array<{ visits?: DawarichLocationVisit[] }>;
}>(creds, '/locations', {
lat: String(lat),
lon: String(lng),
radius_override: String(Math.round(radiusMeters)),
limit: String(limit),
});
const first = Array.isArray(data?.locations) ? data.locations[0] : undefined;
return Array.isArray(first?.visits) ? first.visits : [];
}
}
@@ -0,0 +1,351 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpException,
Param,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import type { User } from '../../types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { ADDON_IDS } from '../../addons';
import { getClientIp } from '../audit/client-ip';
import { DawarichService } from './dawarich.service';
import { DawarichSyncService } from './dawarich-sync.service';
import { DawarichSuggestionsService, AcceptError } from './dawarich-suggestions.service';
import { DawarichTracksService } from './dawarich-tracks.service';
import { DawarichError } from './dawarich.client';
import {
DawarichAcceptDto,
DawarichAtlasAcceptDto,
DawarichBucketConfirmDto,
DawarichSettingsDto,
DawarichSuggestionStateDto,
} from './dawarich.dto';
/**
* `/api/integrations/dawarich` — the per-user Dawarich connection and everything
* it produces (#2279).
*
* Two shapes of answer, deliberately different:
*
* - **Connection routes** (`status`, `test`) answer 200 even when the instance
* is unreachable, with the reason in the body. A form that is showing someone
* their own typo needs a field to render, not an exception.
* - **Data routes** answer the status the failure deserves, because a caller
* that asked for suggestions and got an empty list would take it as "there
* are none".
*
* The whole group is gated on the `dawarich` addon, with `AddonGuard` ahead of
* `JwtAuthGuard` — the order every addon-gated controller in TREK uses, so that
* a switched-off addon answers the same 404 to everyone rather than 401 to some
* callers and 404 to others. It does mean an unauthenticated caller can learn
* whether the addon is on; that is true of every addon here and is not a secret
* worth a second guard order for.
*/
@Controller('api/integrations/dawarich')
@UseGuards(AddonGuard, JwtAuthGuard)
@RequireAddon(ADDON_IDS.DAWARICH, 'Dawarich')
export class DawarichController {
constructor(
private readonly dawarich: DawarichService,
private readonly sync: DawarichSyncService,
private readonly suggestions: DawarichSuggestionsService,
private readonly tracks: DawarichTracksService,
) {}
// ── Connection ─────────────────────────────────────────────────────────────
@Get('settings')
getSettings(@CurrentUser() user: User) {
return this.dawarich.getConnection(user.id);
}
@Put('settings')
async putSettings(
@CurrentUser() user: User,
@Body() body: DawarichSettingsDto,
@Req() req: Request,
) {
const result = await this.dawarich.saveSettings(
user.id,
body.url,
body.apiKey,
!!body.allowInsecureTls,
body.syncEnabled !== false,
getClientIp(req),
);
if (!result.success) {
throw new HttpException({ error: result.error }, 400);
}
// A changed address or key means every cached line was drawn from somewhere
// else. Keeping it would show the previous instance's route under the new
// connection's name.
this.tracks.forget(user.id);
return result.warning ? { success: true, warning: result.warning } : { success: true };
}
@Delete('settings')
@HttpCode(200)
disconnect(@CurrentUser() user: User, @Req() req: Request) {
this.dawarich.disconnect(user.id, getClientIp(req));
this.tracks.forget(user.id);
return { success: true };
}
@Post('test')
@HttpCode(200)
test(@CurrentUser() user: User, @Body() body: DawarichSettingsDto) {
return this.dawarich.testConnection(user.id, body.url, body.apiKey, !!body.allowInsecureTls);
}
/** Pull now, rather than waiting for the next tick. */
@Post('sync')
@HttpCode(200)
async syncNow(@CurrentUser() user: User) {
return this.sync.syncUser(user.id);
}
// ── Suggestions ────────────────────────────────────────────────────────────
@Get('suggestions')
listSuggestions(
@CurrentUser() user: User,
@Query('tripId') tripId?: string,
@Query('state') state?: string,
) {
return this.suggestions.list(user.id, {
tripId: parseOptionalId(tripId, 'tripId'),
state: parseState(state),
});
}
@Post('suggestions/:id/accept')
@HttpCode(200)
acceptSuggestion(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: DawarichAcceptDto,
@Req() req: Request,
) {
return this.guard(() =>
this.suggestions.accept(user.id, parseId(id), body, socketId(req)),
);
}
@Put('suggestions/:id/state')
@HttpCode(200)
setSuggestionState(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: DawarichSuggestionStateDto,
) {
const updated = this.suggestions.setState(user.id, parseId(id), body.state);
if (!updated) throw new HttpException({ error: 'Suggestion not found' }, 404);
return updated;
}
// ── Bucket list ────────────────────────────────────────────────────────────
/** Ask the recordings whether the caller ever reached their own wishes. */
@Post('bucket-list/scan')
@HttpCode(200)
async scanBucketList(@CurrentUser() user: User) {
return this.guardAsync(() => this.suggestions.scanBucketList(user.id));
}
@Post('bucket-list/confirm')
@HttpCode(200)
confirmBucketVisits(@CurrentUser() user: User, @Body() body: DawarichBucketConfirmDto) {
return { updated: this.suggestions.confirmBucketVisits(user.id, body.itemIds, body.visitedAt) };
}
@Delete('bucket-list/:itemId/visit')
@HttpCode(200)
clearBucketVisit(@CurrentUser() user: User, @Param('itemId') itemId: string) {
const cleared = this.suggestions.clearBucketVisit(user.id, parseId(itemId));
if (!cleared) throw new HttpException({ error: 'Bucket-list entry not found' }, 404);
return { success: true };
}
// ── Atlas ──────────────────────────────────────────────────────────────────
@Get('atlas/suggestions')
async atlasSuggestions(
@CurrentUser() user: User,
@Query('from') from?: string,
@Query('to') to?: string,
) {
const window = parseWindow(from, to);
return this.guardAsync(() => this.suggestions.atlasSuggestions(user.id, window.from, window.to));
}
@Post('atlas/accept')
@HttpCode(200)
acceptAtlasCountries(@CurrentUser() user: User, @Body() body: DawarichAtlasAcceptDto) {
return { marked: this.suggestions.acceptAtlasCountries(user.id, body.countryCodes) };
}
// ── Track overlay ──────────────────────────────────────────────────────────
/**
* The recorded route for a trip. Fetched from Dawarich per request and not
* stored — see DawarichTracksService.
*/
@Get('trips/:tripId/track')
async tripTrack(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('offset') offset?: string,
) {
const track = await this.guardAsync(() =>
this.tracks.forTrip(user.id, parseId(tripId), parseDate(from), parseDate(to), parseOffset(offset)),
);
if (!track) throw new HttpException({ error: 'Trip not found' }, 404);
return track;
}
/** The recorded route for an explicit range — what the journal asks for. */
@Get('track')
async windowTrack(
@CurrentUser() user: User,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('offset') offset?: string,
) {
const window = parseWindow(from, to);
return this.guardAsync(() =>
this.tracks.forWindow(
user.id,
window.from.toISOString(),
window.to.toISOString(),
parseOffset(offset),
),
);
}
// ── Error shaping ──────────────────────────────────────────────────────────
/**
* One place where a domain error becomes an HTTP one.
*
* `DawarichError` keeps its own code in the body: the client renders these
* through i18n, and the upstream sentence would otherwise reach a German
* install in English. 502 rather than 500, because the failure is a server
* TREK called, not TREK itself.
*/
private guard<T>(run: () => T): T {
try {
return run();
} catch (err) {
throw toHttp(err);
}
}
private async guardAsync<T>(run: () => Promise<T>): Promise<T> {
try {
return await run();
} catch (err) {
throw toHttp(err);
}
}
}
function toHttp(err: unknown): HttpException {
if (err instanceof AcceptError) {
return new HttpException({ error: err.message, code: err.code }, err.status);
}
if (err instanceof DawarichError) {
return new HttpException(
{ error: err.message, code: err.code, ...(err.detail ? { detail: err.detail } : {}) },
err.code === 'unauthorized' || err.code === 'forbidden' ? 400 : 502,
);
}
if (err instanceof HttpException) return err;
return new HttpException({ error: 'Dawarich request failed', code: 'server_error' }, 502);
}
/** Ids are integers. `parseInt` would turn "12abc" into 12 and log a request nobody made. */
function parseId(raw: string): number {
if (!/^\d+$/.test(raw)) throw new HttpException({ error: 'Invalid id' }, 400);
const id = Number(raw);
if (!Number.isSafeInteger(id) || id <= 0) throw new HttpException({ error: 'Invalid id' }, 400);
return id;
}
function parseOptionalId(raw: string | undefined, label: string): number | undefined {
if (raw === undefined || raw === '') return undefined;
if (!/^\d+$/.test(raw)) throw new HttpException({ error: `Invalid ${label}` }, 400);
return Number(raw);
}
/** An unknown state is a 400 rather than a silently unfiltered list. */
function parseState(raw: string | undefined): string | undefined {
if (raw === undefined || raw === '') return undefined;
if (raw !== 'new' && raw !== 'accepted' && raw !== 'dismissed') {
throw new HttpException({ error: 'state must be one of: new, accepted, dismissed' }, 400);
}
return raw;
}
/**
* The caller's UTC offset in minutes, as `Date.getTimezoneOffset()` negated —
* +120 for Berlin in summer.
*
* Points from Dawarich are bare instants, so "which day was that" can only be
* answered in somebody's timezone, and the browser is the only party that knows
* which one. Absent or unreadable means UTC, which is what the endpoint did
* before it asked. Bounded to the real range of world offsets (-12h..+14h) so a
* junk value cannot shift a day by a week.
*/
function parseOffset(raw: string | undefined): number {
if (raw === undefined || raw === '') return 0;
const value = Number(raw);
if (!Number.isFinite(value)) return 0;
return Math.max(-720, Math.min(840, Math.trunc(value)));
}
function parseDate(raw: string | undefined): string | undefined {
if (raw === undefined || raw === '') return undefined;
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
throw new HttpException({ error: 'Dates must be ISO, YYYY-MM-DD' }, 400);
}
return raw;
}
/**
* An explicit instant window. Both ends are required together: a half-open
* range would silently become "everything since the epoch" on one side, which
* is the request that makes an instance time out.
*/
function parseWindow(from: string | undefined, to: string | undefined): { from: Date; to: Date } {
const start = from ? new Date(from) : null;
const end = to ? new Date(to) : null;
if (!start || !end || !Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) {
throw new HttpException({ error: 'from and to are required ISO timestamps' }, 400);
}
if (end <= start) throw new HttpException({ error: 'to must be after from' }, 400);
const MAX_DAYS = 400;
if (end.getTime() - start.getTime() > MAX_DAYS * 86_400_000) {
throw new HttpException({ error: `Window must be ${MAX_DAYS} days or less` }, 400);
}
return { from: start, to: end };
}
/** The originating client's socket id, so a broadcast does not echo back to it. */
function socketId(req: Request): string | undefined {
const header = req.headers['x-socket-id'];
return typeof header === 'string' && header ? header : undefined;
}
@@ -0,0 +1,19 @@
/**
* Server-side createZodDto wrappers over the @trek/shared Dawarich contracts,
* so the global ZodValidationPipe (APP_PIPE) validates bodies by metatype —
* the shared Zod schemas stay the single source of truth.
*/
import { createZodDto } from 'nestjs-zod';
import {
dawarichAcceptSchema,
dawarichAtlasAcceptSchema,
dawarichBucketConfirmSchema,
dawarichSettingsSchema,
dawarichSuggestionStateSchemaBody,
} from '@trek/shared';
export class DawarichSettingsDto extends createZodDto(dawarichSettingsSchema) {}
export class DawarichAcceptDto extends createZodDto(dawarichAcceptSchema) {}
export class DawarichSuggestionStateDto extends createZodDto(dawarichSuggestionStateSchemaBody) {}
export class DawarichBucketConfirmDto extends createZodDto(dawarichBucketConfirmSchema) {}
export class DawarichAtlasAcceptDto extends createZodDto(dawarichAtlasAcceptSchema) {}
Binary file not shown.
@@ -0,0 +1,240 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { AuthService } from '../auth/auth.service';
import { AcceptError, DawarichSuggestionsService } from './dawarich-suggestions.service';
import { DawarichTracksService } from './dawarich-tracks.service';
/** Same gate as the controller's @RequireAddon(ADDON_IDS.DAWARICH): no addon, no tool. */
const dawarichAddonOn = addonGate(ADDON_IDS.DAWARICH);
/**
* A stay list is short by nature — a day of travel produces a handful — but a
* year of them is not, and an assistant paying for the whole backlog on every
* turn is the failure mode. The browser can afford the full list because a
* human scrolls it.
*/
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
/**
* Dawarich MCP surface — the review half of the integration, mirroring
* `/api/integrations/dawarich/suggestions` and the track overlay.
*
* The scopes are chosen by **what each tool writes**, not by what domain the
* data came from, which is why accepting is three tools rather than one with a
* `target` parameter: creating a place is `places:write`, writing a journal
* entry is `journey:write`, and ticking off a wish is `atlas:write`. A single
* tool would have to claim the union of all three, and a caller that only
* wanted to fill in a journal would be holding a credential that can also edit
* an itinerary.
*
* Reading the suggestions themselves rides `journey:read`. They are candidate
* journal entries before they are anything else, and that is the narrowest
* existing scope that honestly covers "stays this person recorded".
*
* The connection routes (settings, test, disconnect) are deliberately absent:
* they take an API key for somebody's complete location history, and that is
* exactly what no assistant scope should be able to read or replace.
*/
@McpController()
export class DawarichMcp {
constructor(
private readonly suggestions: DawarichSuggestionsService,
private readonly tracks: DawarichTracksService,
private readonly auth: AuthService,
readonly addons: AddonsService,
) {}
@Tool({
name: 'list_dawarich_suggestions',
description:
"List the stays TREK pulled from the caller's connected Dawarich instance and is holding for review: where they were, when they arrived and left, how long they stayed, and which trip the stay falls into. Each entry carries the id the accept tools take. Use it to answer what someone actually did on a trip, or to fill a travel journal from what was recorded rather than from memory. Nothing here has been added to a trip yet — accepting is a separate, explicit step.",
inputSchema: {
tripId: z.number().int().positive().optional().describe('Only stays that fall inside this trip'),
state: z.enum(['new', 'accepted', 'dismissed']).optional()
.describe('Default: every state. "new" is the review backlog.'),
limit: z.number().int().min(1).max(MAX_LIMIT).optional()
.describe(`Maximum stays to return, most recent first (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT})`),
},
// Reads TREK's own table; the fetch from the remote instance happens on a cron.
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'journey', mode: 'read' },
when: dawarichAddonOn,
})
listSuggestions(
{ tripId, state, limit }: { tripId?: number; state?: 'new' | 'accepted' | 'dismissed'; limit?: number },
ctx: McpContext,
) {
const all = this.suggestions.list(ctx.userId, { tripId, state });
const cap = limit ?? DEFAULT_LIMIT;
const page = all.suggestions.slice(0, cap);
return ok({
suggestions: page,
total: all.suggestions.length,
truncated: all.suggestions.length > page.length,
connected: all.connected,
lastSyncAt: all.lastSyncAt,
lastSyncState: all.lastSyncState,
});
}
@Tool({
name: 'accept_dawarich_suggestion_as_place',
description:
'Turn a reviewed Dawarich stay into a place on a trip, optionally pinned to one of its days. Call list_dawarich_suggestions first for the id. Correct the name or the coordinates in the same call if the detector got them wrong — that is what the review step is for. Use this when the stay is somewhere worth having on the itinerary; use accept_dawarich_suggestion_as_journal_entry when it is a moment worth writing about instead.',
inputSchema: {
suggestionId: z.number().int().positive(),
tripId: z.number().int().positive().optional().describe('Defaults to the trip the stay fell into'),
dayId: z.number().int().positive().optional().describe('Pin it to this day; omitted leaves it unplanned'),
name: z.string().trim().min(1).max(255).optional(),
notes: z.string().max(5000).optional(),
lat: z.number().min(-90).max(90).optional(),
lng: z.number().min(-180).max(180).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'places', mode: 'write' },
when: dawarichAddonOn,
})
acceptAsPlace(
args: { suggestionId: number; tripId?: number; dayId?: number; name?: string; notes?: string; lat?: number; lng?: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const { suggestionId, ...rest } = args;
return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'place', ...rest }));
}
@Tool({
name: 'accept_dawarich_suggestion_as_journal_entry',
description:
"Turn a reviewed Dawarich stay into a dated entry in a travel journal. Call list_dawarich_suggestions first for the id. The stay's own date, arrival time, name and coordinates prefill the entry; pass a title or a story to write it properly rather than leaving the detector's label. Use this to fill in a journal after a trip from what was actually recorded.",
inputSchema: {
suggestionId: z.number().int().positive(),
journalId: z.number().int().positive().describe('The journey the entry is added to'),
name: z.string().trim().min(1).max(255).optional().describe('Entry title; defaults to the stay name'),
notes: z.string().max(5000).optional().describe('The story text'),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
time: z.string().regex(/^\d{2}:\d{2}$/).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'journey', mode: 'write' },
when: dawarichAddonOn,
})
acceptAsJournalEntry(
args: { suggestionId: number; journalId: number; name?: string; notes?: string; date?: string; time?: string },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const { suggestionId, ...rest } = args;
return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'journal', ...rest }));
}
@Tool({
name: 'mark_bucket_list_item_visited_from_dawarich',
description:
"Tick a bucket-list wish off because a recorded stay proves the caller got there. Call list_dawarich_suggestions first: a stay that sits on top of a wish already carries its id as matchedBucketListItemId. Use it when someone asks which of the places they wanted to see they have actually reached.",
inputSchema: {
suggestionId: z.number().int().positive(),
bucketListItemId: z.number().int().positive().optional()
.describe('Defaults to the wish the stay was matched to'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'atlas', mode: 'write' },
when: dawarichAddonOn,
})
markBucketVisited(
{ suggestionId, bucketListItemId }: { suggestionId: number; bucketListItemId?: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
return this.run(() =>
this.suggestions.accept(ctx.userId, suggestionId, { target: 'bucket_list', bucketListItemId }),
);
}
@Tool({
name: 'dismiss_dawarich_suggestion',
description:
'Take a Dawarich stay out of the review list without adding it anywhere, or put a dismissed one back. Use it for the stays a detector produces that are not places anyone went — a traffic jam, a car park, the office.',
inputSchema: {
suggestionId: z.number().int().positive(),
state: z.enum(['dismissed', 'new']).optional().describe('Default: dismissed'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'journey', mode: 'write' },
when: dawarichAddonOn,
})
dismiss(
{ suggestionId, state }: { suggestionId: number; state?: 'dismissed' | 'new' },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const updated = this.suggestions.setState(ctx.userId, suggestionId, state ?? 'dismissed');
if (!updated) return errorResult('Suggestion not found');
return ok({ suggestion: updated });
}
@Tool({
name: 'get_dawarich_trip_track',
description:
"Fetch the route actually recorded during a trip, grouped by local day, straight from the caller's Dawarich instance. Each day carries its segments with start and end times and, where Dawarich classified it, how it was travelled. Use it to answer what route someone really took, or how a day's movement compares with what was planned. Nothing is stored in TREK — this is a live read of the recording.",
inputSchema: {
tripId: z.number().int().positive(),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Narrow to this first day'),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Narrow to this last day'),
},
// Reaches a remote instance rather than TREK's own database.
annotations: { ...TOOL_ANNOTATIONS_READONLY, openWorldHint: true },
access: { group: 'journey', mode: 'read' },
when: dawarichAddonOn,
})
async tripTrack(
{ tripId, from, to }: { tripId: number; from?: string; to?: string },
ctx: McpContext,
) {
let track;
try {
track = await this.tracks.forTrip(ctx.userId, tripId, from, to);
} catch (err) {
return errorResult(err instanceof Error ? err.message : 'Could not read the Dawarich recording');
}
if (!track) return errorResult('Trip not found');
// The geometry is the whole payload and a month of it is enormous in a tool
// result, so the summary goes out and the lines stay on the map.
return ok({
source: track.source,
fetchedAt: track.fetchedAt,
truncated: track.truncated,
pointCount: track.pointCount,
days: track.days.map((day) => ({
date: day.date,
segments: day.segments.map((segment) => ({
mode: segment.mode,
startedAt: segment.startedAt,
endedAt: segment.endedAt,
distanceMeters: segment.distanceMeters,
points: segment.points.length,
})),
})),
});
}
/** The MCP echo of the controller's error shaping: a refusal, not an exception. */
private run<T>(action: () => T) {
try {
return ok(action() as object);
} catch (err) {
if (err instanceof AcceptError) return errorResult(err.message);
throw err;
}
}
}
@@ -0,0 +1,66 @@
import { Module } from '@nestjs/common';
import { AddonsModule } from '../addons/addons.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { AtlasModule } from '../atlas/atlas.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { PlacesModule } from '../places/places.module';
import { AssignmentsDomainModule } from '../assignments/assignments-domain.module';
import { JourneyDomainModule } from '../journey/journey-domain.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { DawarichClient } from './dawarich.client';
import { DawarichController } from './dawarich.controller';
import { DawarichMcp } from './dawarich.mcp';
import { DawarichService } from './dawarich.service';
import { DawarichSuggestionsService } from './dawarich-suggestions.service';
import { DawarichSyncJob } from './dawarich-sync.job';
import { DawarichSyncService } from './dawarich-sync.service';
import { DawarichTracksService } from './dawarich-tracks.service';
/**
* The Dawarich integration (#2279): a per-user connection to a self-hosted
* location-history instance, the visits it reports, and the recorded route
* drawn over a trip.
*
* Everything hangs off `/api/integrations/dawarich`, so there is no cycle to
* dodge and no split into a core module the way AirTrail needed — nothing in
* TREK injects Dawarich, Dawarich injects TREK. The direction of that arrow is
* the architecture: this module reads other domains' services to *create* what
* a user accepted, and never the other way round.
*
* The leaf modules are picked deliberately. `AssignmentsDomainModule` rather
* than `AssignmentsModule`, and `JourneyDomainModule` rather than
* `JourneyModule`, so accepting a suggestion does not drag two controller
* stacks and both photo providers into this graph.
*/
@Module({
imports: [
AddonsModule,
AuditModule,
// The MCP tools ask AuthService whether the caller is the demo user, the
// same gate every other write tool carries.
AuthModule,
AtlasModule,
// Accepting a stay writes a place and a day assignment, so it asks the same
// permissions the planner asks before doing either.
PermissionsModule,
PlacesModule,
AssignmentsDomainModule,
JourneyDomainModule,
SchedulingModule,
McpSharedModule,
],
controllers: [DawarichController],
providers: [
DawarichClient,
DawarichService,
DawarichSyncService,
DawarichSuggestionsService,
DawarichTracksService,
DawarichSyncJob,
DawarichMcp,
],
exports: [DawarichService, DawarichSuggestionsService, DawarichTracksService],
})
export class DawarichModule {}
@@ -0,0 +1,401 @@
import { Injectable } from '@nestjs/common';
import {
DAWARICH_KEY_MASK,
type DawarichCapabilities,
type DawarichConnection,
type DawarichStatus,
type DawarichSyncState,
} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { AuditService } from '../audit/audit.service';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { checkSsrf } from '../../utils/ssrfGuard';
import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client';
/**
* The Dawarich connection: credentials, the probe, and what the connected
* instance can actually do.
*
* Credentials live in `dawarich_connections` rather than in more `users`
* columns. AirTrail put its four on `users` and said so out loud — correct while
* it was the only integration of that shape, and this is the second one. The
* deciding difference is that this connection carries state (last sync, last
* error, probed capabilities) that belongs to a sync, not to an identity, and
* that a personal location archive is the one credential worth keeping furthest
* from the row every other feature joins against.
*
* The key is encrypted at rest and never leaves again: `getConnection` returns a
* mask, and there is no route that reads it back.
*/
@Injectable()
export class DawarichService {
constructor(
private readonly db: DatabaseService,
private readonly audit: AuditService,
private readonly client: DawarichClient,
) {}
private readRow(userId: number): ConnRow | undefined {
return this.db.get<ConnRow>(
`SELECT user_id, url, api_key, allow_insecure_tls, sync_enabled,
last_sync_at, last_sync_state, last_sync_error, capabilities
FROM dawarich_connections WHERE user_id = ?`,
userId,
);
}
/** Decrypted credentials for an outbound call, or null when nothing is connected. */
getCredentials(userId: number): DawarichCreds | null {
const row = this.readRow(userId);
if (!row?.url || !row?.api_key) return null;
const apiKey = decrypt_api_key(row.api_key);
if (!apiKey) return null;
return { baseUrl: row.url, apiKey, allowInsecureTls: !!row.allow_insecure_tls };
}
/** True when the user has both an address and a key on file. */
isConnected(userId: number): boolean {
const row = this.readRow(userId);
return !!(row?.url && row?.api_key);
}
/** Every user whose connection is usable and whose background sync is on. */
listSyncableUserIds(): number[] {
const rows = this.db.all<{ user_id: number }>(
`SELECT user_id FROM dawarich_connections
WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS NOT NULL`,
);
return rows.map((r) => r.user_id);
}
/** The connection as the settings card shows it. The key is a mask, always. */
getConnection(userId: number): DawarichConnection {
const row = this.readRow(userId);
return {
url: row?.url || '',
apiKeyMasked: row?.api_key ? DAWARICH_KEY_MASK : '',
allowInsecureTls: !!row?.allow_insecure_tls,
// A row that does not exist yet is a user who has never opened the card,
// and the default there is "poll once it is connected".
syncEnabled: row ? !!row.sync_enabled : true,
connected: !!(row?.url && row?.api_key),
lastSyncAt: row?.last_sync_at ?? null,
lastSyncState: normalizeSyncState(row?.last_sync_state),
lastSyncError: row?.last_sync_error ?? null,
capabilities: parseCapabilities(row?.capabilities),
};
}
getCapabilities(userId: number): DawarichCapabilities | null {
return parseCapabilities(this.readRow(userId)?.capabilities);
}
/**
* Save the address and, when a new one was typed, the key.
*
* A blank key field or the mask means "keep what is stored" — the field is
* never prefilled, so blank is the normal state of a form someone opened to
* change the URL.
*
* A private address is saved with a warning rather than refused: a
* self-hosted Dawarich on a LAN is the common case, not an attack. Only a URL
* that could not work at all (malformed, unresolvable, not http) is rejected.
*/
async saveSettings(
userId: number,
url: string | undefined,
apiKey: string | undefined,
allowInsecureTls: boolean,
syncEnabled: boolean,
clientIp: string | null,
): Promise<{ success: boolean; warning?: string; warningCode?: string; warningIp?: string; error?: string; code?: string }> {
const trimmedUrl = (url || '').trim();
let warning: string | undefined;
let warningCode: string | undefined;
let warningIp: string | undefined;
if (trimmedUrl) {
const ssrf = await checkSsrf(trimmedUrl);
if (!ssrf.allowed && !ssrf.isPrivate) {
// The sentence is kept for a caller reading the API directly; the code
// beside it is what the UI renders, in the reader's own language.
return { success: false, code: 'invalid_url', error: ssrf.error ?? 'Invalid Dawarich URL' };
}
if (ssrf.isPrivate) {
this.audit.writeAudit({
userId,
action: 'dawarich.private_ip_configured',
ip: clientIp,
details: { dawarich_url: trimmedUrl, resolved_ip: ssrf.resolvedIp },
});
warning = `Dawarich URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`;
warningCode = 'private_ip';
warningIp = ssrf.resolvedIp ?? undefined;
}
}
const previousUrl = this.db.get<{ url: string | null }>(
'SELECT url FROM dawarich_connections WHERE user_id = ?',
userId,
)?.url ?? null;
const provided = (apiKey || '').trim();
const newKey = provided && provided !== DAWARICH_KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;
// All three writes or none: a row that took the new address but not the new
// key would point a credential minted for one instance at another one, and
// the next sync would send it there.
this.db.transaction(() => {
this.db.run(
`INSERT INTO dawarich_connections (user_id, url, allow_insecure_tls, sync_enabled, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
url = excluded.url,
allow_insecure_tls = excluded.allow_insecure_tls,
sync_enabled = excluded.sync_enabled,
updated_at = CURRENT_TIMESTAMP`,
userId,
trimmedUrl || null,
allowInsecureTls ? 1 : 0,
syncEnabled ? 1 : 0,
);
if (newKey !== undefined) {
this.db.run('UPDATE dawarich_connections SET api_key = ? WHERE user_id = ?', newKey, userId);
}
// A key belongs to the instance it was minted on. Pointing the connection
// at a different host without retyping the key would quietly send that
// instance's credential somewhere it was never issued for, so the key goes
// with the host — and only with the host: correcting a typo in the path,
// or adding a trailing slash, keeps it.
if (newKey === undefined && movedHost(previousUrl, trimmedUrl)) {
this.db.run(
`UPDATE dawarich_connections
SET api_key = NULL, capabilities = NULL, last_sync_state = 'never',
last_sync_error = NULL, last_sync_at = NULL
WHERE user_id = ?`,
userId,
);
}
// Clearing the address leaves a key that can no longer be used for
// anything. Keeping it would be storing a live credential for a server
// nobody named.
if (!trimmedUrl) {
this.db.run(
`UPDATE dawarich_connections
SET api_key = NULL, capabilities = NULL, last_sync_state = 'never',
last_sync_error = NULL, last_sync_at = NULL
WHERE user_id = ?`,
userId,
);
}
});
return warning ? { success: true, warning, warningCode, warningIp } : { success: true };
}
/**
* Forget the connection entirely.
*
* The suggestions it produced are deliberately left alone: one that was
* accepted became a place or a journal entry the user wrote, and deleting
* their journal because they revoked an API key would be the integration
* destroying user content on its way out. What goes is the credential.
*/
disconnect(userId: number, clientIp: string | null): void {
this.db.run('DELETE FROM dawarich_connections WHERE user_id = ?', userId);
this.audit.writeAudit({ userId, action: 'dawarich.disconnected', ip: clientIp, details: {} });
}
/**
* Try the connection as it is about to be saved — with the typed key if there
* is one, otherwise with the stored one.
*
* Answers 200 with `connected: false` rather than throwing, because "wrong
* key" is an answer the form has to render, not a server error.
*/
async testConnection(
userId: number,
url: string | undefined,
apiKey: string | undefined,
allowInsecureTls: boolean,
): Promise<DawarichStatus> {
const typedKey = (apiKey || '').trim();
const stored = this.getCredentials(userId);
const baseUrl = (url || '').trim() || stored?.baseUrl || '';
const key = typedKey && typedKey !== DAWARICH_KEY_MASK ? typedKey : stored?.apiKey;
if (!baseUrl || !key) {
return { connected: false, error: 'not_connected' };
}
const creds: DawarichCreds = { baseUrl, apiKey: key, allowInsecureTls };
try {
const capabilities = await this.probeCapabilities(creds);
// Only persist against a connection that is actually stored — a test run
// from a half-filled form must not overwrite what is on file.
if (stored && baseUrl === stored.baseUrl) this.storeCapabilities(userId, capabilities);
return {
connected: true,
visitCount: capabilities.visits ? await this.countRecentVisits(creds) : 0,
capabilities,
};
} catch (err) {
if (err instanceof DawarichError) {
return { connected: false, error: err.code, ...(err.detail ? { errorDetail: err.detail } : {}) };
}
return { connected: false, error: 'unreachable' };
}
}
/**
* Ask the instance what it offers, once, instead of assuming a version.
*
* Each endpoint is tried with a tiny window; a 404 means this build does not
* have it and the feature that needs it stays off, which is how an older
* Dawarich still connects and still does most of what people want. Anything
* other than a 404 — an unreachable host, a rejected key — is a real failure
* and propagates: reporting "connected, but nothing works" would be worse
* than the error.
*/
async probeCapabilities(creds: DawarichCreds): Promise<DawarichCapabilities> {
const { version } = await this.client.probe(creds);
const to = new Date();
const from = new Date(to.getTime() - 24 * 60 * 60 * 1000);
const visitsProbe = await this.tryEndpoint(() => this.client.listVisits(creds, from, to));
const tracks = await this.tryEndpoint(() => this.client.listTracks(creds, from, to));
const points = await this.tryEndpoint(() => this.client.listPoints(creds, from, to));
const visitedCities = await this.tryEndpoint(() => this.client.listVisitedCities(creds, from, to));
// A coordinate no recording will ever be near: the probe is about whether
// the route answers, not about what it finds.
const locations = await this.tryEndpoint(() => this.client.findVisitsNear(creds, 0, 0, 50, 1));
const sample = visitsProbe.ok ? visitsProbe.value.visits[0] : undefined;
return {
visits: visitsProbe.ok,
tracks: tracks.ok,
points: points.ok,
locations: locations.ok,
visitedCities: visitedCities.ok,
// Read off a real row when there is one. With no visit in the probe window
// the honest answer is "not seen", and the sync re-probes on every run, so
// a later version's new field is picked up on its own.
visitUpdatedAt: !!sample && sample.updated_at != null,
visitCountryCode: !!sample?.place && sample.place.country_code != null,
serverVersion: version,
probedAt: new Date().toISOString(),
};
}
/** A 404 means "this build has no such endpoint"; everything else is a real failure. */
private async tryEndpoint<T>(run: () => Promise<T>): Promise<{ ok: true; value: T } | { ok: false }> {
try {
return { ok: true, value: await run() };
} catch (err) {
if (err instanceof DawarichError && err.code === 'not_found') return { ok: false };
throw err;
}
}
/** Visits in the last 30 days — the number the connection card shows as proof of life. */
private async countRecentVisits(creds: DawarichCreds): Promise<number> {
const to = new Date();
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
try {
const { visits } = await this.client.listVisits(creds, from, to);
return visits.length;
} catch {
// The connection is already proven at this point; a count is a nicety.
return 0;
}
}
storeCapabilities(userId: number, capabilities: DawarichCapabilities): void {
this.db.run(
'UPDATE dawarich_connections SET capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?',
JSON.stringify(capabilities),
userId,
);
}
recordSyncResult(userId: number, state: DawarichSyncState, error: string | null): void {
this.db.run(
`UPDATE dawarich_connections
SET last_sync_at = ?, last_sync_state = ?, last_sync_error = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?`,
new Date().toISOString(),
state,
error,
userId,
);
}
}
interface ConnRow {
user_id: number;
url: string | null;
api_key: string | null;
allow_insecure_tls: number | null;
sync_enabled: number | null;
last_sync_at: string | null;
last_sync_state: string | null;
last_sync_error: string | null;
capabilities: string | null;
}
/**
* An unrecognised stored state reads as `never` rather than being passed
* through: the client switches on it, and a value it has no branch for would
* render as nothing at all.
*/
function normalizeSyncState(raw: string | null | undefined): DawarichSyncState {
return raw === 'ok' || raw === 'partial' || raw === 'failed' ? raw : 'never';
}
/**
* Capabilities are stored as JSON and read back defensively: the column is
* written by an older build's shape after an upgrade, and a malformed value
* means "not probed yet", never a crash on the settings page.
*/
function parseCapabilities(raw: string | null | undefined): DawarichCapabilities | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<DawarichCapabilities>;
if (typeof parsed !== 'object' || parsed === null) return null;
return {
visits: !!parsed.visits,
tracks: !!parsed.tracks,
points: !!parsed.points,
locations: !!parsed.locations,
visitedCities: !!parsed.visitedCities,
visitUpdatedAt: !!parsed.visitUpdatedAt,
visitCountryCode: !!parsed.visitCountryCode,
serverVersion: typeof parsed.serverVersion === 'string' ? parsed.serverVersion : null,
probedAt: typeof parsed.probedAt === 'string' ? parsed.probedAt : new Date(0).toISOString(),
};
} catch {
return null;
}
}
/**
* Did the connection move to a different Dawarich instance?
*
* Scheme, host and port, because that is what decides where a credential is
* sent. A path change ("/", "/api", a typo) is the same instance. Anything
* unparseable is compared as written — a conservative answer that errs towards
* dropping the key rather than shipping it somewhere new.
*/
function movedHost(before: string | null, after: string): boolean {
if (!before || !after) return false;
try {
const a = new URL(before);
const b = new URL(after);
return a.origin !== b.origin;
} catch {
return before !== after;
}
}
+14 -9
View File
@@ -21,13 +21,17 @@ import { TokenService } from '../tokens/token.service';
* - **No session JWT.** A session cookie leaking into a third-party integration is
* exactly what a machine credential exists to prevent, and a JWT that reaches
* this surface is almost always an accident.
* - **No OAuth tokens (yet).** They carry scopes this surface does not interpret
* yet, and accepting a credential whose restrictions you ignore is worse than
* refusing it. When `/api/v1` grows write routes, scopes get honoured first.
* - **No OAuth tokens.** They carry MCP scopes, which are a different vocabulary
* from the read sections this surface understands — `trips:write` has no
* meaning on a read-only API, and quietly mapping one onto the other would be
* inventing permissions nobody granted. Accepting a credential whose
* restrictions you reinterpret is worse than refusing it.
*
* The guard authenticates but does not authorise: it resolves `req.user` and stops
* there. Which trips that user may read is decided per row against
* `DatabaseService.canAccessTrip`, never from anything the caller sent.
* The guard resolves two things and then stops: **who** is calling (`req.user`)
* and **what their key may read** (`req.apiToken`). Both are needed and neither
* is sufficient. Which trips that user may reach is still decided per row
* against `DatabaseService.canAccessTrip`, never from anything the caller sent;
* the grant only narrows that further, and can never widen it.
*/
@Injectable()
export class ApiTokenGuard implements CanActivate {
@@ -42,14 +46,15 @@ export class ApiTokenGuard implements CanActivate {
401,
);
}
const user = this.tokens.verifyApiToken(token);
if (!user) {
const resolved = this.tokens.verifyApiTokenWithGrant(token);
if (!resolved) {
throw new HttpException(
{ error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
401,
);
}
req.user = user;
req.user = resolved.user;
req.apiToken = resolved.grant;
return true;
}
}
@@ -1,5 +1,6 @@
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared';
import type { RateLimitService } from '../common/rate-limit.service';
/**
@@ -44,3 +45,57 @@ export function requireUserId(req: Request): number {
}
return id;
}
/**
* What the key on this request may read.
*
* A request that somehow reached a route without the guard has no grant, and
* the honest answer there is "nothing" rather than "everything" — the same
* fail-closed reasoning as `requireUserId` throwing a 401. A key with no
* narrowing has already been resolved to the full list by the token service,
* so there is no absent-means-all rule left to get wrong here.
*/
export function grantedScopes(req: Request): readonly PublicApiScope[] {
return req.apiToken?.scopes ?? [];
}
/**
* Refuse a request whose key does not cover the section it asked for.
*
* **403, and a code of its own.** Not 401: the credential is valid and
* re-authenticating will not help. Not 404: an integrator debugging a key they
* narrowed themselves needs to be told that, not sent looking for a missing
* trip. The two existing codes are deliberately left alone so
* "expired/invalid" and "not enough access" stay distinguishable.
*/
export function requireScope(req: Request, scope: PublicApiScope): void {
if (!grantedScopes(req).includes(scope)) {
throw new HttpException(
{
error: `This API key is not allowed to read ${scope}`,
code: 'API_SCOPE_FORBIDDEN',
required_scope: scope,
},
403,
);
}
}
/**
* Narrow a set of requested sections to what the key may actually read.
*
* Filtering rather than refusing, for one specific case: `include` defaults to
* everything, so a narrow key asking for a trip with no `include` at all would
* otherwise be refused for wanting sections it never named. It gets what it may
* have. A caller that *names* a forbidden section is a different matter and is
* refused by `requireScope` at the controller, because silently dropping what
* somebody explicitly asked for is how integrators end up debugging their own
* correct code.
*/
export function narrowToGrant<T extends string>(sections: readonly T[], req: Request): T[] {
const granted = new Set<string>(grantedScopes(req));
return sections.filter((section) => granted.has(section));
}
/** Every section, for the callers that need the canonical list. */
export const ALL_PUBLIC_API_SCOPES: readonly PublicApiScope[] = PUBLIC_API_SCOPES;
@@ -10,7 +10,12 @@ import {
} from '@trek/shared';
import { ApiTokenGuard } from './api-token.guard';
import { PublicApiService } from './public-api.service';
import { enforcePublicApiRateLimit, requireUserId } from './public-api-request';
import {
enforcePublicApiRateLimit,
narrowToGrant,
requireScope,
requireUserId,
} from './public-api-request';
import { RateLimitService } from '../common/rate-limit.service';
/**
@@ -48,6 +53,7 @@ export class PublicApiController {
@Get('trips')
listTrips(@Req() req: Request): PublicApiTripList {
this.limit(req);
requireScope(req, 'trips');
return { trips: this.api.listTrips(requireUserId(req)) };
}
@@ -61,6 +67,7 @@ export class PublicApiController {
@Get('bucket-list')
listBucketList(@Req() req: Request): PublicApiBucketList {
this.limit(req);
requireScope(req, 'bucket-list');
return { items: this.api.listBucketList(requireUserId(req)) };
}
@@ -78,8 +85,20 @@ export class PublicApiController {
@Query('include') include?: string,
): PublicApiTrip {
this.limit(req);
requireScope(req, 'trips');
const tripId = parseTripId(id);
const trip = this.api.getTrip(tripId, requireUserId(req), parseInclude(include));
// A named section the key may not read is refused outright; an unnamed one —
// `include` absent, which means "everything" — is simply narrowed. Silently
// dropping a section somebody asked for by name is how an integrator ends
// up debugging their own correct code.
const asked = parseInclude(include);
if (include !== undefined && include.trim() !== '') {
for (const section of asked) requireScope(req, section);
}
const allowed = narrowToGrant(asked, req);
const trip = this.api.getTrip(tripId, requireUserId(req), allowed);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
+123 -8
View File
@@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common';
import { randomBytes, createHash } from 'crypto';
import {
PUBLIC_API_SCOPES,
type PublicApiGrant,
type PublicApiScope,
} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
@@ -57,21 +62,46 @@ export class TokenService {
}
private listTokens(userId: number, kind: TokenKind) {
return this.db.all(
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC',
const rows = this.db.all<TokenRow>(
'SELECT id, name, token_prefix, created_at, last_used_at, scope_mode, api_scopes FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC',
userId, kind
);
// The MCP list keeps the exact shape it has always had: those tokens carry
// no read scopes, and two columns that are always "everything" would be
// noise in a panel that cannot act on them.
if (kind === 'mcp') {
return rows.map(({ scope_mode: _mode, api_scopes: _scopes, ...rest }) => rest);
}
return rows.map((row) => {
const grant = resolveGrant(row.scope_mode, row.api_scopes);
return {
id: row.id,
name: row.name,
token_prefix: row.token_prefix,
created_at: row.created_at,
last_used_at: row.last_used_at,
scope_mode: grant.mode,
scopes: grant.scopes,
};
});
}
createMcpToken(userId: number, rawName: unknown) {
return this.createToken(userId, rawName, 'mcp');
}
createApiToken(userId: number, rawName: unknown) {
return this.createToken(userId, rawName, 'api');
/**
* An integration key, optionally narrowed to a few sections.
*
* Omitting `scopes` means the key reads everything — what every key did
* before this argument existed. Narrowing is opt-in on purpose: shipping the
* column must not change what an already-running integration can do.
*/
createApiToken(userId: number, rawName: unknown, scopes?: readonly string[]) {
return this.createToken(userId, rawName, 'api', scopes);
}
private createToken(userId: number, rawName: unknown, kind: TokenKind): { error?: string; status?: number; token?: Record<string, unknown> } {
private createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): { error?: string; status?: number; token?: Record<string, unknown> } {
const name = rawName as string | undefined;
if (!name?.trim()) return { error: 'Token name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 };
@@ -83,9 +113,15 @@ export class TokenService {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const tokenPrefix = rawToken.slice(0, 13);
// An MCP token never carries read scopes, so it is stored as "everything" —
// the column default — rather than being handed a list it would ignore.
const narrowed = kind === 'api' ? sanitizeScopes(scopes) : null;
const result = this.db.run(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind) VALUES (?, ?, ?, ?, ?)',
userId, name.trim(), tokenHash, tokenPrefix, kind
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind, scope_mode, api_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)',
userId, name.trim(), tokenHash, tokenPrefix, kind,
narrowed ? 'limited' : 'all',
narrowed ? JSON.stringify(narrowed) : null,
);
const token = this.db.get(
@@ -93,7 +129,10 @@ export class TokenService {
result.lastInsertRowid
);
return { token: { ...(token as object), raw_token: rawToken } };
const grant = kind === 'api'
? { scope_mode: narrowed ? 'limited' : 'all', scopes: narrowed ?? [...PUBLIC_API_SCOPES] }
: {};
return { token: { ...(token as object), ...grant, raw_token: rawToken } };
}
deleteMcpToken(userId: number, tokenId: string) {
@@ -183,6 +222,27 @@ export class TokenService {
return this.verifyToken(rawToken, 'api');
}
/**
* The same lookup, plus what the key is allowed to read.
*
* A second method rather than a widened `verifyApiToken`: the guard needs
* both halves, nothing else needs either, and the existing signature is
* pinned by tests that assert exactly a `User`.
*/
verifyApiTokenWithGrant(rawToken: string): { user: User; grant: PublicApiGrant } | null {
const hash = createHash('sha256').update(rawToken).digest('hex');
const row = this.db.get<User & { scope_mode: string | null; api_scopes: string | null }>(`
SELECT u.id, u.username, u.email, u.role, mt.scope_mode, mt.api_scopes
FROM mcp_tokens mt
JOIN users u ON mt.user_id = u.id
WHERE mt.token_hash = ? AND mt.kind = 'api'
`, hash);
if (!row) return null;
this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash);
const { scope_mode, api_scopes, ...user } = row;
return { user: user as User, grant: resolveGrant(scope_mode, api_scopes) };
}
/**
* Hash, look up, and require the kind the calling surface accepts.
*
@@ -206,3 +266,58 @@ export class TokenService {
return null;
}
}
interface TokenRow {
id: number;
name: string;
token_prefix: string;
created_at: string;
last_used_at: string | null;
scope_mode: string | null;
api_scopes: string | null;
}
/**
* The requested sections, cleaned up — unknown values dropped, duplicates
* collapsed, order pinned to the canonical list so two keys with the same
* access read the same in the panel.
*
* Returns null for "no narrowing was asked for", which is what makes the caller
* store full access rather than an empty list that would lock the key out of
* everything.
*/
function sanitizeScopes(scopes: readonly string[] | undefined): PublicApiScope[] | null {
if (!scopes || scopes.length === 0) return null;
const wanted = new Set(scopes);
const kept = PUBLIC_API_SCOPES.filter((scope) => wanted.has(scope));
return kept.length > 0 ? [...kept] : null;
}
/**
* What a stored row means, read defensively — in both directions.
*
* A row that does not say 'limited' is a full-access key: that is what every key
* written before this column existed had, and a missing narrowing must not cost
* a running integration its access. Narrowing is a deliberate act; its absence
* is not a denial.
*
* But once a row HAS said 'limited', every way of failing to read its list
* denies. Unparseable JSON, a non-array, or a list of scope names this version
* no longer knows (a renamed constant, a hand-edited row) resolves to no scopes
* at all rather than to everything: the whole reason `scope_mode` is an explicit
* flag instead of "NULL means everything" is that a key minted as restricted
* must never widen on its own. A key that stops working is a support ticket; a
* key that quietly reads every trip is the bug this feature exists to prevent.
*/
function resolveGrant(mode: string | null, raw: string | null): PublicApiGrant {
if (mode !== 'limited') return { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
let parsed: unknown;
try {
parsed = raw ? JSON.parse(raw) : null;
} catch {
parsed = null;
}
if (!Array.isArray(parsed)) return { mode: 'limited', scopes: [] };
const kept = sanitizeScopes(parsed.filter((value): value is string => typeof value === 'string'));
return { mode: 'limited', scopes: kept ?? [] };
}
+11
View File
@@ -3,6 +3,7 @@
// (`(req as AuthRequest).user`, `getRequest<Request & { user?: User }>()`) with
// a single source of truth so downstream code can read `req.user` directly.
import type { PublicApiGrant } from '@trek/shared';
import type { User } from '../types';
declare module 'express-serve-static-core' {
@@ -13,6 +14,16 @@ declare module 'express-serve-static-core' {
* `optionalAuthenticate` to signal "checked, but unauthenticated".
*/
user?: User | null;
/**
* What the `/api/v1` integration key presented on this request may read.
* Set by `ApiTokenGuard`, and only there.
*
* Its own field rather than something folded into `user`: the grant belongs
* to the credential, not to the person, and a cookie session has no grant at
* all. Putting it on `user` would leak a token's restrictions into every
* other auth path that happens to read the same object.
*/
apiToken?: PublicApiGrant;
}
}
+345
View File
@@ -0,0 +1,345 @@
/**
* Dawarich e2e — drives /api/integrations/dawarich through the REAL AddonGuard
* and the REAL JwtAuthGuard, over the real DI-native services (DatabaseModule +
* DawarichModule) against a temp SQLite db carrying the full schema.
*
* Two things are replaced and nothing else:
*
* - `AddonsService.isAddonEnabled`, because the gate is what is under test and
* an admin toggle is not worth seeding for it;
* - `DawarichClient`, because a test must never open a socket to somebody's
* location archive. Every method is a spy that fails loudly if a route this
* file exercises turns out to reach upstream after all.
*
* Covered: the 404-before-401 ordering of the addon gate, the unauthenticated
* 401, the shape of a connection nobody has configured, the two URL rejections
* that need no DNS, the suggestions envelope's `connected: false`, and the
* cross-user isolation — someone else's suggestion is invisible and untouchable
* through your own session, and answers the same 404 a missing one does.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec('PRAGMA foreign_keys = ON');
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: number | string, userId: number) =>
db
.prepare(
'SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)',
)
.get(userId, tripId, userId),
isOwner: () => false,
}));
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { createUser } from '../helpers/factories';
import { DawarichModule } from '../../src/nest/integrations/dawarich.module';
import { DawarichClient } from '../../src/nest/integrations/dawarich.client';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
/**
* Every outbound call an instance would ever receive, stubbed to throw. A route
* that unexpectedly reaches upstream fails this suite instead of quietly
* hanging on a DNS lookup in CI.
*/
function makeClientStub() {
const boom = (name: string) => vi.fn(() => {
throw new Error(`DawarichClient.${name} must not be called from the e2e suite`);
});
return {
probe: boom('probe'),
listVisits: boom('listVisits'),
listTracks: boom('listTracks'),
listPoints: boom('listPoints'),
listVisitedCities: boom('listVisitedCities'),
findVisitsNear: boom('findVisitsNear'),
};
}
/** A suggestion row with only the NOT NULL columns filled in. */
function seedSuggestion(userId: number, sourceVisitId: string, name: string): number {
const info = db
.prepare(
`INSERT INTO dawarich_visit_suggestions
(user_id, source_visit_id, name, lat, lng, started_at, ended_at, duration_minutes, local_date, source_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(userId, sourceVisitId, name, 48.8584, 2.2945, '2026-05-01T10:00:00+02:00', '2026-05-01T12:30:00+02:00', 150, '2026-05-01', 'hash-' + sourceVisitId);
return Number(info.lastInsertRowid);
}
describe('Dawarich e2e (real addon gate + real auth guard + real services + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let ownerId: number;
let strangerId: number;
let strangerSuggestionId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, DawarichModule] })
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled })
.overrideProvider(DawarichClient)
.useValue(makeClientStub())
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
// by metatype, exactly as they do under buildApp().
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
beforeAll(async () => {
createTables(db as never);
runMigrations(db as never);
ownerId = createUser(db as never, { username: 'dawarich-owner', email: 'dawarich-owner@test.example' }).user.id;
strangerId = createUser(db as never, { username: 'dawarich-stranger', email: 'dawarich-stranger@test.example' }).user.id;
strangerSuggestionId = seedSuggestion(strangerId, 'visit-stranger-1', 'Eiffel Tower');
app = await build();
server = app.getHttpServer();
});
beforeEach(() => {
isAddonEnabled.mockReturnValue(true);
db.prepare('DELETE FROM dawarich_connections').run();
db.prepare('DELETE FROM dawarich_visit_suggestions WHERE user_id = ?').run(ownerId);
});
afterAll(async () => {
await app.close();
});
// ── Auth + addon gate ────────────────────────────────────────────────────
it('DAWARICH-E2E-001: 401 without a session cookie', async () => {
const res = await request(server).get('/api/integrations/dawarich/settings');
expect(res.status).toBe(401);
});
it('DAWARICH-E2E-002: a session on a disabled addon gets 404 with the Dawarich label, not a 403 that confirms the route', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server)
.get('/api/integrations/dawarich/settings')
.set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Dawarich addon is not enabled' });
});
it('DAWARICH-E2E-003: the addon gate beats the auth guard — no cookie and no addon is still 404, so an anonymous probe learns nothing', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).get('/api/integrations/dawarich/suggestions');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Dawarich addon is not enabled' });
});
it('DAWARICH-E2E-004: a disabled addon 404s the write routes too, and nothing is persisted', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server)
.put('/api/integrations/dawarich/settings')
.set('Cookie', sessionCookie(ownerId))
.send({ url: 'https://dawarich.example', apiKey: 'secret' });
expect(res.status).toBe(404);
expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 });
});
it('DAWARICH-E2E-005: the addon flag is re-read per request — switching it back on reopens the route without a restart', async () => {
isAddonEnabled.mockReturnValue(false);
expect((await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId))).status).toBe(404);
isAddonEnabled.mockReturnValue(true);
expect((await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId))).status).toBe(200);
});
// ── Connection ───────────────────────────────────────────────────────────
it('DAWARICH-E2E-010: GET settings for a user who has never connected answers the empty connection, with the poll defaulted on', async () => {
const res = await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(200);
expect(res.body).toEqual({
url: '',
// Never the key, and not even a placeholder when there is nothing stored.
apiKeyMasked: '',
allowInsecureTls: false,
// No row yet is a user who has not opened the card; the default there is
// "poll once it is connected", not "off".
syncEnabled: true,
connected: false,
lastSyncAt: null,
lastSyncState: 'never',
lastSyncError: null,
capabilities: null,
});
});
it('DAWARICH-E2E-011: PUT settings with an unparseable URL is a 400 and writes nothing', async () => {
const res = await request(server)
.put('/api/integrations/dawarich/settings')
.set('Cookie', sessionCookie(ownerId))
.send({ url: 'not a url', apiKey: 'secret' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Invalid URL' });
// Crucially: the key is not stored on a rejected address either.
expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 });
});
it('DAWARICH-E2E-012: PUT settings with a non-HTTP scheme is a 400 — file:// and friends never reach the fetch layer', async () => {
const res = await request(server)
.put('/api/integrations/dawarich/settings')
.set('Cookie', sessionCookie(ownerId))
.send({ url: 'file:///etc/passwd' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Only HTTP and HTTPS URLs are allowed' });
expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 });
});
it('DAWARICH-E2E-013: PUT settings rejects a body the shared contract does not accept, before any of this runs', async () => {
const res = await request(server)
.put('/api/integrations/dawarich/settings')
.set('Cookie', sessionCookie(ownerId))
.send({ apiKey: 'secret' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/^url: /);
});
it('DAWARICH-E2E-014: clearing the address saves the row, keeps the connection unconnected and persists the poll toggle', async () => {
const put = await request(server)
.put('/api/integrations/dawarich/settings')
.set('Cookie', sessionCookie(ownerId))
.send({ url: '', syncEnabled: false });
expect(put.status).toBe(200);
expect(put.body).toEqual({ success: true });
const row = db.prepare('SELECT url, api_key, sync_enabled FROM dawarich_connections WHERE user_id = ?').get(ownerId);
expect(row).toEqual({ url: null, api_key: null, sync_enabled: 0 });
const res = await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId));
expect(res.body.connected).toBe(false);
expect(res.body.syncEnabled).toBe(false);
expect(res.body.apiKeyMasked).toBe('');
});
it('DAWARICH-E2E-015: DELETE settings answers 200 even when there was nothing to disconnect', async () => {
const res = await request(server).delete('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
// ── Suggestions ──────────────────────────────────────────────────────────
it('DAWARICH-E2E-020: GET suggestions without a connection answers the envelope with connected:false — an empty list alone would read as "no stays"', async () => {
const res = await request(server).get('/api/integrations/dawarich/suggestions').set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(200);
expect(res.body).toEqual({
suggestions: [],
connected: false,
lastSyncAt: null,
lastSyncState: 'never',
lastSyncError: null,
});
});
it('DAWARICH-E2E-021: an unknown state filter is a 400 rather than a silently unfiltered list', async () => {
const res = await request(server)
.get('/api/integrations/dawarich/suggestions?state=pending')
.set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'state must be one of: new, accepted, dismissed' });
});
it('DAWARICH-E2E-022: a tripId that only starts with digits is a 400', async () => {
const res = await request(server)
.get('/api/integrations/dawarich/suggestions?tripId=12abc')
.set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Invalid tripId' });
});
// ── Cross-user isolation ─────────────────────────────────────────────────
it("DAWARICH-E2E-030: another user's suggestion never appears in your own list", async () => {
const mine = seedSuggestion(ownerId, 'visit-mine-1', 'Gare du Nord');
const res = await request(server).get('/api/integrations/dawarich/suggestions').set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(200);
const ids = res.body.suggestions.map((s: { id: number }) => s.id);
expect(ids).toEqual([mine]);
expect(ids).not.toContain(strangerSuggestionId);
// And the row really is there for the other user, so this is isolation and
// not an empty table.
expect(db.prepare('SELECT user_id FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId))
.toEqual({ user_id: strangerId });
});
it("DAWARICH-E2E-031: PUT state on another user's suggestion is a 404 — the same answer a missing id gets, so nothing is enumerable", async () => {
const res = await request(server)
.put(`/api/integrations/dawarich/suggestions/${strangerSuggestionId}/state`)
.set('Cookie', sessionCookie(ownerId))
.send({ state: 'dismissed' });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Suggestion not found' });
expect(db.prepare('SELECT state FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId))
.toEqual({ state: 'new' });
const missing = await request(server)
.put('/api/integrations/dawarich/suggestions/999999/state')
.set('Cookie', sessionCookie(ownerId))
.send({ state: 'dismissed' });
expect(missing.status).toBe(404);
expect(missing.body).toEqual(res.body);
});
it("DAWARICH-E2E-032: POST accept on another user's suggestion is a 404 with the domain's own code", async () => {
const res = await request(server)
.post(`/api/integrations/dawarich/suggestions/${strangerSuggestionId}/accept`)
.set('Cookie', sessionCookie(ownerId))
.send({ target: 'place', tripId: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Suggestion not found', code: 'not_found' });
expect(db.prepare('SELECT state, target FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId))
.toEqual({ state: 'new', target: null });
});
it('DAWARICH-E2E-033: your own suggestion is reachable through the same route, which is what makes the two 404s above meaningful', async () => {
const mine = seedSuggestion(ownerId, 'visit-mine-2', 'Musée d’Orsay');
const res = await request(server)
.put(`/api/integrations/dawarich/suggestions/${mine}/state`)
.set('Cookie', sessionCookie(ownerId))
.send({ state: 'dismissed' });
expect(res.status).toBe(200);
expect(res.body.id).toBe(mine);
expect(res.body.state).toBe('dismissed');
expect(db.prepare('SELECT state FROM dawarich_visit_suggestions WHERE id = ?').get(mine)).toEqual({ state: 'dismissed' });
});
});
+243 -2
View File
@@ -21,10 +21,15 @@ const { db } = vi.hoisted(() => {
tmp.exec(`
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);
-- scope_mode/api_scopes are spelled exactly as the scopes migration adds
-- them (NOT NULL DEFAULT 'all', and a nullable JSON list): a row here has to
-- be able to be the row the guard reads in production, or the scope cases
-- below would be passing against a table that does not exist anywhere else.
CREATE TABLE mcp_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
name TEXT NOT NULL, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME,
kind TEXT NOT NULL DEFAULT 'mcp');
kind TEXT NOT NULL DEFAULT 'mcp',
scope_mode TEXT NOT NULL DEFAULT 'all', api_scopes TEXT);
CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
title TEXT NOT NULL, description TEXT, start_date TEXT, end_date TEXT, currency TEXT,
is_archived INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -76,6 +81,10 @@ vi.mock('../../src/db/database', async (importActual) => {
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RateLimitModule } from '../../src/nest/common/rate-limit.module';
import { PublicApiModule } from '../../src/nest/public-api/public-api.module';
import { ApiTokenGuard } from '../../src/nest/public-api/api-token.guard';
import { TokensModule } from '../../src/nest/tokens/tokens.module';
import { PublicStatsController } from '../../src/nest/atlas/public-stats.controller';
import { AtlasService } from '../../src/nest/atlas/atlas.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
/** Mints a token the way TokenService does, so the guard's hash lookup is real. */
@@ -90,17 +99,67 @@ function seedToken(userId: number, raw: string, kind: 'api' | 'mcp' = 'api'): st
return raw;
}
/**
* The same mint, narrowed. `rawScopes` goes into the column verbatim — including
* the deliberately unparseable value one case needs, which is why this takes a
* string rather than an array.
*/
function seedLimitedToken(userId: number, raw: string, rawScopes: string): string {
db.prepare(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind, scope_mode, api_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(
userId,
'test-limited',
createHash('sha256').update(raw).digest('hex'),
raw.slice(0, 13),
'api',
'limited',
rawScopes,
);
return raw;
}
const ADA_TOKEN = 'trek_' + 'a'.repeat(48);
const BOB_TOKEN = 'trek_' + 'b'.repeat(48);
/** A valid credential for /mcp — must not open this surface. */
const MCP_TOKEN = 'trek_' + 'c'.repeat(48);
/** Narrowed keys, all Ada's, so only the grant differs between them. */
const TRIPS_ONLY_TOKEN = 'trek_' + 'd'.repeat(48);
const TRIPS_DAYS_TOKEN = 'trek_' + 'e'.repeat(48);
/** api_scopes that cannot be parsed — the fall-back-to-everything case. */
const BROKEN_SCOPES_TOKEN = 'trek_' + 'f'.repeat(48);
/** Stubbed so /api/v1/stats can be mounted without AtlasModule's whole graph. */
const STATS = {
getTravelStats: () => ({
countries: ['JP', 'IT'],
cities: ['tokyo'],
coords: [],
totalTrips: 2,
totalDays: 3,
totalPlaces: 4,
totalDistanceKm: 1234,
}),
lastTrip: () => null,
};
describe('Public API v1 e2e (real guard + real SQL)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RateLimitModule, PublicApiModule] }).compile();
const moduleRef = await Test.createTestingModule({
imports: [DatabaseModule, RateLimitModule, TokensModule, PublicApiModule],
// `/api/v1/stats` lives in atlas/ because its figures do, but it is guarded
// and scoped by this directory's code — so it is mounted here with the real
// guard and a stubbed AtlasService. Importing AtlasModule instead would pull
// AuthModule and the storage registry, which reads app_settings on init and
// would need half the schema this file deliberately does not carry. What is
// under test is the refusal, not the arithmetic (public-stats.controller.test
// owns that), and the refusal happens before AtlasService is ever touched.
controllers: [PublicStatsController],
providers: [ApiTokenGuard, { provide: AtlasService, useValue: STATS }],
}).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
@@ -113,6 +172,13 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
seedToken(1, ADA_TOKEN);
seedToken(2, BOB_TOKEN);
seedToken(1, MCP_TOKEN, 'mcp');
// ADA_TOKEN above is seeded without ever naming the two scope columns — the
// row a key minted before the feature existed leaves behind. It is the
// backwards-compatibility case and every existing test in this file rides on
// it, so if narrowing ever leaks into the default those tests fail first.
seedLimitedToken(1, TRIPS_ONLY_TOKEN, JSON.stringify(['trips']));
seedLimitedToken(1, TRIPS_DAYS_TOKEN, JSON.stringify(['trips', 'days', 'notes']));
seedLimitedToken(1, BROKEN_SCOPES_TOKEN, '{"not":"a list"');
// Ada owns trip 1; Bob owns trip 2; trip 3 is Bob's but Ada is a member.
db.prepare(
@@ -160,6 +226,20 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
return token ? req.set('Authorization', `Bearer ${token}`) : req;
};
/**
* Several reads in a row, one after the other.
*
* Not `Promise.all`: the Nest app is never told to listen, so supertest binds
* an ephemeral port itself on the first request. Fired in parallel, four
* requests race four `listen(0)` calls on the same server and the losers come
* back as ECONNRESET — reliably on a loaded CI runner, almost never here.
*/
const getEach = async (...calls: Array<[string, string?]>) => {
const out = [];
for (const [path, token] of calls) out.push(await get(path, token));
return out;
};
describe('authentication', () => {
it('401s without a token', async () => {
const res = await get('/api/v1/trips');
@@ -359,4 +439,165 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
expect(res.status).toBe(401);
});
});
/**
* Read scopes (#2279).
*
* The question a mock cannot answer: does a key stored in the database a
* particular way actually reach the sections it should, and stop at the ones it
* should not? Every case below starts from a real row and ends at a real HTTP
* status, because the two halves — what the column says and what the route does
* — are written in different files and have no reason to agree on their own.
*
* The first case is the important one. Narrowing is a feature; not breaking the
* keys that already exist is a promise.
*/
describe('read scopes', () => {
it('PUBAPI-SCOPE-001: a key minted before scopes existed is stored as a full grant', () => {
const row = db
.prepare('SELECT scope_mode, api_scopes FROM mcp_tokens WHERE token_prefix = ?')
.get(ADA_TOKEN.slice(0, 13)) as { scope_mode: string; api_scopes: string | null };
// The two columns were never named at insert time — exactly the row the
// ALTER leaves behind for a key that already existed.
expect(row).toEqual({ scope_mode: 'all', api_scopes: null });
});
it('PUBAPI-SCOPE-002: and still reads every section, which is the whole promise', async () => {
const [trips, bucket, stats, trip] = await getEach(
['/api/v1/trips', ADA_TOKEN],
['/api/v1/bucket-list', ADA_TOKEN],
['/api/v1/stats', ADA_TOKEN],
['/api/v1/trips/1', ADA_TOKEN],
);
expect([trips.status, bucket.status, stats.status, trip.status]).toEqual([200, 200, 200, 200]);
expect(stats.body).toMatchObject({ total_trips: 2, total_countries: 2 });
// The full payload, not a narrowed one: every section is there.
expect(trip.body.days).toHaveLength(2);
expect(trip.body.accommodations).toBeDefined();
expect(trip.body.travellers).toBeDefined();
expect(trip.body.unplanned_places).toBeDefined();
expect(trip.body.unscheduled_reservations).toBeDefined();
});
it('PUBAPI-SCOPE-003: a key narrowed to trips reads trips', async () => {
const res = await get('/api/v1/trips', TRIPS_ONLY_TOKEN);
expect(res.status).toBe(200);
expect(res.body.trips.map((t: { id: number }) => t.id).sort()).toEqual([1, 3]);
});
it('PUBAPI-SCOPE-004: and is refused the bucket list, with a code of its own', async () => {
const res = await get('/api/v1/bucket-list', TRIPS_ONLY_TOKEN);
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: 'This API key is not allowed to read bucket-list',
code: 'API_SCOPE_FORBIDDEN',
required_scope: 'bucket-list',
});
// And nothing of the withheld list leaked out through the refusal.
expect(JSON.stringify(res.body)).not.toContain('Hokkaido');
});
it('PUBAPI-SCOPE-005: and refused the stats route, the widest answer on the surface', async () => {
const res = await get('/api/v1/stats', TRIPS_ONLY_TOKEN);
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: 'This API key is not allowed to read stats',
code: 'API_SCOPE_FORBIDDEN',
required_scope: 'stats',
});
});
/**
* A valid credential that may not read something is not an authentication
* problem, and the two have to stay distinguishable: re-minting a key fixes a
* 401 and does nothing at all for a 403.
*/
it('PUBAPI-SCOPE-006: the refusal is a 403 and leaves both 401 bodies exactly as they were', async () => {
const [none, unknown, wrongKind, noneOnStats] = await getEach(
['/api/v1/trips'],
['/api/v1/trips', 'trek_' + 'z'.repeat(48)],
['/api/v1/trips', MCP_TOKEN],
['/api/v1/stats'],
);
expect(none.status).toBe(401);
expect(none.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' });
expect(unknown.status).toBe(401);
expect(unknown.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' });
expect(wrongKind.status).toBe(401);
expect(wrongKind.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' });
expect(noneOnStats.status).toBe(401);
expect(noneOnStats.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' });
});
it('PUBAPI-SCOPE-007: a trip asked for without include carries only the granted sections', async () => {
const res = await get('/api/v1/trips/1', TRIPS_ONLY_TOKEN);
// Not a refusal: `include` absent means "everything", and a key that never
// named a forbidden section gets what it may have.
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: 1, title: 'Toskana' });
expect(res.body.days).toBeUndefined();
expect(res.body.accommodations).toBeUndefined();
expect(res.body.travellers).toBeUndefined();
expect(res.body.unplanned_places).toBeUndefined();
expect(res.body.unscheduled_reservations).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('Uffizien');
});
it('PUBAPI-SCOPE-008: a wider grant fills in exactly the sections it covers', async () => {
const res = await get('/api/v1/trips/1', TRIPS_DAYS_TOKEN);
expect(res.status).toBe(200);
expect(res.body.days).toHaveLength(2);
expect(res.body.days[0].day_notes).toEqual([{ text: 'Tickets mitnehmen', time: '09:00' }]);
// notes was granted, places was not — and days is not a back door to them.
expect(res.body.days[0].places).toEqual([]);
expect(res.body.accommodations).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('Uffizien');
});
it('PUBAPI-SCOPE-009: an include the key does cover still answers', async () => {
const res = await get('/api/v1/trips/1?include=days,notes', TRIPS_DAYS_TOKEN);
expect(res.status).toBe(200);
expect(res.body.days[0].day_notes).toHaveLength(1);
});
it('PUBAPI-SCOPE-010: naming a forbidden section is refused rather than silently dropped', async () => {
const res = await get('/api/v1/trips/1?include=days,places', TRIPS_DAYS_TOKEN);
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: 'This API key is not allowed to read places',
code: 'API_SCOPE_FORBIDDEN',
required_scope: 'places',
});
});
it('PUBAPI-SCOPE-011: a key that says it is narrowed and cannot say how reads nothing', async () => {
// The direction matters. A key that never said 'limited' keeps the full
// access it was minted with; one that HAS said it is restricted must not
// widen because its list became unreadable — a hand-edited row, or a scope
// constant a later version renamed away. Failing closed turns that into a
// support ticket instead of a key quietly reading every trip.
const [bucket, trip] = await getEach(
['/api/v1/bucket-list', BROKEN_SCOPES_TOKEN],
['/api/v1/trips/1', BROKEN_SCOPES_TOKEN],
);
expect(bucket.status).toBe(403);
expect(bucket.body).toMatchObject({ code: 'API_SCOPE_FORBIDDEN' });
expect(trip.status).toBe(403);
});
it('PUBAPI-SCOPE-012: a scope never widens access — the owner check still decides', async () => {
// 'trips' granted, Bob's trip asked for: a grant narrows what a key may read
// and can never hand it a row its owner cannot reach.
const res = await get('/api/v1/trips/2', TRIPS_ONLY_TOKEN);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('PUBAPI-SCOPE-013: a narrowed key is stamped as used like any other', async () => {
await get('/api/v1/trips', TRIPS_ONLY_TOKEN);
const row = db
.prepare('SELECT last_used_at FROM mcp_tokens WHERE token_prefix = ?')
.get(TRIPS_ONLY_TOKEN.slice(0, 13)) as { last_used_at: string | null };
expect(row.last_used_at).not.toBeNull();
});
});
});
+4
View File
@@ -42,6 +42,10 @@ const RESET_TABLES = [
// one case into the next.
'roadtrip_day_tracks',
'roadtrip_vias',
// Dawarich (#2279). The suggestions reference places and bucket_list, so they
// are cleared before both; the connection row hangs off the user.
'dawarich_visit_suggestions',
'dawarich_connections',
// Day content
'day_notes',
'todo_category_assignees',

Some files were not shown because too many files have changed in this diff Show More