From 5b4a7e82ffd850cc458ecc7103541617c9bebb25 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 23:35:43 +0200 Subject: [PATCH] feat(client): show Dawarich stays, routes and marks where they belong The trip planner and the journal get a "From Dawarich" list: the stays recorded for those dates, each one a row that can be dismissed or turned into a place or an entry after a review step that shows time, name, note and day. A place that came from a recording carries the Dawarich mark next to its name so its origin stays visible afterwards. The recorded route lies over the trip and journal maps as an overlay, coloured per day and cut along the reader's own timezone rather than UTC. It is fetched for the window on screen and never stored. The Atlas gets its own panel beside the map for the countries and cities the recordings prove, and the wish list is ticked off there, on the day of the visit, undoably. The phone has its own take on all of it, in the mobile shell's colours and with native sheets. --- client/public/brands/airtrail.svg | 11 + client/public/brands/dawarich.svg | 1 + client/src/api/dawarich.ts | 83 +++ client/src/components/Admin/AddonManager.tsx | 20 +- client/src/components/Admin/AddonTile.tsx | 4 +- client/src/components/Atlas/useGlassGlare.ts | 48 ++ .../Dawarich/DawarichAcceptDialog.tsx | 387 ++++++++++++ .../Dawarich/DawarichAtlasDialog.test.tsx | 171 ++++++ .../Dawarich/DawarichAtlasDialog.tsx | 578 ++++++++++++++++++ .../Dawarich/DawarichAtlasSidePanel.tsx | 133 ++++ .../src/components/Dawarich/DawarichBadge.tsx | 71 +++ .../Dawarich/DawarichSuggestionsPanel.tsx | 523 ++++++++++++++++ .../Dawarich/dawarichAtlasModel.test.ts | 113 ++++ .../components/Dawarich/dawarichAtlasModel.ts | 84 +++ .../Dawarich/dawarichSuggestionModel.test.ts | 130 ++++ .../Dawarich/dawarichSuggestionModel.ts | 117 ++++ .../Dawarich/dawarichTripRefresh.ts | 18 + .../src/components/Map/DawarichTrailLayer.tsx | 78 +++ .../src/components/Map/DawarichTrailPill.tsx | 93 +++ client/src/components/Map/MapView.tsx | 12 + client/src/components/Map/MapViewAuto.tsx | 3 + client/src/components/Map/MapViewGL.test.tsx | 5 + client/src/components/Map/MapViewGL.tsx | 11 +- .../src/components/Map/TripRouteOverview.tsx | 44 +- client/src/components/Map/dawarichTrail.ts | 145 +++++ client/src/components/Map/useDawarichTrail.ts | 104 ++++ .../src/components/Map/useDawarichTrailGL.ts | 90 +++ .../Planner/DayPlanSidebar.test.tsx | 54 +- .../src/components/Planner/DayPlanSidebar.tsx | 351 ++++++----- .../src/components/Planner/PlaceInspector.tsx | 13 + .../src/components/Planner/PlacesSidebar.tsx | 28 +- .../Settings/AirTrailConnectionSection.tsx | 3 +- .../Settings/DawarichConnectionSection.tsx | 193 ++++++ .../components/Settings/IntegrationsTab.tsx | 5 +- client/src/components/Settings/Section.tsx | 6 +- client/src/components/shared/AirTrailIcon.tsx | 14 + client/src/components/shared/BrandIcon.tsx | 65 ++ client/src/components/shared/DawarichIcon.tsx | 14 + client/src/hooks/useDawarichConnection.ts | 265 ++++++++ client/src/hooks/useDawarichJournalTrail.ts | 93 +++ client/src/hooks/useDawarichSuggestions.ts | 175 ++++++ .../screens/admin/MAdminAddonManager.tsx | 17 +- client/src/mobile/screens/atlas/MAtlas.tsx | 32 +- .../screens/atlas/MAtlasBucketSheet.tsx | 1 + .../mobile/screens/journey/MJourneyDetail.tsx | 114 +++- .../settings/MDawarichConnectionSection.tsx | 120 ++++ .../settings/MSettingsIntegrations.tsx | 5 + .../src/mobile/screens/trip/map/MMapArea.tsx | 19 +- .../screens/trip/places/MPlacesBrowser.tsx | 24 + .../screens/trip/sheets/MPlaceSheet.tsx | 12 +- client/src/pages/AtlasPage.tsx | 98 ++- client/src/pages/JourneyDetailPage.tsx | 14 + client/src/pages/TripPlannerPage.tsx | 22 +- .../src/pages/TripPlannerPage.wiring.test.tsx | 7 + client/src/pages/atlas/atlasModel.ts | 4 + client/src/pages/atlas/useAtlas.ts | 56 +- .../pages/journeyDetail/useJourneyDetail.ts | 15 +- client/src/pages/settings/useSettings.ts | 3 +- .../src/pages/tripPlanner/useTripPlanner.ts | 23 +- client/src/repo/dawarichRepo.ts | 67 ++ client/tests/helpers/mobileTrip.ts | 7 + .../mobile/journey/MJourneyDetail.test.tsx | 45 +- shared/src/i18n/ar/dawarich.ts | 178 ++++++ shared/src/i18n/ar/index.ts | 2 + shared/src/i18n/br/dawarich.ts | 180 ++++++ shared/src/i18n/br/index.ts | 2 + shared/src/i18n/ca/dawarich.ts | 179 ++++++ shared/src/i18n/ca/index.ts | 2 + shared/src/i18n/cs/dawarich.ts | 179 ++++++ shared/src/i18n/cs/index.ts | 2 + shared/src/i18n/de/dawarich.ts | 183 ++++++ shared/src/i18n/de/index.ts | 2 + shared/src/i18n/en/dawarich.ts | 178 ++++++ shared/src/i18n/en/index.ts | 2 + shared/src/i18n/es/dawarich.ts | 180 ++++++ shared/src/i18n/es/index.ts | 2 + shared/src/i18n/fr/dawarich.ts | 182 ++++++ shared/src/i18n/fr/index.ts | 2 + shared/src/i18n/gr/dawarich.ts | 183 ++++++ shared/src/i18n/gr/index.ts | 2 + shared/src/i18n/hu/dawarich.ts | 181 ++++++ shared/src/i18n/hu/index.ts | 2 + shared/src/i18n/id/dawarich.ts | 180 ++++++ shared/src/i18n/id/index.ts | 2 + shared/src/i18n/it/dawarich.ts | 182 ++++++ shared/src/i18n/it/index.ts | 2 + shared/src/i18n/ja/dawarich.ts | 183 ++++++ shared/src/i18n/ja/index.ts | 2 + shared/src/i18n/ko/dawarich.ts | 178 ++++++ shared/src/i18n/ko/index.ts | 2 + shared/src/i18n/nl/dawarich.ts | 180 ++++++ shared/src/i18n/nl/index.ts | 2 + shared/src/i18n/pl/dawarich.ts | 178 ++++++ shared/src/i18n/pl/index.ts | 2 + shared/src/i18n/ru/dawarich.ts | 178 ++++++ shared/src/i18n/ru/index.ts | 2 + shared/src/i18n/sv/dawarich.ts | 178 ++++++ shared/src/i18n/sv/index.ts | 2 + shared/src/i18n/tr/dawarich.ts | 181 ++++++ shared/src/i18n/tr/index.ts | 2 + shared/src/i18n/uk/dawarich.ts | 178 ++++++ shared/src/i18n/uk/index.ts | 2 + shared/src/i18n/vi/dawarich.ts | 178 ++++++ shared/src/i18n/vi/index.ts | 2 + shared/src/i18n/zh-TW/dawarich.ts | 178 ++++++ shared/src/i18n/zh-TW/index.ts | 2 + shared/src/i18n/zh/dawarich.ts | 178 ++++++ shared/src/i18n/zh/index.ts | 2 + 108 files changed, 8945 insertions(+), 295 deletions(-) create mode 100644 client/public/brands/airtrail.svg create mode 100644 client/public/brands/dawarich.svg create mode 100644 client/src/api/dawarich.ts create mode 100644 client/src/components/Atlas/useGlassGlare.ts create mode 100644 client/src/components/Dawarich/DawarichAcceptDialog.tsx create mode 100644 client/src/components/Dawarich/DawarichAtlasDialog.test.tsx create mode 100644 client/src/components/Dawarich/DawarichAtlasDialog.tsx create mode 100644 client/src/components/Dawarich/DawarichAtlasSidePanel.tsx create mode 100644 client/src/components/Dawarich/DawarichBadge.tsx create mode 100644 client/src/components/Dawarich/DawarichSuggestionsPanel.tsx create mode 100644 client/src/components/Dawarich/dawarichAtlasModel.test.ts create mode 100644 client/src/components/Dawarich/dawarichAtlasModel.ts create mode 100644 client/src/components/Dawarich/dawarichSuggestionModel.test.ts create mode 100644 client/src/components/Dawarich/dawarichSuggestionModel.ts create mode 100644 client/src/components/Dawarich/dawarichTripRefresh.ts create mode 100644 client/src/components/Map/DawarichTrailLayer.tsx create mode 100644 client/src/components/Map/DawarichTrailPill.tsx create mode 100644 client/src/components/Map/dawarichTrail.ts create mode 100644 client/src/components/Map/useDawarichTrail.ts create mode 100644 client/src/components/Map/useDawarichTrailGL.ts create mode 100644 client/src/components/Settings/DawarichConnectionSection.tsx create mode 100644 client/src/components/shared/AirTrailIcon.tsx create mode 100644 client/src/components/shared/BrandIcon.tsx create mode 100644 client/src/components/shared/DawarichIcon.tsx create mode 100644 client/src/hooks/useDawarichConnection.ts create mode 100644 client/src/hooks/useDawarichJournalTrail.ts create mode 100644 client/src/hooks/useDawarichSuggestions.ts create mode 100644 client/src/mobile/screens/settings/MDawarichConnectionSection.tsx create mode 100644 client/src/repo/dawarichRepo.ts create mode 100644 shared/src/i18n/ar/dawarich.ts create mode 100644 shared/src/i18n/br/dawarich.ts create mode 100644 shared/src/i18n/ca/dawarich.ts create mode 100644 shared/src/i18n/cs/dawarich.ts create mode 100644 shared/src/i18n/de/dawarich.ts create mode 100644 shared/src/i18n/en/dawarich.ts create mode 100644 shared/src/i18n/es/dawarich.ts create mode 100644 shared/src/i18n/fr/dawarich.ts create mode 100644 shared/src/i18n/gr/dawarich.ts create mode 100644 shared/src/i18n/hu/dawarich.ts create mode 100644 shared/src/i18n/id/dawarich.ts create mode 100644 shared/src/i18n/it/dawarich.ts create mode 100644 shared/src/i18n/ja/dawarich.ts create mode 100644 shared/src/i18n/ko/dawarich.ts create mode 100644 shared/src/i18n/nl/dawarich.ts create mode 100644 shared/src/i18n/pl/dawarich.ts create mode 100644 shared/src/i18n/ru/dawarich.ts create mode 100644 shared/src/i18n/sv/dawarich.ts create mode 100644 shared/src/i18n/tr/dawarich.ts create mode 100644 shared/src/i18n/uk/dawarich.ts create mode 100644 shared/src/i18n/vi/dawarich.ts create mode 100644 shared/src/i18n/zh-TW/dawarich.ts create mode 100644 shared/src/i18n/zh/dawarich.ts diff --git a/client/public/brands/airtrail.svg b/client/public/brands/airtrail.svg new file mode 100644 index 000000000..6349a0370 --- /dev/null +++ b/client/public/brands/airtrail.svg @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/client/public/brands/dawarich.svg b/client/public/brands/dawarich.svg new file mode 100644 index 000000000..cfe2843ee --- /dev/null +++ b/client/public/brands/dawarich.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/api/dawarich.ts b/client/src/api/dawarich.ts new file mode 100644 index 000000000..d2c3ff295 --- /dev/null +++ b/client/src/api/dawarich.ts @@ -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 => + 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): Promise => + 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 => + apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data), + accept: (id: number, body: DawarichAccept): Promise => + apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data), + setState: (id: number, state: 'new' | 'dismissed'): Promise => + apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data), + + // ── Bucket list ──────────────────────────────────────────────────────────── + scanBucketList: (): Promise => + 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 => + 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 => + apiClient + .get(`/integrations/dawarich/trips/${tripId}/track`, { params: { ...params, offset: localOffsetMinutes() }, signal }) + .then(r => r.data), + windowTrack: (from: string, to: string, signal?: AbortSignal): Promise => + 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() +} diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index c9b041a88..2deda0596 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -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 + // has to be told. + if (name === 'Dawarich') return + // '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 const Icon = ICON_MAP[name] || Puzzle return } @@ -212,7 +228,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, return ( } + icon={} name={label.name} description={label.description} enabled={addon.enabled} diff --git a/client/src/components/Admin/AddonTile.tsx b/client/src/components/Admin/AddonTile.tsx index 99e68f1c6..10760615a 100644 --- a/client/src/components/Admin/AddonTile.tsx +++ b/client/src/components/Admin/AddonTile.tsx @@ -44,7 +44,9 @@ export default function AddonTile({ >
diff --git a/client/src/components/Atlas/useGlassGlare.ts b/client/src/components/Atlas/useGlassGlare.ts new file mode 100644 index 000000000..540401de7 --- /dev/null +++ b/client/src/components/Atlas/useGlassGlare.ts @@ -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 + /** The soft light under the cursor. */ + glareRef: RefObject + /** The lit stretch of border around it. */ + borderGlareRef: RefObject + 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(null) + const glareRef = useRef(null) + const borderGlareRef = useRef(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 } +} diff --git a/client/src/components/Dawarich/DawarichAcceptDialog.tsx b/client/src/components/Dawarich/DawarichAcceptDialog.tsx new file mode 100644 index 000000000..9faeca089 --- /dev/null +++ b/client/src/components/Dawarich/DawarichAcceptDialog.tsx @@ -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('') + const [dayId, setDayId] = useState('') + const [journalId, setJournalId] = useState('') + // 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(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 = ( +
+ {/* 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. */} +
+
+ + + +

