diff --git a/client/src/components/Dawarich/dawarichSuggestionModel.test.ts b/client/src/components/Dawarich/dawarichSuggestionModel.test.ts index 4e49da102..b522483c1 100644 --- a/client/src/components/Dawarich/dawarichSuggestionModel.test.ts +++ b/client/src/components/Dawarich/dawarichSuggestionModel.test.ts @@ -7,6 +7,7 @@ import { formatDayOption, formatDuration, groupByDay, + openStaysByDate, timeRange, } from './dawarichSuggestionModel' @@ -166,3 +167,49 @@ describe('formatDayOption', () => { expect(formatDayOption(1, null, 'en-GB', t)).toEqual({ label: 'planner.dayN:{"n":1}', badge: undefined }) }) }) + +describe('openStaysByDate', () => { + it('FE-DAWARICH-SUGMODEL-015: buckets the open stays by their own local day', () => { + // What a journal timeline asks for: it draws one day and folds that day's stays into + // it, so it looks them up by date rather than walking a flat list per day. + const byDate = openStaysByDate([ + stay({ id: 1, localDate: '2026-09-10' }), + stay({ id: 2, localDate: '2026-09-11' }), + stay({ id: 3, localDate: '2026-09-10' }), + ]) + + expect([...byDate.keys()].sort()).toEqual(['2026-09-10', '2026-09-11']) + expect(byDate.get('2026-09-10')!.map(s => s.id)).toEqual([1, 3]) + }) + + it('FE-DAWARICH-SUGMODEL-016: within a day they are in the order they were lived', () => { + // A run of stays reads as an afternoon only in that order; whatever order the server + // sent them in is not it. + const byDate = openStaysByDate([ + stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T16:00:00+02:00' }), + stay({ id: 2, localDate: '2026-09-10', startedAt: '2026-09-10T08:30:00+02:00' }), + stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T12:15:00+02:00' }), + ]) + + expect(byDate.get('2026-09-10')!.map(s => s.id)).toEqual([2, 3, 1]) + }) + + it('FE-DAWARICH-SUGMODEL-017: only what is still waiting', () => { + // An accepted stay is an entry on that timeline already, and a dismissed one was waved + // away on purpose; offering either again is offering to do it twice. + const byDate = openStaysByDate([ + stay({ id: 1, localDate: '2026-09-10', state: 'accepted' }), + stay({ id: 2, localDate: '2026-09-10', state: 'dismissed' }), + stay({ id: 3, localDate: '2026-09-10' }), + ]) + + expect(byDate.get('2026-09-10')!.map(s => s.id)).toEqual([3]) + }) + + it('FE-DAWARICH-SUGMODEL-018: a day whose every stay is handled is not a day at all', () => { + // An empty bucket would draw a fold saying "0 stays" on a day that has none. + const byDate = openStaysByDate([stay({ id: 1, localDate: '2026-09-10', state: 'accepted' })]) + + expect(byDate.size).toBe(0) + }) +}) diff --git a/client/src/components/Dawarich/dawarichSuggestionModel.ts b/client/src/components/Dawarich/dawarichSuggestionModel.ts index dfe530969..7dbfe2179 100644 --- a/client/src/components/Dawarich/dawarichSuggestionModel.ts +++ b/client/src/components/Dawarich/dawarichSuggestionModel.ts @@ -45,6 +45,30 @@ export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] { .reverse() } +/** + * The stays still waiting, by the local day they happened on. + * + * What a journal timeline needs: it draws one day at a time and folds that day's stays + * into it, so it asks by date rather than walking a flat list per day. Only `new` ones — + * an accepted stay is already an entry on that timeline and a dismissed one was waved + * away on purpose — and within a day the order is the order they were lived in, which is + * what makes a run of them read as an afternoon. + * + * A Map rather than the day list `groupByDay` builds: that one is ordered newest-first for + * a panel to print, and this one is looked up by the day the timeline is already drawing. + */ +export function openStaysByDate(suggestions: DawarichSuggestion[]): Map { + const byDate = new Map(); + for (const stay of suggestions) { + if (stay.state !== 'new') continue; + const bucket = byDate.get(stay.localDate); + if (bucket) bucket.push(stay); + else byDate.set(stay.localDate, [stay]); + } + for (const stays of byDate.values()) stays.sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)); + return byDate; +} + /** `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) : '' diff --git a/client/src/components/Journey/JourneyDayDawarich.test.tsx b/client/src/components/Journey/JourneyDayDawarich.test.tsx new file mode 100644 index 000000000..2b20b4c75 --- /dev/null +++ b/client/src/components/Journey/JourneyDayDawarich.test.tsx @@ -0,0 +1,147 @@ +// FE-JRN-DAYDAW-001 to FE-JRN-DAYDAW-008 +/** + * The fold a journal day carries for the stays Dawarich recorded on it. + * + * The rows inside it are `SuggestionRow`, which the panel's own suite pins end to end, so + * what is worth holding here is the fold: that it says nothing on a quiet day, that the + * line reads before it is opened, and that the two actions reach the journal with the + * stay they belong to. The whole point of this component is that forty stays no longer + * stand between a reader and their first entry, and that only works while it starts shut. + */ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '../../../tests/helpers/render' +import { fireEvent } from '@testing-library/react' +import type { DawarichSuggestion } from '@trek/shared' +import { useSettingsStore } from '../../store/settingsStore' +import JourneyDayDawarich from './JourneyDayDawarich' + +function stay(over: Partial & { id: number }): DawarichSuggestion { + const date = over.localDate ?? '2026-09-10' + return { + id: over.id, + sourceVisitId: String(over.id), + tripId: null, + tripTitle: null, + name: over.name ?? 'Cafe Reichard', + lat: 50.94, + lng: 6.96, + startedAt: over.startedAt ?? `${date}T10:15:00+02:00`, + endedAt: over.endedAt ?? `${date}T12:40:00+02:00`, + durationMinutes: over.durationMinutes ?? 145, + localDate: date, + sourceStatus: 'suggested', + confidence: null, + confidenceBand: null, + state: 'new', + target: null, + acceptedPlaceId: null, + acceptedJournalEntryId: null, + acceptedBucketListItemId: null, + sourceChanged: false, + sourceMissing: false, + matchedBucketListItemId: null, + matchedBucketListName: null, + countryCode: null, + firstSeenAt: `${date}T06:00:00Z`, + lastSeenAt: `${date}T06:00:00Z`, + ...over, + } as DawarichSuggestion +} + +function fold(suggestions: DawarichSuggestion[], over: Partial<{ busyId: number | null }> = {}) { + const onAccept = vi.fn() + const onDismiss = vi.fn() + const view = render( + , + ) + return { onAccept, onDismiss, ...view } +} + +const head = () => screen.getByRole('button', { expanded: false }) ?? screen.getByRole('button') + +describe('JourneyDayDawarich', () => { + it('FE-JRN-DAYDAW-001: a day with nothing pending draws nothing at all', () => { + // Not an empty row saying so: a connected, quiet integration is invisible, and this + // one would otherwise appear on every single day of the journey. + const { container } = fold([]) + + expect(container).toBeEmptyDOMElement() + }) + + it('FE-JRN-DAYDAW-002: shut, it says how many and which part of the day they cover', () => { + fold([ + stay({ id: 1, startedAt: '2026-09-10T08:00:00+02:00', endedAt: '2026-09-10T09:30:00+02:00' }), + stay({ id: 2, startedAt: '2026-09-10T14:00:00+02:00', endedAt: '2026-09-10T17:08:00+02:00' }), + ]) + + expect(screen.getByText('2 stays from Dawarich')).toBeInTheDocument() + // First arrival to last departure, not each stay's own range. + expect(screen.getByText('08:00 – 17:08')).toBeInTheDocument() + }) + + it('FE-JRN-DAYDAW-003: one stay takes the singular', () => { + // A single key with a placeholder would read "1 stays". + fold([stay({ id: 1 })]) + + expect(screen.getByText('1 stay from Dawarich')).toBeInTheDocument() + }) + + it('FE-JRN-DAYDAW-004: it starts shut, and the stays are not in the page until it opens', () => { + fold([stay({ id: 1, name: 'Cafe Reichard' })]) + + expect(screen.queryByText('Cafe Reichard')).toBeNull() + expect(head()).toHaveAttribute('aria-expanded', 'false') + + fireEvent.click(head()) + + expect(screen.getByText('Cafe Reichard')).toBeInTheDocument() + }) + + it('FE-JRN-DAYDAW-005: open, each stay is a row with its own two actions', () => { + const { onDismiss } = fold([stay({ id: 7, name: 'Cafe Reichard' })]) + fireEvent.click(head()) + + const row = screen.getByText('Cafe Reichard').closest('div')!.parentElement! + fireEvent.click(within(row).getByRole('button', { name: 'Not a place I visited' })) + + expect(onDismiss).toHaveBeenCalledTimes(1) + expect(onDismiss.mock.calls[0][0]).toMatchObject({ id: 7 }) + }) + + it('FE-JRN-DAYDAW-006: accepting hands back the stay it was asked about and where it goes', () => { + const { onAccept } = fold([stay({ id: 7, name: 'Cafe Reichard' })]) + fireEvent.click(head()) + + fireEvent.click(screen.getByRole('button', { name: /journal|journey/i })) + + expect(onAccept).toHaveBeenCalledTimes(1) + expect(onAccept.mock.calls[0][0]).toMatchObject({ id: 7 }) + expect(onAccept.mock.calls[0][1]).toBe('journal') + }) + + it('FE-JRN-DAYDAW-007: the stay a write is in flight for is the one that goes busy', () => { + fold([stay({ id: 7, name: 'Cafe Reichard' }), stay({ id: 8, name: 'Zum Treppchen' })], { busyId: 7 }) + fireEvent.click(head()) + + const busyRow = screen.getByText('Cafe Reichard').closest('div')!.parentElement! + const idleRow = screen.getByText('Zum Treppchen').closest('div')!.parentElement! + expect(within(busyRow).getAllByRole('button')[0]).toBeDisabled() + expect(within(idleRow).getAllByRole('button')[0]).not.toBeDisabled() + }) + + it('FE-JRN-DAYDAW-008: the span follows the traveller own clock', () => { + useSettingsStore.setState(s => ({ settings: { ...s.settings, time_format: '12h' } })) + fold([ + stay({ id: 1, startedAt: '2026-09-10T08:00:00+02:00', endedAt: '2026-09-10T09:30:00+02:00' }), + stay({ id: 2, startedAt: '2026-09-10T14:00:00+02:00', endedAt: '2026-09-10T17:08:00+02:00' }), + ]) + + expect(screen.getByText('8:00 AM – 5:08 PM')).toBeInTheDocument() + useSettingsStore.setState(s => ({ settings: { ...s.settings, time_format: '24h' } })) + }) +}) diff --git a/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx b/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx index 677f4240c..e16c22d4d 100644 --- a/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx @@ -377,8 +377,11 @@ describe('JourneySettingsDialog', () => { it('FE-JRN-SETTINGS-023: the trip-GPX switch reads off by default and turns on in one click (#2194)', async () => { const { onRefresh, onSaved } = mountDialog() - const sw = screen.getByRole('switch', { name: /GPX/i }) - expect(sw).toHaveAttribute('aria-checked', 'false') + // The shared ToggleSwitch is a button with aria-pressed, not a role="switch" with + // aria-checked: the dialog drew its own switches by hand until they turned out to be + // white on near-white in the dark. + const sw = screen.getByRole('button', { name: /GPX/i }) + expect(sw).toHaveAttribute('aria-pressed', 'false') await userEvent.click(sw) @@ -393,8 +396,8 @@ describe('JourneySettingsDialog', () => { // The column is INTEGER, so what arrives over the wire is 1, not true. mountDialog(buildJourney({ show_trip_tracks: 1 })) - const sw = screen.getByRole('switch', { name: /GPX/i }) - expect(sw).toHaveAttribute('aria-checked', 'true') + const sw = screen.getByRole('button', { name: /GPX/i }) + expect(sw).toHaveAttribute('aria-pressed', 'true') await userEvent.click(sw) await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: false })) @@ -404,7 +407,7 @@ describe('JourneySettingsDialog', () => { updateJourney.mockRejectedValueOnce(new Error('nope')) const { onClose } = mountDialog() - await userEvent.click(screen.getByRole('switch', { name: /GPX/i })) + await userEvent.click(screen.getByRole('button', { name: /GPX/i })) await waitFor(() => expect(toastSpy).toHaveBeenCalled()) expect(onClose).not.toHaveBeenCalled() diff --git a/client/src/pages/JourneyDetailPage.wiring.test.tsx b/client/src/pages/JourneyDetailPage.wiring.test.tsx index 593385ed8..52abdb574 100644 --- a/client/src/pages/JourneyDetailPage.wiring.test.tsx +++ b/client/src/pages/JourneyDetailPage.wiring.test.tsx @@ -100,6 +100,10 @@ function buildHook(over: Record = {}): Record hideSkeletons: false, setHideSkeletons: vi.fn(), query: '', setQuery: vi.fn(), dismissSuggestion: vi.fn(async () => {}), restoreSuggestions: vi.fn(async () => {}), openAtEntryId: null, + // The stays Dawarich recorded, by the day they fall on. Empty here: the fold each day + // carries has its own suite (FE-JRN-DAYDAW), and what this file pins is the wiring. + dawarichByDate: new Map(), dawarichBusyId: null, + acceptDawarich: vi.fn(async () => {}), dismissDawarich: vi.fn(), mapRef: { current: null }, fullMapRef: { current: null }, galleryUploadRef: { current: null }, galleryProviders: [], setGalleryProviders: vi.fn(), galleryBrowseRef: { current: null }, activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(), diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index dbb5efdba..69627f81c 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -13,6 +13,7 @@ import { lockBodyScroll } from '../../utils/bodyScrollLock' import type { JourneyEntry } from '../../store/journeyStore' import { createDraftJourneyEntry } from './JourneyDetailPage.helpers' import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions' +import { openStaysByDate } from '../../components/Dawarich/dawarichSuggestionModel' import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared' import { useDawarichJournalTrail } from '../../hooks/useDawarichJournalTrail' @@ -312,17 +313,7 @@ export function useJourneyDetail() { * lived in, which is what makes a run of them read as an afternoon. */ const dawarich = useDawarichSuggestions() - const dawarichByDate = useMemo(() => { - const byDate = new Map() - for (const stay of dawarich.suggestions) { - if (stay.state !== 'new') continue - const bucket = byDate.get(stay.localDate) - if (bucket) bucket.push(stay) - else byDate.set(stay.localDate, [stay]) - } - for (const stays of byDate.values()) stays.sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)) - return byDate - }, [dawarich.suggestions]) + const dawarichByDate = useMemo(() => openStaysByDate(dawarich.suggestions), [dawarich.suggestions]) /** * Accepting writes the stay into THIS journal and reloads it, which is how the new entry