test(journey): cover the Dawarich fold, and mend what the switches and the wiring assumed

Three things the CI would have caught.

The settings dialog's switches are the shared ToggleSwitch now, which is a button with
aria-pressed rather than a role="switch" with aria-checked, and three tests still looked
for the old shape.

The page's wiring test mocks the whole hook, so the day map the timeline reads was
undefined there and every case in the file died on it. The mock carries it now, empty,
because the fold has a suite of its own.

That suite is new: what it pins is the fold rather than the rows inside it, which the
panel's own tests already cover end to end. That it says nothing on a quiet day, that the
line reads shut, that the count takes the singular at one, that it starts closed, that
both actions reach the journal with the stay they belong to, and that the span follows
the traveller's own clock.

The grouping the hook does moved into the model beside the other pure shaping, where it
is four cheap tests instead of a round trip through MSW: by day, in the order they were
lived, only what is still waiting, and no bucket at all for a day whose stays are all
handled.
This commit is contained in:
Maurice
2026-09-17 15:32:19 +02:00
parent 2879433bd3
commit 8860e16149
6 changed files with 232 additions and 16 deletions
@@ -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)
})
})
@@ -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<string, DawarichSuggestion[]> {
const byDate = new Map<string, DawarichSuggestion[]>();
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) : ''
@@ -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<DawarichSuggestion> & { 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(
<JourneyDayDawarich
suggestions={suggestions}
busyId={over.busyId ?? null}
onAccept={onAccept}
onDismiss={onDismiss}
/>,
)
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' } }))
})
})
@@ -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()
@@ -100,6 +100,10 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
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(),
@@ -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<string, DawarichSuggestion[]>()
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