mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): badges and a place picture on the phone stage bars, a compact head card
The stage bar over the road trip map joined its figures with middle dots and led with a bare day colour dot. It now leads with a picture of the place it names (PlaceAvatar, falling back to the category icon), ringed in the day colour while day colours are on, and shows the stop count, the arrival and the distance as badges. The header pill over the tab does the same with the day and the distance, and leaves out a distance nothing has measured yet instead of printing 0 m. Both bars carry an accessible name built from their parts, because pills side by side lose the spaces between them when a screen reader reads them out. The badges are a new non interactive MBadge, a span, since every place it sits in is itself a button. The value rules shared by both bars live in stageBadges.ts. The head card at the top of the chain is more compact: the drive and the stop count ride on the date line as badges, the two clocks are a size smaller and the separate stats band is gone. The day colour dot is gone from it: on the list half the line it keys lies hidden under the chain, so it read as an unexplained status light.
This commit is contained in:
@@ -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(<MBadge>3 stops</MBadge>)
|
||||
|
||||
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(<MBadge>neutral</MBadge>)
|
||||
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(<MBadge tone="strong">strong</MBadge>)
|
||||
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(<MBadge>Sat 2</MBadge>)
|
||||
expect(screen.getByText('Sat 2').className).toContain('uppercase')
|
||||
|
||||
render(<MBadge caps={false}>300 m</MBadge>)
|
||||
// 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(<MBadge>small</MBadge>)
|
||||
expect(screen.getByText('small').className).toContain('h-[18px]')
|
||||
|
||||
render(<MBadge size="sm" className="ms-auto">larger</MBadge>)
|
||||
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(<MBadge icon={<Route size={10} />}>412 km</MBadge>)
|
||||
|
||||
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(<MBadge>412 km</MBadge>)
|
||||
const figure = screen.getByText('412 km')
|
||||
expect(figure.className).toContain('whitespace-nowrap')
|
||||
expect(figure.className).toContain('h-[18px]')
|
||||
|
||||
render(<MBadge wrap size="sm">Still working out the rest of the drive</MBadge>)
|
||||
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\]/)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<span
|
||||
className={`inline-flex flex-none items-center gap-[3px] rounded-full font-geist font-medium tabular-nums ${
|
||||
size === 'sm' ? 'px-[9px] text-[0.65625rem]' : 'px-[7px] text-[0.59375rem]'
|
||||
} ${
|
||||
wrap
|
||||
? `max-w-full whitespace-normal py-[2px] leading-[1.15] ${size === 'sm' ? 'min-h-[22px]' : 'min-h-[18px]'}`
|
||||
: `whitespace-nowrap leading-none ${size === 'sm' ? 'h-[22px]' : 'h-[18px]'}`
|
||||
} ${caps ? 'uppercase tracking-[.07em]' : ''} ${
|
||||
tone === 'strong'
|
||||
? 'bg-m-act text-m-actfg'
|
||||
: 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
|
||||
} ${className}`}
|
||||
>
|
||||
{/* The icon only repeats what the label says, so it stays out of the name. */}
|
||||
{icon && <span aria-hidden="true" className="flex flex-none">{icon}</span>}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 overflow-hidden bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] text-m-ink">
|
||||
@@ -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' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSheet('rtinfo')}
|
||||
className="absolute left-[52px] right-[52px] top-1/2 mx-auto flex h-[34px] w-fit max-w-full -translate-y-1/2 items-center gap-[6px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[13px] backdrop-blur-[24px] backdrop-saturate-[1.7]"
|
||||
aria-label={rtHeaderLabel || undefined}
|
||||
className="absolute left-[52px] right-[52px] top-1/2 mx-auto flex h-[34px] w-fit max-w-full -translate-y-1/2 items-center gap-[5px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[10px] backdrop-blur-[24px] backdrop-saturate-[1.7]"
|
||||
>
|
||||
<Route size={14} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate text-[0.8125rem] font-semibold tabular-nums text-m-ink">
|
||||
{rtHeaderLabel}
|
||||
</span>
|
||||
{rtHeaderLabel ? (
|
||||
<>
|
||||
{rtHeaderDay && <MBadge size="sm">{rtHeaderDay}</MBadge>}
|
||||
{/* Unit symbols keep their case: m and M are different units. */}
|
||||
{rtHeaderDistance && <MBadge size="sm" caps={false}>{rtHeaderDistance}</MBadge>}
|
||||
</>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-[0.8125rem] font-semibold text-m-ink">{t('roadtrip.title')}</span>
|
||||
)}
|
||||
<SlidersHorizontal size={13} strokeWidth={2} className="flex-none text-m-faint" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
<EmptyStage planner={planner} loading={rt.loading} />
|
||||
) : (
|
||||
<>
|
||||
{/* 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. */}
|
||||
<section className="rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-4 py-3.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-muted">
|
||||
{/* 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. */}
|
||||
<section className="rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<span className="me-auto whitespace-nowrap font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-muted">
|
||||
{stageDateLabel(planner, stage.dayId) ?? t('roadtrip.day', { number: stage.dayNumber })}
|
||||
</span>
|
||||
{tint && (
|
||||
// theme-lint-disable: the day colours are map paint, see dayColors.ts.
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: tint.line, boxShadow: `0 0 0 2px ${tint.casing}` }} aria-hidden="true" />
|
||||
)}
|
||||
{/* 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. */}
|
||||
<span className="flex max-w-full flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
{/* 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. */}
|
||||
<MBadge caps={false} wrap>
|
||||
{stage.distance > 0
|
||||
? t('roadtrip.leg.driveText', {
|
||||
distance: formatDistance(stage.distance / 1000, unit),
|
||||
time: formatDurationShort(stage.duration),
|
||||
})
|
||||
: t('roadtrip.summary.partial')}
|
||||
</MBadge>
|
||||
<MBadge>{t('roadtrip.day.stopCount', { count: rt.stops })}</MBadge>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-3">
|
||||
<span className="min-w-0">
|
||||
<span className="block font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
|
||||
{t('mobileTrip.rtStart')}
|
||||
</span>
|
||||
<span dir="ltr" className="block text-[1.875rem] font-extrabold leading-none tabular-nums text-m-ink">
|
||||
<span dir="ltr" className="mt-0.5 block text-[1.5rem] font-extrabold leading-none tabular-nums text-m-ink">
|
||||
{rt.clocks.start ?? '-'}
|
||||
</span>
|
||||
</span>
|
||||
@@ -141,24 +168,11 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
<span className="block font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
|
||||
{t('roadtrip.stay.arrive')}
|
||||
</span>
|
||||
<span dir="ltr" className="block text-[1.875rem] font-extrabold leading-none tabular-nums text-m-ink">
|
||||
<span dir="ltr" className="mt-0.5 block text-[1.5rem] font-extrabold leading-none tabular-nums text-m-ink">
|
||||
{rt.clocks.arrive ?? '-'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-center gap-2 border-t border-[color:var(--m-rowbr)] pt-2.5">
|
||||
<span className="font-geist text-[0.71875rem] font-semibold tabular-nums text-m-muted">
|
||||
{stage.distance > 0
|
||||
? t('roadtrip.leg.driveText', {
|
||||
distance: formatDistance(stage.distance / 1000, unit),
|
||||
time: formatDurationShort(stage.duration),
|
||||
})
|
||||
: t('roadtrip.summary.partial')}
|
||||
</span>
|
||||
<span className="ms-auto whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.59375rem] font-bold text-m-muted">
|
||||
{t('roadtrip.day.stopCount', { count: rt.stops })}
|
||||
</span>
|
||||
</div>
|
||||
{stage.dayWarning && (
|
||||
<div
|
||||
className="mt-2 rounded-[13px] px-[11px] py-[7px]"
|
||||
@@ -224,8 +238,20 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
*
|
||||
* It reads as a place, so a tap opens that place's stop, the same sheet its row in the
|
||||
* chain opens. It used to switch to the chain instead, which broke the promise its name
|
||||
* makes; the header's list switch is the way back to the chain. Name, clock and tap all
|
||||
* come off one row (see stageEnd), so the bar can never name one stop and open another.
|
||||
* makes; the header's list switch is the way back to the chain. Name, picture, clock and
|
||||
* tap all come off one row (see stageEnd), so the bar can never name one stop and open
|
||||
* another.
|
||||
*
|
||||
* It leads with a picture of that place rather than a coloured dot, because a place is
|
||||
* recognised by how it looks long before its name is read. The facts beside it are badges
|
||||
* rather than one line joined with dots: each one is a figure on its own, and a pill edge
|
||||
* separates them at a glance where a middle dot has to be found first. Every badge is
|
||||
* neutral. The whole bar is one button, and a filled pill inside it would read as a
|
||||
* second control that does something else.
|
||||
*
|
||||
* Its height is a contract: 38px picture, a 39px text column, 10px padding and the border
|
||||
* make the 61px the map area lifts its round buttons over (--m-stage-lift in MMapArea).
|
||||
* Growing the picture or a badge moves those buttons into the bar's top edge.
|
||||
*/
|
||||
function StageBar({ planner, rt, onOpen }: {
|
||||
planner: MTripTabPanelProps['planner']
|
||||
@@ -238,45 +264,70 @@ function StageBar({ planner, rt, onOpen }: {
|
||||
const stage = rt.stage
|
||||
|
||||
if (!stage) {
|
||||
// No stage means the day filter is off, which on the map is the whole drive.
|
||||
// No stage means the day filter is off, which on the map is the whole drive. Its total
|
||||
// is left out while nothing is measured, rather than printed as a drive of 0 m.
|
||||
const total = distanceBadge(planner.roadtripRoutes.totalDistance, unit)
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-[14px] py-[11px] shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[24px] backdrop-saturate-[1.6]">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{t('roadtrip.title')}</span>
|
||||
<span className="whitespace-nowrap font-geist text-[0.6875rem] font-semibold tabular-nums text-m-muted">
|
||||
{formatDistance(planner.roadtripRoutes.totalDistance / 1000, unit)}
|
||||
</span>
|
||||
{total && <MBadge size="sm" caps={false}>{total}</MBadge>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-[38px] w-[38px] flex-none items-center justify-center overflow-hidden rounded-full border-2 border-[color:var(--m-avbr)] bg-[color:var(--m-ic)] text-m-muted"
|
||||
// theme-lint-disable: map paint, see dayColors.ts.
|
||||
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: tint.line, boxShadow: `0 0 0 2px ${tint.casing}` }} aria-hidden="true" />
|
||||
)}
|
||||
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 ? (
|
||||
<PlaceAvatar key={rt.endPlace.id} place={rt.endPlace} category={rt.endCategory} size={34} />
|
||||
) : (
|
||||
<MapPin size={16} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">
|
||||
{end?.stop.name ?? t('roadtrip.stop.none')}
|
||||
</span>
|
||||
<span className="mt-px block font-geist text-[0.6875rem] tabular-nums text-m-muted">
|
||||
{[t('roadtrip.day.stopCount', { count: rt.stops }), end?.time].filter(Boolean).join(' · ')}
|
||||
<span className="block truncate text-[0.8125rem] font-semibold leading-[18px] text-m-ink">{name}</span>
|
||||
<span className="mt-[3px] flex min-w-0 items-center gap-[5px] overflow-hidden">
|
||||
<MBadge>{stopCount}</MBadge>
|
||||
{/* dir=ltr for the reason the rows carry it: a clock reads the same way round in an RTL locale. */}
|
||||
{end?.time && <MBadge caps={false}><span dir="ltr">{end.time}</span></MBadge>}
|
||||
</span>
|
||||
</span>
|
||||
<span className="whitespace-nowrap font-geist text-[0.6875rem] font-semibold tabular-nums text-m-muted">
|
||||
{stage.distance > 0 ? formatDistance(stage.distance / 1000, unit) : t('roadtrip.leg.pending')}
|
||||
</span>
|
||||
{distance ? <MBadge size="sm" caps={false}>{distance}</MBadge> : <MBadge size="sm">{pending}</MBadge>}
|
||||
</>
|
||||
)
|
||||
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 <div className={box}>{inner}</div>
|
||||
return <button type="button" onClick={() => onOpen(end)} className={box}>{inner}</button>
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(end)}
|
||||
aria-label={badgeLabel([name, stopCount, end.time, distance ?? pending])}
|
||||
className={box}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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(', ')
|
||||
}
|
||||
@@ -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<typeof stageClocks>
|
||||
/** 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,
|
||||
|
||||
@@ -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<string, unknown>) => 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<TripPlanner> {
|
||||
|
||||
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<RoadtripDay>)
|
||||
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<TripPlanner>), 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<RoadtripDay>)
|
||||
view.rerender(<MRoadtripTab planner={planner({ roadtripRoutes: routes({ days: [fujiOnly] }) })} shell={shell} tab="roadtrip" />)
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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<TripPlanner>)
|
||||
// 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<TripPlanner>)
|
||||
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<TripPlanner>)
|
||||
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<TripPlanner>)
|
||||
// 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<TripPlanner>)
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user