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 && {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.
-
- )}
+ {/* The two badges wrap as a pair. Loose in the line, a long drive would keep
+ the date and the drive on one line and drop the count alone onto the next,
+ at the opposite edge from the badge it belongs with. */}
+
+ {/* The drive badge alone may break inside itself. A badge keeps its text on
+ one line, which is right for a figure, but the partial sentence runs to
+ nearly fifty characters in some locales and would push past the card on
+ a narrow phone, so this one stops at the row's width and lets its height
+ follow the text. The stop count is a figure and keeps to one line. */}
+
+ {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 })}
+
)
}
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. */}
+
- )}
+ style={tint ? { borderColor: tint.line } : undefined}
+ >
+ {/* Keyed by place: the avatar takes its photo from the first place it is handed and
+ keeps it while a new one is still loading or has none, so without a remount a
+ swipe to the next day would still picture the previous day's destination. */}
+ {rt.endPlace ? (
+
+ ) : (
+
+ )}
+
-
- {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')
})
})
})