feat(roadtrip): the rail and its dialogs in the planner's card language

Day heads take the day plan's layout: the number on a raised tile with the date
under it, the name beside it and its facts as pills that never wrap but give way
in steps. Rides, terminals and hire desks are cards in their type's colour with
their icon on the line and their times in the arrival column; a stop's bookings
sit on its own badge row. Every badge on the rail is one pill shape, the drive
bands are round, and the stay, stop and driving settings dialogs use the shared
dialog frame.
This commit is contained in:
Maurice
2026-10-11 02:36:58 +02:00
parent 3ff88062f1
commit 856f5b5b08
14 changed files with 721 additions and 639 deletions
@@ -17,8 +17,8 @@ export default function EndDayControl({ active, onToggle }: EndDayControlProps)
const { t } = useTranslation()
const [pending, setPending] = useState(false)
return (
<fieldset disabled={pending} aria-busy={pending} className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 disabled:opacity-60">
<span className="flex flex-1 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary"><Moon size={13} className={`shrink-0 ${active ? 'text-accent-on' : 'text-content-faint'}`} aria-hidden />{t('roadtrip.window.endHere')}</span>
<fieldset disabled={pending} aria-busy={pending} className="flex min-h-[38px] min-w-0 items-center justify-between gap-2 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-1.5 disabled:opacity-60">
<span className="flex flex-1 items-center gap-1.5 text-[length:calc(12.5px*var(--fs-scale-body,1))] font-medium text-content"><Moon size={13} className={`shrink-0 ${active ? 'text-accent-on' : 'text-content-faint'}`} aria-hidden />{t('roadtrip.window.endHere')}</span>
<ToggleSwitch on={active} label={t('roadtrip.window.endHere')} onToggle={async () => {
if (pending) return
setPending(true)
+31 -30
View File
@@ -1,41 +1,42 @@
import React from 'react'
import { Tooltip } from '../shared/Tooltip'
import { FS } from './typeScale'
import { fs } from '../shared/DialogShell'
/**
* A figure in the two-part shell the rail uses everywhere: what it is on the left, the
* number on the right, one hairline between them.
* A fact on a stop's second line: what it is in the quiet ink (an icon or a word), then
* the figure. The day plan's white pill, the one its transports wear for their times, so
* a stop and the ride beside it carry their facts the same way.
*
* One component because the shell had been typed out six times, the stay, the walk, the
* fill-up, the two findings and the limits button each carrying their own copy, and the
* copies had started to drift. The warning tone keeps the border in the warning colour
* itself, not its soft tint: at this size a tinted edge disappears into the card.
* copies had started to drift.
*/
const TONES = {
neutral: {
shell: 'border-edge',
lead: 'bg-surface-tertiary text-content-faint',
value: 'border-edge text-content-secondary',
},
warning: {
shell: 'border-warning',
lead: 'bg-warning-soft text-warning',
value: 'border-warning text-warning',
},
neutral: { lead: 'text-content-faint', value: 'text-content-secondary' },
warning: { lead: 'text-warning', value: 'text-warning' },
/** A confirmed booking: the word in the success colour, the figure plain. */
success: { lead: 'text-success', value: 'text-content-secondary' },
/** A booking still pending: the word in the warning colour, the figure plain. */
pending: { lead: 'text-warning', value: 'text-content-secondary' },
} as const
// White with a light shadow and a hairline, so it still stands off the white card it sits on.
const PILL = 'inline-flex h-[20px] flex-none items-center gap-1 self-start whitespace-nowrap rounded-full bg-surface-card px-2 font-geist font-normal leading-none tabular-nums shadow-sm ring-1 ring-inset ring-edge-faint'
interface FigureBadgeProps {
/** An icon, or a word set as a caption (`caption`). */
/** An icon, or a word (`caption`). */
lead: React.ReactNode
/** The figure. Absent draws the lead alone, a badge that is only a sign. */
value?: React.ReactNode
tone?: 'neutral' | 'warning'
tone?: 'neutral' | 'warning' | 'success' | 'pending'
tooltip?: string
/** The lead is a word, set small, wide and in capitals like the stay's. */
/** The lead is a word rather than an icon, set in capitals like the day plan's phase pills. */
caption?: boolean
/** The figure in the quiet ink: an invitation, or a value inherited rather than set. */
faint?: boolean
valueSize?: 'label' | 'micro'
/** May give up width to the pills beside it, its words cut short rather than wrapping the row. */
shrink?: boolean
dir?: 'ltr'
/**
* Makes the badge the control that changes its figure. A span with the button role, not
@@ -49,25 +50,24 @@ interface FigureBadgeProps {
}
export default function FigureBadge({
lead, value, tone = 'neutral', tooltip, caption, faint, valueSize = 'label', dir, onActivate, ariaLabel,
lead, value, tone = 'neutral', tooltip, caption, faint, dir, onActivate, ariaLabel, shrink,
}: FigureBadgeProps): React.ReactElement {
const colours = TONES[tone]
const shell = `inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border ${colours.shell}`
const pill = shrink ? PILL.replace('flex-none', 'min-w-0 shrink overflow-hidden') : PILL
const body = (
<>
<span
className={`flex items-center gap-0.5 px-1 ${colours.lead} ${caption ? 'font-geist font-semibold uppercase tracking-[0.12em]' : ''}`}
style={{ fontSize: FS.micro }}
className={`flex min-w-0 items-center gap-1 ${value === undefined && (tone === 'neutral' || tone === 'warning') ? colours.value : colours.lead} ${caption ? 'uppercase tracking-[.05em]' : ''}`}
style={caption ? fs(9.5) : undefined}
>
{lead}
</span>
{value === undefined ? null : (
<span
className={`flex items-center border-s bg-surface-card px-1.5 font-semibold tabular-nums ${faint ? 'border-edge text-content-faint' : colours.value}`}
style={{ fontSize: FS[valueSize] }}
>
{value}
</span>
<>
{/* A straight cut between what it is and the figure, the split the dialogs' pills use. */}
<span aria-hidden className="mx-0.5 h-2.5 w-px flex-none bg-edge" />
<span className={`flex items-center ${faint ? 'text-content-faint' : colours.value}`}>{value}</span>
</>
)}
</>
)
@@ -84,12 +84,13 @@ export default function FigureBadge({
e.stopPropagation()
onActivate(e.currentTarget)
}}
className={`${shell} cursor-pointer transition-colors hover:border-content-faint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
className={`${pill} cursor-pointer transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
style={fs(10.5)}
>
{body}
</span>
) : (
<span dir={dir} className={shell}>{body}</span>
<span dir={dir} className={pill} style={fs(10.5)}>{body}</span>
)
return tooltip ? <Tooltip label={tooltip}>{badge}</Tooltip> : badge
}
@@ -4,7 +4,7 @@ import {
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check,
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
} from 'lucide-react'
import Modal from '../shared/Modal'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { convertDistance, formatDistance } from '../../utils/units'
@@ -110,11 +110,11 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
"min" does — and that column was the reason every section had a right edge of
its own. In the flow it simply wraps under a long label and costs nothing. */}
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-body text-content-secondary">{label}</span>
<span className="text-content-secondary" style={fs(12.5, 'body')}>{label}</span>
{/* The unit as a chip rather than as more grey words after the label. "kWh/100 km"
set as prose reads as part of the question; on a chip it reads as what the
number beside it will be measured in, which is what it is. */}
<span className="inline-flex items-center rounded-md bg-surface-tertiary px-1.5 py-0.5 text-caption leading-none text-content-muted">
<span className="inline-flex items-center rounded-full bg-surface-card px-2 py-[2px] font-geist leading-none text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint" style={fs(10.5)}>
{suffix}
</span>
</span>
@@ -129,7 +129,7 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
// screen-reader text.
<span className="flex items-center gap-1.5" title={derived} data-testid="limit-derived">
<Sparkles size={12} className="shrink-0 text-accent-on" aria-hidden />
<span className="text-body font-semibold tabular-nums text-content">{value ?? 0}</span>
<span className="font-semibold tabular-nums text-content" style={fs(13, 'body')}>{value ?? 0}</span>
<span className="sr-only">{derived}</span>
</span>
) : (
@@ -151,7 +151,8 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
// The spinners are the other half of the width problem: they eat a third of a
// narrow field and nobody sets a driving limit by clicking an arrow.
className="w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-end text-body tabular-nums text-content [appearance:textfield] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
className="w-full rounded-[10px] border border-edge bg-surface-input px-3 py-1.5 text-end tabular-nums text-content outline-none [appearance:textfield] placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)] disabled:opacity-50 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
style={fs(13, 'body')}
/>
)}
</span>
@@ -183,7 +184,7 @@ function AvoidRow({ icon: Icon, label, hint, on, disabled, onToggle }: {
<div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}>
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-body text-content-secondary">{label}</span>
<span className="text-content-secondary" style={fs(12.5, 'body')}>{label}</span>
{hint ? <span id={hintId} className="text-caption leading-snug text-content-faint">{hint}</span> : null}
</span>
{disabled
@@ -213,7 +214,7 @@ function Panel({ icon: Icon, title, note, children }: {
children: React.ReactNode
}): React.ReactElement {
return (
<section className="flex min-w-0 flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5">
<section className="flex min-w-0 flex-col gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary p-3.5">
<SectionTitle icon={Icon}>{title}</SectionTitle>
{children}
{note ? <SettingsHint>{note}</SettingsHint> : null}
@@ -227,8 +228,8 @@ function SectionTitle({ icon: Icon, children }: {
children: React.ReactNode
}): React.ReactElement {
return (
<p className="flex items-center gap-2 text-caption font-semibold uppercase tracking-wide text-content-faint">
<Icon size={13} aria-hidden />
<p className="m-0 flex items-center gap-1.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
<Icon size={12} aria-hidden />
{children}
</p>
)
@@ -286,6 +287,8 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
const settings = useRoadtripSettings(s => s)
const unit = useSettingsStore(s => s.settings.distance_unit)
const [open, setOpen] = useState(false)
const labelId = useId()
const close = () => { setOpen(false); setPreview(null) }
const distanceUnit: DistanceUnit = unit === 'imperial' ? 'imperial' : 'metric'
const imperial = distanceUnit === 'imperial'
// No way to save is the caller saying the reader may look but not change. Every
@@ -428,7 +431,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
{badges.map(({ key, Icon, text, label }) => (
<FigureBadge
key={key}
lead={label ? <Icon size={9} aria-label={label} /> : <Icon size={9} aria-hidden />}
lead={label ? <Icon size={10} strokeWidth={2.2} aria-label={label} /> : <Icon size={10} strokeWidth={2.2} aria-hidden />}
value={text}
tooltip={label}
/>
@@ -444,12 +447,27 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
</button>
{open ? (
<Modal isOpen onClose={() => { setOpen(false); setPreview(null) }} size="4xl" title={
<span className="flex items-center gap-2">
<SlidersHorizontal size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.limit.title')}
</span>
}>
<DialogShell
onClose={close}
labelledBy={labelId}
width="wide"
align="top"
header={(
<DialogHeader
tile={<DialogTile><SlidersHorizontal size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={close}
title={t('roadtrip.limit.title')}
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={close}>{t('common.close')}</DialogButton>
</DialogFooter>
)}
>
<div className="flex min-w-0 flex-col gap-4">
{/* Two columns, kept. One column reads more tidily but turns the dialog into
something that has to be scrolled, and a setting you cannot see while you
@@ -530,7 +548,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
from the browser instead of from a rebuilt listbox. */}
<fieldset className="min-w-0">
<legend className="sr-only">{t('roadtrip.limit.vehicleLabel')}</legend>
<div className="grid grid-cols-3 gap-1 rounded-xl border border-edge bg-surface-tertiary p-1">
<div className="grid grid-cols-3 gap-0.5 rounded-[10px] bg-surface-tertiary p-[3px]">
{VEHICLES.map(({ key, labelKey, Icon }) => (
<label key={key ?? 'unset'} className="min-w-0">
<input
@@ -549,7 +567,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
onSave?.('roadtrip_vehicle', key ?? '')
}}
/>
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent peer-disabled:pointer-events-none">
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-[8px] px-3 py-1.5 text-center font-medium text-content-muted transition-colors hover:text-content peer-checked:bg-surface-card peer-checked:text-content peer-checked:shadow-sm peer-focus-visible:ring-2 peer-focus-visible:ring-accent peer-disabled:pointer-events-none" style={fs(12.5, 'body')}>
<Icon size={14} className="shrink-0" aria-hidden />
{t(labelKey)}
</span>
@@ -695,7 +713,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
</div>
</div>
</div>
</Modal>
</DialogShell>
) : null}
</>
)
@@ -21,19 +21,15 @@ export const RAIL_DASH: CSSProperties = {
export const DISC = 'grid h-6 w-6 shrink-0 place-items-center rounded-full'
/**
* The small capitalised caption over a number, and the badges in a day's header.
*
* Wide letter-spacing and uppercase rather than a size change: the labels have to stay
* legible at a third of the column's width in 23 languages, and shrinking them further
* was what made "Driving time" unreadable before it was ever clipped.
* The small caption over a number: the eyebrow the planner's dialogs and cards use, so
* the trip head reads like the cards beside it.
*/
export const STAT_LABEL = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint'
export const STAT_LABEL = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
/**
* A day-header badge: the date, the drive, the count.
* A pill in a day's head: the date, the drive, the count, a finding.
*
* Medium weight in the quiet ink, not semibold in the strong one. Three uppercase badges
* with wide tracking already carry as much emphasis as a line can take; adding weight and
* contrast on top made the day's supporting facts shout louder than the day's own name.
* The day plan's pill: round, sentence case, quiet ink on the card colour with a soft
* shadow, so a day head reads the same in both tabs.
*/
export const DAY_BADGE = 'inline-flex h-[20px] items-center rounded-lg px-2 font-geist font-medium uppercase tracking-[0.09em] text-content-muted'
export const DAY_BADGE = 'inline-flex h-[20px] items-center gap-1 rounded-full px-2 font-geist font-normal uppercase tracking-[.05em] text-content-secondary'
@@ -22,7 +22,7 @@ import { dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal'
import { missedLeaveOf, stayDraftOf } from './stayReading'
import { DAY_BADGE, RAIL_GRID, STAT_LABEL } from './RoadtripSidebar.constants'
import { RAIL_GRID, STAT_LABEL } from './RoadtripSidebar.constants'
import { RefuelBand } from './RoadtripSidebarRefuelBand'
import { DriveBand, SpillBlock } from './RoadtripSidebarDriveBands'
import { BookendStop, BookingChips, RideBlock, TerminalStop, UndatedRides } from './RoadtripSidebarBookingRows'
@@ -294,6 +294,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
if (stop.carrier.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return null
const ride = stop.carrier.role === 'departure' && next?.carrier?.role === 'arrival' && sameRide(next)
const open = onOpenBooking ? () => onOpenBooking(stop.carrier!.reservationId) : undefined
const booking = reservations?.find(res => res.id === stop.carrier!.reservationId)
// The index the road leaves from: the arrival's when the block holds both ends.
const tail = ride ? i + 1 : i
const after = day.stops[tail + 1]
@@ -301,6 +302,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
<li key={stop.assignmentId}>
{ride ? (
<RideBlock
booking={booking}
departure={stop}
arrival={next!}
entries={[day.schedule.entries[i], day.schedule.entries[i + 1]]}
@@ -312,6 +314,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
/>
) : (
<TerminalStop
booking={booking}
stop={stop}
entry={day.schedule.entries[i]}
late={lateness}
@@ -382,6 +385,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
onEditStay={onEditStay ? () => onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined}
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
bookings={bookings.atStop.get(i)}
canEditBookings={canEditBookings}
onOpenBooking={onOpenBooking}
/>
) : (
<Stop
@@ -399,11 +405,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
onEditStay={onEditStay ? () => onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined}
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
bookings={bookings.atStop.get(i)}
canEditBookings={canEditBookings}
onOpenBooking={onOpenBooking}
/>
)}
{bookings.atStop.has(i) ? (
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
) : null}
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
<DriveBand
leg={day.legs[i]}
@@ -568,7 +574,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
{t('roadtrip.day', { number: day.dayNumber })}
</h3>
{day.date ? (
<time dateTime={day.date} className={`${DAY_BADGE} ms-auto border border-edge`} style={{ fontSize: FS.label }}>
<time dateTime={day.date} className="ms-auto whitespace-nowrap text-content-faint" style={{ fontSize: FS.time }}>
{formatDate(day.date, language)}
</time>
) : null}
@@ -23,7 +23,7 @@ export function OffRoadBadge({ meters }: { meters: number }): React.ReactElement
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
return (
<FigureBadge
lead={<Footprints size={9} aria-hidden />}
lead={<Footprints size={10} strokeWidth={2.2} aria-hidden />}
value={formatDistance(meters / 1000, distanceUnit)}
tooltip={t('roadtrip.stop.offRoad', { distance: formatDistance(meters / 1000, distanceUnit) })}
/>
@@ -54,7 +54,7 @@ export function FillBadge({ percent, own, onEdit }: {
if (percent === null && !onEdit) return null
return (
<FigureBadge
lead={<BatteryCharging size={9} aria-hidden />}
lead={<BatteryCharging size={10} strokeWidth={2.2} aria-hidden />}
value={percent === null ? '+' : `${percent} %`}
faint={percent === null || !own}
valueSize="micro"
@@ -144,7 +144,7 @@ export function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): Re
return (
<FigureBadge
tone="warning"
lead={<Icon size={9} aria-hidden />}
lead={<Icon size={10} strokeWidth={2.2} aria-hidden />}
value={warning.code === 'range'
? formatDistance(warning.sinceKm ?? 0, distanceUnit)
: `+${formatDurationShort((warning.overMinutes ?? 0) * 60)}`}
@@ -215,7 +215,7 @@ export function LateBadge({ late }: { late: ScheduleWarning }): React.ReactEleme
<FigureBadge
tone="warning"
dir="ltr"
lead={<AlertTriangle size={9} aria-label={label} />}
lead={<AlertTriangle size={10} strokeWidth={2.2} aria-label={label} />}
value={`+${formatDurationShort((late.minutes ?? 0) * 60)}`}
tooltip={label}
/>
@@ -1,5 +1,5 @@
import React from 'react'
import { AlertTriangle, Clock, type LucideIcon } from 'lucide-react'
import { AlertTriangle, ArrowRight, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
@@ -9,19 +9,15 @@ import type { BookendReading } from './roadtripRowModel'
import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge } from './nightBookend'
import type { RoadtripStop } from './useRoadtripRoutes'
import type { Reservation, RouteSegment } from '../../types'
import { bookingOpens, carrierIcon, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide'
import { bookingOpens, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide'
import FigureBadge from './FigureBadge'
import { tintOf } from '../Planner/planParts'
import { typeInfo } from '../Planner/bookings/bookingsModel'
import { bookingClock, bookingIcon } from './stopBookings'
import { FS } from './typeScale'
import { DISC, RAIL_GRID } from './RoadtripSidebar.constants'
import { Arrival, DayCarry, DriveFindingBadge, LateBadge } from './RoadtripSidebarBadges'
/** The rail's dash laid on its side: the ride between two terminals, drawn like a leg. */
const RIDE_DASH: React.CSSProperties = {
height: 1.5,
backgroundImage: 'repeating-linear-gradient(90deg, var(--border-primary) 0 4px, transparent 4px 8px)',
}
/**
* One end of a ride on a row of its own: a terminal whose ride lands on another day, or a
* hire car's desk.
@@ -32,8 +28,10 @@ const RIDE_DASH: React.CSSProperties = {
* on the right, and on a departure the check-in with whatever the drive makes of it. A desk
* has no other end beside it, so it says which one it is. The whole row opens the booking.
*/
export function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
export function TerminalStop({ stop, booking, entry, late, continues, starts, onOpen }: {
stop: RoadtripStop
/** The booking the terminal belongs to, for its type colour and title. */
booking?: Reservation
entry: ScheduleEntry | undefined
late: ScheduleWarning[]
continues: boolean
@@ -43,25 +41,80 @@ export function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const { end, desk, checkIn } = terminalReading(stop, entry, late, t, is12h)
const place = <span className="min-w-0 truncate font-semibold text-content" style={{ fontSize: FS.name }}>{end.place}</span>
return (
<DiscRow Icon={carrierIcon(stop.carrier!.type)} starts={starts} continues={continues} onOpen={onOpen} opens={onOpen ? t('roadtrip.ride.open') : undefined}>
<span className="flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-center gap-1.5 leading-6">
{end.code ? <CodeChip text={end.code} /> : null}
{onOpen ? <Tooltip label={t('roadtrip.ride.open')}>{place}</Tooltip> : place}
</span>
{desk || checkIn ? (
<span className="flex flex-wrap items-center gap-1">
{desk ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{desk}</span> : null}
{checkIn ? <CheckInBadge checkIn={checkIn} /> : null}
</span>
) : null}
<TransportCard
type={booking?.type ?? stop.carrier!.type}
title={booking?.title ?? end.place}
clock={end.clock ? <TimetableClock end={end} /> : entry?.arrival ? <Arrival entry={entry} /> : null}
checkIn={checkIn?.state === 'ok' ? checkIn : null}
starts={starts}
continues={continues}
onOpen={onOpen}
>
{desk ? <FigureBadge caption lead={desk} /> : end.code ? <FigureBadge caption lead={end.code} /> : null}
</TransportCard>
)
}
/**
* A booking on the rail drawn the way the day plan draws it: a card in the type's colour
* with the booking's title and its time and phase as the day plan's pills. Its icon sits
* on the line in the disc a stop's number takes, so the chain reads stop, ride, stop in
* one column.
*/
function TransportCard({ type, title, clock, checkIn, starts, continues, onOpen, warn, children }: {
type: string
title: string
/** The time on the right, in the column every stop's arrival reads down. */
clock?: React.ReactNode
/** The check-in, as the last pill of the row. */
checkIn?: CheckInReading | null
starts?: boolean
continues: boolean
onOpen?: () => void
/** The ride is missed: the card takes the warning edge. */
warn?: boolean
children?: React.ReactNode
}): React.ReactElement {
const { t } = useTranslation()
const info = typeInfo(type)
const color = info.color
const pills = React.Children.toArray(children).filter(Boolean)
return (
<div className="grid" style={RAIL_GRID}>
<span className="flex flex-col items-center" aria-hidden>
<span className={`w-[1.5px] flex-1 rounded-sm ${starts ? '' : 'bg-edge'}`} />
<span className={`${DISC} my-1 bg-surface-tertiary text-content-secondary`}>
<info.Icon size={13} strokeWidth={2} />
</span>
{end.clock ? <TimetableClock end={end} className="leading-6" /> : entry?.arrival ? <Arrival entry={entry} /> : null}
<span className={`w-[1.5px] flex-1 rounded-sm ${continues ? 'bg-edge' : ''}`} />
</span>
</DiscRow>
<button
type="button"
onClick={onOpen}
disabled={!onOpen}
onMouseEnter={e => { e.currentTarget.style.background = tintOf(color, 12) }}
onMouseLeave={e => { e.currentTarget.style.background = tintOf(color, 7) }}
className="my-0.5 flex min-w-0 flex-col gap-0.5 rounded-xl px-2 pb-1 pt-0.5 text-start transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-default"
style={{ background: tintOf(color, 7), border: `1px solid ${warn ? 'var(--warning)' : tintOf(color, 24)}` }}
>
{/* The name in the stop size with the clock on the right in the arrival column,
and under both the facts as pills across the whole card. */}
<span className="flex w-full min-w-0 items-start gap-2">
<span className="min-w-0 flex-1 truncate font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
{title}
</span>
{clock}
</span>
{pills.length || checkIn ? (
<span className="flex w-full min-w-0 flex-nowrap items-center gap-1 pb-0.5">
{pills}
{checkIn ? <CheckInBadge checkIn={checkIn} /> : null}
</span>
) : null}
{onOpen ? <span className="sr-only">{t('roadtrip.ride.open')}</span> : null}
</button>
</div>
)
}
@@ -139,7 +192,7 @@ export function BookendStop({ reading, entry, late, driveFindings, continues, st
<FigureBadge
tone={badge.warning ? 'warning' : 'neutral'}
caption
lead={badge.warning ? <><AlertTriangle size={9} aria-hidden />{badge.lead}</> : badge.lead}
lead={badge.warning ? <><AlertTriangle size={10} aria-hidden />{badge.lead}</> : badge.lead}
value={badge.value ?? undefined}
tooltip={badge.hint ?? undefined}
/>
@@ -153,18 +206,6 @@ export function BookendStop({ reading, entry, late, driveFindings, continues, st
)
}
/** A terminal's code, or the name of one without it, in the chip the ride's ends stand in. */
function CodeChip({ text }: { text: string }): React.ReactElement {
return (
<span
className="block h-[16px] min-w-0 max-w-[9rem] shrink-0 truncate rounded border border-edge bg-surface-card px-1 font-geist font-semibold leading-[14px] text-content"
style={{ fontSize: FS.label }}
>
{text}
</span>
)
}
/**
* The timetable's clock at one end of a ride. Weighted like a pinned arrival because the
* booking fixes it, and named for what it is: the time a clock in this column otherwise
@@ -175,7 +216,7 @@ function TimetableClock({ end, className = '' }: { end: RideEnd; className?: str
<Tooltip label={end.label ?? ''}>
<span
dir="ltr"
className={`shrink-0 whitespace-nowrap font-semibold tabular-nums text-content-secondary ${className}`}
className={`shrink-0 whitespace-nowrap font-semibold leading-6 tabular-nums text-content-secondary ${className}`}
style={{ fontSize: FS.time }}
>
<span aria-hidden>{end.clock}</span>
@@ -198,7 +239,7 @@ function CheckInBadge({ checkIn }: { checkIn: CheckInReading }): React.ReactElem
<FigureBadge
tone={warning ? 'warning' : 'neutral'}
caption
lead={warning ? <><AlertTriangle size={9} aria-hidden />{checkIn.lead}</> : checkIn.lead}
lead={warning ? <><AlertTriangle size={10} aria-hidden />{checkIn.lead}</> : checkIn.lead}
value={checkIn.value}
tooltip={checkIn.hint}
/>
@@ -206,19 +247,17 @@ function CheckInBadge({ checkIn }: { checkIn: CheckInReading }): React.ReactElem
}
/**
* A ride that leaves and lands on the same day, as one thing in the chain. One block on
* one disc rather than three rows, because it IS one thing, a flight, and three rows read
* as three places the day went to.
*
* Four lines, each fact once: the booking; the two codes with their timetable clocks and
* the ride drawn between them as a leg; the places; and the check-in, which carries the
* finding when the drive gets there late, beside the minutes the ride takes. A ride the
* drive cannot catch edges the block in the warning colour. The whole block opens the
* booking; the road resumes below it as usual.
* A ride that leaves and lands on the same day, as one thing in the chain: one card on one
* disc rather than three rows, because it IS one thing, a flight, and three rows read as
* three places the day went to. Both timetable clocks sit in the arrival column, the
* route and the check-in under the title. A ride the drive cannot catch edges the card in
* the warning colour. The whole card opens the booking; the road resumes below it.
*/
export function RideBlock({ departure, arrival, entries, late, seg, continues, starts, onOpen }: {
export function RideBlock({ departure, arrival, booking, entries, late, seg, continues, starts, onOpen }: {
departure: RoadtripStop
arrival: RoadtripStop
/** The booking the ride is, for its type colour and title. */
booking?: Reservation
entries: [ScheduleEntry | undefined, ScheduleEntry | undefined]
late: ScheduleWarning[]
seg: RouteSegment | undefined
@@ -229,51 +268,48 @@ export function RideBlock({ departure, arrival, entries, late, seg, continues, s
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const ride = rideReading({ departure, arrival, entries, warnings: late, seg }, t, is12h)
const title = <span className="min-w-0 flex-1 truncate font-medium text-content-secondary" style={{ fontSize: FS.meta }}>{ride.title}</span>
return (
<DiscRow Icon={carrierIcon(departure.carrier!.type)} starts={starts} continues={continues} onOpen={onOpen} opens={onOpen ? t('roadtrip.ride.open') : undefined}>
<span
className={`my-0.5 flex min-w-0 flex-col gap-1 rounded-lg border bg-surface-tertiary px-2 pb-1.5 pt-1 transition-colors group-hover:bg-surface-selected ${
ride.checkIn?.state === 'missed' ? 'border-warning' : 'border-edge-faint'
}`}
>
<span className="flex min-w-0 items-center">
{onOpen ? <Tooltip label={t('roadtrip.ride.open')}>{title}</Tooltip> : title}
</span>
<span className="grid min-w-0 items-center gap-1.5" style={{ gridTemplateColumns: 'auto auto minmax(12px, 1fr) auto auto' }}>
<CodeChip text={ride.from.chip} />
{ride.from.clock ? <TimetableClock end={ride.from} /> : <span />}
<span style={RIDE_DASH} aria-hidden />
{ride.to.clock ? <TimetableClock end={ride.to} /> : <span />}
<CodeChip text={ride.to.chip} />
</span>
{ride.from.placeLine || ride.to.placeLine ? (
<span className="flex min-w-0 justify-between gap-2 text-content-muted" style={{ fontSize: FS.meta }}>
<span className="min-w-0 truncate">{ride.from.placeLine}</span>
<span className="min-w-0 truncate text-end">{ride.to.placeLine}</span>
</span>
) : null}
{ride.checkIn || ride.duration ? (
<span className="flex flex-wrap items-center gap-1">
{ride.checkIn ? <CheckInBadge checkIn={ride.checkIn} /> : null}
{ride.duration ? (
<FigureBadge lead={<Clock size={9} aria-hidden />} value={ride.duration} tooltip={t('roadtrip.ride.duration')} />
) : null}
</span>
) : null}
const clock = ride.from.clock || ride.to.clock ? (
<Tooltip label={[ride.from.label, ride.to.label].filter(Boolean).join(', ')}>
<span dir="ltr" className="shrink-0 whitespace-nowrap font-semibold leading-6 tabular-nums text-content-secondary" style={{ fontSize: FS.time }}>
{ride.from.clock}<DayCarry days={ride.from.dayOffset} />
{ride.to.clock ? <>{' \u2013 '}{ride.to.clock}<DayCarry days={ride.to.dayOffset} /></> : null}
</span>
</DiscRow>
</Tooltip>
) : null
const places = [ride.from.placeLine ?? ride.from.place, ride.to.placeLine ?? ride.to.place].join(' \u2192 ')
return (
<TransportCard
type={booking?.type ?? departure.carrier!.type}
title={booking?.title ?? ride.title}
clock={clock}
checkIn={ride.checkIn?.state === 'ok' ? ride.checkIn : null}
starts={starts}
continues={continues}
onOpen={onOpen}
>
<FigureBadge
shrink
caption
lead={(
<span dir="ltr" className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate">{ride.from.chip}</span>
<ArrowRight size={10} strokeWidth={2.2} className="flex-none" aria-hidden />
<span className="min-w-0 truncate">{ride.to.chip}</span>
</span>
)}
tooltip={places}
/>
</TransportCard>
)
}
/**
* The bookings a stop carries, under its row: the table, the tickets, the tour, each a
* chip with the booking panel's icon, its name and the clock it starts at. Chips because
* a booking is a fact about the stop, not a stop of its own: the count, the numbering
* and the drive are none the different for it. Each opens its booking.
* The bookings a stop carries, under its row the way the day plan hangs them under a
* place: a pill in the status colour with the booking's icon, then its time. A booking
* is a fact about the stop, not a stop of its own, so it takes no line, no disc and no
* card. Each pill opens its booking.
*
* Under the stop's button rather than inside it, because a button cannot hold buttons.
* The rail keeps its line through the row so the chain reads unbroken.
*/
export function BookingChips({ bookings, continues, canEdit, onOpen }: {
bookings: Reservation[]
@@ -282,45 +318,54 @@ export function BookingChips({ bookings, continues, canEdit, onOpen }: {
canEdit: boolean
onOpen?: (reservationId: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
return (
<div className="grid" style={RAIL_GRID}>
<span className="flex flex-col items-center" aria-hidden>
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" /> : null}
</span>
<div className="flex min-w-0 flex-wrap gap-1 px-1.5 pb-1.5">
{bookings.map(r => {
const Icon = bookingIcon(r.type)
const clock = bookingClock(r)
const body = (
<>
<Icon size={10} strokeWidth={2} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate">{r.title}</span>
{clock ? <span className="shrink-0 font-medium tabular-nums text-content-faint">{formatClockTime(clock, is12h)}</span> : null}
</>
)
const chip = 'inline-flex h-[20px] max-w-full items-center gap-1 rounded-md border border-edge bg-surface-card px-1.5 text-content-secondary'
return onOpen && bookingOpens(r, canEdit) ? (
<Tooltip key={r.id} label={t('roadtrip.ride.open')}>
<button
type="button"
onClick={() => onOpen(r.id)}
className={`${chip} transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
style={{ fontSize: FS.meta }}
>
{body}
</button>
</Tooltip>
) : (
<span key={r.id} className={chip} style={{ fontSize: FS.meta }}>{body}</span>
)
})}
<div className="flex min-w-0 flex-wrap items-center gap-1 px-1.5 pb-1.5">
{bookings.map(r => <ReservationPill key={r.id} booking={r} canEdit={canEdit} onOpen={onOpen} />)}
</div>
</div>
)
}
/**
* A booking on a stop's badge row: the status colour, the booking's icon and the word,
* like the day plan's pill under a place, with its time only when that is not already the
* arrival on the right. A span with the button role, because the stop row around it is a
* button already.
*/
export function ReservationPill({ booking, arrival, canEdit, onOpen }: {
booking: Reservation
/** The stop's arrival as the row prints it, so a time equal to it is not said twice. */
arrival?: string | null
canEdit: boolean
onOpen?: (reservationId: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const Icon = bookingIcon(booking.type)
const confirmed = booking.status === 'confirmed'
const status = confirmed ? t('reservations.confirmed') : t('reservations.pending')
const clock = bookingClock(booking)
const time = clock ? formatClockTime(clock, is12h) : null
const shownTime = time && time !== arrival ? time : null
const open = onOpen && bookingOpens(booking, canEdit) ? () => onOpen(booking.id) : undefined
// The same pill as the stay and the check-in: the word in capitals, a straight cut, the time.
return (
<FigureBadge
caption
tone={confirmed ? 'success' : 'pending'}
lead={<><Icon size={10} strokeWidth={2.2} aria-hidden />{t('places.formReservation')}</>}
value={shownTime ?? undefined}
tooltip={`${booking.title} (${status})`}
onActivate={open ? () => open() : undefined}
ariaLabel={`${booking.title}, ${status}`}
/>
)
}
/**
* The rides the drive leaves out because they are on no day (#2461).
*
@@ -3,6 +3,7 @@ import { Bike, CarFront, Footprints, Moon, Shuffle, Zap, type LucideIcon } from
import MDancingTrek from '../../mobile/components/MDancingTrek'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import { formatClockTime } from '../../utils/formatters'
@@ -66,14 +67,14 @@ export function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }:
// would otherwise cut off the one figure the band exists for.
const band = (
<>
<Icon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden />
<Icon size={11} strokeWidth={2} className="shrink-0" aria-hidden />
{leg && ride ? (
<>
{carrier ? <span className="min-w-0 truncate font-medium" style={{ fontSize: FS.meta }}>{carrier.title}</span> : null}
{duration ? <span className="shrink-0 font-medium tabular-nums" style={{ fontSize: FS.meta }}>{duration}</span> : null}
{carrier ? <span className="min-w-0 truncate font-medium" style={fs(10.5)}>{carrier.title}</span> : null}
{duration ? <span className="shrink-0 font-medium tabular-nums" style={fs(10.5)}>{duration}</span> : null}
</>
) : (
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
<span className="min-w-0 truncate font-medium tabular-nums" style={fs(10.5)}>
{!leg
? t('roadtrip.leg.pending')
: t('roadtrip.leg.driveText', {
@@ -101,7 +102,7 @@ export function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }:
type="button"
onClick={onAskAlternatives}
aria-pressed={alternativesOpen}
className={`group/leg my-1.5 flex w-full items-center gap-1.5 rounded-lg py-1 pe-1 ps-2 text-start transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${
className={`group/leg my-1 flex w-full items-center gap-1.5 rounded-full py-[3px] pe-1 ps-2.5 text-start font-geist transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${
alternativesOpen
? 'bg-surface-selected text-content'
: 'bg-surface-tertiary text-content-muted hover:bg-surface-selected'
@@ -112,16 +113,16 @@ export function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }:
a black chip in the middle of a rail whose only other filled thing is
nothing at all: it read as a button that had been pressed and stuck. */}
<span
className={`ms-auto grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md transition-colors ${
className={`ms-auto grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full transition-colors ${
alternativesOpen ? 'text-content' : 'text-content-faint group-hover/leg:text-content'
}`}
>
<Shuffle size={12} strokeWidth={1.9} aria-label={t('roadtrip.alt.ask')} />
<Shuffle size={11} strokeWidth={2} aria-label={t('roadtrip.alt.ask')} />
</span>
</button>
</Tooltip>
) : (
<div className="my-1.5 flex items-center gap-1.5 rounded-lg bg-surface-tertiary py-1 pe-2 ps-2 text-content-muted">
<div className="my-1 flex w-full items-center gap-1.5 rounded-full bg-surface-tertiary px-2.5 py-[3px] font-geist text-content-muted">
{band}
</div>
)}
@@ -218,10 +219,10 @@ export function SpillBlock({ spill, children }: {
reading in the one place the eye does not look for one, and gave the
row two competing figures with no order between them. */}
<div
className="my-1.5 flex w-full items-center gap-2 rounded-lg py-1 pe-2 ps-2"
className="my-1 flex w-full items-center gap-1.5 rounded-full px-2.5 py-[3px] font-geist"
style={{ background: 'color-mix(in srgb, var(--info) 12%, transparent)', color: 'var(--info)' }}
>
<Moon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden />
<Moon size={11} strokeWidth={2} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
{leg
? t('roadtrip.leg.driveText', {
@@ -1,15 +1,16 @@
import React from 'react'
import { AlertTriangle, Ban, Spline } from 'lucide-react'
import { useElementSize } from '../../hooks/useElementSize'
import { AlertTriangle, Ban, CarFront, ChevronDown, ChevronRight, MapPin, Spline } from 'lucide-react'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import { formatDate } from '../../utils/formatters'
import { formatDurationShort } from './roadtripModel'
import { destinationCount } from './roadtripRowModel'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
import { missedRide } from './carrierRide'
import { dayColor } from './dayColors'
import { FS } from './typeScale'
import { DAY_BADGE, STAT_LABEL } from './RoadtripSidebar.constants'
@@ -45,48 +46,41 @@ function splitValue(value: string): React.ReactNode {
}
/**
* Distance, driving time and stops for the whole trip: the head the left column never
* had, above the day cards and reading as one card with them.
* Distance, driving time and stops for the whole trip: the head above the day cards,
* as three tiles in the cost tab's language, each a small eyebrow over its number.
*
* Three equal centred columns with hairlines between them, rather than three labelled
* rows: the labels are the quiet part and the numbers are what the head exists for, so
* the numbers get the size and the labels get the letter-spacing.
*
* Pulled narrow, the stops go first, with their hairline: they are the figure the rail
* itself shows best, one row per stop, while distance and time are only summed up here.
* Pulled narrow, the stops go first: they are the figure the rail itself shows best, one
* row per stop, while distance and time are only summed up here.
*/
export function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boolean }): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const cells: [string, string][] = [
// `totalStops` already leaves the service stops out, so the head and the day badges
// `totalStops` already leaves the service stops out, so the head and the day pills
// below count the same thing without either of them recounting the other's stops.
[t('roadtrip.summary.distance'), formatDistance(routes.totalDistance / 1000, distanceUnit)],
[t('roadtrip.summary.driving'), formatDurationShort(routes.totalDuration)],
...(narrow ? [] : [[t('roadtrip.summary.stops'), String(routes.totalStops)] as [string, string]]),
]
return (
<header className="mx-3.5 rounded-2xl border border-edge-faint bg-surface-card px-3 pb-3 pt-3.5">
<div className="flex items-center justify-between text-center">
{cells.map(([label, value], i) => (
<React.Fragment key={label}>
{i > 0 ? <span className="h-[28px] w-px shrink-0 bg-edge-faint" aria-hidden /> : null}
<div className="flex flex-1 flex-col items-center gap-1.5 px-1">
<span className={STAT_LABEL} style={{ fontSize: FS.label }}>{label}</span>
<span
className="font-semibold leading-none tracking-[-0.03em] tabular-nums text-content"
style={{ fontSize: FS.total }}
>
{splitValue(value)}
</span>
</div>
</React.Fragment>
<header className="mx-3.5 overflow-hidden rounded-2xl border border-edge-faint bg-surface-card">
<div className="grid gap-1.5 p-1.5" style={{ gridTemplateColumns: `repeat(${cells.length}, minmax(0, 1fr))` }}>
{cells.map(([label, value]) => (
<div key={label} className="flex min-w-0 flex-col gap-1.5 rounded-xl px-2.5 pb-2 pt-2" style={{ background: NEUTRAL_TINT }}>
<span className={`${STAT_LABEL} truncate`} style={fs(9.5)}>{label}</span>
<span
className="whitespace-nowrap font-semibold leading-none tracking-[-0.03em] tabular-nums text-content"
style={fs(17, 'subtitle')}
>
{splitValue(value)}
</span>
</div>
))}
</div>
{/* Legs land one request at a time, so until the last is in, every number above is
a partial sum. A total that looks final and is not is worse than a slow one. */}
{routes.loading ? (
<p className="mt-2 break-words text-center text-content-faint" style={{ fontSize: FS.meta }}>
<p className="m-0 break-words px-3 pb-2.5 text-center text-content-faint" style={{ fontSize: FS.meta }}>
{t('roadtrip.summary.partial')}
</p>
) : null}
@@ -95,18 +89,42 @@ export function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow
}
/**
* A day card's head: its name and title, its facts as badges, and the fold control.
*
* Rendered as the bare `<header>` with nothing around it, so the card's markup is the same
* as when the card drew it inline. At module scope on purpose: declared inside the card it
* would remount on every render and drop the keyboard focus on the fold control.
* A date short enough for the day tile, in the order the reader's clock setting implies:
* month first with the 12-hour clock ("10/5"), day first with the 24-hour one ("5.10.").
*/
export function DayHeader({ day, collapsed, onToggle, narrow, onFollowTrack, viaCount, trackName }: Readonly<{
function shortDate(iso: string, is12h: boolean): string {
const [, m, d] = iso.split("-").map(Number)
if (!m || !d) return iso
return is12h ? `${m}/${d}` : `${d}.${m}.`
}
/**
* Roughly how wide a day head pill is: its padding, its icon and the gap after it, and
* its text in uppercase Geist at 10px with the pill's letter-spacing.
*/
function pillWidth(text: string): number {
return 16 + 11 + 4 + text.length * 6.6
}
/** The head band's resting and hover wash, the day plan's own. */
const HEAD_REST = NEUTRAL_TINT
const HEAD_HOVER = 'color-mix(in srgb, var(--accent) 10%, transparent)'
/**
* A day card's head, in the day plan's language: the day's number on a raised tile, its
* name and date on one line beside it, its facts as pills under them, and the fold
* control at the end.
*
* At module scope on purpose: declared inside the card it would remount on every render
* and drop the keyboard focus on the fold control.
*/
export function DayHeader({ day, collapsed, onToggle, onFollowTrack, viaCount, trackName }: Readonly<{
day: RoadtripDay
/** Folded down to the header, and off the map with it. */
collapsed?: boolean
onToggle?: () => void
/** The rail is pulled too narrow for every badge; the stop count gives way. */
/** Kept for the rail's call; the row now measures its own room. */
narrow?: boolean
onFollowTrack?: (dayId: number) => void
viaCount?: number
@@ -114,142 +132,159 @@ export function DayHeader({ day, collapsed, onToggle, narrow, onFollowTrack, via
}>): React.ReactElement {
const { t, language } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
// A ride the drive gets to too late is said in the header too, where a folded day still shows it.
const missed = missedRide(day, t, is12h)
// The colour the map draws this day in, or none at all while the map is drawing one
// blue line for the whole trip.
const is12h = useSettingsStore(s => s.settings.time_format) === "12h"
// The colour the map draws this day in, or none while the map draws one blue line for
// the whole trip. With it on, the tile wears the colour and the band a tenth of it, so
// a green line on the map finds its card.
const dayColorsOn = useRoadtripSettings(s => !!s.roadtrip_day_colors)
const tint = dayColorsOn ? dayColor(day.dayNumber).line : null
const rest = tint ? `color-mix(in srgb, ${tint} 11%, transparent)` : HEAD_REST
const hover = tint ? `color-mix(in srgb, ${tint} 17%, transparent)` : HEAD_HOVER
const dayName = t('roadtrip.day', { number: day.dayNumber })
const stops = destinationCount(day)
const pill = `${DAY_BADGE} flex-none bg-surface-card shadow-sm`
const warn = `${DAY_BADGE} flex-none bg-warning-soft text-warning`
// The pills never wrap. When the row runs out of room they give way in steps:
// 1 the stop count drops its word, 2 the drive drops its time, 3 the stop count goes.
// The row's width is measured; each pill's is reckoned from its text, which at this
// size is close enough to choose a step without rendering every step to try it.
const row = useElementSize<HTMLDivElement>()
const driveFull = t('roadtrip.leg.driveText', {
distance: formatDistance(day.distance / 1000, distanceUnit),
time: formatDurationShort(day.duration),
})
const driveShort = formatDistance(day.distance / 1000, distanceUnit)
const stopsFull = t('roadtrip.day.stopCount', { count: stops })
const others = [
day.dayWarning ? t('roadtrip.limit.dayOver', { time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60) }) : null,
day.avoidMissed?.length ? t('roadtrip.avoid.missed', { classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', ') }) : null,
onFollowTrack ? t('roadtrip.track.badge') : null,
].filter((x): x is string => !!x)
const hasDrive = day.legs.length > 0
const hasStops = hasDrive && stops > 0
const needed = (drive: string, stopText: string | null) =>
[hasDrive ? drive : null, hasStops ? stopText : null, ...others]
.filter((x): x is string => x !== null)
.reduce((sum, text, i) => sum + pillWidth(text) + (i ? 4 : 0), 0)
const fits = (w: number) => row.width === 0 || w <= row.width
const compact = fits(needed(driveFull, stopsFull)) ? 0
: fits(needed(driveFull, String(stops))) ? 1
: fits(needed(driveShort, String(stops))) ? 2
: 3
return (
// Centred, with the day's facts as badges beneath its name: at this width a row
// of label-and-value pairs breaks awkwardly, while three short badges wrap
// gracefully and stay readable in every language.
//
// A tint of its own, not just a hairline: the header holds the day's summed facts
// and the list below holds its stops, and `--bg-hover` at 40% is 1% black in light
// mode, a separation nobody could see. `--bg-secondary` lifts off the card in
// both themes while staying a step under the badges sitting on it.
<header
// A button when there is something to fold, a plain header when there is not, so
// a read-only rail never offers a control that does nothing. The whole header is
// the target and the hover is the only sign of it: a chevron or a "fold" label
// beside the day's own facts read as a fourth fact about the day rather than as a
// control, and the header is what somebody is already looking at when they decide
// to put a day away.
// The whole band folds the day and the chevron at its end says so: the band is what
// somebody is already looking at when they decide to put a day away.
{...(onToggle ? {
role: 'button' as const,
// No press-scale: shrinking the band pulls its wash in from the card's edges.
'data-no-press': true,
tabIndex: 0,
'aria-expanded': !collapsed,
'aria-label': dayName,
onClick: onToggle,
onKeyDown: (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() }
},
onMouseEnter: (e: React.MouseEvent<HTMLElement>) => { e.currentTarget.style.background = hover },
onMouseLeave: (e: React.MouseEvent<HTMLElement>) => { e.currentTarget.style.background = rest },
} : {})}
// The margin and the bottom rule belong to the header only while there is a list
// under them. Folded, they left a white strip of card below the tinted header,
// and once the day is tinted, that strip is the one thing on the card that is not.
className={`border-edge-faint bg-surface-secondary px-3.5 pb-2.5 pt-3 ${collapsed ? '' : 'mb-1 border-b'} ${
onToggle ? 'cursor-pointer transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent' : ''
className={`flex items-start gap-2.5 py-2.5 pe-2 ps-2.5 transition-colors ${
onToggle ? 'cursor-pointer select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent' : ''
}`}
// Washed with the colour this day is drawn in, and only while the map is drawing
// days in colour: on the map the colour is what tells one day from the next, and
// a rail that ignored it would leave the reader matching a green line to a card
// that gives no sign of being the green one. A tenth of the hue is enough to
// recognise and quiet enough that the day's own facts still read first.
style={tint ? {
backgroundImage: `linear-gradient(180deg, color-mix(in srgb, ${tint} 13%, var(--bg-secondary)), color-mix(in srgb, ${tint} 5%, var(--bg-secondary)))`,
borderBottomColor: `color-mix(in srgb, ${tint} 30%, transparent)`,
} : undefined}
style={{ background: rest }}
>
<h3
className="text-center font-semibold tracking-[-0.015em] text-content"
style={{ fontSize: FS.dayTitle }}
{/* The day's number on a raised tile, the way the day plan badges a day, with its
date folded in under it where the day plan keeps the weather. */}
<span
className="flex w-9 flex-none flex-col items-center overflow-hidden rounded-[10px] font-geist font-bold tabular-nums shadow-sm"
style={{
background: tint ? `color-mix(in srgb, ${tint} 22%, var(--bg-card))` : 'var(--bg-card)',
color: tint ? 'var(--text-secondary)' : 'var(--text-muted)',
}}
>
{t('roadtrip.day', { number: day.dayNumber })}
</h3>
{/* Read by the hook since it was written, never drawn until now. */}
{day.title ? (
<p className="mt-0.5 break-words text-center text-content-secondary" style={{ fontSize: FS.meta }}>
{day.title}
</p>
) : null}
<div className="mt-1.5 flex flex-wrap justify-center gap-1">
<span className={`grid w-full place-items-center ${day.date ? 'h-6' : 'h-8'}`} style={{ ...fs(12), lineHeight: 1 }}>
{day.dayNumber}
</span>
{day.date ? (
<time dateTime={day.date} className={`${DAY_BADGE} border border-edge bg-surface-card`} style={{ fontSize: FS.label }}>
{formatDate(day.date, language)}
</time>
<>
<span aria-hidden className="h-px w-[60%] flex-none bg-current opacity-25" />
<time dateTime={day.date} aria-label={formatDate(day.date, language)} className="grid h-6 w-full place-items-center font-semibold" style={{ ...fs(9.5), lineHeight: 1 }}>
{shortDate(day.date, is12h)}
</time>
</>
) : null}
{/* A day that is only the night booked for it has no drive to sum up and no
stop to count; the hotel row below says everything it has to say. */}
{day.legs.length > 0 ? (
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.leg.driveText', {
distance: formatDistance(day.distance / 1000, distanceUnit),
time: formatDurationShort(day.duration),
})}
</span>
) : null}
{day.dayWarning ? (
<Tooltip label={t('roadtrip.limit.hint')}>
<span className={`${DAY_BADGE} gap-1 bg-warning-soft text-warning`} style={{ fontSize: FS.label }}>
<AlertTriangle size={10} className="shrink-0" aria-hidden />
{t('roadtrip.limit.dayOver', {
time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60),
})}
</span>
<div className="min-w-0 flex-1 pt-px">
<h3 className="m-0 min-w-0 truncate font-bold text-content" style={fs(13.5, 'body')}>
{day.title || dayName}
</h3>
<div ref={row.ref} className="mt-1.5 flex min-w-0 flex-nowrap items-center gap-1 overflow-hidden" style={{ fontSize: FS.meta }}>
{/* A day that is only the night booked for it has no drive to sum up and no
stop to count; the hotel row below says everything it has to say. */}
{day.legs.length > 0 ? (
<span className={pill}>
<CarFront size={11} strokeWidth={2} className="shrink-0 text-content-faint" aria-hidden />
{compact >= 2 ? driveShort : driveFull}
</span>
</Tooltip>
) : null}
{missed ? (
<Tooltip label={missed.hint}>
<span className={`${DAY_BADGE} gap-1 bg-warning-soft text-warning`} style={{ fontSize: FS.label }}>
<AlertTriangle size={10} className="shrink-0" aria-hidden />
{missed.label}
) : null}
{/* Nor does a day whose drive runs from one hotel to the next with no stop of its
own: "0 stops" beside the drive reads as the stops having gone missing. */}
{day.legs.length > 0 && stops > 0 && compact < 3 ? (
<span className={pill} aria-label={stopsFull}>
<MapPin size={11} strokeWidth={2} className="shrink-0 text-content-faint" aria-hidden />
{compact >= 1 ? stops : stopsFull}
</span>
</Tooltip>
) : null}
{/* Nor does a day whose drive runs from one hotel to the next with no stop of its
own: "0 stops" beside the drive reads as the stops having gone missing. */}
{day.legs.length > 0 && destinationCount(day) > 0 && !narrow ? (
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.day.stopCount', { count: destinationCount(day) })}
</span>
) : null}
{/* The other half of "where possible". The setting is a weighting, so a day
with no untolled crossing comes back on the toll road, and the only thing
worse than not avoiding it is not avoiding it silently, which reads as the
switch being broken. Among the day's facts because that is what it is: a
fact about this day's roads, not a warning about the plan. */}
{day.avoidMissed?.length ? (
<Tooltip label={t('roadtrip.avoid.missedHint')}>
<span className={`${DAY_BADGE} gap-1 bg-surface-card text-content-secondary`} style={{ fontSize: FS.label }}>
<Ban size={10} className="shrink-0" aria-hidden />
{t('roadtrip.avoid.missed', {
classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
})}
</span>
</Tooltip>
) : null}
{/* Among the day's facts rather than beside its title, because "which road this
day takes" is one of them. Tinted once the day carries vias: the rail draws
none of them, so this badge is the only place a drive shaped by hand differs
from one the router picked on its own. */}
{onFollowTrack ? (
<Tooltip label={trackName ? t('roadtrip.track.current', { name: trackName }) : t('roadtrip.track.hint')}>
<button
type="button"
onClick={() => onFollowTrack(day.dayId)}
className={`${DAY_BADGE} gap-1 transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
viaCount ? 'bg-accent-subtle text-content' : 'bg-surface-card hover:bg-surface-hover'
}`}
style={{ fontSize: FS.label }}
>
<Spline size={10} className="shrink-0" aria-hidden />
{t('roadtrip.track.badge')}
</button>
</Tooltip>
) : null}
) : null}
{day.dayWarning ? (
<Tooltip label={t('roadtrip.limit.hint')}>
<span className={warn}>
<AlertTriangle size={11} className="shrink-0" aria-hidden />
{t('roadtrip.limit.dayOver', {
time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60),
})}
</span>
</Tooltip>
) : null}
{/* The other half of "where possible": a day with no untolled crossing comes
back on the toll road, and saying nothing reads as the switch being broken. */}
{day.avoidMissed?.length ? (
<Tooltip label={t('roadtrip.avoid.missedHint')}>
<span className={pill}>
<Ban size={11} className="shrink-0 text-content-faint" aria-hidden />
{t('roadtrip.avoid.missed', {
classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
})}
</span>
</Tooltip>
) : null}
{/* Which road this day takes is one of its facts. Filled once the day carries
vias: the rail draws none of them, so this pill is the only place a drive
shaped by hand differs from one the router picked on its own. */}
{onFollowTrack ? (
<Tooltip label={trackName ? t('roadtrip.track.current', { name: trackName }) : t('roadtrip.track.hint')}>
<button
type="button"
onClick={e => { e.stopPropagation(); onFollowTrack(day.dayId) }}
className={`${DAY_BADGE} shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
viaCount ? 'bg-accent text-accent-text' : 'bg-surface-card hover:text-content'
}`}
>
<Spline size={11} className="shrink-0" aria-hidden />
{t('roadtrip.track.badge')}
</button>
</Tooltip>
) : null}
</div>
</div>
{onToggle ? (
<span aria-hidden className="grid h-7 w-7 flex-none place-items-center rounded-full text-content-muted">
{collapsed ? <ChevronRight size={15} strokeWidth={2} className="rtl:rotate-180" /> : <ChevronDown size={15} strokeWidth={2} />}
</span>
) : null}
</header>
)
}
@@ -14,6 +14,17 @@ import { readStay } from './stayReading'
import { FS } from './typeScale'
import { DISC, RAIL_DASH, RAIL_GRID } from './RoadtripSidebar.constants'
import { Arrival, DriveFindingBadge, FillBadge, LateBadge, NightCheckIn, OffRoadBadge, StayBadge } from './RoadtripSidebarBadges'
import { ReservationPill } from './RoadtripSidebarBookingRows'
import { useSettingsStore } from '../../store/settingsStore'
import { formatClockTime } from '../../utils/formatters'
import type { Reservation } from '../../types'
/** The bookings a stop carries, and who may open them. */
export interface StopBookings {
bookings?: Reservation[]
canEditBookings?: boolean
onOpenBooking?: (reservationId: number) => void
}
/**
* What a stop will actually fill to: its own figure, else the traveller's default.
@@ -33,7 +44,7 @@ function effectiveFill(own: number | null | undefined, setting: number | undefin
* apart. It renders the bare span and nothing around it, so a stop's markup is exactly
* what it was when each shape drew the row itself.
*/
function StopBadgeRow({ stop, entry, late, driveFindings, onEditStay, onPickFill }: Readonly<{
function StopBadgeRow({ stop, entry, late, driveFindings, onEditStay, onPickFill, bookings, canEditBookings, onOpenBooking }: Readonly<StopBookings & {
stop: RoadtripStop
entry: ScheduleEntry | undefined
late: ScheduleWarning[]
@@ -45,10 +56,15 @@ function StopBadgeRow({ stop, entry, late, driveFindings, onEditStay, onPickFill
// it that depends on them.
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent)
const { vehicleKind } = useVehicleRange()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const arrival = entry?.arrival ? formatClockTime(entry.arrival, is12h) : null
return (
<span className="flex flex-wrap items-center gap-1">
<NightCheckIn stop={stop} />
<StayBadge stay={readStay(stop, entry)} onEdit={onEditStay} />
{(bookings ?? []).map(res => (
<ReservationPill key={res.id} booking={res} arrival={arrival} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
))}
{refuelsRange(stop.stopType, vehicleKind) ? (
<FillBadge
percent={effectiveFill(stop.fillPercent, fillPercent)}
@@ -71,7 +87,7 @@ function StopBadgeRow({ stop, entry, late, driveFindings, onEditStay, onPickFill
* the trip is actually for. Its own icon on one flat disc: three kinds of pause that all
* mean "we are still driving", and the icon is what tells them apart.
*/
export function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind, onPickFill }: {
export function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind, onPickFill, bookings, canEditBookings, onOpenBooking }: StopBookings & {
stop: RoadtripStop
entry: ScheduleEntry | undefined
/** How late the drive reaches a time pinned on this pause, or the time it was set to
@@ -157,7 +173,7 @@ export function ServiceStop({ stop, entry, late, driveFindings, selected, onSele
>
<span className="min-w-0 truncate">{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}
</span>
<StopBadgeRow stop={stop} entry={entry} late={late} driveFindings={driveFindings} onEditStay={onEditStay} onPickFill={onPickFill} />
<StopBadgeRow stop={stop} entry={entry} late={late} driveFindings={driveFindings} onEditStay={onEditStay} onPickFill={onPickFill} bookings={bookings} canEditBookings={canEditBookings} onOpenBooking={onOpenBooking} />
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
</button>
@@ -211,7 +227,7 @@ export function RouteViaStop({ via }: { via: RouteVia }): React.ReactElement {
)
}
export function Stop({ stop, number, entry, late, driveFindings, selected, continues, starts, onSelect, onMove, canMove, onEditStay, onPickKind, onPickFill }: {
export function Stop({ stop, number, entry, late, driveFindings, selected, continues, starts, onSelect, onMove, canMove, onEditStay, onPickKind, onPickFill, bookings, canEditBookings, onOpenBooking }: StopBookings & {
stop: RoadtripStop
/** Position within the day: the same count the map badges its markers with. */
number: number
@@ -316,7 +332,7 @@ export function Stop({ stop, number, entry, late, driveFindings, selected, conti
"what does this stop cost", and the second only appears when the gap is far
enough to change the plan. The dashed line on the map already says there is
one; the number is for luggage, a gate, a track a hire car should not be on. */}
<StopBadgeRow stop={stop} entry={entry} late={late} driveFindings={driveFindings} onEditStay={onEditStay} onPickFill={onPickFill} />
<StopBadgeRow stop={stop} entry={entry} late={late} driveFindings={driveFindings} onEditStay={onEditStay} onPickFill={onPickFill} bookings={bookings} canEditBookings={canEditBookings} onOpenBooking={onOpenBooking} />
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
</span>
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react'
import React, { useEffect, useId } from 'react'
import { AlertTriangle, Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
import Modal from '../shared/Modal'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDurationShort } from './roadtripModel'
@@ -33,6 +33,9 @@ interface RoadtripStayModalProps {
onSave: (placeId: number, minutes: number) => Promise<void> | void
}
/** The round minus and plus beside the number. */
const STEP_BUTTON = 'grid h-9 w-9 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm transition-colors hover:text-content disabled:opacity-30'
/** The slider's own track and thumb, drawn from tokens so both themes get it right. */
const SLIDER = [
'h-1.5 w-full cursor-pointer appearance-none rounded-full bg-surface-tertiary',
@@ -70,6 +73,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
arrival: stop?.arrival, is12h, initial: 0, wholeMinutes: false,
})
const leave = useLeaveMode(stop)
const labelId = useId()
// Reopened on a different stop, so it starts from that stop's own value rather than
// from whatever the last one was left on.
@@ -90,44 +94,44 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
}
return (
<Modal isOpen onClose={onClose} size="sm" title={
<span className="flex items-center gap-2">
<Hourglass size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.stop.stay')}
</span>
}>
<div className="flex flex-col gap-5">
<p className="break-words text-center text-caption text-content-muted">{stop.name}</p>
{/* The number, the way a timer shows one. Minus and plus flank it so a value can
be nudged without hunting for the slider's thumb. */}
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="detail"
header={<StayHeader labelId={labelId} name={stop.name} onClose={onClose} />}
footer={(
<DialogFooter>
{/* Removing is the same write as setting one, so it sits with the other actions
rather than behind a confirmation. */}
<DialogButton disabled={saving || !stop.minutes} onClick={() => void commit(0)}>
{t('roadtrip.stay.none')}
</DialogButton>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" disabled={saving} onClick={() => void commit(minutes)}>
{t('common.save')}
</DialogButton>
</DialogFooter>
)}
>
{/* The number, the way a timer shows one. Minus and plus flank it so a value can
be nudged without hunting for the slider's thumb. */}
<div className="flex flex-col gap-4 rounded-[14px] border border-edge-faint bg-surface-secondary px-4 pb-4 pt-5">
<div className="flex items-center justify-center gap-4">
<button
type="button"
onClick={() => nudge(-STEP)}
disabled={minutes === 0}
aria-label={`-${STEP}`}
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
>
<button type="button" onClick={() => nudge(-STEP)} disabled={minutes === 0} aria-label={`-${STEP}`} className={STEP_BUTTON}>
<Minus size={15} aria-hidden />
</button>
<span
aria-live="polite"
className="min-w-[8rem] text-center text-title font-semibold tabular-nums tracking-tight text-content"
className="min-w-[8rem] text-center font-bold tabular-nums tracking-[-0.02em] text-content"
style={fs(28, 'subtitle')}
>
{minutes > 0 ? formatDurationShort(minutes * 60) : t('roadtrip.stay.none')}
</span>
<button
type="button"
onClick={() => nudge(STEP)}
disabled={minutes >= MAX}
aria-label={`+${STEP}`}
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
>
<button type="button" onClick={() => nudge(STEP)} disabled={minutes >= MAX} aria-label={`+${STEP}`} className={STEP_BUTTON}>
<Plus size={15} aria-hidden />
</button>
</div>
<input
type="range"
min={0}
@@ -138,22 +142,17 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
aria-label={t('roadtrip.stay.custom')}
className={SLIDER}
/>
{/* What the stay costs the rest of the day. The arrival cannot move — the drive
decides it — so the arrow shows the one end this dialog does move. */}
{times ? <ArriveLeave arrive={times.arrive} leave={times.leave} carry={times.carry} /> : null}
<div className="flex flex-wrap justify-center gap-1.5">
{PRESETS.map(value => (
<button
key={value}
type="button"
onClick={() => setMinutes(value)}
className={`rounded-full border px-3 py-1 text-caption font-medium tabular-nums transition-colors ${
minutes === value
? 'border-accent bg-accent text-accent-text'
: 'border-edge bg-surface-card text-content-secondary hover:border-content-faint'
aria-pressed={minutes === value}
className={`rounded-full px-3 py-1 font-semibold tabular-nums transition-colors ${
minutes === value ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-secondary shadow-sm hover:text-content'
}`}
style={fs(11.5, 'body')}
>
{formatDurationShort(value * 60)}
</button>
@@ -161,34 +160,25 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
</div>
</div>
<div className="mt-6 flex items-center gap-2">
{/* Removing is the same write as setting one, so it sits with the other actions
rather than behind a confirmation. */}
<button
type="button"
disabled={saving || !stop.minutes}
onClick={() => void commit(0)}
className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
>
{t('roadtrip.stay.none')}
</button>
<button
type="button"
onClick={onClose}
className="ms-auto rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
{t('common.cancel')}
</button>
<button
type="button"
disabled={saving}
onClick={() => void commit(minutes)}
className="rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text transition-opacity disabled:opacity-40"
>
{t('common.save')}
</button>
</div>
</Modal>
{/* What the stay costs the rest of the day. The arrival cannot move (the drive
decides it), so the arrow points at the one end this dialog does move. */}
{times ? <ArriveLeave arrive={times.arrive} leave={times.leave} carry={times.carry} /> : null}
</DialogShell>
)
}
/** The head band both shapes of the dialog share: the hourglass, "Stay", the stop's name. */
function StayHeader({ labelId, name, onClose }: { labelId: string; name: string; onClose: () => void }): React.ReactElement {
const { t } = useTranslation()
return (
<DialogHeader
tile={<DialogTile><Hourglass size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('roadtrip.stop.stay')}
title={name}
/>
)
}
@@ -215,82 +205,80 @@ function LeaveTime({ stop, leave, until: leaveAt, is12h, onClose }: {
const until = formatClockTime(leaveAt, is12h)
const arrive = stop.arrival ? formatClockTime(stop.arrival, is12h) : null
const departure = stop.departure ? formatClockTime(stop.departure, is12h) : until
const labelId = useId()
return (
<Modal isOpen onClose={onClose} size="sm" title={
<span className="flex items-center gap-2">
<Hourglass size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.stop.stay')}
</span>
}>
<div className="flex flex-col gap-5">
<p className="break-words text-center text-caption text-content-muted">{stop.name}</p>
<span className="text-center text-title font-semibold tabular-nums tracking-tight text-content">
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="detail"
header={<StayHeader labelId={labelId} name={stop.name} onClose={onClose} />}
footer={(
<DialogFooter>
{leave.remove ? (
<DialogButton disabled={leave.removing} onClick={() => void leave.remove?.()}>
{t('roadtrip.stay.clearLeave')}
</DialogButton>
) : null}
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.close')}</DialogButton>
</DialogFooter>
)}
>
<div className="flex flex-col items-center gap-2 rounded-[14px] border border-edge-faint bg-surface-secondary px-4 py-5 text-center">
<span className="font-bold tabular-nums tracking-[-0.02em] text-content" style={fs(28, 'subtitle')}>
{leave.minutes === null ? t('roadtrip.stay.until', { time: until }) : formatDurationShort(leave.minutes * 60)}
</span>
{leave.missedBy !== null ? (
<p className="flex items-center justify-center gap-1.5 text-center text-body font-medium text-warning">
<p className="m-0 flex items-center justify-center gap-1.5 font-medium text-warning" style={fs(12.5, 'body')}>
<AlertTriangle size={14} className="shrink-0" aria-hidden />
{t('roadtrip.warn.missedLeave', { count: leave.missedBy })}
</p>
) : (
<p className="text-center text-body text-content-secondary">
<p className="m-0 text-content-secondary" style={fs(12.5, 'body')}>
{t(leave.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', { time: until })}
</p>
)}
{/* The day ending first means the row's departure is only where it stops for the
night, not a time it leaves. */}
{arrive && !leave.dayEndsFirst ? <ArriveLeave arrive={arrive} leave={departure} /> : null}
</div>
<div className="mt-6 flex items-center gap-2">
{leave.remove ? (
<button
type="button"
disabled={leave.removing}
onClick={() => void leave.remove?.()}
className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
>
{t('roadtrip.stay.clearLeave')}
</button>
) : null}
<button
type="button"
onClick={onClose}
className="ms-auto rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
{t('common.close')}
</button>
</div>
</Modal>
{/* The day ending first means the row's departure is only where it stops for the
night, not a time it leaves. */}
{arrive && !leave.dayEndsFirst ? <ArriveLeave arrive={arrive} leave={departure} /> : null}
</DialogShell>
)
}
/** Arrival, arrow, departure: the two ends of the stay, with the day the second lands on. */
/** Arrival, arrow, departure: the two ends of the stay as field boxes, with the day the second lands on. */
function ArriveLeave({ arrive, leave, carry = 0 }: { arrive: string; leave: string; carry?: number }): React.ReactElement {
const { t } = useTranslation()
return (
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.arrive')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{arrive}</span>
</span>
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.leave')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
{leave}
{carry > 0 ? (
<span className="ms-0.5">
{`+${carry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
) : null}
</span>
<div className="flex items-stretch gap-2">
<TimeBox label={t('roadtrip.stay.arrive')} value={arrive} quiet />
<span className="grid place-items-center text-content-faint" aria-hidden>
<ArrowRight size={14} className="rtl:rotate-180" />
</span>
<TimeBox
label={t('roadtrip.stay.leave')}
value={(
<>
{leave}
{carry > 0 ? (
<span className="ms-0.5">
{`+${carry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
) : null}
</>
)}
/>
</div>
)
}
/** One end of the stay: an eyebrow over the clock, in the cards' field box. */
function TimeBox({ label, value, quiet }: { label: string; value: React.ReactNode; quiet?: boolean }): React.ReactElement {
return (
<span className="flex min-w-0 flex-1 flex-col gap-1 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{label}</span>
<span dir="ltr" className={`font-semibold tabular-nums ${quiet ? 'text-content-secondary' : 'text-content'}`} style={fs(15, 'body')}>{value}</span>
</span>
)
}
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useId, useState } from 'react'
import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react'
import Modal from '../shared/Modal'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { PANEL, Segmented } from '../shared/dialogParts'
import CustomTimePicker from '../shared/CustomTimePicker'
import StayPortals from './StayPortals'
import ChargingInfo from './ChargingInfo'
@@ -92,6 +93,7 @@ export default function RoadtripStopPopup({
// people sleep and a hotel is somewhere people stop for lunch.
const [night, setNight] = useState<boolean>(() => nightByDefault(draft))
const [checkIn, setCheckIn] = useState(() => checkInByDefault(draft))
const labelId = useId()
// The popup stays mounted while the draft is swapped for another one, so every
// answer is seeded again from the new draft, the way the phone sheet does it.
@@ -126,158 +128,117 @@ export default function RoadtripStopPopup({
}
}
const tone = kind?.color ?? 'var(--text-faint)'
return (
<Modal isOpen onClose={onClose} title={t(draft.editing ? 'common.edit' : 'roadtrip.stop.addTitle')} size={overnight ? 'xl' : 'sm'}>
<div className="flex flex-col gap-4">
<div className={overnight ? "grid grid-cols-1 gap-5 sm:grid-cols-2" : ""}>
<div className="flex min-w-0 flex-col gap-4">
<div className="flex items-start gap-3">
<span
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
// theme-lint-disable — the road-trip palette, the same one the map pin and the
// result row use; a token here would make the three disagree.
style={{ background: `${kind?.color ?? '#64748B'}1f`, color: kind?.color ?? '#64748B' }}
>
{kind ? <kind.Icon size={17} aria-hidden /> : <ParkingSquare size={17} aria-hidden />}
</span>
<div className="min-w-0 flex-1">
<p className="break-words text-body font-semibold text-content">{draft.poi.name}</p>
<p className="mt-0.5 text-caption text-content-muted">
{t('roadtrip.stop.landsOn', { day: draft.dayNumber, position: draft.position + 1 })}
</p>
</div>
</div>
{duplicateName ? (
// The full place form warns about duplicates; losing that warning would make
// this the easiest way to add the same petrol station twice.
<p className="flex items-start gap-1.5 rounded-lg bg-warning-soft px-2.5 py-2 text-caption text-warning">
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
{t('roadtrip.stop.duplicate', { name: duplicateName })}
</p>
) : null}
{/* Only where sleeping is on the table. A petrol station gets no choice, because
there is nothing to choose between. */}
{overnight && onSaveNight ? (
<div className="flex rounded-lg border border-edge p-0.5">
{[
{ on: false, label: t('roadtrip.stay.mode.pause'), Icon: Hourglass },
{ on: true, label: t('roadtrip.stay.mode.night'), Icon: BedDouble },
].map(({ on, label, Icon }) => (
<button
key={label}
type="button"
aria-pressed={night === on}
onClick={() => setNight(on)}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-caption transition-colors ${
night === on
? 'bg-accent font-semibold text-accent-text'
: 'text-content-secondary hover:bg-surface-hover'
}`}
>
<Icon size={13} aria-hidden />
{label}
</button>
))}
</div>
) : null}
{asNight ? (
<div className="flex flex-col gap-3">
{/* Check-in only, and optional at that: it is the hour the drive can stop
for the night, which is the one thing about a booking the chain uses.
A check-out says when the room has to be back, never when anybody sets
off, so it is a booking detail and lives under Days with the rest of
them. A hotel found on a map has no idea when its reception opens. */}
<label className="min-w-0 flex-1">
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('day.checkIn')}
<DialogShell
onClose={onClose}
labelledBy={labelId}
width={overnight ? 'editor' : 'detail'}
header={(
<DialogHeader
tile={(
<DialogTile>
{/* The road-trip palette, the same one the map pin and the result row use. */}
<span style={{ color: tone }}>
{kind ? <kind.Icon size={20} strokeWidth={1.9} aria-hidden /> : <ParkingSquare size={20} strokeWidth={1.9} aria-hidden />}
</span>
<CustomTimePicker value={checkIn} onChange={setCheckIn} aria-label={t('day.checkIn')} placeholder="" style={{ marginTop: 6, width: '100%' }} />
</label>
{websiteHref || draft.poi.phone ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
{websiteHref ? (
<a
href={websiteHref}
target="_blank"
rel="noreferrer noopener"
className="text-accent hover:underline"
>
{t('places.formWebsite')}
</a>
) : null}
{draft.poi.phone ? (
<a href={`tel:${draft.poi.phone}`} className="text-accent hover:underline">
{draft.poi.phone}
</a>
) : null}
</div>
) : null}
</div>
) : (
<>
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stop.kind')}
</span>
<StopKindChips value={stopType} onPick={pickKind} />
</div>
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stop.stay')}
</span>
<StopStayChips value={dwell} onPick={setDwell} />
</div>
{/* How busy the charging area is and what it charges, while the stop is still
being considered rather than once it is on the trip. Only for the kind that
has an answer: a petrol station or a bakery asks nothing, so picking either
of those costs no request at all.
One panel for one station, for as long as the dialog is open. The corridor
list deliberately does not do this per row: it publishes results box by
box, each row would own its own refresh timer, and a station in a dense
row of chargers is the case the upstream match refuses as ambiguous. */}
{stopType === 'charging' ? (
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.charging.availability')}
</span>
<div className="mt-1.5">
<ChargingInfo lat={draft.poi.lat} lng={draft.poi.lng} name={draft.poi.name} />
</div>
</div>
) : null}
</>
)}
</div>
{overnight ? <StayPortals lat={draft.poi.lat} lng={draft.poi.lng} name={draft.poi.name} camping={draft.poi.category === 'campsite'}
arrival={overnight.days.find(day => day.id === draft.dayId)?.date}
departure={overnight.days.find(day => day.id === overnight.defaultEndDayId)?.date} /> : null}
</div>
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
<button
type="button"
onClick={() => onMoreDetails({ stopType, dwellMinutes: dwell })}
className="rounded-lg px-2.5 py-1.5 text-caption text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
</DialogTile>
)}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t(draft.editing ? 'common.edit' : 'roadtrip.stop.addTitle')}
title={draft.poi.name}
sub={t('roadtrip.stop.landsOn', { day: draft.dayNumber, position: draft.position + 1 })}
pills={overnight && onSaveNight ? (
// Only where sleeping is on the table. A petrol station gets no choice,
// because there is nothing to choose between.
<Segmented
value={night ? 'night' : 'pause'}
label={t('roadtrip.stop.kind')}
onChange={v => setNight(v === 'night')}
options={[
{ value: 'pause', label: t('roadtrip.stay.mode.pause'), icon: <Hourglass size={13} aria-hidden /> },
{ value: 'night', label: t('roadtrip.stay.mode.night'), icon: <BedDouble size={13} aria-hidden /> },
]}
/>
) : undefined}
/>
)}
footer={(
<DialogFooter>
<DialogButton onClick={() => onMoreDetails({ stopType, dwellMinutes: dwell })}>
{t('roadtrip.stop.moreDetails')}
</button>
<button
type="button"
onClick={submit}
disabled={saving}
className="ms-auto rounded-lg bg-accent px-3.5 py-1.5 text-body font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
</DialogButton>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={() => void submit()} disabled={saving}>
{t(draft.editing ? 'common.save' : 'roadtrip.poi.add')}
</button>
</DialogButton>
</DialogFooter>
)}
>
{duplicateName ? (
// The full place form warns about duplicates; losing that warning would make
// this the easiest way to add the same petrol station twice.
<p className="m-0 flex items-start gap-1.5 rounded-[12px] bg-warning-soft px-3 py-2.5 text-warning" style={fs(12, 'body')}>
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
{t('roadtrip.stop.duplicate', { name: duplicateName })}
</p>
) : null}
<div className={overnight ? 'grid grid-cols-1 items-start gap-5 sm:grid-cols-2' : 'flex flex-col gap-5'}>
<div className="flex min-w-0 flex-col gap-5">
{asNight ? (
<>
{/* Check-in only, and optional at that: it is the hour the drive can stop for
the night, the one thing about a booking the chain uses. A check-out is a
booking detail and lives under Days with the rest of them. */}
<DialogSection label={t('day.checkIn')}>
<CustomTimePicker value={checkIn} onChange={setCheckIn} aria-label={t('day.checkIn')} placeholder="" style={{ width: '100%' }} />
</DialogSection>
{websiteHref || draft.poi.phone ? (
<div className="flex flex-wrap gap-x-3 gap-y-1" style={fs(12, 'body')}>
{websiteHref ? (
<a href={websiteHref} target="_blank" rel="noreferrer noopener" className="font-medium text-content hover:underline">
{t('places.formWebsite')}
</a>
) : null}
{draft.poi.phone ? (
<a href={`tel:${draft.poi.phone}`} className="font-medium text-content hover:underline">{draft.poi.phone}</a>
) : null}
</div>
) : null}
</>
) : (
<>
<DialogSection label={t('roadtrip.stop.kind')}>
<div className={PANEL}>
<StopKindChips value={stopType} onPick={pickKind} white className="flex flex-wrap gap-1.5" />
</div>
</DialogSection>
<DialogSection label={t('roadtrip.stop.stay')}>
<div className={PANEL}>
<StopStayChips value={dwell} onPick={setDwell} white className="flex flex-wrap gap-1.5" />
</div>
</DialogSection>
{/* How busy the charging area is and what it charges, while the stop is still
being considered. Only for the kind that has an answer: a petrol station
or a bakery asks nothing, so picking either costs no request at all. */}
{stopType === 'charging' ? (
<DialogSection label={t('roadtrip.charging.availability')}>
<ChargingInfo lat={draft.poi.lat} lng={draft.poi.lng} name={draft.poi.name} />
</DialogSection>
) : null}
</>
)}
</div>
{overnight ? (
<StayPortals lat={draft.poi.lat} lng={draft.poi.lng} name={draft.poi.name} camping={draft.poi.category === 'campsite'}
arrival={overnight.days.find(day => day.id === draft.dayId)?.date}
departure={overnight.days.find(day => day.id === overnight.defaultEndDayId)?.date} />
) : null}
</div>
</Modal>
</DialogShell>
)
}
@@ -25,10 +25,10 @@ export default function StayPortals({ name, camping, lat, lng, arrival, departur
}
return (
<aside className="flex min-w-0 flex-col gap-3 rounded-xl bg-surface-secondary p-4">
<aside className="flex min-w-0 flex-col gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary p-3.5">
<p className="text-body font-semibold text-content">{t('roadtrip.stay.portals')}</p>
<p className="text-caption text-content-muted">{t('roadtrip.stay.portalHint')}</p>
<div className="flex items-center gap-2 rounded-lg border border-edge bg-surface px-3 py-2">
<div className="flex items-center gap-2 rounded-[10px] border border-edge bg-surface-input px-3 py-2">
<span className="min-w-0 flex-1 select-text break-words text-caption text-content-secondary">{name}</span>
<button type="button" onClick={() => void copy()} aria-label={t(copied ? 'common.copied' : 'common.copy')}
className="shrink-0 rounded-md p-1 text-content-muted hover:bg-surface-hover hover:text-content">
@@ -39,7 +39,7 @@ export default function StayPortals({ name, camping, lat, lng, arrival, departur
<div className="flex flex-col gap-2">
{(camping ? CAMPING_PORTALS : HOTEL_PORTALS).map(portal => (
<a key={portal.name} href={portal.name === 'trivago' ? trivagoSearchHref(name, arrival, departure) : portal.name === 'PiNCAMP' || portal.name === 'Pitchup' ? campingSearchHref(portal.name, name, lat, lng, arrival, departure) : portal.href} target="_blank" rel="noopener noreferrer"
className="flex items-center justify-between gap-3 rounded-lg border border-edge bg-surface px-3 py-2.5 text-body font-medium text-content transition-colors hover:bg-surface-hover">
className="flex items-center justify-between gap-3 rounded-[10px] bg-surface-card px-3 py-2.5 text-body font-medium text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover">
<span className="flex min-w-0 items-center gap-2.5">
<img src={portal.icon} alt="" width={20} height={20} className="h-5 w-5 shrink-0 rounded object-contain" />
{portal.name}
@@ -1,9 +1,10 @@
import { Clock, Pencil } from 'lucide-react'
import { Pencil } from 'lucide-react'
import EndDayControl, { type EndDayControlProps } from './EndDayControl'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatClockTime } from '../../utils/formatters'
import { formatDurationShort } from './roadtripModel'
import { DialogSection, fs } from '../shared/DialogShell'
export interface RoadtripStayControl {
minutes: number | null
@@ -12,22 +13,36 @@ export interface RoadtripStayControl {
onEdit?: () => void
}
/** The white field box the inspector's other sections (rating, hours, files) use. */
export const VISIT_BOX = 'flex min-h-[38px] min-w-0 items-center justify-between gap-2 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2'
/**
* A stop's road trip controls in the place inspector: how long it takes and whether the
* day ends here, each under its eyebrow in the field box the sections around it use.
*/
export default function VisitControls({ endDay, stay }: { endDay?: EndDayControlProps; stay?: RoadtripStayControl }) {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const until = stay?.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null
return (
<div className={`grid gap-2 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
{endDay && <EndDayControl {...endDay} />}
{stay && <button type="button" onClick={stay.onEdit} disabled={!stay.onEdit}
className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 text-start enabled:hover:bg-surface-tertiary disabled:cursor-default">
<span className="flex items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium uppercase text-content-secondary"><Clock size={13} className="shrink-0 text-content-faint" aria-hidden />{t('roadtrip.stop.stayShort')}</span>
{' '}<span className="flex min-w-0 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary">
{stay.minutes === null ? (until ? null : '+') : formatDurationShort(stay.minutes * 60)}
{until ? <>{' '}<span className="truncate text-content-faint">{until}</span></> : null}
{stay.onEdit && <Pencil size={12} className="shrink-0 text-content-faint" aria-hidden />}
</span>
</button>}
<div className={`grid items-start gap-3 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
{stay && (
<DialogSection label={t('roadtrip.stop.stayShort')}>
<button type="button" onClick={stay.onEdit} disabled={!stay.onEdit}
className={`${VISIT_BOX} w-full text-start transition-colors enabled:hover:bg-surface-hover disabled:cursor-default`}>
<span className="flex min-w-0 items-center gap-1.5 font-medium text-content" style={fs(12.5, 'body')}>
{stay.minutes === null ? (until ? null : '+') : formatDurationShort(stay.minutes * 60)}
{until ? <span className="truncate text-content-faint">{until}</span> : null}
</span>
{stay.onEdit && <Pencil size={12} className="shrink-0 text-content-faint" aria-hidden />}
</button>
</DialogSection>
)}
{endDay && (
<DialogSection label={t('roadtrip.window.title')}>
<EndDayControl {...endDay} />
</DialogSection>
)}
</div>
)
}