mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(roadtrip): switch a stop between a pause and a destination from the phone chain
The desktop rail turns a place on the drive into a service stop by its own disc: click the 3, pick the pump, and the 3 becomes an orange disc while everything below it renumbers. The phone chain drew the same disc but it did nothing, so the only way to mark a petrol stop was to open the trip on a computer. The disc is now the control here too, for the same reason it is there: it is exactly what changes, so it is its own preview. It opens 'rtkind', a sheet rather than the desktop's popover under the disc, because a palette aimed at an anchor would land under the thumb that opened it. The kinds stay discs rather than seven rows of words, each named for a screen reader. A second tap on the kind a stop already is takes it back to a destination, and once there is something to undo the sheet says so in words too. It writes through planner.setRoadtripStopKind, the very call the rail makes, so the chain redraws off the store with no second code path. Without place_edit there is no handler and the disc is a plain disc, not a disabled-looking control. A leg pill with no figures in it is fixed on the way past: a segment that reaches the chain before its routing round has landed carries metres and seconds but empty texts, and reading only the texts printed the template with both slots empty, so the pill read " in " and said nothing. The numbers are formatted when the texts are missing, which is what the rail does with the same segment, and with neither the leg says it is pending.
This commit is contained in:
@@ -31,28 +31,47 @@ export interface RowChrome {
|
||||
unit: DistanceUnit
|
||||
}
|
||||
|
||||
/** 30px disc: a number for a destination, the kind's colour for a service stop. */
|
||||
function Disc({ row }: { row: StopRow }) {
|
||||
/**
|
||||
* 30px disc: a number for a destination, the kind's colour for a service stop.
|
||||
*
|
||||
* With `onPickKind` it is also the control that switches between the two, which is what
|
||||
* the desktop rail does with its own disc. The disc is what changes, so it is its own
|
||||
* preview: tap the 3, pick the pump, and the 3 becomes an orange disc while everything
|
||||
* below it renumbers. The tap is stopped here rather than allowed to reach the row, which
|
||||
* opens the stop, and the 34px column the row gives this disc is the whole target.
|
||||
*/
|
||||
function Disc({ row, t, onPickKind }: { row: StopRow; t: TranslationFn; onPickKind?: () => void }) {
|
||||
const kind = row.stop.stopType ? STOP_KIND_BY_KEY[row.stop.stopType] : undefined
|
||||
if (row.service && kind) {
|
||||
const Icon = kind.Icon
|
||||
return (
|
||||
const face = row.service && kind
|
||||
? (
|
||||
<span
|
||||
className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full text-white"
|
||||
// theme-lint-disable: the stop kinds share their colour with the map markers,
|
||||
// which sit on tiles rather than on one of the app's own surfaces.
|
||||
style={{ background: serviceColor(row.stop.stopType) }}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.1} aria-hidden="true" />
|
||||
<kind.Icon size={15} strokeWidth={2.1} aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
: (
|
||||
// Deliberately not font-geist: `.m-root .font-geist` caps the tier at Medium,
|
||||
// which would quietly undo the bold on a number that has to read at 12px.
|
||||
<span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full bg-[color:var(--m-ic)] text-[0.75rem] font-bold tabular-nums text-m-ink">
|
||||
{row.number}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!onPickKind) return face
|
||||
return (
|
||||
// Deliberately not font-geist: `.m-root .font-geist` caps the tier at Medium,
|
||||
// which would quietly undo the bold on a number that has to read at 12px.
|
||||
<span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full bg-[color:var(--m-ic)] text-[0.75rem] font-bold tabular-nums text-m-ink">
|
||||
{row.number}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={row.service ? t('roadtrip.stop.kind') : t('roadtrip.stop.makeService')}
|
||||
onClick={e => { e.stopPropagation(); onPickKind() }}
|
||||
onKeyDown={e => { e.stopPropagation() }}
|
||||
className="grid place-items-center rounded-full"
|
||||
>
|
||||
{face}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,10 +109,12 @@ function warningMark(warning: ScheduleWarning, chrome: RowChrome): ReactNode {
|
||||
* why it is a div with role rather than a button: the desktop needs controls nested
|
||||
* inside its row, and a button inside a button is not markup.
|
||||
*/
|
||||
export function RtStopRow({ row, chrome, onOpen }: {
|
||||
export function RtStopRow({ row, chrome, onOpen, onPickKind }: {
|
||||
row: StopRow
|
||||
chrome: RowChrome
|
||||
onOpen: () => void
|
||||
/** Absent for a traveller who may not edit places: the disc is then not a control. */
|
||||
onPickKind?: () => void
|
||||
}) {
|
||||
const { t, unit } = chrome
|
||||
const marks: ReactNode[] = []
|
||||
@@ -119,7 +140,7 @@ export function RtStopRow({ row, chrome, onOpen }: {
|
||||
onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen() } }}
|
||||
className="grid cursor-pointer items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr auto' }}
|
||||
>
|
||||
<span className="flex justify-center"><Disc row={row} /></span>
|
||||
<span className="flex justify-center"><Disc row={row} t={t} onPickKind={onPickKind} /></span>
|
||||
<span className="min-w-0 py-2">
|
||||
<span className={`block text-[0.875rem] leading-[1.25] ${row.service ? 'truncate font-medium text-m-muted' : 'line-clamp-2 font-semibold text-m-ink'}`}>
|
||||
{row.stop.name}
|
||||
@@ -142,6 +163,30 @@ const LEG_ICONS: Record<string, typeof CarFront> = {
|
||||
cycling: Bike,
|
||||
}
|
||||
|
||||
/**
|
||||
* What a leg pill says: how far and how long, or that it is not routed yet.
|
||||
*
|
||||
* A segment carries both a pre-formatted text and the raw metres and seconds, and the two
|
||||
* do not always arrive together: a segment that reaches the chain before its routing round
|
||||
* has landed has the numbers at zero and the texts empty. Reading only the texts printed
|
||||
* the template with nothing in its slots, so a leg showed as a bare " in " (the
|
||||
* separator of `roadtrip.leg.driveText` with both values missing) — the screenshot is a
|
||||
* hotel followed by a pill saying nothing.
|
||||
*
|
||||
* So the text is used when there is one and the numbers are formatted when there is not,
|
||||
* which is what the desktop rail does with the same segment. With neither, the leg says
|
||||
* it is pending, the honest answer and the one the missing-segment case already gave.
|
||||
*/
|
||||
function legText(seg: RouteSegment | undefined, { t, unit }: RowChrome): string {
|
||||
if (!seg) return t('roadtrip.leg.pending')
|
||||
const metres = Number.isFinite(seg.distance) ? seg.distance : 0
|
||||
const seconds = Number.isFinite(seg.duration) ? seg.duration : 0
|
||||
const distance = seg.distanceText || (metres > 0 ? formatDistance(metres / 1000, unit) : '')
|
||||
const time = seg.durationText || seg.drivingText || (seconds > 0 ? formatDurationShort(seconds) : '')
|
||||
if (!distance || !time) return t('roadtrip.leg.pending')
|
||||
return t('roadtrip.leg.driveText', { distance, time })
|
||||
}
|
||||
|
||||
/**
|
||||
* The drive between two stops. 12px rather than the desktop's 10: a four-hour leg
|
||||
* deserves the space it takes.
|
||||
@@ -171,9 +216,7 @@ export function RtLegRow({ seg, mode, chrome, onAlternatives, alternativesOpen =
|
||||
}) {
|
||||
const { t } = chrome
|
||||
const Icon = mode && LEG_ICONS[mode] ? LEG_ICONS[mode] : mode?.startsWith('plugin:') ? Zap : CarFront
|
||||
const text = seg
|
||||
? t('roadtrip.leg.driveText', { distance: seg.distanceText, time: seg.durationText ?? seg.drivingText })
|
||||
: t('roadtrip.leg.pending')
|
||||
const text = legText(seg, chrome)
|
||||
return (
|
||||
<div className="grid items-center gap-x-[10px]" style={{ gridTemplateColumns: '34px 1fr auto' }}>
|
||||
{/* flex-col, not flex: in a row the dashes would stretch sideways and read as
|
||||
|
||||
@@ -62,6 +62,10 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
|
||||
const stage = rt.stage
|
||||
|
||||
// The disc is only a control for somebody who may change a place. Read here rather than
|
||||
// inside the row so the whole chain asks once, the way `rt` and `chrome` are handled.
|
||||
const canEditPlaces = planner.can('place_edit', planner.trip)
|
||||
|
||||
const openStop = (row: StopRow) => {
|
||||
shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
|
||||
}
|
||||
@@ -207,7 +211,23 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
|
||||
<section className="mt-2.5 overflow-hidden rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-3 pt-1">
|
||||
{rt.rows.map((row, i) => {
|
||||
if (row.kind === 'stop') return <RtStopRow key={`s${i}`} row={row} chrome={chrome} onOpen={() => openStop(row)} />
|
||||
if (row.kind === 'stop') {
|
||||
return (
|
||||
<RtStopRow
|
||||
key={`s${i}`}
|
||||
row={row}
|
||||
chrome={chrome}
|
||||
onOpen={() => openStop(row)}
|
||||
onPickKind={canEditPlaces
|
||||
? () => shell.openSheet('rtkind', {
|
||||
placeId: row.stop.placeId,
|
||||
stopType: row.stop.stopType ?? null,
|
||||
name: row.stop.name,
|
||||
})
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'leg') {
|
||||
// Only where the desk rail offers it too (legReroutable), and with the
|
||||
// card's day id, the one the desk passes: the planner finds the day each
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { STOP_KINDS } from '../../../../components/Roadtrip/stopKinds'
|
||||
import { FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
/** What the chain hands over when a stop's disc is tapped. */
|
||||
interface RtKindSheetPayload {
|
||||
placeId?: number
|
||||
/** What the stop is now: a service kind, or null for a destination. */
|
||||
stopType?: string | null
|
||||
name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Turning a place on the drive into a pause, and back, on the phone ('rtkind', payload
|
||||
* { placeId, stopType, name }).
|
||||
*
|
||||
* Opened from the stop's own disc in the chain, which is where the desktop opens it from
|
||||
* and for the same reason: the disc is exactly what changes. A destination wears its
|
||||
* running number, a service stop wears its kind's icon in that kind's colour, so the
|
||||
* control is its own preview. Tap the 3, pick the pump, and the 3 becomes an orange disc
|
||||
* while everything below it renumbers.
|
||||
*
|
||||
* A sheet rather than the desktop's popover under the disc: a popover is aimed at an
|
||||
* anchor with a mouse, and the same palette on a phone would land under the thumb that
|
||||
* opened it. The kinds stay a palette of discs rather than a list of words, as they are
|
||||
* on the desktop. At this size the colour and the shape are the label, and seven rows of
|
||||
* text for seven icons would be a menu where a palette does. Each disc still carries its
|
||||
* name for a screen reader.
|
||||
*
|
||||
* It writes through `planner.setRoadtripStopKind`, the very call the desktop rail makes:
|
||||
* one field on one place, with the chain redrawing off the store as soon as it lands.
|
||||
*/
|
||||
export default function MRtKindSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const { t } = useTranslation()
|
||||
const open = shell.sheet?.id === 'rtkind'
|
||||
const payload = (shell.sheet?.payload ?? {}) as RtKindSheetPayload
|
||||
const canEdit = planner.can('place_edit', planner.trip)
|
||||
|
||||
// Hold the last payload so the name and the current kind survive the exit animation
|
||||
// instead of blinking out while the sheet slides away.
|
||||
const heldRef = useRef<RtKindSheetPayload | null>(null)
|
||||
if (open && typeof payload.placeId === 'number') heldRef.current = payload
|
||||
const stop = open && typeof payload.placeId === 'number' ? payload : heldRef.current
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const current = stop?.stopType ?? null
|
||||
|
||||
/**
|
||||
* Writes the kind and closes.
|
||||
*
|
||||
* Closing rather than reopening the stop sheet: the answer to "what kind is this" is
|
||||
* the disc in the chain, which is already on screen behind this sheet and redraws the
|
||||
* moment the store lands. `setRoadtripStopKind` reports its own failures and resolves
|
||||
* either way, so what the guard buys is a sheet that does not sit open pretending to
|
||||
* save.
|
||||
*/
|
||||
const pick = async (kind: RoadtripStopType | null) => {
|
||||
const placeId = stop?.placeId
|
||||
if (!canEdit || saving || placeId == null) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await planner.setRoadtripStopKind(placeId, kind)
|
||||
shell.closeSheet()
|
||||
} catch {
|
||||
// Swallowed rather than toasted again: `setRoadtripStopKind` catches its own
|
||||
// failures and shows the message itself, so a second one here would be the same
|
||||
// news twice. What this catch is for is the sheet, which stays open on a write that
|
||||
// never landed instead of closing as if it had.
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet
|
||||
open={open && !!stop}
|
||||
onClose={shell.closeSheet}
|
||||
variant="bottom"
|
||||
material="opaque"
|
||||
ariaLabel={t('roadtrip.stop.kind')}
|
||||
>
|
||||
{stop && (
|
||||
<>
|
||||
<FormSheetHeader
|
||||
title={t('roadtrip.stop.kind')}
|
||||
onClose={shell.closeSheet}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-1">
|
||||
{stop.name && (
|
||||
<div className="truncate text-[0.875rem] font-semibold text-m-ink">{stop.name}</div>
|
||||
)}
|
||||
|
||||
{/* Wrapped rather than scrolled sideways: seven discs do not fit one phone row,
|
||||
and a row that scrolls hides the kinds past its edge behind a gesture. */}
|
||||
<div className="mt-[18px] flex flex-wrap justify-center gap-[10px]">
|
||||
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
|
||||
const on = current === key
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
aria-label={t(labelKey)}
|
||||
disabled={!canEdit || saving}
|
||||
// Tapping the kind a stop already is takes it back to a destination,
|
||||
// the same second tap the desktop palette answers to.
|
||||
onClick={() => { void pick(on ? null : key) }}
|
||||
className="grid h-[54px] w-[54px] place-items-center rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] disabled:opacity-35"
|
||||
>
|
||||
<span
|
||||
className="grid h-[34px] w-[34px] place-items-center rounded-full text-white"
|
||||
// theme-lint-disable: the road-signage palette from roadtripModel, the
|
||||
// same one the chain disc and the map marker use, so the choice looks
|
||||
// like what it will become.
|
||||
style={{ background: color, ...(on ? { boxShadow: `0 0 0 3px color-mix(in srgb, ${color} 45%, transparent)` } : {}) }}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2.1} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Only once there is something to undo. On a destination it would be a button
|
||||
saying "leave everything as it is". */}
|
||||
{current && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit || saving}
|
||||
onClick={() => { void pick(null) }}
|
||||
className="mt-[18px] flex w-full items-center justify-center gap-2 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-3 text-[0.8125rem] font-semibold text-m-ink disabled:opacity-35"
|
||||
>
|
||||
<MapPin size={15} strokeWidth={2.2} className="flex-none text-m-muted" aria-hidden="true" />
|
||||
{t('roadtrip.stop.backToDestination')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import MExportSheet from './MExportSheet'
|
||||
import MMehrSheet from './MMehrSheet'
|
||||
import MRtStopSheet from '../roadtrip/MRtStopSheet'
|
||||
import MRtStaySheet from '../roadtrip/MRtStaySheet'
|
||||
import MRtKindSheet from '../roadtrip/MRtKindSheet'
|
||||
import MRtInfoSheet from '../roadtrip/MRtInfoSheet'
|
||||
import MRtCorridorSheet from '../roadtrip/MRtCorridorSheet'
|
||||
import MRtDraftSheet from '../roadtrip/MRtDraftSheet'
|
||||
@@ -72,6 +73,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
sheet hangs off planner.stopDraft the way the place editor hangs off its flag. */}
|
||||
<MRtStopSheet planner={planner} shell={shell} />
|
||||
<MRtStaySheet planner={planner} shell={shell} />
|
||||
<MRtKindSheet planner={planner} shell={shell} />
|
||||
<MRtInfoSheet planner={planner} shell={shell} />
|
||||
{/* Before the draft sheet, not after: both sit at the same z, so the one mounted
|
||||
later paints on top, and taking a hit onto the trip opens the draft OVER the
|
||||
|
||||
Reference in New Issue
Block a user