diff --git a/client/src/mobile/components/MBadge.test.tsx b/client/src/mobile/components/MBadge.test.tsx new file mode 100644 index 000000000..5ad7defba --- /dev/null +++ b/client/src/mobile/components/MBadge.test.tsx @@ -0,0 +1,74 @@ +// FE-COMP-MBADGE-001 to FE-COMP-MBADGE-006 +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Route } from 'lucide-react' +import MBadge from './MBadge' + +describe('MBadge', () => { + it('FE-COMP-MBADGE-001: renders its label in a span and is no control', () => { + render(3 stops) + + const badge = screen.getByText('3 stops') + expect(badge.tagName).toBe('SPAN') + // It sits inside buttons, and a button inside a button is invalid markup. + expect(screen.queryByRole('button')).toBeNull() + }) + + it('FE-COMP-MBADGE-002: is neutral by default on the resting chip tokens, and strong on the active ones', () => { + render(neutral) + const neutral = screen.getByText('neutral') + expect(neutral.className).toContain('bg-[color:var(--m-ic)]') + expect(neutral.className).toContain('border-[color:var(--m-rowbr)]') + expect(neutral.className).not.toContain('bg-m-act') + + render(strong) + const strong = screen.getByText('strong') + expect(strong.className).toContain('bg-m-act') + expect(strong.className).toContain('text-m-actfg') + expect(strong.className).not.toContain('bg-[color:var(--m-ic)]') + }) + + it('FE-COMP-MBADGE-003: sets words in caps by default and leaves a unit symbol alone when asked', () => { + render(Sat 2) + expect(screen.getByText('Sat 2').className).toContain('uppercase') + + render(300 m) + // Uppercase would turn metres into megametres. + expect(screen.getByText('300 m').className).not.toContain('uppercase') + }) + + it('FE-COMP-MBADGE-004: is 18px at xs, the default, and 22px at sm', () => { + render(small) + expect(screen.getByText('small').className).toContain('h-[18px]') + + render(larger) + const larger = screen.getByText('larger') + expect(larger.className).toContain('h-[22px]') + expect(larger.className).toContain('ms-auto') + }) + + it('FE-COMP-MBADGE-005: draws an icon before the label, inside the same pill and out of its name', () => { + render(}>412 km) + + const badge = screen.getByText('412 km') + const icon = badge.firstElementChild as HTMLElement + expect(icon.querySelector('svg')).not.toBeNull() + expect(icon).toHaveAttribute('aria-hidden', 'true') + expect(badge.lastChild?.textContent).toBe('412 km') + }) + + it('FE-COMP-MBADGE-006: keeps a figure on one line, and lets a sentence wrap inside the width it is given', () => { + render(412 km) + const figure = screen.getByText('412 km') + expect(figure.className).toContain('whitespace-nowrap') + expect(figure.className).toContain('h-[18px]') + + render(Still working out the rest of the drive) + const sentence = screen.getByText('Still working out the rest of the drive') + expect(sentence.className).toContain('whitespace-normal') + expect(sentence.className).toContain('max-w-full') + expect(sentence.className).toContain('min-h-[22px]') + expect(sentence.className).not.toContain('whitespace-nowrap') + expect(sentence.className).not.toMatch(/(^|\s)h-\[22px\]/) + }) +}) diff --git a/client/src/mobile/components/MBadge.tsx b/client/src/mobile/components/MBadge.tsx new file mode 100644 index 000000000..f5c52fa01 --- /dev/null +++ b/client/src/mobile/components/MBadge.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from 'react' + +interface MBadgeProps { + /** + * `neutral` is MChip's resting look and the one to reach for. `strong` is MChip's active + * look, for the single figure a surface is about, and never inside something tappable: + * a filled pill in a button reads as a second control sitting in the first. + */ + tone?: 'neutral' | 'strong' + /** `xs` is 18px, for a second line under a title. `sm` is 22px, for a 34px pill. */ + size?: 'xs' | 'sm' + /** + * Caps with tracking by default, for words. A figure with a unit symbol passes false, + * because m and M are different units, and so does a clock, which has no case to change. + */ + caps?: boolean + /** + * Lets a long sentence break inside the pill instead of running past its container. A + * figure keeps to one line, which is why this is off by default: the pill then stops at + * the width it is given and grows in height with the text. + */ + wrap?: boolean + icon?: ReactNode + className?: string + children: ReactNode +} + +/** + * The non-interactive sibling of MChip: a fact, not a control. + * + * A span, because every place a badge sits in the road trip chrome is itself a button, and + * a button inside a button is markup that no browser and no screen reader agree on. Caps + * with tracking are the desktop day card's DAY_BADGE, set for a phone. + * + * Pills side by side lose the space between them when a screen reader builds a name out of + * them, so a button made of badges carries its own aria-label (see stageBadges.badgeLabel). + */ +export default function MBadge({ + tone = 'neutral', + size = 'xs', + caps = true, + wrap = false, + icon, + className = '', + children, +}: MBadgeProps) { + return ( + + {/* The icon only repeats what the label says, so it stays out of the name. */} + {icon && } + {children} + + ) +} diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index 3848454d5..b59764b2e 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -6,6 +6,7 @@ import { } from 'lucide-react' import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner' import { pickDockTabs } from './dockTabs' +import MBadge from '../../components/MBadge' import MIconBtn from '../../components/MIconBtn' import MPlanTimeline from './plan/MPlanTimeline' import MMapArea from './map/MMapArea' @@ -15,9 +16,9 @@ import MTripSheets from './sheets/MTripSheets' import MTripLoadingSplash from './MTripLoadingSplash' import { usePluginDayTints, dayTintBackground } from '../../../components/Plugins/PluginDaySchedule' import { stageOf } from '../../../components/Roadtrip/roadtripRowModel' +import { badgeLabel, distanceBadge } from './roadtrip/stageBadges' import type { CorridorReach } from '../../../components/Roadtrip/corridorSearchModel' import { useSettingsStore } from '../../../store/settingsStore' -import { formatDistance } from '../../../utils/units' import type { Day } from '../../../types' /** @@ -401,18 +402,21 @@ export default function MTripShell({ const todoOpenCount = todoItems.filter(i => !i.checked).length // The stage header: the day on screen and what it costs, or the whole drive while - // the day filter is off. Falls back to the addon's own name before anything has - // routed, so the pill never reads as an empty figure. + // the day filter is off. A figure nothing has measured yet is left out rather than + // printed as 0 m, and with neither figure the pill falls back to the addon's own name, + // so it never reads as an empty one. const rtStage = stageOf(planner.roadtripRoutes.days, planner.selectedDayId) const rtStageDay = rtStage ? days.find(d => d.id === rtStage.dayId) : undefined - const rtHeaderLabel = rtStage - ? [ - rtStageDay ? dayChipLabel(rtStageDay, language, t('planner.dayN', { n: rtStage.dayNumber })) : t('planner.dayN', { n: rtStage.dayNumber }), - formatDistance(rtStage.distance / 1000, distanceUnit), - ].join(' · ') - : planner.roadtripRoutes.totalDistance > 0 - ? formatDistance(planner.roadtripRoutes.totalDistance / 1000, distanceUnit) - : t('roadtrip.title') + let rtHeaderDay: string | null = null + if (rtStage) { + const dayN = t('planner.dayN', { n: rtStage.dayNumber }) + rtHeaderDay = rtStageDay ? dayChipLabel(rtStageDay, language, dayN) : dayN + } + const rtHeaderDistance = distanceBadge( + rtStage ? rtStage.distance : planner.roadtripRoutes.totalDistance, + distanceUnit, + ) + const rtHeaderLabel = badgeLabel([rtHeaderDay, rtHeaderDistance]) return (
@@ -637,17 +641,30 @@ export default function MTripShell({ {/* The stage's own header: the day it shows, and a way into the figures behind it. A button rather than the plugin tabs' inert pill, because it is the only entry to the driving settings, and the right slot belongs to - the list ⇄ map switch, same as in the plan tab. */} + the list ⇄ map switch, same as in the plan tab. + + The day and the distance are badges rather than one line joined with a + dot, so each reads as a figure of its own. Both stay neutral: the active + day chip right below is already the filled pill with the same label, and + a second one here would compete with it. The button names itself, because + a screen reader runs the texts of pills side by side together. */} {trTab === 'roadtrip' && ( )} diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx index aec9b405a..38329c88d 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx @@ -5,7 +5,10 @@ import { showStopOnMap, useMRoadtrip } from './useMRoadtrip' import { useMRtCorridor } from './useMRtCorridor' import MRtCorridorBar from './MRtCorridorBar' import { RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows' +import { badgeLabel, distanceBadge } from './stageBadges' +import MBadge from '../../../components/MBadge' import MDancingTrek from '../../../components/MDancingTrek' +import PlaceAvatar from '../../../../components/shared/PlaceAvatar' import { dayColor } from '../../../../components/Roadtrip/dayColors' import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' import { getNavigationTargets } from '../../../../components/Planner/placeNavigation' @@ -36,7 +39,6 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) { const rt = useMRoadtrip(planner) const corridor = useMRtCorridor(planner, shell) const unit = useSettingsStore(s => s.settings.distance_unit) - const dayColorsOn = useRoadtripSettings(s => s.roadtrip_day_colors, planner.tripId) const chrome: RowChrome = { t, unit } // The same gesture the day timeline uses, called rather than rebuilt: 370 lines of @@ -58,7 +60,6 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) { }) const stage = rt.stage - const tint = stage && dayColorsOn ? dayColor(stage.dayNumber) : null const openStop = (row: StopRow) => { shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId }) @@ -113,27 +114,53 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) { ) : ( <> - {/* Head card: when the stage starts and when it reaches its last place. Both - clocks are ones the chain below repeats, read off the same arrival column - (see stageClocks), so a first stop pinned at 10:00 heads the card at 10:00 - and not at the end of its stay. They carry dir=ltr for the same reason the - rows do: a clock reads the same way round in an RTL locale. */} -
-
- + {/* Head card: a header over the chain rather than a third of it. The day's + facts ride on the date line as badges instead of in a band of their own + under the clocks, and they move under the date together when a long + locale or the sentence for a partial total does not fit beside it. + + The two clocks stay the largest figures on the screen: when the stage + starts and when it reaches its last place. Both are ones the chain below + repeats, read off the same arrival column (see stageClocks), so a first + stop pinned at 10:00 heads the card at 10:00 and not at the end of its + stay. They carry dir=ltr for the same reason the rows do: a clock reads + the same way round in an RTL locale. + + No day colour here. It keys the day's line on the map, and on this half + the map lies under the list, so a dot in the card had nothing on screen + to explain it and read as a status light. The stage bar's picture ring + carries the colour on the half where the line is visible. */} +
+
+ {stageDateLabel(planner, stage.dayId) ?? t('roadtrip.day', { number: stage.dayNumber })} - {tint && ( - // theme-lint-disable: the day colours are map paint, see dayColors.ts. -
{t('mobileTrip.rtStart')} - + {rt.clocks.start ?? '-'} @@ -141,24 +168,11 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) { {t('roadtrip.stay.arrive')} - + {rt.clocks.arrive ?? '-'}
-
- - {stage.distance > 0 - ? t('roadtrip.leg.driveText', { - distance: formatDistance(stage.distance / 1000, unit), - time: formatDurationShort(stage.duration), - }) - : t('roadtrip.summary.partial')} - - - {t('roadtrip.day.stopCount', { count: rt.stops })} - -
{stage.dayWarning && (
{t('roadtrip.title')} - - {formatDistance(planner.roadtripRoutes.totalDistance / 1000, unit)} - + {total && {total}}
) } const tint = dayColorsOn ? dayColor(stage.dayNumber) : null const end = rt.end + const name = end?.stop.name ?? t('roadtrip.stop.none') + const stopCount = t('roadtrip.day.stopCount', { count: rt.stops }) + const distance = distanceBadge(stage.distance, unit) + const pending = t('roadtrip.leg.pending') const inner = ( <> - {tint && ( + {/* Hidden from the name, which the title already carries: the photo's alt text is the + place name a second time. The day colour rings the picture when the day colours + are on, because the ring is what ties this bar to its own line on the map. */} + - - {end?.stop.name ?? t('roadtrip.stop.none')} - - - {[t('roadtrip.day.stopCount', { count: rt.stops }), end?.time].filter(Boolean).join(' · ')} + {name} + + {stopCount} + {/* dir=ltr for the reason the rows carry it: a clock reads the same way round in an RTL locale. */} + {end?.time && {end.time}} - - {stage.distance > 0 ? formatDistance(stage.distance / 1000, unit) : t('roadtrip.leg.pending')} - + {distance ? {distance} : {pending}} ) - const box = 'flex w-full items-center gap-2.5 rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-[14px] py-[11px] text-left shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[24px] backdrop-saturate-[1.6]' + const box = 'flex w-full items-center gap-2.5 rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-[11px] py-[10px] text-left shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[24px] backdrop-saturate-[1.6]' // A stage without a single stop has nothing for a tap to open, so a plain block rather // than a dead button, the rule the whole-drive line above follows too. if (!end) return
{inner}
- return + return ( + + ) } /** diff --git a/client/src/mobile/screens/trip/roadtrip/stageBadges.test.ts b/client/src/mobile/screens/trip/roadtrip/stageBadges.test.ts new file mode 100644 index 000000000..1a1bd29e9 --- /dev/null +++ b/client/src/mobile/screens/trip/roadtrip/stageBadges.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { badgeLabel, distanceBadge } from './stageBadges' + +// FE-MOB-RTBADGE-001 to FE-MOB-RTBADGE-004 + +describe('distanceBadge', () => { + it('FE-MOB-RTBADGE-001: formats metres in the traveller\'s own unit', () => { + expect(distanceBadge(412_000, 'metric')).toBe('412 km') + expect(distanceBadge(412_000, 'imperial')).toBe('256 mi') + // A short hop keeps its metres reading, the one the route connectors print. + expect(distanceBadge(300, 'metric')).toBe('300 m') + }) + + it('FE-MOB-RTBADGE-002: nothing measured is no badge, never a 0 m one', () => { + expect(distanceBadge(0, 'metric')).toBeNull() + expect(distanceBadge(-5, 'metric')).toBeNull() + expect(distanceBadge(Number.NaN, 'metric')).toBeNull() + expect(distanceBadge(Number.POSITIVE_INFINITY, 'imperial')).toBeNull() + }) +}) + +describe('badgeLabel', () => { + it('FE-MOB-RTBADGE-003: joins the parts with a comma and leaves the empty ones out', () => { + expect(badgeLabel(['Sat 2', null, '', undefined, '123 km'])).toBe('Sat 2, 123 km') + }) + + it('FE-MOB-RTBADGE-004: a single part is itself, and no parts is an empty name', () => { + expect(badgeLabel(['Sat 2'])).toBe('Sat 2') + expect(badgeLabel([])).toBe('') + expect(badgeLabel([null, undefined])).toBe('') + }) +}) diff --git a/client/src/mobile/screens/trip/roadtrip/stageBadges.ts b/client/src/mobile/screens/trip/roadtrip/stageBadges.ts new file mode 100644 index 000000000..9f3e36243 --- /dev/null +++ b/client/src/mobile/screens/trip/roadtrip/stageBadges.ts @@ -0,0 +1,32 @@ +import { formatDistance } from '../../../../utils/units' +import type { DistanceUnit } from '../../../../types' + +/** + * What the two bars of the road trip tab put in their badges, kept out of their markup. + * + * Phone only: the shell's stage header and the stage bar over the map are the two places + * that draw these figures as badges, and the desktop has neither of them. + */ + +/** + * A distance as a badge reads it, or null when there is nothing to read. + * + * Nothing measured is no badge rather than a "0 m" one. A stage sits at zero while its + * legs are still routing, and a zero in a badge reads as a drive that goes nowhere, which + * is a claim, where a missing badge only says that the figure is not there yet. + */ +export function distanceBadge(meters: number, unit: DistanceUnit): string | null { + return Number.isFinite(meters) && meters > 0 ? formatDistance(meters / 1000, unit) : null +} + +/** + * The accessible name of a button made of badges. + * + * The eye separates two pills by their edges, but the name a screen reader builds from + * them runs the texts together ("Sat 2123 km"), so the button spells the separators out. + * A comma rather than Intl.ListFormat: the parts are facts in a row, not a list with an + * "and" before its last item, and the client's ES2020 lib does not type that API anyway. + */ +export function badgeLabel(parts: readonly (string | null | undefined)[]): string { + return parts.filter((part): part is string => !!part).join(', ') +} diff --git a/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts b/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts index 4dc58ffa1..2485dd8c3 100644 --- a/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts +++ b/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts @@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from 'react' import { destinationCount, roadtripRows, stageClocks, stageEnd, stageOf, upNextStop } from '../../../../components/Roadtrip/roadtripRowModel' import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings' import { useSettingsStore } from '../../../../store/settingsStore' +import { useTripStore } from '../../../../store/tripStore' import { isEffectivelyOffline, onNetworkModeChange } from '../../../../sync/networkMode' import type { RoadtripDay } from '@trek/shared/roadtrip' import type { MTripShellApi, TripPlanner } from '../MTripShell' import type { RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel' +import type { Category, Place } from '../../../../types' /** Minutes since midnight, local time. */ const nowMinutes = (): number => { @@ -21,8 +23,12 @@ export interface MRoadtripController { rows: RoadtripRow[] /** The head card's two figures, both taken from the arrival column the rows print. See stageClocks. */ clocks: ReturnType - /** The stop the bar over the map names and opens, with the clock it shows. See stageEnd. */ + /** The stop the bar over the map names, pictures and opens, with the clock it shows. See stageEnd. */ end: StopRow | null + /** The place row behind `end`, for the picture the bar leads with. Null without one. */ + endPlace: Place | null + /** Its category, whose icon and colour stand in for a place that has no photo. */ + endCategory: Category | null stops: number /** True while the routing round is still working through the trip's days. */ loading: boolean @@ -64,6 +70,21 @@ export function useMRoadtrip(planner: TripPlanner): MRoadtripController { const clocks = useMemo(() => stageClocks(rows), [rows]) const end = useMemo(() => stageEnd(rows), [rows]) + // The place behind the end comes from the whole trip store, not planner.places: with + // service stops hidden from the day lists the phone filters them out of that list, while + // the stage still draws them, and a day that ends at a hidden campsite still ends there. + // The stop sheet and the stage pins read the same list for the same reason. + const tripPlaces = useTripStore(s => s.places) + const endPlace = useMemo( + () => (end ? tripPlaces.find(p => p.id === end.stop.placeId) ?? null : null), + [end, tripPlaces], + ) + const categories = planner.categories + const endCategory = useMemo( + () => (endPlace?.category_id == null ? null : categories.find(c => c.id === endPlace.category_id) ?? null), + [endPlace, categories], + ) + // "Today" is the stage's own date, not the selected day's index: a trip can be // planned for next year, and a countdown on a day in March is noise. const isToday = useMemo(() => { @@ -77,6 +98,8 @@ export function useMRoadtrip(planner: TripPlanner): MRoadtripController { rows, clocks, end, + endPlace, + endCategory, stops: stage ? destinationCount(stage) : 0, loading: roadtripRoutes.loading, empty: !roadtripRoutes.loading && roadtripRoutes.days.length === 0, diff --git a/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx b/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx index 94571dc04..c6ac1ecca 100644 --- a/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx +++ b/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx @@ -1,12 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen, within } from '../../../helpers/render' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' +import { resetAllStores, seedStore } from '../../../helpers/store' +import { useTripStore } from '../../../../src/store/tripStore' import MRoadtripTab from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripTab' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { Day, Place } from '../../../../src/types' import type { RoadtripDay, RoadtripRoutes, RouteSegment } from '@trek/shared/roadtrip' -// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-034 +// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-044 +// +// The stage bar pictures the place its day ends at. It reads that place out of the trip +// store rather than the planner, the unfiltered list, so the picture tests seed the store. // The preference store is a zustand slice keyed by user and trip; the tab only ever // reads two flags out of it, so the hook is the smaller seam. @@ -19,6 +24,15 @@ vi.mock('../../../../src/hooks/useRoadtripSettings', () => ({ useRoadtripSettings: (select: (p: Record) => unknown) => select(mocks.prefs), })) +// The picture asks the photo service for any place without an image of its own. Stubbed to +// know nothing and fetch nothing, so no test of the bar ever reaches for the network. +vi.mock('../../../../src/services/photoService', () => ({ + getCached: () => null, + isLoading: () => false, + fetchPhoto: vi.fn(), + onThumbReady: () => () => {}, +})) + // The swipe itself is the plan tab's, tested there; this keeps the real hook and only // records what the road trip tab hands it. vi.mock('../../../../src/mobile/screens/trip/plan/useMPlanDaySwipe', async importOriginal => { @@ -148,6 +162,7 @@ function today(date: string): Partial { describe('MRoadtripTab', () => { beforeEach(() => { + resetAllStores() mocks.prefs = {} }) @@ -246,16 +261,18 @@ describe('MRoadtripTab', () => { expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull() }) - it('FE-MOB-RTTAB-010: paints the day tint only once the day colours are on', () => { - const plain = renderTab() - expect(plain.container.querySelector('span.h-2.w-2.rounded-full')).toBeNull() - plain.unmount() - + it('FE-MOB-RTTAB-010: draws no day colour dot in the head card, even with the day colours on', () => { + // The colour keys the day's line on the map, which lies under the list on this half, + // so a dot here had nothing on screen to explain it. mocks.prefs = { roadtrip_day_colors: true } - const tinted = renderTab() - const dot = tinted.container.querySelector('span.h-2.w-2.rounded-full') as HTMLElement - // dayColor(2).line, the second of the eight, see dayColors.ts. - expect(dot).toHaveStyle({ background: '#ff9f0a' }) + renderTab() + + const card = screen.getByText('mobileTrip.rtStart').closest('section') as HTMLElement + // The dot was the card's only inline paint (this stage carries no day warning). + expect(card.querySelectorAll('[style]')).toHaveLength(0) + expect(card.querySelector('[aria-hidden="true"]')).toBeNull() + // The date is text and nothing else, with no swatch leading or trailing it. + expect(screen.getByText(/May 2/).children).toHaveLength(0) }) it('FE-MOB-RTTAB-011: dashes the two figures for a stage the routing could not time', () => { @@ -323,6 +340,66 @@ describe('MRoadtripTab', () => { const firstRow = screen.getByText('Hamburg Speicherstadt').closest('[role=button]') as HTMLElement expect(within(firstRow).getByText('10:00')).toBeInTheDocument() }) + + it('FE-MOB-RTTAB-042: carries the day\'s facts on the date line as badges, above the clocks', () => { + renderTab() + + const date = screen.getByText(/May 2/) + const line = date.parentElement as HTMLElement + // The date takes the start and the badges pack to the end, and the line may wrap. + expect(date.className).toContain('me-auto') + expect(line.className).toContain('flex-wrap') + const drive = within(line).getByText('roadtrip.leg.driveText:412 km,5 h').closest('.rounded-full') as HTMLElement + const stops = within(line).getByText('roadtrip.day.stopCount:2').closest('.rounded-full') as HTMLElement + expect(drive).not.toBeNull() + expect(stops).not.toBeNull() + expect(drive).not.toBe(stops) + // The two share one wrapper beside the date, so they wrap under it as a pair and a long + // drive never leaves the count alone on the next line, away from the drive. + const pair = drive.parentElement as HTMLElement + expect(stops.parentElement).toBe(pair) + expect(pair.parentElement).toBe(line) + expect(pair.className).toContain('flex-wrap') + expect(pair.className).toContain('max-w-full') + // The distance keeps its unit's case; the count is a word, in caps like the stage bar's. + expect(drive.className).not.toContain('uppercase') + expect(stops.className).toContain('uppercase') + // The clocks are the band under the line, not part of it. + expect(within(line).queryByText('mobileTrip.rtStart')).toBeNull() + const clocks = screen.getByText('mobileTrip.rtStart') + expect(line.compareDocumentPosition(clocks) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('FE-MOB-RTTAB-043: puts the partial total on the date line in a badge that may wrap', () => { + renderTab(planner({ roadtripRoutes: routes({ days: [stage({ distance: 0 })] }) })) + + const line = screen.getByText(/May 2/).parentElement as HTMLElement + const badge = within(line).getByText('roadtrip.summary.partial') + // The sentence breaks inside its badge rather than pushing past the card, and the + // badge stops at the line's width and grows with the text instead of clipping it. + expect(badge.className).toContain('rounded-full') + expect(badge.className).toContain('whitespace-normal') + expect(badge.className).toContain('max-w-full') + expect(badge.className).not.toMatch(/(^|\s)h-\[18px\]/) + expect(badge.className).not.toContain('uppercase') + // The stop count beside it is a figure, and stays on one line. + expect(within(line).getByText('roadtrip.day.stopCount:2').className).toContain('whitespace-nowrap') + }) + + it('FE-MOB-RTTAB-044: sets the clocks smaller and draws no divider band, so the card stays a header', () => { + renderTab() + + for (const [label, clock] of [['mobileTrip.rtStart', '08:30'], ['roadtrip.stay.arrive', '12:40']] as const) { + const value = within(screen.getByText(label).parentElement as HTMLElement).getByText(clock) + expect(value.className).toContain('text-[1.5rem]') + expect(value.className).not.toContain('text-[1.875rem]') + } + const card = screen.getByText('mobileTrip.rtStart').closest('section') as HTMLElement + expect(card.querySelector('[class*="border-t"]')).toBeNull() + const padding = card.className.split(' ') + expect(padding).toContain('py-3') + expect(padding).not.toContain('py-3.5') + }) }) describe('up next', () => { @@ -430,16 +507,26 @@ describe('MRoadtripTab', () => { expect(screen.queryByText('roadtrip.window.stop')).toBeNull() }) - it('FE-MOB-RTTAB-023: names the last stop of the day, its count, that stop\'s own arrival and the distance', () => { + it('FE-MOB-RTTAB-023: names the last stop of the day, and badges its count, that stop\'s own arrival and the distance', () => { renderTab(planner(), mapShell()) - const bar = screen.getByRole('button', { name: /Kyoto Station/ }) + // The badges run their texts together in a computed name, so the bar spells it out. + const bar = screen.getByRole('button', { name: 'Kyoto Station, roadtrip.day.stopCount:2, 12:40, 412 km' }) expect(within(bar).getByText('Kyoto Station')).toBeInTheDocument() + // One badge per figure, and no middle dot left between them. + const badges = ['roadtrip.day.stopCount:2', '12:40', '412 km'].map(text => within(bar).getByText(text).closest('.rounded-full')) + expect(badges).not.toContain(null) + expect(new Set(badges).size).toBe(3) + expect(bar.textContent).not.toContain('·') // 12:40 is when Kyoto Station is reached. The bar used to print 22:00, the automatic // day end after it, which is where the window closed and not a clock of the stop named. - expect(within(bar).getByText('roadtrip.day.stopCount:2 · 12:40')).toBeInTheDocument() expect(within(bar).queryByText(/22:00/)).toBeNull() - expect(within(bar).getByText('412 km')).toBeInTheDocument() + // Words in caps, the unit in its own case. And neutral: the whole bar is the button, + // and a filled pill inside it would read as a second one. + expect(within(bar).getByText('roadtrip.day.stopCount:2').className).toContain('uppercase') + const distance = within(bar).getByText('412 km') + expect(distance.className).not.toContain('uppercase') + expect(distance.className).not.toContain('bg-m-act') }) it('FE-MOB-RTTAB-024: drops the arrival from the bar when the day has none, and the pending distance with it', () => { @@ -449,7 +536,9 @@ describe('MRoadtripTab', () => { } as unknown as Partial) renderTab(planner({ roadtripRoutes: routes({ days: [untimed] }) }), mapShell()) - const bar = screen.getByRole('button', { name: /roadtrip.leg.pending/ }) + // The name leaves the missing clock out too, rather than reading an empty part or a + // placeholder between the count and the distance. + const bar = screen.getByRole('button', { name: 'Kyoto Station, roadtrip.day.stopCount:2, roadtrip.leg.pending' }) expect(within(bar).getByText('roadtrip.day.stopCount:2')).toBeInTheDocument() expect(within(bar).getByText('roadtrip.leg.pending')).toBeInTheDocument() }) @@ -500,7 +589,8 @@ describe('MRoadtripTab', () => { const { shell } = renderTab(planner({ roadtripRoutes: routes({ days: [spilledOnly] }) }), mapShell()) const bar = screen.getByRole('button', { name: /Fuji Viewpoint/ }) - expect(within(bar).getByText('roadtrip.day.stopCount:1 · 00:40')).toBeInTheDocument() + expect(within(bar).getByText('roadtrip.day.stopCount:1')).toBeInTheDocument() + expect(within(bar).getByText('00:40')).toBeInTheDocument() fireEvent.click(bar) expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 1, assignmentId: 501 }) }) @@ -514,6 +604,102 @@ describe('MRoadtripTab', () => { // whole-drive line is a plain block. The search bar above it stays a button. expect(screen.getByText('roadtrip.title').closest('button')).toBeNull() }) + + it('FE-MOB-RTTAB-035: pictures the place the day ends at, from the trip store and not the filtered day lists', () => { + seedStore(useTripStore, { places: [PLACES[0], { ...PLACES[1], image_url: 'https://img.test/kyoto.jpg' }] }) + // planner.places is the list the phone takes hidden service stops out of, so the + // picture must not depend on it: a day can end at a hidden campsite. + renderTab(planner({ places: [] }), mapShell()) + + const bar = screen.getByRole('button', { name: /Kyoto Station/ }) + const img = bar.querySelector('img') as HTMLImageElement + expect(img).toHaveAttribute('src', 'https://img.test/kyoto.jpg') + // Out of the name: the photo's alt text is the title a second time. + expect(img.closest('[aria-hidden="true"]')).not.toBeNull() + expect(bar).toHaveAccessibleName('Kyoto Station, roadtrip.day.stopCount:2, 12:40, 412 km') + expect(bar.querySelector('.lucide-map-pin')).toBeNull() + }) + + it('FE-MOB-RTTAB-036: shows a neutral pin when no place row stands behind the stop', () => { + seedStore(useTripStore, { places: [] }) + renderTab(planner(), mapShell()) + + const bar = screen.getByRole('button', { name: /Kyoto Station/ }) + expect(bar.querySelector('img')).toBeNull() + expect(bar.firstElementChild?.querySelector('.lucide-map-pin')).not.toBeNull() + }) + + it('FE-MOB-RTTAB-037: a place without a photo is pictured by its category', () => { + seedStore(useTripStore, { places: [{ ...PLACES[1], category_id: 5 }] }) + const categories = [{ id: 5, name: 'Station', color: '#123456', icon: 'Train' }] + renderTab(planner({ categories } as unknown as Partial), mapShell()) + + const ring = screen.getByRole('button', { name: /Kyoto Station/ }).firstElementChild as HTMLElement + expect(ring.querySelector('img')).toBeNull() + expect(ring.firstElementChild).toHaveStyle({ backgroundColor: '#123456' }) + }) + + it('FE-MOB-RTTAB-038: rings the picture in the day colour only once the day colours are on', () => { + const plain = renderTab(planner(), mapShell()) + const plainRing = screen.getByRole('button', { name: /Kyoto Station/ }).firstElementChild as HTMLElement + expect(plainRing).toHaveAttribute('aria-hidden', 'true') + expect(plainRing.style.borderColor).toBe('') + plain.unmount() + + mocks.prefs = { roadtrip_day_colors: true } + renderTab(planner(), mapShell()) + const ring = screen.getByRole('button', { name: /Kyoto Station/ }).firstElementChild as HTMLElement + // dayColor(2).line, the second of the eight, see dayColors.ts. + expect(ring).toHaveStyle({ borderColor: '#ff9f0a' }) + }) + + it('FE-MOB-RTTAB-039: a new stage never keeps the picture of the one before', () => { + seedStore(useTripStore, { places: [PLACES[0], { ...PLACES[1], image_url: 'https://img.test/kyoto.jpg' }] }) + const shell = mapShell() + const view = renderTab(planner(), shell) + expect(screen.getByRole('button', { name: /Kyoto Station/ }).querySelector('img')).not.toBeNull() + + // The next stage ends at Fuji Viewpoint, which has no photo of its own. The avatar + // keeps the photo it was first handed until it remounts, which is what its key is for. + const fujiOnly = stage({ + stops: [stage().stops[0]], + legs: [], + legVias: [], + dryPoints: [], + driveWarnings: [], + schedule: { entries: [{ arrival: '00:40', departure: '01:25', anchored: false, dayOffset: 0 }], warnings: [] }, + } as unknown as Partial) + view.rerender() + + const bar = screen.getByRole('button', { name: /Fuji Viewpoint/ }) + expect(bar.querySelector('img')).toBeNull() + }) + + it('FE-MOB-RTTAB-040: the head card and the stage bar agree on when the day arrives', () => { + const list = renderTab() + const arrive = screen.getByText('roadtrip.stay.arrive').parentElement as HTMLElement + expect(within(arrive).getByText('12:40')).toBeInTheDocument() + list.unmount() + + renderTab(planner(), mapShell()) + const bar = screen.getByRole('button', { name: /Kyoto Station/ }) + // Same clock, and it keeps its digit order in an RTL locale like the head card's. + expect(within(bar).getByText('12:40')).toHaveAttribute('dir', 'ltr') + }) + + it('FE-MOB-RTTAB-041: the whole-drive bar badges a measured total and leaves a total of nothing out', () => { + const measured = renderTab(planner({ selectedDayId: null }), mapShell()) + const total = screen.getByText('980 km') + expect(total.className).toContain('rounded-full') + expect(total.className).not.toContain('uppercase') + measured.unmount() + + renderTab(planner({ selectedDayId: null, roadtripRoutes: routes({ totalDistance: 0 }) }), mapShell()) + // Nothing routed yet is not a drive of 0 m. + expect(screen.getByText('roadtrip.title')).toBeInTheDocument() + expect(screen.queryByText('0 m')).toBeNull() + expect(screen.getByText('roadtrip.title').parentElement?.querySelector('.rounded-full')).toBeNull() + }) }) describe('the corridor search', () => { diff --git a/client/tests/unit/mobile/trip/MTripShell.test.tsx b/client/tests/unit/mobile/trip/MTripShell.test.tsx index 83ae31af6..96b9893e0 100644 --- a/client/tests/unit/mobile/trip/MTripShell.test.tsx +++ b/client/tests/unit/mobile/trip/MTripShell.test.tsx @@ -4,7 +4,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { Day, PackingItem, TodoItem } from '../../../../src/types' -// FE-MOB-SHELL-001 to FE-MOB-SHELL-064 +// FE-MOB-SHELL-001 to FE-MOB-SHELL-066 const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner })) @@ -739,7 +739,10 @@ describe('MTripShell', () => { roadtripRoutes: { days: [stage()], totalDistance: 240000 }, } as unknown as Partial) // The stage beats the total: 123 km is today's drive, 240 km is the whole trip. - expect(screen.getByRole('button', { name: 'Sat 2 · 123 km' })).toBeInTheDocument() + const header = screen.getByRole('button', { name: 'Sat 2, 123 km' }) + // Two badges rather than one line joined with a dot. + expect(within(header).getByText('Sat 2')).not.toBe(within(header).getByText('123 km')) + expect(header.textContent).not.toContain('·') }) it('FE-MOB-SHELL-062: with the day filter off it reads the whole drive', () => { @@ -749,7 +752,11 @@ describe('MTripShell', () => { selectedDayId: null, roadtripRoutes: { days: [stage()], totalDistance: 240000 }, } as unknown as Partial) - expect(screen.getByRole('button', { name: '240 km' })).toBeInTheDocument() + const header = screen.getByRole('button', { name: '240 km' }) + // The total alone: without a stage there is no day to badge. + const badges = header.querySelectorAll('span.rounded-full') + expect(badges).toHaveLength(1) + expect(badges[0]).toHaveTextContent('240 km') }) it('FE-MOB-SHELL-063: before anything has routed it falls back to the addon name, and the plan tab never shows it', () => { @@ -769,7 +776,39 @@ describe('MTripShell', () => { selectedDayId: 99, roadtripRoutes: { days: [stage({ dayId: 99, dayNumber: 4, distance: 50000 })], totalDistance: 240000 }, } as unknown as Partial) - expect(screen.getByRole('button', { name: 'planner.dayN:4 · 50 km' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'planner.dayN:4, 50 km' })).toBeInTheDocument() + }) + + it('FE-MOB-SHELL-065: a stage the round has not measured yet shows its day badge alone', () => { + renderShell({ + TRIP_TABS: RT_TABS, + activeTab: 'roadtrip', + roadtripRoutes: { days: [stage({ distance: 0 })], totalDistance: 240000 }, + } as unknown as Partial) + // Not "Sat 2 · 0 m", and not the whole trip's 240 km standing in for the day either. + // The active day chip below shares the name, so the header is the one with the sliders. + const header = screen.getAllByRole('button', { name: 'Sat 2' }) + .find(button => button.querySelector('.lucide-sliders-horizontal')) as HTMLElement + expect(header).toBeDefined() + expect(within(header).queryByText('0 m')).toBeNull() + expect(within(header).queryByText('240 km')).toBeNull() + }) + + it('FE-MOB-SHELL-066: the header figures are badges, the day in caps and the distance in its own case', () => { + renderShell({ + TRIP_TABS: RT_TABS, + activeTab: 'roadtrip', + roadtripRoutes: { days: [stage()], totalDistance: 240000 }, + } as unknown as Partial) + const header = screen.getByRole('button', { name: 'Sat 2, 123 km' }) + const badges = Array.from(header.children).filter(el => el.tagName === 'SPAN') + expect(badges.map(b => b.textContent)).toEqual(['Sat 2', '123 km']) + for (const badge of badges) expect(badge.className).toContain('rounded-full') + expect(badges[0].className).toContain('uppercase') + // m and M are different units, so the distance keeps its case. + expect(badges[1].className).not.toContain('uppercase') + // Neutral, both: the active day chip right below is already the filled pill. + expect(badges[0].className).not.toContain('bg-m-act') }) }) })