+ {t('dawarich.accept.recorded', { + from: new Date(suggestion.startedAt).toLocaleString(locale), + to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }), + })} +

+
+
+ + {t('dawarich.accept.duration', { minutes: suggestion.durationMinutes })} + + {/* 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' && ( + {t('dawarich.sourceStatus.suggested')} + )} + {KNOWN_BANDS.includes(suggestion.confidenceBand ?? '') && ( + + {t('dawarich.confidence.' + suggestion.confidenceBand)} + + )} +
+
+ + {/* 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 && ( +

+ + + {t('dawarich.suggestions.matchesWish', { name: suggestion.matchedBucketListName })} + {' — '} + {t('dawarich.bucket.rule', { + meters: DAWARICH_BUCKET_MATCH_RADIUS_M, + minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES, + })} + +

+ )} + +
+ + 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" + /> +
+ + {/* 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' && ( +
+
+ + {t('dawarich.accept.date')} + + +
+
+ + {t('dawarich.accept.from')} + + +
+
+ + {t('dawarich.accept.to')} + + +
+
+ )} + + {/* 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' && ( +
+
+ + {t('dawarich.accept.day')} + + {/* 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. */} + 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%' }} + /> +
+
+ + {t('dawarich.accept.trip')} + + { setTripId(typeof next === 'number' ? next : ''); setDayId('') }} + options={trips.map(trip => ({ value: trip.id, label: trip.label }))} + placeholder={t('dawarich.accept.pickTrip')} + style={{ width: '100%' }} + /> +
+
+ )} + + {target === 'journal' && ( +
+ + {t('dawarich.accept.journal')} + + setJournalId(typeof next === 'number' ? next : '')} + options={journals.map(journal => ({ value: journal.id, label: journal.label }))} + placeholder={t('dawarich.accept.pickJournal')} + style={{ width: '100%' }} + /> +
+ )} + + {target !== 'bucket_list' && ( +
+ +