mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): the ride block and the stay rows read as badges
The flight block in the road trip rail printed each fact two or three times: the booking title in capitals with its duration cut off, the airport code three times per side, an unlabelled 14:15 on the right with "Departure 15:15" under it, 17:20 twice, and a warning "+9 h 39 min" measured against a check-in nobody could see, for a flight that was missed. It now reads in four lines: the booking as typed; the two codes with their timetable clocks and the ride between them; the places without the code in brackets; and the check-in as a badge that carries its own state, the hour when the drive is on time, by how much when it is late for the check-in, and "Missed, there at 23:54" when it gets there after the departure, with the duration beside it. A missed ride edges the block and says so in the day header, also when the day is folded. Trains and buses say boarding. The phone shows the same, and its rows now read the lateness and missed-leave findings at all. The stays at the edge of a day are named by the hotel, with the edge in a badge: Check-out with the latest hour, Day start, Overnight, or Check-in with the hour the room is ready. A plan that leaves after the check-out turns that badge instead of adding a line. They wear the hotel stop's own disc, the stay's own stop shows its check-in the same way, and the driving settings button shows when the stays are switched on. The two-part badge the rail used in six hand-typed copies is one FigureBadge now, and the airport code is stripped by one shared helper instead of two private copies.
This commit is contained in:
@@ -16,7 +16,7 @@ import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types'
|
||||
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
||||
import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
@@ -57,14 +57,6 @@ function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', se
|
||||
}
|
||||
}
|
||||
|
||||
// "Paris Charles de Gaulle (CDG)" → "Paris Charles de Gaulle". Done as a trim
|
||||
// plus an anchored test instead of /\s*\([A-Z]{3}\)\s*$/, because the leading
|
||||
// \s* backtracks over every space in a long name for a quadratic worst case.
|
||||
function stripAirportCode(name: string): string {
|
||||
const trimmed = name.trimEnd()
|
||||
return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name
|
||||
}
|
||||
|
||||
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
|
||||
if (!e || !e.code) return null
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { Footprints } from 'lucide-react'
|
||||
import FigureBadge from './FigureBadge'
|
||||
|
||||
// FE-FIGURE-BADGE-001 to FE-FIGURE-BADGE-005
|
||||
|
||||
/** The classes a cell carries, as a set, so the order they are written in does not matter. */
|
||||
const classes = (el: Element | null): string[] => (el?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean).sort()
|
||||
const of = (list: string): string[] => list.split(' ').sort()
|
||||
|
||||
describe('FigureBadge', () => {
|
||||
it('FE-FIGURE-BADGE-001: the neutral shell is the one the stay, the walk and the fill-up wore before', () => {
|
||||
const { container } = render(<FigureBadge lead={<Footprints size={9} aria-hidden />} value="240 m" />)
|
||||
const shell = container.firstElementChild!
|
||||
expect(classes(shell)).toEqual(of('inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'))
|
||||
expect(classes(shell.children[0])).toEqual(of('flex items-center gap-0.5 px-1 bg-surface-tertiary text-content-faint'))
|
||||
expect(classes(shell.children[1])).toEqual(
|
||||
of('flex items-center border-s bg-surface-card px-1.5 font-semibold tabular-nums border-edge text-content-secondary'),
|
||||
)
|
||||
expect(shell.children[1]).toHaveTextContent('240 m')
|
||||
})
|
||||
|
||||
it('FE-FIGURE-BADGE-002: the warning tone edges both halves in the warning colour itself', () => {
|
||||
const { container } = render(<FigureBadge tone="warning" dir="ltr" lead="!" value="+30 min" />)
|
||||
const shell = container.firstElementChild!
|
||||
expect(shell).toHaveAttribute('dir', 'ltr')
|
||||
expect(classes(shell)).toContain('border-warning')
|
||||
expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['bg-warning-soft', 'text-warning']))
|
||||
expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['border-warning', 'text-warning']))
|
||||
})
|
||||
|
||||
it('FE-FIGURE-BADGE-003: a word in the lead is a caption, a figure can be quiet, and without one the sign stands alone', () => {
|
||||
const caption = render(<FigureBadge caption lead="Stay" value="+" faint valueSize="micro" />)
|
||||
const shell = caption.container.firstElementChild!
|
||||
expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['font-geist', 'font-semibold', 'uppercase', 'tracking-[0.12em]']))
|
||||
expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['text-content-faint']))
|
||||
expect((shell.children[1] as HTMLElement).style.fontSize).toContain('8px')
|
||||
caption.unmount()
|
||||
|
||||
const sign = render(<FigureBadge lead={<Footprints size={9} aria-label="Walk" />} />)
|
||||
expect(sign.container.firstElementChild!.children).toHaveLength(1)
|
||||
expect(screen.getByLabelText('Walk')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-FIGURE-BADGE-004: as a control it is a focusable button role that answers the keys meaning press and keeps them from the row', () => {
|
||||
const onActivate = vi.fn()
|
||||
const onRow = vi.fn()
|
||||
render(
|
||||
<div role="presentation" onClick={onRow} onKeyDown={onRow}>
|
||||
<FigureBadge lead="Stay" value="1 h" onActivate={onActivate} ariaLabel="Stay: 1 h" />
|
||||
</div>,
|
||||
)
|
||||
const badge = screen.getByRole('button', { name: 'Stay: 1 h' })
|
||||
fireEvent.click(badge)
|
||||
fireEvent.keyDown(badge, { key: 'Enter' })
|
||||
fireEvent.keyDown(badge, { key: ' ' })
|
||||
fireEvent.keyDown(badge, { key: 'Tab' })
|
||||
expect(onActivate).toHaveBeenCalledTimes(3)
|
||||
expect(onActivate).toHaveBeenCalledWith(badge)
|
||||
// Only the Tab went on to the row.
|
||||
expect(onRow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-FIGURE-BADGE-005: without a handler it is no control at all', () => {
|
||||
render(<FigureBadge lead="Stay" value="1 h" tooltip="How long you stay" />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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',
|
||||
},
|
||||
} as const
|
||||
|
||||
interface FigureBadgeProps {
|
||||
/** An icon, or a word set as a caption (`caption`). */
|
||||
lead: React.ReactNode
|
||||
/** The figure. Absent draws the lead alone, a badge that is only a sign. */
|
||||
value?: React.ReactNode
|
||||
tone?: 'neutral' | 'warning'
|
||||
tooltip?: string
|
||||
/** The lead is a word, set small, wide and in capitals like the stay's. */
|
||||
caption?: boolean
|
||||
/** The figure in the quiet ink: an invitation, or a value inherited rather than set. */
|
||||
faint?: boolean
|
||||
valueSize?: 'label' | 'micro'
|
||||
dir?: 'ltr'
|
||||
/**
|
||||
* Makes the badge the control that changes its figure. A span with the button role, not
|
||||
* a <button>: the stop row around it is already one, and a button inside a button is
|
||||
* invalid HTML that browsers resolve by dropping the inner element. The press stops at
|
||||
* the badge, so the row does not select its stop and move the map from under the panel.
|
||||
*/
|
||||
onActivate?: (anchor: HTMLElement) => void
|
||||
/** What a screen reader hears for the control, when the badge is one. */
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export default function FigureBadge({
|
||||
lead, value, tone = 'neutral', tooltip, caption, faint, valueSize = 'label', dir, onActivate, ariaLabel,
|
||||
}: FigureBadgeProps): React.ReactElement {
|
||||
const colours = TONES[tone]
|
||||
const shell = `inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border ${colours.shell}`
|
||||
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 }}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
const badge = onActivate ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
dir={dir}
|
||||
aria-label={ariaLabel}
|
||||
onClick={e => { e.stopPropagation(); onActivate(e.currentTarget) }}
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
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`}
|
||||
>
|
||||
{body}
|
||||
</span>
|
||||
) : (
|
||||
<span dir={dir} className={shell}>{body}</span>
|
||||
)
|
||||
return tooltip ? <Tooltip label={tooltip}>{badge}</Tooltip> : badge
|
||||
}
|
||||
@@ -312,6 +312,24 @@ describe('RoadtripLimitsCard', () => {
|
||||
fireEvent.click(toggle)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_hotel_bookends', false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-021: switched on, the button says so with the bed, instead of saying nothing is set', () => {
|
||||
const off = render(<RoadtripLimitsCard onSave={vi.fn()} />)
|
||||
expect(screen.getByText('None set')).toBeInTheDocument()
|
||||
expect(off.container.querySelector('svg.lucide-bed-double')).toBeNull()
|
||||
off.unmount()
|
||||
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
|
||||
})
|
||||
const { container } = render(<RoadtripLimitsCard onSave={vi.fn()} />)
|
||||
expect(screen.queryByText('None set')).toBeNull()
|
||||
const bed = container.querySelector('svg.lucide-bed-double')!
|
||||
expect(bed).toHaveAttribute('aria-label', 'Start and end each day at your stay')
|
||||
// A sign alone: the switch has no figure to put beside it.
|
||||
expect(bed.parentElement!.nextElementSibling).toBeNull()
|
||||
expect(screen.getByRole('button')).toHaveAccessibleName(expect.stringContaining('Start and end each day at your stay'))
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ import SettingsHint from './SettingsHint'
|
||||
import { dayWindow } from './dayWindow'
|
||||
import { BOOKEND_ICON } from './nightBookend'
|
||||
import { useHotelBookends } from './useHotelBookends'
|
||||
import FigureBadge from './FigureBadge'
|
||||
|
||||
/**
|
||||
* Everything that decides when the rail speaks up about the driving.
|
||||
@@ -397,7 +398,10 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
avoiding.length && canAvoid
|
||||
? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) }
|
||||
: null,
|
||||
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
|
||||
// A switch has no figure, so its badge is the sign alone and its words are the tooltip.
|
||||
// Without it a trip whose days start and end at the stay said "None set" on the button.
|
||||
bookends.on ? { key: 'bookends', Icon: BOOKEND_ICON, label: t('roadtrip.line.hotelBookends') } : null,
|
||||
].filter(Boolean) as { key: string; Icon: typeof Clock; text?: string; label?: string }[]
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -421,21 +425,13 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
</span>
|
||||
{badges.length ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{badges.map(({ key, Icon, text }) => (
|
||||
<span
|
||||
{badges.map(({ key, Icon, text, label }) => (
|
||||
<FigureBadge
|
||||
key={key}
|
||||
className="inline-flex h-[16px] items-stretch overflow-hidden rounded border border-edge"
|
||||
>
|
||||
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint">
|
||||
<Icon size={9} aria-hidden />
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
lead={label ? <Icon size={9} aria-label={label} /> : <Icon size={9} aria-hidden />}
|
||||
value={text}
|
||||
tooltip={label}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within, fireEvent } from '@testing-library/react'
|
||||
import { act, render, screen, within, fireEvent } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripSidebar from './RoadtripSidebar'
|
||||
import RoadtripModeSwitch from './RoadtripModeSwitch'
|
||||
@@ -1136,13 +1136,24 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes over both terminals with their timetable, and no number', () => {
|
||||
it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes, both codes with their timetable, the places and the check-in, and no number', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} />)
|
||||
expect(screen.getByText('LH 2020')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 h 10 min')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LH 2020 · 1 h 10 min')).toBeNull()
|
||||
// Each code, each timetable clock and each place once.
|
||||
expect(screen.getAllByText('HAM')).toHaveLength(1)
|
||||
expect(screen.getAllByText('MUC')).toHaveLength(1)
|
||||
expect(screen.getAllByText('13:20')).toHaveLength(1)
|
||||
expect(screen.getAllByText('14:30')).toHaveLength(1)
|
||||
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
|
||||
expect(screen.getByText('Arrival 14:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('Munich Airport')).toBeInTheDocument()
|
||||
// What the clocks are is said to a screen reader, not printed a second time.
|
||||
expect(screen.getByText('Departure 13:20')).toHaveClass('sr-only')
|
||||
expect(screen.getByText('Arrival 14:30')).toHaveClass('sr-only')
|
||||
// The check-in, labelled, where an unlabelled 12:20 used to stand in the arrival column.
|
||||
expect(screen.getByText('Check-in')).toBeInTheDocument()
|
||||
expect(screen.getByText('12:20')).toBeInTheDocument()
|
||||
// Hamburg is 1 and Munich is 2: the terminals between them take no number, and
|
||||
// the day header counts two stops.
|
||||
expect(screen.queryByText('3')).not.toBeInTheDocument()
|
||||
@@ -1154,9 +1165,9 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
const onAskAlternatives = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={onAskAlternatives} />)
|
||||
// One control for the whole ride, not one per terminal and one for the band.
|
||||
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
|
||||
expect(screen.getAllByRole('button', { name: /Open booking$/ })).toHaveLength(1)
|
||||
fireEvent.click(screen.getByText('Hamburg Airport'))
|
||||
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
|
||||
fireEvent.click(screen.getByText('LH 2020'))
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(2)
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(70)
|
||||
// One shuffle: the road into the departure terminal. Not the ride, not the road out
|
||||
@@ -1189,10 +1200,17 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
},
|
||||
})
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [overnight], totalStops: 1 })} onOpenBooking={vi.fn()} onAskAlternatives={vi.fn()} />)
|
||||
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
|
||||
expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LH 2020 · 1 h 10 min')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
|
||||
// The block's pieces on one row: the code and the place, the timetable's clock on the
|
||||
// right, and the check-in under them.
|
||||
const row = screen.getByText('Hamburg Airport').closest('button')!
|
||||
expect(within(row).getByText('HAM')).toBeInTheDocument()
|
||||
expect(within(row).getByText('13:20')).toBeInTheDocument()
|
||||
expect(within(row).getByText('Check-in')).toBeInTheDocument()
|
||||
expect(within(row).getByText('12:20')).toBeInTheDocument()
|
||||
// The chain's arrival there is the check-in, not a second clock in the column.
|
||||
expect(screen.queryByText('21:00')).toBeNull()
|
||||
expect(screen.queryByText('LH 2020')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: /Open booking$/ })).toHaveLength(1)
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -1218,10 +1236,11 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
it('FE-ROADTRIP-SIDEBAR-054: the join into the day a ride lands on is the ride, not a car driving no distance', () => {
|
||||
const flown = leg({ mode: 'flight', distance: 0, duration: 9 * 3600, distanceText: '', durationText: '9 h' })
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(flown)], totalStops: 1 })} />)
|
||||
expect(screen.getByText('LH 2020 · 9 h')).toBeInTheDocument()
|
||||
// The booking and its minutes, the minutes standing apart so a long title cannot cut them off.
|
||||
const band = screen.getByText('LH 2020').parentElement!
|
||||
expect(within(band).getByText('9 h')).toHaveClass('shrink-0')
|
||||
expect(screen.queryByText(/0 m in 9 h/)).not.toBeInTheDocument()
|
||||
// Under the booking's own icon, not the car the road out of the airport wears.
|
||||
const band = screen.getByText('LH 2020 · 9 h').parentElement!
|
||||
expect(band.querySelector('svg.lucide-plane')).toBeInTheDocument()
|
||||
expect(band.querySelector('svg.lucide-car-front')).toBeNull()
|
||||
})
|
||||
@@ -1278,9 +1297,17 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
|
||||
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
|
||||
const onOpenBooking = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [rentalDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
|
||||
expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('Return 11:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
// Each desk says which one it is, having no other end beside it, and its clock once.
|
||||
const pickup = screen.getByText('Sixt Hauptbahnhof').closest('button')!
|
||||
expect(within(pickup).getByText('Pickup')).toBeInTheDocument()
|
||||
expect(within(pickup).getAllByText('09:00')).toHaveLength(1)
|
||||
expect(within(pickup).getByText('Pick-up 09:00')).toHaveClass('sr-only')
|
||||
const drop = screen.getByText('Sixt Airport').closest('button')!
|
||||
expect(within(drop).getByText('Return')).toBeInTheDocument()
|
||||
expect(within(drop).getAllByText('11:30')).toHaveLength(1)
|
||||
expect(within(drop).getByText('HAM')).toBeInTheDocument()
|
||||
// A desk has no check-in.
|
||||
expect(screen.queryByText('Check-in')).toBeNull()
|
||||
expect(screen.queryByText('3')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Sixt Airport'))
|
||||
@@ -1470,9 +1497,11 @@ describe('RoadtripSidebar with a booked night at the edge of the day', () => {
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-064: names the hotel it sets out from and the one it ends at, and counts neither', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} />)
|
||||
expect(screen.getByText('Check-out · Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getByText('until 10:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('Back to Hotel Alpenblick')).toBeInTheDocument()
|
||||
// The hotel is the row's name; which edge of the stay it is, and the hour, sit in its badge.
|
||||
expect(screen.getAllByText('Hotel Alpenblick')).toHaveLength(2)
|
||||
expect(screen.getByText('Check-out').closest('button')).toHaveTextContent('10:00')
|
||||
expect(screen.getByText('Overnight')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/·/)).toBeNull()
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
// The places are one and two; the hotel wears no number.
|
||||
expect(screen.getByText('Lookout').closest('button')).toHaveTextContent('1')
|
||||
@@ -1486,14 +1515,13 @@ describe('RoadtripSidebar with a booked night at the edge of the day', () => {
|
||||
warnings: [],
|
||||
},
|
||||
})
|
||||
// The check-out badge itself turns, instead of a sentence added under the row.
|
||||
const { unmount } = wrap(<RoadtripSidebar routes={routes({ days: [late] })} />)
|
||||
expect(screen.getByText('until 10:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('Leaves after check-out')).toBeInTheDocument()
|
||||
expect(screen.getByText('Check-out').className).toContain('text-warning')
|
||||
unmount()
|
||||
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} />)
|
||||
expect(screen.getByText('until 10:00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Leaves after check-out')).toBeNull()
|
||||
expect(screen.getByText('Check-out').className).not.toContain('text-warning')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-065: the hotel rows are neither dragged nor dropped on, and carry no stay, kind or fill control', () => {
|
||||
@@ -1501,7 +1529,7 @@ describe('RoadtripSidebar with a booked night at the edge of the day', () => {
|
||||
<RoadtripSidebar routes={routes({ days: [loop()] })} onReorderStop={vi.fn()} onEditStay={vi.fn()} onSetStopKind={vi.fn()} onSetStopFill={vi.fn()} />,
|
||||
)
|
||||
expect(container.querySelectorAll('li[draggable="true"]')).toHaveLength(2)
|
||||
const hotelRow = screen.getByText('Back to Hotel Alpenblick').closest('li')!
|
||||
const hotelRow = screen.getByText('Overnight').closest('li')!
|
||||
expect(hotelRow).not.toHaveAttribute('draggable')
|
||||
// The row is one button, with nothing inside it to press.
|
||||
expect(within(hotelRow).getAllByRole('button')).toHaveLength(1)
|
||||
@@ -1521,13 +1549,13 @@ describe('RoadtripSidebar with a booked night at the edge of the day', () => {
|
||||
const onOpenBooking = vi.fn()
|
||||
const onSelectStop = vi.fn()
|
||||
const editor = wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onOpenBooking={onOpenBooking} onSelectStop={onSelectStop} canEditBookings />)
|
||||
fireEvent.click(screen.getByText('Back to Hotel Alpenblick'))
|
||||
fireEvent.click(screen.getByText('Overnight'))
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(41)
|
||||
expect(onSelectStop).not.toHaveBeenCalled()
|
||||
editor.unmount()
|
||||
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onOpenBooking={onOpenBooking} onSelectStop={onSelectStop} />)
|
||||
fireEvent.click(screen.getByText('Check-out · Hotel Alpenblick'))
|
||||
fireEvent.click(screen.getByText('Check-out'))
|
||||
// The place alone, without an assignment: the hotel is no stop of the day.
|
||||
expect(onSelectStop).toHaveBeenCalledWith(900)
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(1)
|
||||
@@ -1553,13 +1581,13 @@ describe('RoadtripSidebar with a booked night at the edge of the day', () => {
|
||||
})
|
||||
const stops = [resume, bookend('morning', {}, { ownerIndex: 0 }), stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 })]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg({ distance: 0 }), leg()] })] })} />)
|
||||
expect(screen.getByText('From Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getByText('Day start')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Continue journey')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-070: a drive to the hotel over the limit is flagged on the hotel row', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [loop({ driveWarnings: [{ index: 3, code: 'leg', overMinutes: 30 }] })] })} />)
|
||||
const hotelRow = screen.getByText('Back to Hotel Alpenblick').closest('li')!
|
||||
const hotelRow = screen.getByText('Overnight').closest('li')!
|
||||
expect(within(hotelRow).getByText('+30 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1579,7 +1607,7 @@ describe('RoadtripSidebar with a booked night at the edge of the day', () => {
|
||||
})
|
||||
const stops = [resume, bookend('morning', { checkingOut: true }, { ownerIndex: 0 }), stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 })]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg({ distance: 0 }), leg()] })] })} />)
|
||||
expect(lineAbove('Check-out · Hotel Alpenblick')).toBe(false)
|
||||
expect(lineAbove('Check-out')).toBe(false)
|
||||
// Below it the rail runs on as always.
|
||||
expect(lineAbove('Lookout')).toBe(true)
|
||||
})
|
||||
@@ -1597,7 +1625,7 @@ describe('RoadtripSidebar with a booked night at the edge of the day', () => {
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-072: a day that only drives from one stay to the next says its drive and no count of stops', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [transfer()] })} />)
|
||||
expect(screen.getByText('Check-in · Wallinga')).toBeInTheDocument()
|
||||
expect(screen.getByText('Check-in').closest('button')).toHaveTextContent('Wallinga')
|
||||
const header = screen.getByText('Day 2').closest('header')!
|
||||
expect(within(header).getByText(/100 km/)).toBeInTheDocument()
|
||||
expect(within(header).queryByText(/stops?$/)).toBeNull()
|
||||
@@ -1609,7 +1637,7 @@ describe('RoadtripSidebar with a booked night at the edge of the day', () => {
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [day(), transfer()] })} onReorderStop={vi.fn()} onMoveStopToDay={onMoveStopToDay} />,
|
||||
)
|
||||
const hotelRow = screen.getByText('Check-in · Wallinga').closest('li')!
|
||||
const hotelRow = screen.getByText('Wallinga').closest('li')!
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
fireEvent.dragStart(rows[1], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
fireEvent.dragOver(hotelRow)
|
||||
@@ -1619,4 +1647,146 @@ describe('RoadtripSidebar with a booked night at the edge of the day', () => {
|
||||
// From day 1, its second stop (assignment 2), onto day 2 as its first.
|
||||
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 2, 2, 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-081: the hotel at the edge of the day wears the hotel stop\'s own disc, not a terminal\'s', () => {
|
||||
const stops = [
|
||||
bookend('morning', { checkingOut: true }, { ownerIndex: 0 }),
|
||||
stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 }),
|
||||
stop({ assignmentId: 2, name: 'Gasthof Post', ownerIndex: 1, stopType: 'hotel', lat: 48, lng: 12 }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} />)
|
||||
const disc = (row: HTMLElement) => row.querySelector('svg.lucide-bed-double')!.parentElement as HTMLElement
|
||||
const hotelStop = disc(screen.getByText('Gasthof Post').closest('div.grid') as HTMLElement)
|
||||
const edge = disc(screen.getByText('Check-out').closest('button')!)
|
||||
expect(edge.style.background).not.toBe('')
|
||||
expect(edge.style.background).toBe(hotelStop.style.background)
|
||||
expect(edge.style.color).toBe(hotelStop.style.color)
|
||||
expect(edge.className).not.toContain('bg-surface-tertiary')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-082: the own stop of a stay on its check-in day carries the check-in hour as a badge', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 }),
|
||||
stop({ assignmentId: 2, name: 'Gasthof Post', ownerIndex: 1, stopType: 'hotel', night: true, checkInTime: '15:00' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg()] })] })} />)
|
||||
const hotel = screen.getByText('Gasthof Post').closest('li')!
|
||||
expect(within(hotel).getByText('Check-in').closest('.flex-wrap')).toHaveTextContent('15:00')
|
||||
expect(within(screen.getByText('Lookout').closest('li')!).queryByText('Check-in')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripSidebar with a flight the drive gets to late (#2460)', () => {
|
||||
/** LH 2078 as the routing round seats it: pinned an hour ahead for the check-in, named the way every airport writer names an endpoint. */
|
||||
const end = (role: 'departure' | 'arrival', type = 'flight') =>
|
||||
stop({
|
||||
assignmentId: role === 'departure' ? -3000000780 : -3000000781,
|
||||
name: role === 'departure' ? 'Hamburg (HAM)' : 'Munich (MUC)',
|
||||
placeId: -78,
|
||||
lat: role === 'departure' ? 53.63 : 48.35,
|
||||
lng: role === 'departure' ? 9.99 : 11.78,
|
||||
time: role === 'departure' ? '14:15' : '17:20',
|
||||
dwellMinutes: role === 'departure' ? 60 : 0,
|
||||
carrier: {
|
||||
reservationId: 78,
|
||||
type,
|
||||
role,
|
||||
title: 'LH 2078 HAM-MUC (ohne Endpunkte)',
|
||||
code: role === 'departure' ? 'HAM' : 'MUC',
|
||||
at: role === 'departure' ? '15:15' : '17:20',
|
||||
},
|
||||
})
|
||||
/** Out of the Hamburg hotel, the flight, into the Munich one, with the drive to the airport `late` minutes past check-in. */
|
||||
const flightDay = (late: number | null, type = 'flight') => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hotel Atlantic' }), end('departure', type), end('arrival', type), stop({ assignmentId: 2, name: 'Hotel Bayerischer Hof' })]
|
||||
return day({
|
||||
dayId: 3,
|
||||
dayNumber: 3,
|
||||
stops,
|
||||
legs: [leg(), leg({ mode: type, distance: 0, duration: 7500, distanceText: '', durationText: '2 h 5 min' }), leg()],
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '09:00', departure: '09:00', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '14:15', departure: '15:15', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '17:20', departure: '17:20', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '18:00', departure: '18:00', anchored: false, dayOffset: 0 },
|
||||
],
|
||||
warnings: late === null ? [] : [{ index: 1, code: 'late' as const, minutes: late }],
|
||||
},
|
||||
})
|
||||
}
|
||||
const block = () => screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)').closest('button')!.children[1] as HTMLElement
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-075: a missed flight says so on its check-in and on the day, with no delta against a clock nobody sees', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} onOpenBooking={vi.fn()} />)
|
||||
// The title as typed, the duration beside the check-in, each code once and the places without it.
|
||||
const title = screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)')
|
||||
expect(title.className).not.toContain('uppercase')
|
||||
expect(within(block()).getByText('Missed').closest('.flex-wrap')).toHaveTextContent('2 h 5 min')
|
||||
expect(title.parentElement).not.toHaveTextContent('2 h 5 min')
|
||||
expect(screen.getAllByText('HAM')).toHaveLength(1)
|
||||
expect(screen.getByText('Hamburg')).toBeInTheDocument()
|
||||
expect(screen.getByText('Munich')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/\(HAM\)/)).toBeNull()
|
||||
// The check-in carries the finding; the separate late pill is gone.
|
||||
expect(within(block()).getByText('Missed')).toBeInTheDocument()
|
||||
expect(within(block()).getByText('there at 23:54')).toBeInTheDocument()
|
||||
expect(screen.queryByText('+9 h 39 min')).toBeNull()
|
||||
expect(block().className).toContain('border-warning')
|
||||
const header = screen.getByText('Day 3').closest('header')!
|
||||
expect(within(header).getByText('Flight missed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-076: a folded day still says its flight is missed', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} collapsedDayIds={new Set([3])} onToggleDay={vi.fn()} />)
|
||||
const header = screen.getByText('Day 3').closest('header')!
|
||||
expect(within(header).getByText('Flight missed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-077: late for the check-in but in time for the flight says by how much, and leaves the day alone', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(25)], totalStops: 2 })} />)
|
||||
expect(within(block()).getByText('Check-in')).toBeInTheDocument()
|
||||
expect(within(block()).getByText('25 min late')).toBeInTheDocument()
|
||||
expect(block().className).not.toContain('border-warning')
|
||||
expect(screen.queryByText('Flight missed')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-078: a train is boarded, and a missed one is named as a train', () => {
|
||||
const onTime = wrap(<RoadtripSidebar routes={routes({ days: [flightDay(null, 'train')], totalStops: 2 })} />)
|
||||
expect(screen.getByText('Boarding')).toBeInTheDocument()
|
||||
expect(screen.getByText('14:15')).toBeInTheDocument()
|
||||
onTime.unmount()
|
||||
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579, 'train')], totalStops: 2 })} />)
|
||||
expect(within(screen.getByText('Day 3').closest('header')!).getByText('Train missed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-079: the badge explains itself in a sentence, and a booking clock names what it is, not a time you set', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} />)
|
||||
const hover = (el: HTMLElement) => {
|
||||
fireEvent.mouseEnter(el)
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
}
|
||||
hover(screen.getByText('there at 23:54').parentElement!)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'The drive reaches Hamburg at 23:54. LH 2078 HAM-MUC (ohne Endpunkte) departs at 15:15, check-in closes at 14:15.',
|
||||
)
|
||||
fireEvent.mouseLeave(screen.getByText('there at 23:54').parentElement!)
|
||||
|
||||
hover(screen.getByText('15:15').parentElement!)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Departure 15:15')
|
||||
expect(screen.queryByText('Time you set')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-080: a screen reader hears the ride, its clocks and that it is missed, then what pressing it does', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} onOpenBooking={vi.fn()} />)
|
||||
const button = screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)').closest('button')!
|
||||
expect(button).not.toHaveAttribute('aria-label')
|
||||
expect(button).toHaveAccessibleName(expect.stringMatching(/Departure 15:15\s*Arrival 17:20.*Missed\s*there at 23:54\s*2 h 5 min\s*Open booking$/))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import { formatDate, formatClockTime } from '../../utils/formatters'
|
||||
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
|
||||
import { STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import { arrivingReroutable, bookendReading, destinationCount, isHop, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel'
|
||||
import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle, leavesAfterCheckOut } from './nightBookend'
|
||||
import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge, bookendBooking } from './nightBookend'
|
||||
import { spurWorthLabelling } from './accessSpur'
|
||||
import StopKindPicker from './StopKindPicker'
|
||||
import StopFillPicker from './StopFillPicker'
|
||||
@@ -34,7 +34,8 @@ import { FS } from './typeScale'
|
||||
import type { RouteSegment } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import AutomaticDayStop from './AutomaticDayStop'
|
||||
import { bookingOpens, carrierIcon, rideText, terminalLine } from './carrierRide'
|
||||
import { bookingOpens, carrierIcon, missedRide, rideDuration, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide'
|
||||
import FigureBadge from './FigureBadge'
|
||||
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
|
||||
import type { Reservation } from '../../types'
|
||||
import type { StayDraft } from './RoadtripStayModal'
|
||||
@@ -154,14 +155,11 @@ const RAIL_DASH: React.CSSProperties = {
|
||||
backgroundImage: 'repeating-linear-gradient(var(--border-primary) 0 4px, transparent 4px 8px)',
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Named because more than one place builds it now — the walk from the road, and the two
|
||||
* figures on a refuel offer — and a second copy is how the two drift apart.
|
||||
*/
|
||||
const FIGURE_BADGE = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'
|
||||
/** 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)',
|
||||
}
|
||||
|
||||
/** A 24px disc — a stop's number, or a service stop's icon. */
|
||||
const DISC = 'grid h-6 w-6 shrink-0 place-items-center rounded-full'
|
||||
@@ -207,22 +205,11 @@ function OffRoadBadge({ meters }: { meters: number }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
return (
|
||||
<Tooltip label={t('roadtrip.stop.offRoad', { distance: formatDistance(meters / 1000, distanceUnit) })}>
|
||||
<span className="inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge">
|
||||
<span
|
||||
className="flex items-center bg-surface-tertiary px-1 text-content-faint"
|
||||
style={{ fontSize: FS.micro }}
|
||||
>
|
||||
<Footprints size={9} aria-hidden />
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{formatDistance(meters / 1000, distanceUnit)}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<FigureBadge
|
||||
lead={<Footprints size={9} aria-hidden />}
|
||||
value={formatDistance(meters / 1000, distanceUnit)}
|
||||
tooltip={t('roadtrip.stop.offRoad', { distance: formatDistance(meters / 1000, distanceUnit) })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -261,50 +248,15 @@ function FillBadge({ percent, own, onEdit }: {
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
if (percent === null && !onEdit) return null
|
||||
|
||||
const shell = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'
|
||||
const icon = (
|
||||
<span
|
||||
className="flex items-center bg-surface-tertiary px-1 text-content-faint"
|
||||
style={{ fontSize: FS.micro }}
|
||||
>
|
||||
<BatteryCharging size={9} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
const value = (
|
||||
<span
|
||||
className={`flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums ${
|
||||
percent === null ? 'text-content-faint' : own ? 'text-content-secondary' : 'text-content-faint'
|
||||
}`}
|
||||
style={{ fontSize: FS.micro }}
|
||||
>
|
||||
{percent === null ? '+' : `${percent} %`}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!onEdit) return <span className={shell}>{icon}{value}</span>
|
||||
// A span carrying the button role, not a <button>: the whole stop row is already one,
|
||||
// and a button inside a button is invalid HTML that React warns about and that browsers
|
||||
// resolve by dropping the inner element.
|
||||
return (
|
||||
<Tooltip label={percent === null ? t('roadtrip.stop.fillSet') : t('roadtrip.limit.fillBadge', { percent })}>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
// Stops the click reaching the row, which would select the stop and move the map
|
||||
// out from under the panel that is about to open.
|
||||
onClick={e => { e.stopPropagation(); onEdit(e.currentTarget as HTMLElement) }}
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onEdit(e.currentTarget as HTMLElement)
|
||||
}}
|
||||
className={`${shell} cursor-pointer transition-colors hover:border-content-faint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
|
||||
>
|
||||
{icon}{value}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<FigureBadge
|
||||
lead={<BatteryCharging size={9} aria-hidden />}
|
||||
value={percent === null ? '+' : `${percent} %`}
|
||||
faint={percent === null || !own}
|
||||
valueSize="micro"
|
||||
tooltip={onEdit ? (percent === null ? t('roadtrip.stop.fillSet') : t('roadtrip.limit.fillBadge', { percent })) : undefined}
|
||||
onActivate={onEdit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -312,6 +264,18 @@ function FillBadge({ percent, own, onEdit }: {
|
||||
* How long the stop takes. A stop the traveller leaves at a set time is stood at until
|
||||
* then, so it reads the stay the schedule made of that and says until when.
|
||||
*/
|
||||
/**
|
||||
* The check-in of the night a stay's own stop begins, on the day it does: the hour the
|
||||
* room is ready, which is also what holds the arrival clock beside it when the drive gets
|
||||
* there first.
|
||||
*/
|
||||
function NightCheckIn({ stop }: { stop: RoadtripStop }): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
if (!stop.night || !stop.checkInTime) return null
|
||||
return <FigureBadge caption lead={t('roadtrip.bookend.checkIn')} value={formatClockTime(stop.checkInTime, is12h)} />
|
||||
}
|
||||
|
||||
function StayBadge({ stay, onEdit }: { stay: StayReading; onEdit?: () => void }): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -319,52 +283,23 @@ function StayBadge({ stay, onEdit }: { stay: StayReading; onEdit?: () => void })
|
||||
const text = shown === null ? null : formatDurationShort(shown * 60)
|
||||
const until = stay.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null
|
||||
if (!text && !until && !onEdit) return null
|
||||
|
||||
const shell = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'
|
||||
const label = (
|
||||
<span
|
||||
className="flex items-center bg-surface-tertiary px-1 font-geist font-semibold uppercase tracking-[0.12em] text-content-faint"
|
||||
style={{ fontSize: FS.micro }}
|
||||
>
|
||||
{t('roadtrip.stop.stayShort')}
|
||||
</span>
|
||||
)
|
||||
const value = (
|
||||
<span
|
||||
className={`flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums ${
|
||||
text ? 'text-content-secondary' : 'text-content-faint'
|
||||
}`}
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{text ?? (until ? null : '+')}
|
||||
{until ? <span className={`font-medium text-content-faint ${text ? 'ms-1' : ''}`}>{until}</span> : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!onEdit) return <span className={shell}>{label}{value}</span>
|
||||
// A span carrying the button role, not a <button>: the whole stop row is already one,
|
||||
// and a button inside a button is invalid HTML that React warns about and that browsers
|
||||
// resolve by dropping the inner element.
|
||||
return (
|
||||
<Tooltip label={t('roadtrip.stop.stay')}>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
// Stops the click reaching the row, which would select the stop and move the map
|
||||
// out from under the dialog that is about to open.
|
||||
onClick={e => { e.stopPropagation(); onEdit() }}
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
aria-label={text || until ? `${t('roadtrip.stop.stay')}: ${[text, until].filter(Boolean).join(' ')}` : t('roadtrip.stay.add')}
|
||||
className={`${shell} cursor-pointer transition-colors hover:border-content-faint`}
|
||||
>
|
||||
{label}{value}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<FigureBadge
|
||||
caption
|
||||
lead={t('roadtrip.stop.stayShort')}
|
||||
value={(
|
||||
<>
|
||||
{text ?? (until ? null : '+')}
|
||||
{until ? <span className={`font-medium text-content-faint ${text ? 'ms-1' : ''}`}>{until}</span> : null}
|
||||
</>
|
||||
)}
|
||||
faint={!text}
|
||||
tooltip={onEdit ? t('roadtrip.stop.stay') : undefined}
|
||||
onActivate={onEdit}
|
||||
ariaLabel={onEdit
|
||||
? text || until ? `${t('roadtrip.stop.stay')}: ${[text, until].filter(Boolean).join(' ')}` : t('roadtrip.stay.add')
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -636,32 +571,16 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
tooltip says which one it is, which is how two numbers stay
|
||||
legible in a column this narrow. */}
|
||||
<span className="mt-0.5 flex items-center gap-1">
|
||||
<Tooltip label={t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}>
|
||||
<span className={FIGURE_BADGE}>
|
||||
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint">
|
||||
<Milestone size={9} aria-hidden />
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{formatDistance(poi.offRouteKm, distanceUnit)}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })}>
|
||||
<span className={FIGURE_BADGE}>
|
||||
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint">
|
||||
<DryIcon size={9} aria-hidden />
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{formatDistance(Math.round(poi.spareKm), distanceUnit)}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<FigureBadge
|
||||
lead={<Milestone size={9} aria-hidden />}
|
||||
value={formatDistance(poi.offRouteKm, distanceUnit)}
|
||||
tooltip={t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
|
||||
/>
|
||||
<FigureBadge
|
||||
lead={<DryIcon size={9} aria-hidden />}
|
||||
value={formatDistance(Math.round(poi.spareKm), distanceUnit)}
|
||||
tooltip={t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
{onAccept ? (
|
||||
@@ -707,10 +626,11 @@ function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const mode = leg?.mode ?? 'driving'
|
||||
// A ride reads as the ride block's pill does: the booking and its minutes under the
|
||||
// A ride reads as the ride block's head does: the booking and its minutes under the
|
||||
// booking's icon. Through the road branches it was a car driving no distance for nine
|
||||
// hours, and a ride without a timetable was short enough to pass for a hop.
|
||||
const ride = isCarrierMode(mode)
|
||||
const duration = ride ? rideDuration(leg) : null
|
||||
const Icon = ride ? carrierIcon(mode) : mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront
|
||||
// A hop (the hire desk beside the terminal) keeps the line and drops the pill: there is
|
||||
// nothing to say about it and no other way to drive it.
|
||||
@@ -726,19 +646,26 @@ function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: {
|
||||
}
|
||||
// The band's contents, shared by the clickable and the read-only shape so the two can
|
||||
// never drift apart in what they say.
|
||||
// A ride's minutes stand apart from its title and never truncate: a long booking title
|
||||
// would otherwise cut off the one figure the band exists for.
|
||||
const band = (
|
||||
<>
|
||||
<Icon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
|
||||
{!leg
|
||||
? t('roadtrip.leg.pending')
|
||||
: ride
|
||||
? rideText(carrier, leg)
|
||||
{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}
|
||||
</>
|
||||
) : (
|
||||
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
|
||||
{!leg
|
||||
? t('roadtrip.leg.pending')
|
||||
: t('roadtrip.leg.driveText', {
|
||||
distance: formatDistance(leg.distance / 1000, distanceUnit),
|
||||
time: formatDurationShort(leg.duration),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
@@ -795,13 +722,14 @@ function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: {
|
||||
}
|
||||
|
||||
/**
|
||||
* One end of a ride: the airport, station or port the drive stops at or resumes from.
|
||||
* 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.
|
||||
*
|
||||
* No number, no stay, no kind picker and no drag handle: it is not a stop anybody chose
|
||||
* and cannot be moved or turned into anything, it is where the booking puts the
|
||||
* traveller. What it shows is the booking's own clock under its name, and on the right
|
||||
* the time the chain wants the traveller there, which for a departure is a check-in ahead
|
||||
* of the timetable. The whole row opens the booking.
|
||||
* traveller. Built from the ride block's pieces: the code, the place, the timetable's clock
|
||||
* 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.
|
||||
*/
|
||||
function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
|
||||
stop: RoadtripStop
|
||||
@@ -812,25 +740,46 @@ function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
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} label={onOpen ? t('roadtrip.ride.open') : undefined}>
|
||||
<span className="min-w-0 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
|
||||
<RideEnd stop={stop} entry={entry} late={late} />
|
||||
<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}
|
||||
</span>
|
||||
{end.clock ? <TimetableClock end={end} className="leading-6" /> : entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
</span>
|
||||
</DiscRow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the rail that is one button on a plain disc: a terminal, a ride, a booked night.
|
||||
* A row of the rail that is one button on a disc: a terminal, a ride, a booked night.
|
||||
* The line runs in from above unless the chain starts here, and on below while it goes on.
|
||||
*
|
||||
* What pressing it does is said after the row's own content rather than instead of it: an
|
||||
* aria-label on the button would be all a screen reader heard, and a ride block holds the
|
||||
* clocks and the warning somebody needs to hear first.
|
||||
*/
|
||||
function DiscRow({ Icon, starts, continues, onOpen, label, children }: {
|
||||
function DiscRow({ Icon, face, starts, continues, onOpen, opens, children }: {
|
||||
Icon: LucideIcon
|
||||
/** The disc's own colours, for a row that stands for something with a face of its own. */
|
||||
face?: React.CSSProperties
|
||||
starts?: boolean
|
||||
continues: boolean
|
||||
onOpen?: () => void
|
||||
label?: string
|
||||
opens?: string
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
@@ -838,18 +787,18 @@ function DiscRow({ Icon, starts, continues, onOpen, label, children }: {
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
disabled={!onOpen}
|
||||
aria-label={label}
|
||||
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent disabled:cursor-default"
|
||||
style={RAIL_GRID}
|
||||
>
|
||||
<span className="flex flex-col items-center">
|
||||
{starts ? null : <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden />}
|
||||
<span className={`${DISC} my-1 bg-surface-tertiary text-content-secondary`}>
|
||||
<span className={`${DISC} my-1 ${face ? '' : 'bg-surface-tertiary text-content-secondary'}`} style={face}>
|
||||
<Icon size={13} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
|
||||
</span>
|
||||
{children}
|
||||
{opens ? <span className="sr-only">{opens}</span> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -858,11 +807,13 @@ function DiscRow({ Icon, starts, continues, onOpen, label, children }: {
|
||||
* A booked night at the edge of the day: the hotel the day sets out from, or the one it
|
||||
* ends at (`seatNightBookends`).
|
||||
*
|
||||
* Laid out like a terminal, with no number, stay, kind picker or drag handle: it is the
|
||||
* stay's place and no stop of the day, so nothing about it is changed here. Under its line
|
||||
* the latest hour the room is handed back, on the morning it is, and whatever the drive
|
||||
* into it runs over; on the right the time the chain has the traveller there. The whole
|
||||
* row opens the booking behind the night, or the hotel's place when there is none to open.
|
||||
* On the hotel stop's own disc, so the same hotel wears one face whether the day checks
|
||||
* in, sets out from it or comes back to it. Otherwise flat, with no number, stay, kind
|
||||
* picker or drag handle: it is the stay's place and no stop of the day, so nothing about it
|
||||
* is changed here. Under its line the latest hour the room is handed back, on the morning
|
||||
* it is, and whatever the drive into it runs over; on the right the time the chain has the
|
||||
* traveller there. The whole row opens the booking behind the night, or the hotel's place
|
||||
* when there is none to open.
|
||||
*/
|
||||
function BookendStop({ reading, entry, late, driveFindings, continues, starts, onOpen }: {
|
||||
reading: BookendReading
|
||||
@@ -875,29 +826,25 @@ function BookendStop({ reading, entry, late, driveFindings, continues, starts, o
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const meta = bookendMeta(reading, t, is12h)
|
||||
const afterCheckOut = leavesAfterCheckOut(reading, entry)
|
||||
const badges = afterCheckOut || late.length + driveFindings.length > 0
|
||||
const badge = bookendBadge(reading, entry, t, is12h)
|
||||
return (
|
||||
<DiscRow Icon={BOOKEND_ICON} starts={starts} continues={continues} onOpen={onOpen}>
|
||||
<DiscRow Icon={BOOKEND_ICON} face={BOOKEND_DISC} starts={starts} continues={continues} onOpen={onOpen}>
|
||||
<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="min-w-0 break-words font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
{bookendTitle(reading, t)}
|
||||
{reading.name}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<FigureBadge
|
||||
tone={badge.warning ? 'warning' : 'neutral'}
|
||||
caption
|
||||
lead={badge.warning ? <><AlertTriangle size={9} aria-hidden />{badge.lead}</> : badge.lead}
|
||||
value={badge.value ?? undefined}
|
||||
tooltip={badge.hint ?? undefined}
|
||||
/>
|
||||
{driveFindings.map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
||||
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||
</span>
|
||||
{meta || badges ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{meta ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{meta}</span> : null}
|
||||
{afterCheckOut ? (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-warning" style={{ fontSize: FS.meta }}>
|
||||
<AlertTriangle size={10} aria-hidden="true" />
|
||||
{t('roadtrip.bookend.afterCheckOut')}
|
||||
</span>
|
||||
) : null}
|
||||
{driveFindings.map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
||||
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
</span>
|
||||
@@ -905,69 +852,114 @@ function BookendStop({ reading, entry, late, driveFindings, continues, starts, o
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One end of a ride: the terminal's name and code, the timetable's clock under it, and
|
||||
* on the right the time the chain has the traveller there. Inside the ride's block for
|
||||
* a same-day ride, and the whole of a lone terminal's row.
|
||||
*/
|
||||
function RideEnd({ stop, entry, late }: {
|
||||
stop: RoadtripStop
|
||||
entry: ScheduleEntry | undefined
|
||||
late: ScheduleWarning[]
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const line = terminalLine(stop.carrier!, t, is12h)
|
||||
/** 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="flex min-w-0 items-start gap-2">
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
<span className="min-w-0 break-words">{stop.name}</span>
|
||||
{stop.carrier!.code ? (
|
||||
<span className="shrink-0 font-geist font-medium text-content-faint" style={{ fontSize: FS.meta }}>{stop.carrier!.code}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{line ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{line}</span> : null}
|
||||
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||
</span>
|
||||
</span>
|
||||
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A ride that leaves and lands on the same day, as one thing in the chain: the booking
|
||||
* at the top, the terminal it leaves from, the minutes in the air, the terminal it lands
|
||||
* at. 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. The whole block opens the
|
||||
* 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
|
||||
* means is when the drive arrives.
|
||||
*/
|
||||
function TimetableClock({ end, className = '' }: { end: RideEnd; className?: string }): React.ReactElement {
|
||||
return (
|
||||
<Tooltip label={end.label ?? ''}>
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`shrink-0 whitespace-nowrap font-semibold tabular-nums text-content-secondary ${className}`}
|
||||
style={{ fontSize: FS.time }}
|
||||
>
|
||||
<span aria-hidden>{end.clock}</span>
|
||||
<DayCarry days={end.dayOffset} />
|
||||
<span className="sr-only">{end.label}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The check-in, as one badge that carries its own finding: on time it reads the hour, late
|
||||
* it reads by how much, and too late to catch the ride it says so and when the drive gets
|
||||
* there. Measured against the pin and set beside the departure's timetable clock, the old
|
||||
* "+9 h 39 min" read as a delay to that clock and never said the ride was gone.
|
||||
*/
|
||||
function CheckInBadge({ checkIn }: { checkIn: CheckInReading }): React.ReactElement {
|
||||
const warning = checkIn.state !== 'ok'
|
||||
return (
|
||||
<FigureBadge
|
||||
tone={warning ? 'warning' : 'neutral'}
|
||||
caption
|
||||
lead={warning ? <><AlertTriangle size={9} aria-hidden />{checkIn.lead}</> : checkIn.lead}
|
||||
value={checkIn.value}
|
||||
tooltip={checkIn.hint}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function RideBlock({ departure, arrival, entries, late, seg, continues, starts, onOpen }: {
|
||||
departure: RoadtripStop
|
||||
arrival: RoadtripStop
|
||||
entries: [ScheduleEntry | undefined, ScheduleEntry | undefined]
|
||||
late: [ScheduleWarning[], ScheduleWarning[]]
|
||||
late: ScheduleWarning[]
|
||||
seg: RouteSegment | undefined
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const carrier = departure.carrier!
|
||||
const Icon = carrierIcon(carrier.type)
|
||||
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={Icon} starts={starts} continues={continues} onOpen={onOpen} label={onOpen ? t('roadtrip.ride.open') : undefined}>
|
||||
<span className="my-0.5 flex min-w-0 flex-col gap-1 rounded-lg border border-edge-faint bg-surface-tertiary px-2 pb-1.5 pt-1 transition-colors group-hover:bg-surface-selected">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-content-muted">
|
||||
<Icon size={11} strokeWidth={1.8} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate font-geist font-medium uppercase tracking-[0.09em]" style={{ fontSize: FS.label }}>
|
||||
{rideText(carrier, seg)}
|
||||
</span>
|
||||
<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>
|
||||
<RideEnd stop={departure} entry={entries[0]} late={late[0]} />
|
||||
<RideEnd stop={arrival} entry={entries[1]} late={late[1]} />
|
||||
<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}
|
||||
</span>
|
||||
</DiscRow>
|
||||
)
|
||||
@@ -1125,6 +1117,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
|
||||
<span className="min-w-0 truncate">{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<NightCheckIn stop={stop} />
|
||||
<StayBadge stay={readStay(stop, entry)} onEdit={onEditStay} />
|
||||
{refuelsRange(stop.stopType, vehicleKind) ? (
|
||||
<FillBadge
|
||||
@@ -1214,25 +1207,29 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea
|
||||
if (!text) return null
|
||||
const Icon = warning.code === 'range' ? Fuel : Clock
|
||||
return (
|
||||
<Tooltip label={text}>
|
||||
<span
|
||||
// The border is the warning colour itself, not its soft tint: at this size a tinted
|
||||
// edge disappears into the card and the badge loses the shell the other two wear.
|
||||
className="inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-warning"
|
||||
>
|
||||
<span className="flex items-center bg-warning-soft px-1 text-warning" style={{ fontSize: FS.micro }}>
|
||||
<Icon size={9} aria-hidden />
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center border-s border-warning bg-surface-card px-1.5 font-semibold tabular-nums text-warning"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{warning.code === 'range'
|
||||
? formatDistance(warning.sinceKm ?? 0, distanceUnit)
|
||||
: `+${formatDurationShort((warning.overMinutes ?? 0) * 60)}`}
|
||||
</span>
|
||||
<FigureBadge
|
||||
tone="warning"
|
||||
lead={<Icon size={9} aria-hidden />}
|
||||
value={warning.code === 'range'
|
||||
? formatDistance(warning.sinceKm ?? 0, distanceUnit)
|
||||
: `+${formatDurationShort((warning.overMinutes ?? 0) * 60)}`}
|
||||
tooltip={text}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The timetable convention: past midnight the clock keeps reading small numbers, so the
|
||||
* day it belongs to travels with it instead of sitting a line away as a separate note.
|
||||
*/
|
||||
function DayCarry({ days }: { days: number }): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
if (days <= 0) return null
|
||||
return (
|
||||
<span className="ms-0.5">
|
||||
{`+${days}`}
|
||||
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1240,14 +1237,7 @@ function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const text = formatClockTime(entry.arrival, is12h)
|
||||
// The timetable convention: past midnight the clock keeps reading small numbers, so the
|
||||
// day it belongs to travels with it instead of sitting a line away as a separate note.
|
||||
const carry = entry.dayOffset > 0 ? (
|
||||
<span className="ms-0.5">
|
||||
{`+${entry.dayOffset}`}
|
||||
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
|
||||
</span>
|
||||
) : null
|
||||
const carry = <DayCarry days={entry.dayOffset} />
|
||||
|
||||
// Every arrival is plain text at the row's right edge, pinned or not. The pinned one
|
||||
// used to be a filled pill with a clock, which made a column of quiet clock readings
|
||||
@@ -1381,6 +1371,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
||||
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. */}
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<NightCheckIn stop={stop} />
|
||||
<StayBadge stay={readStay(stop, entry)} onEdit={onEditStay} />
|
||||
{refuelsRange(stop.stopType, vehicleKind) ? (
|
||||
<FillBadge
|
||||
@@ -1413,26 +1404,16 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
||||
function LateBadge({ late }: { late: ScheduleWarning }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const label = t(late.code === 'missedLeave' ? 'roadtrip.warn.missedLeave' : 'roadtrip.warn.late', { minutes: late.minutes ?? 0 })
|
||||
// The same two-part shell the stay and the drive findings wear, so a row of badges reads
|
||||
// as one set instead of a pill among boxes.
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<span
|
||||
dir="ltr"
|
||||
// The same two-part shell the stay and the drive findings wear, so a row of badges
|
||||
// reads as one set instead of a pill among boxes. Warning-coloured edge like the
|
||||
// drive finding: at this size a tinted edge disappears into the card.
|
||||
className="inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-warning"
|
||||
>
|
||||
<span className="flex items-center bg-warning-soft px-1 text-warning" style={{ fontSize: FS.micro }}>
|
||||
<AlertTriangle size={9} aria-label={label} />
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center border-s border-warning bg-surface-card px-1.5 font-semibold tabular-nums text-warning"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{`+${formatDurationShort((late.minutes ?? 0) * 60)}`}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<FigureBadge
|
||||
tone="warning"
|
||||
dir="ltr"
|
||||
lead={<AlertTriangle size={9} aria-label={label} />}
|
||||
value={`+${formatDurationShort((late.minutes ?? 0) * 60)}`}
|
||||
tooltip={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1608,6 +1589,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
|
||||
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
|
||||
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 dayColorsOn = useRoadtripSettings(s => !!s.roadtrip_day_colors)
|
||||
@@ -1738,7 +1722,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
|
||||
departure={stop}
|
||||
arrival={next!}
|
||||
entries={[day.schedule.entries[i], day.schedule.entries[i + 1]]}
|
||||
late={[lateness, latenessAt(i + 1)]}
|
||||
late={lateness}
|
||||
seg={day.legs[i]}
|
||||
continues={tail < last}
|
||||
starts={i === 0}
|
||||
@@ -1961,6 +1945,14 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
|
||||
</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}
|
||||
</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 ? (
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { missedRide, rideDuration, rideReading, terminalReading } from './carrierRide'
|
||||
import type { CarrierTerminal, RoadtripStop, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
|
||||
|
||||
// FE-CARRIER-RIDE-001 to FE-CARRIER-RIDE-009
|
||||
|
||||
/** A translator that shows the key and what went into it, so a test reads which sentence was asked for. */
|
||||
const t = (key: string, params?: Record<string, string | number | null>) =>
|
||||
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
|
||||
|
||||
/**
|
||||
* One end of LH 2078 as the routing round seats it: the departure pinned an hour ahead of
|
||||
* the timetable for the check-in and left at the timetable's minute, the arrival pinned at
|
||||
* its own. Named the way every airport writer names an endpoint.
|
||||
*/
|
||||
function terminal(role: CarrierTerminal['role'], over: Partial<CarrierTerminal> = {}, stop: Partial<RoadtripStop> = {}): RoadtripStop {
|
||||
const departs = role === 'departure'
|
||||
const carrier: CarrierTerminal = {
|
||||
reservationId: 78,
|
||||
type: 'flight',
|
||||
role,
|
||||
title: 'LH 2078 HAM-MUC (ohne Endpunkte)',
|
||||
code: departs ? 'HAM' : 'MUC',
|
||||
at: departs ? '15:15' : '17:20',
|
||||
...over,
|
||||
}
|
||||
return {
|
||||
assignmentId: departs ? -780 : -781,
|
||||
ownerDayId: 3,
|
||||
ownerIndex: 1,
|
||||
placeId: -78,
|
||||
name: departs ? 'Hamburg (HAM)' : 'Munich (MUC)',
|
||||
lat: departs ? 53.63 : 48.35,
|
||||
lng: departs ? 9.99 : 11.78,
|
||||
time: departs ? '14:15' : '17:20',
|
||||
dwellMinutes: departs ? 60 : 0,
|
||||
legMode: departs ? carrier.type : null,
|
||||
incomingLegMode: departs ? null : carrier.type,
|
||||
stopType: null,
|
||||
carrier,
|
||||
...stop,
|
||||
}
|
||||
}
|
||||
|
||||
const FLIGHT: RouteSegment = {
|
||||
mid: [50, 10], from: [53.63, 9.99], to: [48.35, 11.78],
|
||||
distance: 0, duration: 7500, walkingText: '', drivingText: '', distanceText: '', durationText: '2 h 5 min',
|
||||
mode: 'flight',
|
||||
}
|
||||
|
||||
const late = (minutes: number): ScheduleWarning => ({ index: 1, code: 'late', minutes })
|
||||
|
||||
const read = (warnings: ScheduleWarning[] = [], is12h = false, over: Partial<CarrierTerminal> = {}) =>
|
||||
rideReading({
|
||||
departure: terminal('departure', over),
|
||||
arrival: terminal('arrival', over),
|
||||
entries: [
|
||||
{ arrival: '14:15', departure: '15:15', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '17:20', departure: '17:20', anchored: true, dayOffset: 0 },
|
||||
],
|
||||
warnings,
|
||||
seg: FLIGHT,
|
||||
}, t, is12h)
|
||||
|
||||
describe('a ride as its block reads it', () => {
|
||||
it('FE-CARRIER-RIDE-001: every fact once, the title as typed, the code off the place, and the check-in with its hour', () => {
|
||||
const ride = read()
|
||||
expect(ride.title).toBe('LH 2078 HAM-MUC (ohne Endpunkte)')
|
||||
expect(ride.duration).toBe('2 h 5 min')
|
||||
expect(ride.from).toEqual({
|
||||
code: 'HAM',
|
||||
place: 'Hamburg',
|
||||
chip: 'HAM',
|
||||
placeLine: 'Hamburg',
|
||||
clock: '15:15',
|
||||
dayOffset: 0,
|
||||
label: 'roadtrip.ride.departureFlight(time=15:15)',
|
||||
})
|
||||
expect(ride.to).toMatchObject({ chip: 'MUC', placeLine: 'Munich', clock: '17:20', label: 'roadtrip.ride.arrival(time=17:20)' })
|
||||
expect(ride.checkIn).toEqual({
|
||||
state: 'ok',
|
||||
lead: 'roadtrip.ride.checkIn',
|
||||
value: '14:15',
|
||||
hint: 'roadtrip.ride.hintFlight(title=LH 2078 HAM-MUC (ohne Endpunkte),departs=15:15,pin=14:15)',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-CARRIER-RIDE-002: reached after the check-in but before take-off is tight, and says by how much', () => {
|
||||
const checkIn = read([late(25)]).checkIn!
|
||||
expect(checkIn.state).toBe('tight')
|
||||
expect(checkIn.lead).toBe('roadtrip.ride.checkIn')
|
||||
expect(checkIn.value).toBe('roadtrip.ride.lateBy(time=25 min)')
|
||||
expect(checkIn.hint).toBe(
|
||||
'roadtrip.ride.lateHintFlight(title=LH 2078 HAM-MUC (ohne Endpunkte),departs=15:15,pin=14:15,place=Hamburg,time=14:40)',
|
||||
)
|
||||
// Reaching the gate as the plane leaves is still a catch.
|
||||
expect(read([late(60)]).checkIn!.state).toBe('tight')
|
||||
})
|
||||
|
||||
it('FE-CARRIER-RIDE-003: reached after take-off is missed, and says when the drive gets there instead of a delta', () => {
|
||||
const checkIn = read([late(579)]).checkIn!
|
||||
expect(checkIn.state).toBe('missed')
|
||||
expect(checkIn.lead).toBe('roadtrip.ride.missed')
|
||||
expect(checkIn.value).toBe('roadtrip.ride.reachedAt(time=23:54)')
|
||||
expect(checkIn.hint).toContain('place=Hamburg,time=23:54')
|
||||
// Past midnight the clock carries its day the way the rail's arrivals do, in the reader's clock.
|
||||
expect(read([late(12 * 60 + 15)], true).checkIn!.value).toBe('roadtrip.ride.reachedAt(time=2:30 AM+1)')
|
||||
// Findings that are not about being late leave the check-in alone.
|
||||
expect(read([{ index: 1, code: 'leg', overMinutes: 30 }]).checkIn!.state).toBe('ok')
|
||||
})
|
||||
|
||||
it('FE-CARRIER-RIDE-004: a train or a bus is boarded, a ferry or a cruise sails, and only a flight has its own departure word', () => {
|
||||
const train = read([], false, { type: 'train', code: null })
|
||||
expect(train.checkIn!.lead).toBe('roadtrip.ride.boarding')
|
||||
expect(train.checkIn!.hint).toMatch(/^roadtrip\.ride\.hintBoarding\(/)
|
||||
expect(train.from.label).toBe('roadtrip.ride.departure(time=15:15)')
|
||||
expect(read([late(25)], false, { type: 'bus' }).checkIn!.hint).toMatch(/^roadtrip\.ride\.lateHintBoarding\(/)
|
||||
const ferry = read([], false, { type: 'ferry' })
|
||||
expect(ferry.checkIn!.lead).toBe('roadtrip.ride.checkIn')
|
||||
expect(ferry.checkIn!.hint).toMatch(/^roadtrip\.ride\.hintShip\(/)
|
||||
expect(read([], false, { type: 'cruise' }).checkIn!.hint).toMatch(/^roadtrip\.ride\.hintShip\(/)
|
||||
})
|
||||
|
||||
it('FE-CARRIER-RIDE-005: a terminal without a code puts its name in the chip, and one without a timetable names no clock and no check-in', () => {
|
||||
const station = rideReading({
|
||||
departure: terminal('departure', { type: 'train', code: null, at: null }, { name: 'Hamburg Hbf', time: null }),
|
||||
arrival: terminal('arrival', { type: 'train', code: null, at: null }, { name: 'München Hbf', time: null }),
|
||||
entries: [undefined, { arrival: '07:20', departure: '07:20', anchored: false, dayOffset: 1 }],
|
||||
warnings: [],
|
||||
seg: undefined,
|
||||
}, t, false)
|
||||
expect(station.from).toMatchObject({ code: null, chip: 'Hamburg Hbf', placeLine: null, clock: null, label: null, dayOffset: 0 })
|
||||
expect(station.to).toMatchObject({ chip: 'München Hbf', dayOffset: 1 })
|
||||
expect(station.duration).toBeNull()
|
||||
expect(station.checkIn).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a terminal on a row of its own', () => {
|
||||
it('FE-CARRIER-RIDE-006: a desk says which one it is and has no check-in; a lone departure has one, a lone arrival has none', () => {
|
||||
const pickup = terminalReading(
|
||||
terminal('pickup', { type: 'car', title: 'Sixt Hamburg', code: null, at: '09:00' }, { name: 'Sixt Hauptbahnhof', time: '09:00' }),
|
||||
undefined,
|
||||
[],
|
||||
t,
|
||||
false,
|
||||
)
|
||||
expect(pickup.desk).toBe('reservations.span.pickup')
|
||||
expect(pickup.checkIn).toBeNull()
|
||||
expect(pickup.end).toMatchObject({ place: 'Sixt Hauptbahnhof', clock: '09:00', label: 'roadtrip.ride.pickup(time=09:00)' })
|
||||
|
||||
const leaving = terminalReading(terminal('departure'), undefined, [late(579)], t, false)
|
||||
expect(leaving.desk).toBeNull()
|
||||
expect(leaving.checkIn!.state).toBe('missed')
|
||||
expect(terminalReading(terminal('arrival'), undefined, [late(30)], t, false).checkIn).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the day a ride is missed on', () => {
|
||||
const day = (warnings: ScheduleWarning[], type = 'flight') => ({
|
||||
stops: [
|
||||
{ ...terminal('pickup', { type: 'car', code: null }), carrier: undefined, name: 'Hotel Atlantic' },
|
||||
terminal('departure', { type }),
|
||||
terminal('arrival', { type }),
|
||||
],
|
||||
schedule: { entries: [], warnings },
|
||||
})
|
||||
|
||||
it('FE-CARRIER-RIDE-007: names the kind of ride in the header and keeps the sentence for its tooltip', () => {
|
||||
expect(missedRide(day([late(579)]), t, false)).toEqual({
|
||||
label: 'roadtrip.day.rideMissed.flight',
|
||||
hint: expect.stringContaining('time=23:54'),
|
||||
})
|
||||
expect(missedRide(day([late(579)], 'ferry'), t, false)!.label).toBe('roadtrip.day.rideMissed.ferry')
|
||||
})
|
||||
|
||||
it('FE-CARRIER-RIDE-008: a ride caught, late or not, and a finding at another stop say nothing in the header', () => {
|
||||
expect(missedRide(day([]), t, false)).toBeNull()
|
||||
expect(missedRide(day([late(25)]), t, false)).toBeNull()
|
||||
expect(missedRide(day([{ index: 2, code: 'late', minutes: 600 }]), t, false)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rideDuration', () => {
|
||||
it('FE-CARRIER-RIDE-009: the leg\'s own text, else its seconds, else nothing', () => {
|
||||
expect(rideDuration(FLIGHT)).toBe('2 h 5 min')
|
||||
expect(rideDuration({ ...FLIGHT, durationText: '' })).toBe('2 h 5 min')
|
||||
expect(rideDuration({ ...FLIGHT, durationText: '', duration: 0 })).toBeNull()
|
||||
expect(rideDuration(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { formatClock, formatDurationShort, parseClock, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import { stripAirportCode } from '../../utils/flightLegs'
|
||||
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
|
||||
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import { CARRIER_TYPES, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { Reservation, TranslationFn } from '../../types'
|
||||
|
||||
/**
|
||||
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in
|
||||
* the phone chain (#2428), and which of the bookings under a stop open there.
|
||||
*
|
||||
* One module for both shells, because the two would otherwise carry the same icon table
|
||||
* and the same three sentences each, and the duplication budget does not stretch to that.
|
||||
* One module for both shells, because the two would otherwise carry the same icon table,
|
||||
* the same reading of the timetable against the drive and the same sentences each, and the
|
||||
* duplication budget does not stretch to that. The shells only draw what comes out of here.
|
||||
* The icons are the booking panel's for the same types, so a flight looks like a flight
|
||||
* wherever the trip shows it.
|
||||
*/
|
||||
@@ -28,25 +30,190 @@ export function carrierIcon(type: string): LucideIcon {
|
||||
}
|
||||
|
||||
/**
|
||||
* The line under a terminal's name: the timetable's clock at it, in the reader's own
|
||||
* clock format. Nothing when the booking names no time.
|
||||
* How long the ride takes on the timetable: the leg's own text, or its seconds when the
|
||||
* text is not in yet. Null for a ride the timetable gives no minutes for.
|
||||
*/
|
||||
export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h: boolean): string | null {
|
||||
if (!carrier.at) return null
|
||||
const time = formatClockTime(carrier.at, is12h)
|
||||
return t(`roadtrip.ride.${carrier.role}`, { time })
|
||||
export function rideDuration(seg: RouteSegment | undefined): string | null {
|
||||
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
|
||||
return seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : null)
|
||||
}
|
||||
|
||||
/** One end of a ride, or a hire car's desk, as a row or the route strip of a block reads it. */
|
||||
export interface RideEnd {
|
||||
/** IATA or station code, when the booking carries one. */
|
||||
code: string | null
|
||||
/** The terminal's name without the code the airport forms append to it. */
|
||||
place: string
|
||||
/** What the route strip's chip holds: the code, or the name of a terminal without one. */
|
||||
chip: string
|
||||
/** The name under the chip, or null when the chip already is the name. */
|
||||
placeLine: string | null
|
||||
/** The timetable's clock here, in the reader's format. Null when the booking names none. */
|
||||
clock: string | null
|
||||
/** Days past the card's own that the clock falls on, for the timetable's "+1". */
|
||||
dayOffset: number
|
||||
/** The clock in words, "Departure 15:15", for its tooltip and a screen reader. */
|
||||
label: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* What the ride's pill says: the booking, then how long it takes when the timetable gives
|
||||
* both ends. A ride with no minutes is still the booking, and says so without a duration.
|
||||
* A ride leg met without its booking is just the minutes.
|
||||
* The departure measured against the drive that gets there.
|
||||
*
|
||||
* `ok` is the drive arriving by the check-in, `tight` after it but still before the ride
|
||||
* leaves, `missed` after it has left. The ride keeps its day either way (`scheduleStopOf`
|
||||
* pins it `dated`), so a drive a day late reads as missed rather than as tomorrow's ride.
|
||||
*/
|
||||
export function rideText(carrier: Pick<CarrierTerminal, 'title'> | undefined, seg: RouteSegment | undefined): string {
|
||||
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
|
||||
const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '')
|
||||
if (!carrier) return duration
|
||||
return duration ? `${carrier.title} · ${duration}` : carrier.title
|
||||
export type RideState = 'ok' | 'tight' | 'missed'
|
||||
|
||||
export interface CheckInReading {
|
||||
state: RideState
|
||||
/** The badge's word: check-in, boarding for a train or a bus, or that the ride is missed. */
|
||||
lead: string
|
||||
/** Its figure: the check-in clock, how late the drive gets there, or when it does. */
|
||||
value: string
|
||||
/** The sentence behind the badge, with the timetable in it. */
|
||||
hint: string
|
||||
}
|
||||
|
||||
export interface RideReading {
|
||||
title: string
|
||||
duration: string | null
|
||||
from: RideEnd
|
||||
to: RideEnd
|
||||
checkIn: CheckInReading | null
|
||||
}
|
||||
|
||||
export interface TerminalReading {
|
||||
end: RideEnd
|
||||
/** A hire car's desk says which one it is, having no other end beside it to say it. */
|
||||
desk: string | null
|
||||
checkIn: CheckInReading | null
|
||||
}
|
||||
|
||||
const DAY_MINUTES = 24 * 60
|
||||
|
||||
/** Trains and buses are boarded, the rest is checked in for (`CHECK_IN_MINUTES`). */
|
||||
const BOARDED = new Set(['train', 'bus'])
|
||||
const SAILS = new Set(['ferry', 'cruise'])
|
||||
|
||||
/** Which of the three timetable sentences a ride takes: they differ in the verb and the word for the check-in. */
|
||||
function sentenceOf(type: string): 'Flight' | 'Ship' | 'Boarding' {
|
||||
if (BOARDED.has(type)) return 'Boarding'
|
||||
return SAILS.has(type) ? 'Ship' : 'Flight'
|
||||
}
|
||||
|
||||
/** Minutes from the card's midnight as a clock, in the timetable's "+1" form past it. */
|
||||
function clockOf(minutes: number, is12h: boolean): string {
|
||||
const days = Math.floor(minutes / DAY_MINUTES)
|
||||
const clock = formatClockTime(formatClock(minutes), is12h)
|
||||
return days > 0 ? `${clock}+${days}` : clock
|
||||
}
|
||||
|
||||
function rideEnd(stop: RoadtripStop, entry: ScheduleEntry | undefined, t: TranslationFn, is12h: boolean): RideEnd {
|
||||
const carrier = stop.carrier!
|
||||
const place = stripAirportCode(stop.name)
|
||||
const clock = carrier.at ? formatClockTime(carrier.at, is12h) : null
|
||||
// German, among others, has a word of its own for a plane leaving.
|
||||
const key = carrier.role === 'departure' && carrier.type === 'flight' ? 'roadtrip.ride.departureFlight' : `roadtrip.ride.${carrier.role}`
|
||||
return {
|
||||
code: carrier.code,
|
||||
place,
|
||||
chip: carrier.code ?? place,
|
||||
placeLine: carrier.code && carrier.code !== place ? place : null,
|
||||
clock,
|
||||
dayOffset: entry?.dayOffset ?? 0,
|
||||
label: clock ? t(key, { time: clock }) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The check-in against the drive, read off the departure's pin and the schedule's `late`
|
||||
* finding at it: the pin is the timetable minus the check-in (`terminalStop`), and the
|
||||
* finding is how far past the pin the chain gets there.
|
||||
*/
|
||||
function checkInOf(
|
||||
departure: RoadtripStop,
|
||||
warnings: readonly ScheduleWarning[],
|
||||
t: TranslationFn,
|
||||
is12h: boolean,
|
||||
): CheckInReading | null {
|
||||
const carrier = departure.carrier
|
||||
if (carrier?.role !== 'departure') return null
|
||||
const departs = parseClock(carrier.at)
|
||||
const pinned = parseClock(departure.time)
|
||||
if (departs === null || pinned === null) return null
|
||||
const late = warnings.find(w => w.code === 'late')
|
||||
const sentence = sentenceOf(carrier.type)
|
||||
const word = t(sentence === 'Boarding' ? 'roadtrip.ride.boarding' : 'roadtrip.ride.checkIn')
|
||||
const pin = clockOf(pinned, is12h)
|
||||
const facts = { title: carrier.title, departs: clockOf(departs, is12h), pin }
|
||||
if (!late) return { state: 'ok', lead: word, value: pin, hint: t(`roadtrip.ride.hint${sentence}`, facts) }
|
||||
|
||||
const reach = pinned + (late.minutes ?? 0)
|
||||
const time = clockOf(reach, is12h)
|
||||
const hint = t(`roadtrip.ride.lateHint${sentence}`, { ...facts, place: stripAirportCode(departure.name), time })
|
||||
if (reach > departs) return { state: 'missed', lead: t('roadtrip.ride.missed'), value: t('roadtrip.ride.reachedAt', { time }), hint }
|
||||
return { state: 'tight', lead: word, value: t('roadtrip.ride.lateBy', { time: formatDurationShort((reach - pinned) * 60) }), hint }
|
||||
}
|
||||
|
||||
/**
|
||||
* A ride that leaves and lands on the same day, as its block reads it: the booking, its
|
||||
* minutes, both ends and the check-in with whatever the drive makes of it.
|
||||
*/
|
||||
export function rideReading(
|
||||
ride: {
|
||||
departure: RoadtripStop
|
||||
arrival: RoadtripStop
|
||||
entries: readonly [ScheduleEntry | undefined, ScheduleEntry | undefined]
|
||||
/** The schedule's findings at the departure. */
|
||||
warnings: readonly ScheduleWarning[]
|
||||
seg: RouteSegment | undefined
|
||||
},
|
||||
t: TranslationFn,
|
||||
is12h: boolean,
|
||||
): RideReading {
|
||||
return {
|
||||
title: ride.departure.carrier!.title,
|
||||
duration: rideDuration(ride.seg),
|
||||
from: rideEnd(ride.departure, ride.entries[0], t, is12h),
|
||||
to: rideEnd(ride.arrival, ride.entries[1], t, is12h),
|
||||
checkIn: checkInOf(ride.departure, ride.warnings, t, is12h),
|
||||
}
|
||||
}
|
||||
|
||||
/** A terminal on a row of its own, its ride landing on another day, or a hire car's desk. */
|
||||
export function terminalReading(
|
||||
stop: RoadtripStop,
|
||||
entry: ScheduleEntry | undefined,
|
||||
warnings: readonly ScheduleWarning[],
|
||||
t: TranslationFn,
|
||||
is12h: boolean,
|
||||
): TerminalReading {
|
||||
const role = stop.carrier!.role
|
||||
return {
|
||||
end: rideEnd(stop, entry, t, is12h),
|
||||
desk: role === 'pickup' || role === 'return' ? t(`reservations.span.${role}`) : null,
|
||||
checkIn: checkInOf(stop, warnings, t, is12h),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first ride of the day the drive reaches too late to catch, as the day's header names
|
||||
* it, so a folded day still says so. Null while every ride is caught.
|
||||
*/
|
||||
export function missedRide(
|
||||
day: Pick<RoadtripDay, 'stops' | 'schedule'>,
|
||||
t: TranslationFn,
|
||||
is12h: boolean,
|
||||
): { label: string; hint: string } | null {
|
||||
for (let i = 0; i < day.stops.length; i++) {
|
||||
const stop = day.stops[i]!
|
||||
const checkIn = checkInOf(stop, day.schedule.warnings.filter(w => w.index === i), t, is12h)
|
||||
if (checkIn?.state !== 'missed') continue
|
||||
const type = (CARRIER_TYPES as readonly string[]).includes(stop.carrier!.type) ? stop.carrier!.type : 'flight'
|
||||
return { label: t(`roadtrip.day.rideMissed.${type}`), hint: checkIn.hint }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BedDouble } from 'lucide-react'
|
||||
import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle, leavesAfterCheckOut, staysAtTheirPlaces } from './nightBookend'
|
||||
import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge, bookendBooking, leavesAfterCheckOut, staysAtTheirPlaces } from './nightBookend'
|
||||
import type { BookendReading } from './roadtripRowModel'
|
||||
import { serviceColor } from './roadtripModel'
|
||||
|
||||
// FE-BOOKEND-001 to FE-BOOKEND-008
|
||||
// FE-BOOKEND-001 to FE-BOOKEND-010
|
||||
|
||||
const reading = (over: Partial<BookendReading> = {}): BookendReading => ({
|
||||
phase: 'morning',
|
||||
variant: 'from',
|
||||
name: 'Hotel Alpenblick',
|
||||
until: null,
|
||||
from: null,
|
||||
reservationId: 41,
|
||||
accommodationId: 5,
|
||||
placeId: 900,
|
||||
@@ -21,21 +23,29 @@ const t = (key: string, params?: Record<string, string | number | null>) =>
|
||||
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
|
||||
|
||||
describe('a booked night as the rail and the phone say it', () => {
|
||||
it('FE-BOOKEND-001: each of the four readings is its own sentence, with the stay named in it', () => {
|
||||
expect(bookendTitle(reading({ variant: 'checkOut' }), t)).toBe('roadtrip.bookend.checkOut(name=Hotel Alpenblick)')
|
||||
expect(bookendTitle(reading({ variant: 'from' }), t)).toBe('roadtrip.bookend.from(name=Hotel Alpenblick)')
|
||||
expect(bookendTitle(reading({ phase: 'evening', variant: 'back' }), t)).toBe('roadtrip.bookend.back(name=Hotel Alpenblick)')
|
||||
expect(bookendTitle(reading({ phase: 'evening', variant: 'checkIn' }), t)).toBe('roadtrip.bookend.checkIn(name=Hotel Alpenblick)')
|
||||
it('FE-BOOKEND-001: the badge names the edge of the stay the day stands at, the row names the hotel', () => {
|
||||
expect(bookendBadge(reading({ variant: 'checkOut' }), undefined, t, false).lead).toBe('roadtrip.bookend.checkOut')
|
||||
expect(bookendBadge(reading({ variant: 'from' }), undefined, t, false).lead).toBe('roadtrip.bookend.from')
|
||||
expect(bookendBadge(reading({ phase: 'evening', variant: 'back' }), undefined, t, false).lead).toBe('roadtrip.bookend.back')
|
||||
expect(bookendBadge(reading({ phase: 'evening', variant: 'checkIn' }), undefined, t, false).lead).toBe('roadtrip.bookend.checkIn')
|
||||
})
|
||||
|
||||
it('FE-BOOKEND-002: the check-out hour is a label in the reader\'s clock', () => {
|
||||
expect(bookendMeta(reading({ variant: 'checkOut', until: '10:00' }), t, false)).toBe('roadtrip.stay.until(time=10:00)')
|
||||
expect(bookendMeta(reading({ variant: 'checkOut', until: '14:30' }), t, true)).toBe('roadtrip.stay.until(time=2:30 PM)')
|
||||
it('FE-BOOKEND-002: the check-out hour is the figure of the badge, in the clock of the reader', () => {
|
||||
expect(bookendBadge(reading({ variant: 'checkOut', until: '10:00' }), undefined, t, false))
|
||||
.toEqual({ lead: 'roadtrip.bookend.checkOut', value: '10:00', warning: false, hint: 'roadtrip.stay.until(time=10:00)' })
|
||||
expect(bookendBadge(reading({ variant: 'checkOut', until: '14:30' }), undefined, t, true).value).toBe('2:30 PM')
|
||||
})
|
||||
|
||||
it('FE-BOOKEND-003: nothing under the line when the morning hands no room back', () => {
|
||||
expect(bookendMeta(reading(), t, false)).toBeNull()
|
||||
expect(bookendMeta(reading({ phase: 'evening', variant: 'back' }), t, false)).toBeNull()
|
||||
it('FE-BOOKEND-003: a morning that hands no room back, or an evening back at the stay, is the word alone', () => {
|
||||
expect(bookendBadge(reading(), undefined, t, false)).toEqual({ lead: 'roadtrip.bookend.from', value: null, warning: false, hint: null })
|
||||
expect(bookendBadge(reading({ phase: 'evening', variant: 'back' }), undefined, t, false).value).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BOOKEND-010: a check-in evening carries the hour the room is ready, and leaving late turns the check-out badge', () => {
|
||||
expect(bookendBadge(reading({ phase: 'evening', variant: 'checkIn', from: '15:00' }), undefined, t, false))
|
||||
.toEqual({ lead: 'roadtrip.bookend.checkIn', value: '15:00', warning: false, hint: null })
|
||||
expect(bookendBadge(reading({ variant: 'checkOut', until: '10:00' }), { arrival: '12:27', dayOffset: 0 }, t, false))
|
||||
.toEqual({ lead: 'roadtrip.bookend.checkOut', value: '10:00', warning: true, hint: 'roadtrip.bookend.afterCheckOut' })
|
||||
})
|
||||
|
||||
it('FE-BOOKEND-007: a day that sets out after the room is handed back says so', () => {
|
||||
@@ -65,6 +75,11 @@ describe('a booked night as the rail and the phone say it', () => {
|
||||
it('FE-BOOKEND-005: the hotel wears the bed the stay chips wear', () => {
|
||||
expect(BOOKEND_ICON).toBe(BedDouble)
|
||||
})
|
||||
|
||||
it('FE-BOOKEND-009: on the disc the hotel stop wears, in its signage colour with the bed in white', () => {
|
||||
expect(BOOKEND_DISC.background).toBe(serviceColor('hotel'))
|
||||
expect(BOOKEND_DISC.color).toBe('#fff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the hotel a booked night stands for', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { BedDouble, type LucideIcon } from 'lucide-react'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import { parseClock, type ScheduleEntry } from './roadtripModel'
|
||||
import { parseClock, serviceColor, type ScheduleEntry } from './roadtripModel'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { BookendReading } from './roadtripRowModel'
|
||||
|
||||
@@ -17,17 +18,44 @@ import type { BookendReading } from './roadtripRowModel'
|
||||
/** The bed the day plan's stay chips wear, so the hotel looks like itself here too. */
|
||||
export const BOOKEND_ICON: LucideIcon = BedDouble
|
||||
|
||||
/** "Check-out · the stay", "From …", "Back to …" or "Check-in · …". */
|
||||
export function bookendTitle(reading: BookendReading, t: TranslationFn): string {
|
||||
return t(`roadtrip.bookend.${reading.variant}`, { name: reading.name })
|
||||
}
|
||||
/**
|
||||
* The disc the bed sits on: the hotel stop's own, in the road-signage colour its map pin
|
||||
* wears. On a plain grey disc the night at the day's edge read as a terminal, and the same
|
||||
* hotel wore two faces on one card, one where the day checks in and another where it
|
||||
* comes back to it.
|
||||
*/
|
||||
export const BOOKEND_DISC: CSSProperties = { background: serviceColor('hotel'), color: '#fff' } // theme-lint-disable: road-signage palette
|
||||
|
||||
/**
|
||||
* The line under it: on a check-out morning the latest the room is handed back, in the
|
||||
* reader's own clock format. A label, never the time the drive leaves at (#2357).
|
||||
* The badge under the hotel's name: which edge of the stay the day stands at, and the hour
|
||||
* that edge names, the latest the room is handed back or the earliest it is ready. Both are
|
||||
* labels, never the time the drive leaves or arrives at (#2357). A plan that sets out after
|
||||
* the check-out turns the badge itself to a warning rather than adding a line to the row.
|
||||
*/
|
||||
export function bookendMeta(reading: BookendReading, t: TranslationFn, is12h: boolean): string | null {
|
||||
return reading.until ? t('roadtrip.stay.until', { time: formatClockTime(reading.until, is12h) }) : null
|
||||
export interface BookendBadge {
|
||||
lead: string
|
||||
value: string | null
|
||||
warning: boolean
|
||||
hint: string | null
|
||||
}
|
||||
|
||||
export function bookendBadge(
|
||||
reading: BookendReading,
|
||||
entry: Pick<ScheduleEntry, 'arrival' | 'dayOffset'> | undefined,
|
||||
t: TranslationFn,
|
||||
is12h: boolean,
|
||||
): BookendBadge {
|
||||
const clock = reading.until ?? reading.from
|
||||
const value = clock ? formatClockTime(clock, is12h) : null
|
||||
const warning = leavesAfterCheckOut(reading, entry)
|
||||
return {
|
||||
lead: t(`roadtrip.bookend.${reading.variant}`),
|
||||
value,
|
||||
warning,
|
||||
hint: warning
|
||||
? t('roadtrip.bookend.afterCheckOut')
|
||||
: reading.until && value ? t('roadtrip.stay.until', { time: value }) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
||||
import type { BookendPhase, NightBookend, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
|
||||
// FE-RTROW-001 to FE-RTROW-066
|
||||
// FE-RTROW-001 to FE-RTROW-067
|
||||
|
||||
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
|
||||
return {
|
||||
@@ -231,14 +231,16 @@ describe('roadtripRows stop detail', () => {
|
||||
})
|
||||
|
||||
it('FE-RTROW-011: a stop shows only its own findings, and only the strongest of them', () => {
|
||||
// Where each finding really comes from: being late from the schedule, the drive's
|
||||
// limits from `driveWarnings`.
|
||||
const rows = stopRows(
|
||||
roadtripRows(
|
||||
day([stop('A'), stop('B')], {
|
||||
driveWarnings: [
|
||||
{ index: 0, code: 'overnight' },
|
||||
{ index: 1, code: 'leg', overMinutes: 20 },
|
||||
{ index: 1, code: 'late', minutes: 15 },
|
||||
],
|
||||
schedule: { entries: [entry(null), entry(null)], warnings: [{ index: 1, code: 'late', minutes: 15 }] },
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -246,6 +248,32 @@ describe('roadtripRows stop detail', () => {
|
||||
expect(rows[1].warning).toEqual({ index: 1, code: 'late', minutes: 15 })
|
||||
})
|
||||
|
||||
it('FE-RTROW-067: being late, or leaving after the time set, reaches the row from the schedule, a terminal\'s included', () => {
|
||||
const terminal = stop('HAM', {
|
||||
carrier: { reservationId: 78, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15' },
|
||||
})
|
||||
const rows = stopRows(
|
||||
roadtripRows(
|
||||
day([stop('A'), stop('B'), terminal], {
|
||||
schedule: {
|
||||
entries: [entry('09:00'), entry('10:00'), entry('14:15')],
|
||||
warnings: [
|
||||
{ index: 1, code: 'missedLeave', minutes: 20 },
|
||||
{ index: 2, code: 'late', minutes: 579 },
|
||||
// Only the lateness is read from the schedule, not the day it changes on.
|
||||
{ index: 0, code: 'overnight' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(rows.map(r => r.warning)).toEqual([
|
||||
null,
|
||||
{ index: 1, code: 'missedLeave', minutes: 20 },
|
||||
{ index: 2, code: 'late', minutes: 579 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-RTROW-012: dwell and the walk in from the road pass through, absent means null', () => {
|
||||
const rows = stopRows(roadtripRows(day([stop('A', { dwellMinutes: 90, offRoadMeters: 240 }), stop('B')])))
|
||||
expect([rows[0].dwellMinutes, rows[0].offRoadMeters]).toEqual([90, 240])
|
||||
@@ -746,6 +774,7 @@ describe('a booked night at the edge of the day', () => {
|
||||
variant: 'checkOut',
|
||||
name: 'Hotel Alpenblick',
|
||||
until: '10:00',
|
||||
from: null,
|
||||
reservationId: 41,
|
||||
accommodationId: 5,
|
||||
placeId: 900,
|
||||
@@ -756,7 +785,11 @@ describe('a booked night at the edge of the day', () => {
|
||||
const stayed = day([stop('Hotel Alpenblick', { lat: 45, lng: 7, night: true }), stop('Lookout'), hotel('evening', { checkingIn: true })])
|
||||
expect(bookendReading(stayed, 2)?.variant).toBe('back')
|
||||
const transfer = day([hotel('morning', { checkingOut: true }), hotel('evening', { checkingIn: true }, { name: 'Wallinga', lat: 46 })])
|
||||
expect(bookendReading(transfer, 1)).toMatchObject({ variant: 'checkIn', name: 'Wallinga' })
|
||||
expect(bookendReading(transfer, 1)).toMatchObject({ variant: 'checkIn', name: 'Wallinga', from: null })
|
||||
// The hour the room is ready rides along on a check-in, as a label and nowhere else.
|
||||
const ready = day([hotel('morning', { checkingOut: true }), hotel('evening', { checkingIn: true, checkIn: '15:00' }, { name: 'Wallinga', lat: 46 })])
|
||||
expect(bookendReading(ready, 1)).toMatchObject({ variant: 'checkIn', from: '15:00' })
|
||||
expect(bookendReading(ready, 0)?.from).toBeNull()
|
||||
// Nothing to read on an ordinary stop, or past the end.
|
||||
expect(bookendReading(loop(), 1)).toBeNull()
|
||||
expect(bookendReading(loop(), 9)).toBeNull()
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface BookendReading {
|
||||
name: string
|
||||
/** The latest the room is handed back, on the check-out morning. A label, never a departure. */
|
||||
until: string | null
|
||||
/** From when the room is ready, on a check-in evening. A label, never an arrival. */
|
||||
from: string | null
|
||||
/** The booking a tap opens, or null for a stay entered without one. */
|
||||
reservationId: number | null
|
||||
accommodationId: number
|
||||
@@ -92,6 +94,7 @@ export function bookendReading(day: Pick<RoadtripDay, 'stops'>, index: number):
|
||||
variant: morning ? (bookend.checkingOut ? 'checkOut' : 'from') : evening,
|
||||
name: stop.name,
|
||||
until: morning && bookend.checkingOut ? bookend.checkOut : null,
|
||||
from: evening === 'checkIn' ? (bookend.checkIn ?? null) : null,
|
||||
reservationId: bookend.reservationId,
|
||||
accommodationId: bookend.accommodationId,
|
||||
placeId: stop.placeId,
|
||||
@@ -158,7 +161,13 @@ function stopRow(day: RoadtripDay, index: number, number: number | null): StopRo
|
||||
// for "the day ended here" and `roadtripRows` turns it into an 'auto' row instead.
|
||||
const stop = day.stops[index]
|
||||
const entry = day.schedule.entries[index]
|
||||
const mine = day.driveWarnings.filter(w => w.index === index)
|
||||
// Being late comes from the schedule and the drive's own findings from the limits: two
|
||||
// lists, and a row reading only the second never showed a stop reached late, or a ride
|
||||
// the drive gets to after it has left.
|
||||
const mine = [
|
||||
...day.driveWarnings,
|
||||
...day.schedule.warnings.filter(w => w.code === 'late' || w.code === 'missedLeave'),
|
||||
].filter(w => w.index === index)
|
||||
return {
|
||||
kind: 'stop',
|
||||
stop,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AlertTriangle, Bike, CarFront, Footprints, Fuel, Hourglass, Moon, Pin, Plus, RotateCcw, Shuffle, Sunrise, X, Zap } from 'lucide-react'
|
||||
import { AlertTriangle, Bike, CarFront, Clock, Footprints, Fuel, Hourglass, Moon, Pin, Plus, RotateCcw, Shuffle, Sunrise, X, Zap } from 'lucide-react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
|
||||
import { bookingOpens, carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
|
||||
import { BOOKEND_ICON, bookendMeta, bookendTitle, leavesAfterCheckOut } from '../../../../components/Roadtrip/nightBookend'
|
||||
import { bookingOpens, carrierIcon, rideReading, terminalReading, type CheckInReading, type RideEnd } from '../../../../components/Roadtrip/carrierRide'
|
||||
import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge } from '../../../../components/Roadtrip/nightBookend'
|
||||
import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings'
|
||||
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
|
||||
import { formatDistance } from '../../../../utils/units'
|
||||
@@ -98,9 +98,23 @@ function Disc({ row, t, onPickKind }: { row: StopRow; t: TranslationFn; onPickKi
|
||||
)
|
||||
}
|
||||
|
||||
function Mark({ icon, children, tone }: { icon: ReactNode; children: ReactNode; tone?: 'warn' }) {
|
||||
/**
|
||||
* A small pill: an icon and a word or figure. With `value` the figure sits in a pill of its
|
||||
* own inside it, the phone's badge in a badge, and `label` is the sentence a screen reader
|
||||
* hears in place of the two, since a row that opens a booking sheet has no other place to
|
||||
* say it.
|
||||
*/
|
||||
function Mark({ icon, children, tone, value, label }: {
|
||||
icon: ReactNode
|
||||
children: ReactNode
|
||||
tone?: 'warn'
|
||||
value?: ReactNode
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
role={label ? 'img' : undefined}
|
||||
aria-label={label}
|
||||
className="inline-flex h-[22px] items-center gap-[4px] rounded-full px-[8px] font-geist text-[0.65625rem] font-semibold"
|
||||
style={tone === 'warn'
|
||||
? { background: 'color-mix(in srgb, var(--m-st-pending) 14%, transparent)', color: 'var(--m-st-pending)' }
|
||||
@@ -108,6 +122,7 @@ function Mark({ icon, children, tone }: { icon: ReactNode; children: ReactNode;
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
{value !== undefined && <span className="ms-[2px] rounded-full bg-[color:var(--m-card)] px-[6px] tabular-nums">{value}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -115,8 +130,8 @@ function Mark({ icon, children, tone }: { icon: ReactNode; children: ReactNode;
|
||||
/** The short form of a finding: the number only, the sentence waits in the sheet. */
|
||||
function warningMark(warning: ScheduleWarning, chrome: RowChrome): ReactNode {
|
||||
const { t, unit } = chrome
|
||||
if (warning.code === 'late') {
|
||||
return <Mark tone="warn" icon={<AlertTriangle size={10} strokeWidth={2} />}>{`+${warning.minutes ?? 0} min`}</Mark>
|
||||
if (warning.code === 'late' || warning.code === 'missedLeave') {
|
||||
return <Mark tone="warn" icon={<AlertTriangle size={10} strokeWidth={2} />}>{`+${formatDurationShort((warning.minutes ?? 0) * 60)}`}</Mark>
|
||||
}
|
||||
if (warning.code === 'range') {
|
||||
return <Mark tone="warn" icon={<Fuel size={10} strokeWidth={2} />}>{formatDistance(warning.sinceKm ?? 0, unit)}</Mark>
|
||||
@@ -155,15 +170,19 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: {
|
||||
onPickKind?: () => void
|
||||
}) {
|
||||
const { t, unit } = chrome
|
||||
if (row.stop.carrier) return <RtTerminalRow row={row} chrome={chrome} onOpen={onOpen} />
|
||||
const marks: ReactNode[] = []
|
||||
if (row.stop.night && row.stop.checkInTime) {
|
||||
marks.push(
|
||||
<Mark key="checkin" icon={null} value={formatClockTime(row.stop.checkInTime, chrome.is12h)}>{t('roadtrip.bookend.checkIn')}</Mark>,
|
||||
)
|
||||
}
|
||||
if (row.dwellMinutes) {
|
||||
marks.push(
|
||||
<Mark key="dwell" icon={<Hourglass size={10} strokeWidth={2} />}>{formatDurationShort(row.dwellMinutes * 60)}</Mark>,
|
||||
)
|
||||
}
|
||||
if (row.warning) marks.push(<span key="warn">{warningMark(row.warning, chrome)}</span>)
|
||||
const terminal = row.stop.carrier ? terminalLine(row.stop.carrier, t, chrome.is12h) : null
|
||||
if (terminal) marks.push(<span key="terminal" className="font-geist text-[0.65625rem] font-medium text-m-muted">{terminal}</span>)
|
||||
if (!marks.length && row.offRoadMeters) {
|
||||
marks.push(
|
||||
<Mark key="off" icon={<Footprints size={10} strokeWidth={2} />}>
|
||||
@@ -194,10 +213,10 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: {
|
||||
|
||||
/**
|
||||
* A booked night at the edge of the stage: the hotel the day sets out from, or the one it
|
||||
* ends at. Laid out like a stop, on the bed a stay wears, with no number and no kind to
|
||||
* pick: it is the stay's place, not a stop of the day. Under it the latest hour the room
|
||||
* is handed back, on the morning it is, and the one finding a row has room for. The whole
|
||||
* row opens the booking behind the night, or the stay.
|
||||
* ends at. Laid out like a stop, on the hotel stop's own disc with the bed a stay wears,
|
||||
* with no number and no kind to pick: it is the stay's place, not a stop of the day. Under
|
||||
* it the latest hour the room is handed back, on the morning it is, and the one finding a
|
||||
* row has room for. The whole row opens the booking behind the night, or the stay.
|
||||
*/
|
||||
export function RtBookendRow({ row, bookend, chrome, onOpen }: {
|
||||
row: StopRow
|
||||
@@ -205,25 +224,27 @@ export function RtBookendRow({ row, bookend, chrome, onOpen }: {
|
||||
chrome: RowChrome
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const { t } = chrome
|
||||
const meta = bookendMeta(bookend, t, chrome.is12h)
|
||||
const afterCheckOut = leavesAfterCheckOut(bookend, row.entry)
|
||||
const badge = bookendBadge(bookend, row.entry, chrome.t, chrome.is12h)
|
||||
return (
|
||||
<TapRow onOpen={onOpen}>
|
||||
<span className="flex justify-center">
|
||||
<span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
|
||||
<span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full" style={BOOKEND_DISC}>
|
||||
<BOOKEND_ICON size={15} strokeWidth={2.1} aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 py-2">
|
||||
<span className="line-clamp-2 block text-[0.875rem] font-semibold leading-[1.25] text-m-ink">{bookendTitle(bookend, t)}</span>
|
||||
{(meta || afterCheckOut || row.warning) && (
|
||||
<span className="mt-[4px] flex flex-wrap items-center gap-[5px]">
|
||||
{meta && <span className="font-geist text-[0.65625rem] font-medium text-m-muted">{meta}</span>}
|
||||
{afterCheckOut && <Mark tone="warn" icon={<AlertTriangle size={10} strokeWidth={2} />}>{t('roadtrip.bookend.afterCheckOut')}</Mark>}
|
||||
{row.warning && warningMark(row.warning, chrome)}
|
||||
</span>
|
||||
)}
|
||||
<span className="line-clamp-2 block text-[0.875rem] font-semibold leading-[1.25] text-m-ink">{bookend.name}</span>
|
||||
<span className="mt-[4px] flex flex-wrap items-center gap-[5px]">
|
||||
<Mark
|
||||
tone={badge.warning ? 'warn' : undefined}
|
||||
icon={badge.warning ? <AlertTriangle size={10} strokeWidth={2} /> : null}
|
||||
value={badge.value ?? undefined}
|
||||
label={badge.warning ? [badge.lead, badge.value, badge.hint].filter(Boolean).join(' ') : undefined}
|
||||
>
|
||||
{badge.lead}
|
||||
</Mark>
|
||||
{row.warning && warningMark(row.warning, chrome)}
|
||||
</span>
|
||||
</span>
|
||||
{row.time && (
|
||||
<span dir="ltr" className="whitespace-nowrap text-[0.8125rem] font-medium tabular-nums text-m-faint">
|
||||
@@ -239,34 +260,95 @@ const LEG_ICONS: Record<string, typeof CarFront> = {
|
||||
cycling: Bike,
|
||||
}
|
||||
|
||||
/** One end of a ride inside its block: name, code, the timetable line, the clock. */
|
||||
function RideEnd({ row, chrome }: { row: StopRow; chrome: RowChrome }) {
|
||||
const carrier = row.stop.carrier!
|
||||
const line = terminalLine(carrier, chrome.t, chrome.is12h)
|
||||
/** The ride between two terminals, drawn in the dash the chain's legs wear, laid on its side. */
|
||||
const RIDE_CONN: CSSProperties = {
|
||||
backgroundImage: 'repeating-linear-gradient(90deg, var(--m-conn) 0 4px, transparent 4px 8px)',
|
||||
}
|
||||
|
||||
/** A terminal's code, or the name of one without it. On the card it takes the chain's own ground. */
|
||||
function RtChip({ text, onCard }: { text: string; onCard?: boolean }) {
|
||||
return (
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-[6px]">
|
||||
<span className="line-clamp-2 text-[0.875rem] font-semibold leading-[1.25] text-m-ink">{row.stop.name}</span>
|
||||
{carrier.code && <span className="flex-none font-geist text-[0.65625rem] font-medium text-m-faint">{carrier.code}</span>}
|
||||
</span>
|
||||
{line && <span className="mt-[2px] block font-geist text-[0.65625rem] font-medium text-m-muted">{line}</span>}
|
||||
</span>
|
||||
{row.time && (
|
||||
<span dir="ltr" className={`whitespace-nowrap text-[0.8125rem] tabular-nums ${row.pinned ? 'font-semibold text-m-ink' : 'font-medium text-m-faint'}`}>
|
||||
{formatClockTime(row.time, chrome.is12h)}
|
||||
</span>
|
||||
)}
|
||||
<span className={`block max-w-[7.5rem] flex-none truncate rounded-full px-[7px] py-px font-geist text-[0.6875rem] font-semibold text-m-ink ${
|
||||
onCard ? 'bg-[color:var(--m-ic)]' : 'bg-[color:var(--m-card)]'
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The timetable's clock at one end, with the day it falls on and its role said to a screen reader. */
|
||||
function RtClock({ end, t }: { end: RideEnd; t: TranslationFn }) {
|
||||
if (!end.clock) return null
|
||||
return (
|
||||
<span dir="ltr" className="flex-none whitespace-nowrap text-[0.8125rem] font-semibold tabular-nums text-m-ink">
|
||||
<span aria-hidden="true">
|
||||
{end.clock}
|
||||
{end.dayOffset > 0 && <span className="ms-px text-[0.6875rem]">{`+${end.dayOffset}`}</span>}
|
||||
</span>
|
||||
<span className="sr-only">{end.dayOffset > 0 ? `${end.label} ${t('roadtrip.warn.overnight')}` : end.label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The check-in as one mark with the hour, or the lateness, in a pill inside it; see `carrierRide`. */
|
||||
function CheckInMark({ checkIn }: { checkIn: CheckInReading }) {
|
||||
const warning = checkIn.state !== 'ok'
|
||||
return (
|
||||
<Mark
|
||||
tone={warning ? 'warn' : undefined}
|
||||
icon={warning ? <AlertTriangle size={10} strokeWidth={2} /> : null}
|
||||
value={checkIn.value}
|
||||
label={checkIn.hint}
|
||||
>
|
||||
{checkIn.lead}
|
||||
</Mark>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A ride that leaves and lands on the day, as one block in the chain (#2428): the
|
||||
* booking and its minutes at the top, the terminal it leaves from, the terminal it lands
|
||||
* at. One block on one disc, because it is one thing, and three rows read as three
|
||||
* places the day went to. The block is the tap target and opens the booking, the same
|
||||
* sheet the map's endpoint badge opens.
|
||||
* A terminal whose ride lands on another day, or a hire car's desk: the code and the place,
|
||||
* the timetable's clock, and on a departure the check-in, which carries the lateness the
|
||||
* row's own mark would otherwise repeat. A desk says which one it is. The tap opens the
|
||||
* booking, as the rail's row does.
|
||||
*/
|
||||
function RtTerminalRow({ row, chrome, onOpen }: { row: StopRow; chrome: RowChrome; onOpen: () => void }) {
|
||||
const { t, is12h } = chrome
|
||||
const { end, desk, checkIn } = terminalReading(row.stop, row.entry, row.warning ? [row.warning] : [], t, is12h)
|
||||
const warning = checkIn && row.warning?.code === 'late' ? null : row.warning
|
||||
return (
|
||||
<TapRow onOpen={onOpen}>
|
||||
<span className="flex justify-center"><Disc row={row} t={t} /></span>
|
||||
<span className="min-w-0 py-2">
|
||||
<span className="flex min-w-0 items-center gap-[6px]">
|
||||
{end.code && <RtChip text={end.code} onCard />}
|
||||
<span className="line-clamp-2 min-w-0 text-[0.875rem] font-semibold leading-[1.25] text-m-ink">{end.place}</span>
|
||||
</span>
|
||||
{(desk || checkIn || warning) && (
|
||||
<span className="mt-[4px] flex flex-wrap items-center gap-[5px]">
|
||||
{desk && <span className="font-geist text-[0.65625rem] font-medium text-m-muted">{desk}</span>}
|
||||
{checkIn && <CheckInMark checkIn={checkIn} />}
|
||||
{warning && warningMark(warning, chrome)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{end.clock ? <RtClock end={end} t={t} /> : row.time && (
|
||||
<span dir="ltr" className="whitespace-nowrap text-[0.8125rem] font-medium tabular-nums text-m-faint">
|
||||
{formatClockTime(row.time, is12h)}
|
||||
</span>
|
||||
)}
|
||||
</TapRow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A ride that leaves and lands on the day, as one block in the chain (#2428), in the same
|
||||
* four lines the rail draws: the booking with its minutes, the two codes with their
|
||||
* timetable clocks and the ride between them, the places, and the check-in carrying
|
||||
* whatever the drive makes of it. One block on one disc, because it is one thing, and
|
||||
* three rows read as three places the day went to. A ride the drive cannot catch is
|
||||
* edged in the warning colour. The block is the tap target and opens the booking, the
|
||||
* same sheet the map's endpoint badge opens.
|
||||
*/
|
||||
export function RtRideRow({ row, chrome, onOpen }: {
|
||||
row: Extract<RoadtripRow, { kind: 'ride' }>
|
||||
@@ -274,17 +356,40 @@ export function RtRideRow({ row, chrome, onOpen }: {
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const Icon = carrierIcon(row.carrier.type)
|
||||
const ride = rideReading({
|
||||
departure: row.departure.stop,
|
||||
arrival: row.arrival.stop,
|
||||
entries: [row.departure.entry, row.arrival.entry],
|
||||
warnings: row.departure.warning ? [row.departure.warning] : [],
|
||||
seg: row.seg,
|
||||
}, chrome.t, chrome.is12h)
|
||||
const body = (
|
||||
<>
|
||||
<span className="flex items-center gap-[6px] text-m-muted">
|
||||
<Icon size={12} strokeWidth={2} className="flex-none" aria-hidden="true" />
|
||||
<span className="truncate font-geist text-[0.65625rem] font-semibold uppercase tracking-[0.06em]">{rideText(row.carrier, row.seg)}</span>
|
||||
<span className="min-w-0 truncate text-[0.75rem] font-semibold text-m-ink">{ride.title}</span>
|
||||
<span className="flex min-w-0 items-center gap-[6px]">
|
||||
<RtChip text={ride.from.chip} />
|
||||
<RtClock end={ride.from} t={chrome.t} />
|
||||
<span className="h-[2px] min-w-[12px] flex-1" style={RIDE_CONN} aria-hidden="true" />
|
||||
<RtClock end={ride.to} t={chrome.t} />
|
||||
<RtChip text={ride.to.chip} />
|
||||
</span>
|
||||
<RideEnd row={row.departure} chrome={chrome} />
|
||||
<RideEnd row={row.arrival} chrome={chrome} />
|
||||
{(ride.from.placeLine || ride.to.placeLine) && (
|
||||
<span className="flex min-w-0 justify-between gap-2 font-geist text-[0.65625rem] text-m-muted">
|
||||
<span className="min-w-0 truncate">{ride.from.placeLine}</span>
|
||||
<span className="min-w-0 truncate text-end">{ride.to.placeLine}</span>
|
||||
</span>
|
||||
)}
|
||||
{(ride.checkIn || ride.duration) && (
|
||||
<span className="flex flex-wrap items-center gap-[5px]">
|
||||
{ride.checkIn && <CheckInMark checkIn={ride.checkIn} />}
|
||||
{ride.duration && <Mark icon={<Clock size={10} strokeWidth={2} />}>{ride.duration}</Mark>}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
const cls = 'my-1 flex min-w-0 flex-col gap-[7px] rounded-[16px] bg-[color:var(--m-ic)] px-[12px] py-[9px] text-start'
|
||||
const cls = `my-1 flex min-w-0 flex-col gap-[7px] rounded-[16px] border bg-[color:var(--m-ic)] px-[11px] py-[8px] text-start ${
|
||||
ride.checkIn?.state === 'missed' ? 'border-[color:var(--m-st-pending)]' : 'border-transparent'
|
||||
}`
|
||||
return (
|
||||
<div className="grid items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr' }}>
|
||||
<span className="flex justify-center">
|
||||
|
||||
@@ -4,7 +4,7 @@ import MSheet from '../../../components/MSheet'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { formatDate, resolveDayId, splitReservationDateTime } from '../../../../utils/formatters'
|
||||
import { orderedEndpoints, parseReservationMetadata } from '../../../../utils/flightLegs'
|
||||
import { orderedEndpoints, parseReservationMetadata, stripAirportCode } from '../../../../utils/flightLegs'
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect'
|
||||
import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
|
||||
@@ -56,13 +56,6 @@ function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence:
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return { role, sequence, name: l.name, code: null, lat: l.lat, lng: l.lng, timezone: null, local_date: date, local_time: time }
|
||||
}
|
||||
// "Paris Charles de Gaulle (CDG)" → "Paris Charles de Gaulle", the same trim-plus-
|
||||
// anchored-test the desktop TransportModal uses instead of /\s*\([A-Z]{3}\)\s*$/,
|
||||
// because that leading \s* backtracks over every space in a long name.
|
||||
function stripAirportCode(name: string): string {
|
||||
const trimmed = name.trimEnd()
|
||||
return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name
|
||||
}
|
||||
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
|
||||
if (!e || !e.code) return null
|
||||
return { iata: e.code, icao: null, name: e.name, city: stripAirportCode(e.name), country: '', lat: e.lat, lng: e.lng, tz: e.timezone || '' }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFlightLegs, getTrainLegs, isMultiLegTrain } from './flightLegs'
|
||||
import { getFlightLegs, getTrainLegs, isMultiLegTrain, stripAirportCode } from './flightLegs'
|
||||
import type { Reservation } from '../types'
|
||||
|
||||
function res(partial: Partial<Reservation>): Reservation {
|
||||
@@ -96,3 +96,14 @@ describe('per-segment booking references (#1943)', () => {
|
||||
expect(getTrainLegs(train)[0].confirmation_number).toBe('BOOK2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripAirportCode', () => {
|
||||
it('drops the code the forms and the airport backfill append, and nothing else', () => {
|
||||
expect(stripAirportCode('Paris Charles de Gaulle (CDG)')).toBe('Paris Charles de Gaulle')
|
||||
expect(stripAirportCode('Hamburg (HAM) ')).toBe('Hamburg')
|
||||
// A station has no such suffix, and a bracket that is not a code stays.
|
||||
expect(stripAirportCode('Hamburg Hbf')).toBe('Hamburg Hbf')
|
||||
expect(stripAirportCode('Frankfurt (Main)')).toBe('Frankfurt (Main)')
|
||||
expect(stripAirportCode('Munich (muc)')).toBe('Munich (muc)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,6 +48,17 @@ export function orderedEndpoints(r: Pick<Reservation, 'endpoints'>): Reservation
|
||||
return (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* An airport endpoint's name without the code every writer appends to it:
|
||||
* "Paris Charles de Gaulle (CDG)" → "Paris Charles de Gaulle". Done as a trim plus an
|
||||
* anchored test instead of /\s*\([A-Z]{3}\)\s*$/, because the leading \s* backtracks over
|
||||
* every space in a long name for a quadratic worst case.
|
||||
*/
|
||||
export function stripAirportCode(name: string): string {
|
||||
const trimmed = name.trimEnd()
|
||||
return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered legs of a flight. `metadata.legs` is preferred; otherwise a single leg
|
||||
* is derived from the endpoints (and finally the flat metadata) so that legacy
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import { fireEvent, render, screen, within } from '../../../helpers/render'
|
||||
import {
|
||||
RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome,
|
||||
} from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripRows'
|
||||
@@ -9,7 +9,7 @@ import type { Reservation, TranslationFn } from '../../../../src/types'
|
||||
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
|
||||
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
|
||||
|
||||
// FE-MOB-RTROW-001 to FE-MOB-RTROW-059
|
||||
// FE-MOB-RTROW-001 to FE-MOB-RTROW-065
|
||||
|
||||
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
|
||||
const t: TranslationFn = (key, params) =>
|
||||
@@ -46,6 +46,16 @@ const SEG: RouteSegment = {
|
||||
}
|
||||
|
||||
describe('RtStopRow', () => {
|
||||
it('FE-MOB-RTROW-065: the own stop of a stay on its check-in day carries the check-in hour, other stops do not', () => {
|
||||
const hotel = { ...stopRow().stop, name: 'Gasthof Post', stopType: 'hotel', night: true, checkInTime: '15:00' }
|
||||
const first = render(<RtStopRow row={stopRow({ stop: hotel, service: true, number: null })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
expect(screen.getByText('roadtrip.bookend.checkIn').parentElement).toHaveTextContent('15:00')
|
||||
first.unmount()
|
||||
|
||||
render(<RtStopRow row={stopRow()} chrome={chrome} onOpen={vi.fn()} />)
|
||||
expect(screen.queryByText('roadtrip.bookend.checkIn')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-001: gives a destination its number and no kind icon', () => {
|
||||
const { container } = render(<RtStopRow row={stopRow()} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
@@ -230,6 +240,18 @@ describe('RtStopRow', () => {
|
||||
expect(screen.getByText('14:05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-064: lateness reads in hours and minutes, and leaving after the time set is marked the same way', () => {
|
||||
const late = render(<RtStopRow row={stopRow({ warning: { index: 1, code: 'late', minutes: 579 } })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
expect(screen.getByText('+9 h 39 min')).toBeInTheDocument()
|
||||
late.unmount()
|
||||
|
||||
const { container } = render(<RtStopRow row={stopRow({ warning: { index: 1, code: 'missedLeave', minutes: 20 } })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
expect(screen.getByText('+20 min')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-alert-triangle')).not.toBeNull()
|
||||
// It used to fall through to the night's word.
|
||||
expect(screen.queryByText('roadtrip.warn.overnight')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-013: writes an overnight warning as a word, since it has no figure', () => {
|
||||
const warning: ScheduleWarning = { index: 1, code: 'overnight' }
|
||||
const { container } = render(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
@@ -248,7 +270,10 @@ describe('RtStopRow', () => {
|
||||
const { container } = render(<RtStopRow row={desk} chrome={chrome} onOpen={vi.fn()} onPickKind={onPickKind} />)
|
||||
|
||||
expect(screen.getByText('Sixt Hauptbahnhof')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.ride.pickup:09:00')).toBeInTheDocument()
|
||||
// Which desk it is under the name, and the booking's clock once, on the right.
|
||||
expect(screen.getByText('reservations.span.pickup')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('09:00')).toHaveLength(1)
|
||||
expect(screen.getByText('roadtrip.ride.pickup:09:00')).toHaveClass('sr-only')
|
||||
expect(screen.queryByText('2')).toBeNull()
|
||||
// The booking's own icon on the disc, and the disc is not a control even for an
|
||||
// editor: nobody turns a rental desk into a petrol station.
|
||||
@@ -275,35 +300,49 @@ function carrier(role: CarrierTerminal['role'], over: Partial<CarrierTerminal> =
|
||||
|
||||
const FLIGHT_SEG = { ...SEG, distance: 0, duration: 4200, distanceText: '', durationText: '1 h 10 min' }
|
||||
|
||||
function rideRow(over: Partial<Extract<RoadtripRow, { kind: 'ride' }>> = {}): Extract<RoadtripRow, { kind: 'ride' }> {
|
||||
const end = (role: 'departure' | 'arrival'): StopRow => stopRow({
|
||||
number: null,
|
||||
/**
|
||||
* One end of LH 2020 as its row carries it: the departure pinned an hour ahead of the
|
||||
* timetable for the check-in, and `late` minutes past that pin when the drive gets there late.
|
||||
*/
|
||||
const rideEnd = (role: 'departure' | 'arrival', late?: number): StopRow => stopRow({
|
||||
number: null,
|
||||
time: role === 'departure' ? '12:20' : '14:30',
|
||||
pinned: true,
|
||||
warning: late ? { index: 1, code: 'late', minutes: late } : null,
|
||||
stop: {
|
||||
...stopRow().stop,
|
||||
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
|
||||
placeId: -70,
|
||||
name: role === 'departure' ? 'Hamburg Airport' : 'Munich Airport',
|
||||
time: role === 'departure' ? '12:20' : '14:30',
|
||||
pinned: true,
|
||||
stop: {
|
||||
...stopRow().stop,
|
||||
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
|
||||
placeId: -70,
|
||||
name: role === 'departure' ? 'Hamburg Airport' : 'Munich Airport',
|
||||
carrier: carrier(role),
|
||||
},
|
||||
})
|
||||
return { kind: 'ride', index: 1, carrier: carrier('departure'), seg: FLIGHT_SEG, departure: end('departure'), arrival: end('arrival'), ...over }
|
||||
carrier: carrier(role),
|
||||
},
|
||||
})
|
||||
|
||||
function rideRow(over: Partial<Extract<RoadtripRow, { kind: 'ride' }>> = {}, late?: number): Extract<RoadtripRow, { kind: 'ride' }> {
|
||||
return { kind: 'ride', index: 1, carrier: carrier('departure'), seg: FLIGHT_SEG, departure: rideEnd('departure', late), arrival: rideEnd('arrival'), ...over }
|
||||
}
|
||||
|
||||
describe('RtRideRow', () => {
|
||||
it('FE-MOB-RTROW-051: a ride is one block: the booking and its minutes over both terminals, each with its code, its timetable line and its clock', () => {
|
||||
it('FE-MOB-RTROW-051: a ride is one block in four lines: the booking and its minutes, both codes with their timetable, the places, the check-in', () => {
|
||||
render(<RtRideRow row={rideRow()} chrome={chrome} />)
|
||||
|
||||
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('LH 2020')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 h 10 min')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/·/)).toBeNull()
|
||||
// Each code and each timetable clock once, the words for the clocks for a screen reader only.
|
||||
expect(screen.getAllByText('HAM')).toHaveLength(1)
|
||||
expect(screen.getAllByText('MUC')).toHaveLength(1)
|
||||
expect(screen.getAllByText('13:20')).toHaveLength(1)
|
||||
expect(screen.getAllByText('14:30')).toHaveLength(1)
|
||||
expect(screen.getByText('roadtrip.ride.departureFlight:13:20')).toHaveClass('sr-only')
|
||||
expect(screen.getByText('roadtrip.ride.arrival:14:30')).toHaveClass('sr-only')
|
||||
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.ride.departure:13:20')).toBeInTheDocument()
|
||||
expect(screen.getByText('12:20')).toBeInTheDocument()
|
||||
expect(screen.getByText('Munich Airport')).toBeInTheDocument()
|
||||
expect(screen.getByText('MUC')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.ride.arrival:14:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('14:30')).toBeInTheDocument()
|
||||
// The check-in, with its hour in the pill inside it and its sentence for a screen reader.
|
||||
const checkIn = screen.getByRole('img', { name: 'roadtrip.ride.hintFlight:LH 2020,13:20,12:20' })
|
||||
expect(checkIn).toHaveTextContent('roadtrip.ride.checkIn')
|
||||
expect(within(checkIn).getByText('12:20')).toBeInTheDocument()
|
||||
// The terminals take no number: Hamburg is 1 and Munich is 2 either side of them.
|
||||
expect(screen.queryByText('2')).toBeNull()
|
||||
})
|
||||
@@ -315,7 +354,7 @@ describe('RtRideRow', () => {
|
||||
// One control for the whole ride, not one per terminal.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
fireEvent.click(screen.getByText('Hamburg Airport'))
|
||||
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
|
||||
fireEvent.click(screen.getByText('LH 2020'))
|
||||
expect(onOpen).toHaveBeenCalledTimes(2)
|
||||
opens.unmount()
|
||||
|
||||
@@ -328,11 +367,50 @@ describe('RtRideRow', () => {
|
||||
render(<RtRideRow row={rideRow({ seg: undefined })} chrome={{ ...chrome, is12h: true }} />)
|
||||
|
||||
expect(screen.getByText('LH 2020')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/LH 2020 ·/)).toBeNull()
|
||||
expect(screen.getByText('roadtrip.ride.departure:1:20 PM')).toBeInTheDocument()
|
||||
expect(screen.queryByText('1 h 10 min')).toBeNull()
|
||||
expect(screen.getByText('1:20 PM')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.ride.departureFlight:1:20 PM')).toBeInTheDocument()
|
||||
expect(screen.getByText('12:20 PM')).toBeInTheDocument()
|
||||
expect(screen.queryByText('13:20')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-060: a flight the drive reaches after take-off is missed, says when the drive gets there, and edges the block', () => {
|
||||
render(<RtRideRow row={rideRow({}, 579)} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
const mark = screen.getByRole('img', { name: 'roadtrip.ride.lateHintFlight:LH 2020,13:20,12:20,Hamburg Airport,21:59' })
|
||||
expect(mark).toHaveTextContent('roadtrip.ride.missed')
|
||||
expect(within(mark).getByText('roadtrip.ride.reachedAt:21:59')).toBeInTheDocument()
|
||||
expect(mark.querySelector('.lucide-alert-triangle')).not.toBeNull()
|
||||
expect(screen.getByRole('button').className).toContain('border-[color:var(--m-st-pending)]')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-061: late for the check-in but in time for the flight says by how much, on the check-in word', () => {
|
||||
const { container } = render(<RtRideRow row={rideRow({}, 25)} chrome={chrome} />)
|
||||
|
||||
const mark = screen.getByRole('img', { name: /^roadtrip\.ride\.lateHintFlight:/ })
|
||||
expect(mark).toHaveTextContent('roadtrip.ride.checkIn')
|
||||
expect(within(mark).getByText('roadtrip.ride.lateBy:25 min')).toBeInTheDocument()
|
||||
expect(container.querySelector('.border-transparent')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-062: a departure on a row of its own reads the block\'s pieces, and its lateness once, on the check-in', () => {
|
||||
const row = stopRow({
|
||||
number: null,
|
||||
time: '12:20',
|
||||
pinned: true,
|
||||
warning: { index: 1, code: 'late', minutes: 579 },
|
||||
stop: { ...stopRow().stop, assignmentId: -3000000140, placeId: -70, name: 'Hamburg (HAM)', time: '12:20', carrier: carrier('departure') },
|
||||
})
|
||||
render(<RtStopRow row={row} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hamburg')).toBeInTheDocument()
|
||||
// The timetable on the right, not the pin with a glyph calling it a time somebody set.
|
||||
expect(screen.getByText('13:20')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('roadtrip.stop.pinned')).toBeNull()
|
||||
expect(screen.getByRole('img', { name: /^roadtrip\.ride\.lateHintFlight:/ })).toHaveTextContent('roadtrip.ride.missed')
|
||||
expect(screen.queryByText('+9 h 39 min')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RtBookingChips', () => {
|
||||
@@ -808,6 +886,7 @@ describe('RtBookendRow', () => {
|
||||
variant: 'checkOut',
|
||||
name: 'Hotel Alpenblick',
|
||||
until: '10:00',
|
||||
from: null,
|
||||
reservationId: 41,
|
||||
accommodationId: 5,
|
||||
placeId: 900,
|
||||
@@ -820,8 +899,10 @@ describe('RtBookendRow', () => {
|
||||
const onOpen = vi.fn()
|
||||
const { container } = render(<RtBookendRow row={row()} bookend={reading()} chrome={chrome} onOpen={onOpen} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.stay.until:10:00')).toBeInTheDocument()
|
||||
// The hotel is the name; the edge of the stay and its hour sit in one badge under it.
|
||||
expect(screen.getByText('Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.bookend.checkOut').parentElement).toHaveTextContent('10:00')
|
||||
expect(screen.queryByText(/·/)).toBeNull()
|
||||
expect(screen.getByText('08:40')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-bed-double')).not.toBeNull()
|
||||
expect(screen.queryByText('2')).toBeNull()
|
||||
@@ -833,8 +914,9 @@ describe('RtBookendRow', () => {
|
||||
it('FE-MOB-RTROW-058: an evening says nothing under its name but what the drive into it ran over', () => {
|
||||
const warning: ScheduleWarning = { index: 3, code: 'leg', overMinutes: 30 }
|
||||
const quiet = render(<RtBookendRow row={row({ time: null })} bookend={reading({ phase: 'evening', variant: 'back', until: null })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
expect(screen.getByText('roadtrip.bookend.back:Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/roadtrip\.stay\.until/)).toBeNull()
|
||||
expect(screen.getByText('Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.bookend.back')).toBeInTheDocument()
|
||||
expect(screen.queryByText('10:00')).toBeNull()
|
||||
expect(screen.queryByText('08:40')).toBeNull()
|
||||
quiet.unmount()
|
||||
|
||||
@@ -843,14 +925,30 @@ describe('RtBookendRow', () => {
|
||||
expect(screen.getByText('7:10 PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-063: the hotel wears the hotel stop\'s own disc, not a terminal\'s', () => {
|
||||
const edge = render(<RtBookendRow row={row()} bookend={reading()} chrome={chrome} onOpen={vi.fn()} />)
|
||||
const bookendDisc = edge.container.querySelector('.lucide-bed-double')!.parentElement as HTMLElement
|
||||
const bookendFace = [bookendDisc.style.background, bookendDisc.style.color]
|
||||
edge.unmount()
|
||||
|
||||
const stay = render(<RtStopRow row={row({ number: null, service: true })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
const stopDisc = stay.container.querySelector('.lucide-bed-double')!.parentElement as HTMLElement
|
||||
expect(bookendFace[0]).not.toBe('')
|
||||
expect(bookendFace[0]).toBe(stopDisc.style.background)
|
||||
// White on it, as the stop's bed is.
|
||||
expect(bookendFace[1]).toBe('rgb(255, 255, 255)')
|
||||
expect(stopDisc).toHaveClass('text-white')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-059: a check-out morning the drive leaves after the room is handed back is marked', () => {
|
||||
const entry = (arrival: string) => ({ arrival, departure: arrival, anchored: false, dayOffset: 0 })
|
||||
const late = render(<RtBookendRow row={row({ time: '12:27', entry: entry('12:27') })} bookend={reading()} chrome={chrome} onOpen={vi.fn()} />)
|
||||
expect(screen.getByText('roadtrip.stay.until:10:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.bookend.afterCheckOut')).toBeInTheDocument()
|
||||
// The check-out badge itself turns and says why, instead of a second mark beside it.
|
||||
expect(screen.getByLabelText('roadtrip.bookend.checkOut 10:00 roadtrip.bookend.afterCheckOut')).toBeInTheDocument()
|
||||
late.unmount()
|
||||
|
||||
render(<RtBookendRow row={row({ entry: entry('08:40') })} bookend={reading()} chrome={chrome} onOpen={vi.fn()} />)
|
||||
expect(screen.queryByText('roadtrip.bookend.afterCheckOut')).toBeNull()
|
||||
expect(screen.queryByLabelText(/roadtrip\.bookend\.afterCheckOut/)).toBeNull()
|
||||
expect(screen.getByText('roadtrip.bookend.checkOut')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { CarrierTerminal, RoadtripDay, RoadtripRoutes, RoadtripStop, RouteS
|
||||
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
|
||||
import { openLeg } from '../../../helpers/legAlternatives'
|
||||
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-065
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-066
|
||||
//
|
||||
// The stage bar pictures the place its day ends at. It reads that place out of the trip
|
||||
// store rather than the planner, the unfiltered list, so the picture tests seed the store.
|
||||
@@ -108,13 +108,13 @@ function stage(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
{ arrival: '12:40', departure: null, anchored: false, dayOffset: 0 },
|
||||
{ arrival: '22:00', departure: null, anchored: false, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
warnings: [{ index: 2, code: 'late', minutes: 25 }],
|
||||
},
|
||||
legVias: [[], [], []],
|
||||
geometry: [],
|
||||
distance: 412_000,
|
||||
duration: 18_000,
|
||||
driveWarnings: [{ index: 2, code: 'late', minutes: 25 }],
|
||||
driveWarnings: [],
|
||||
dayWarning: null,
|
||||
spills: [{ at: 0, count: 1, fromDayNumber: 1, departure: '23:10', leg: undefined }],
|
||||
dryPoints: [{ legIndex: 1, intoLegKm: 82, drivenMeters: 82_000, sinceKm: 520, lat: 35.2, lng: 137.5 }],
|
||||
@@ -843,15 +843,17 @@ describe('MRoadtripTab', () => {
|
||||
// The desk is unnumbered and its disc is no control: only the two real stops
|
||||
// offer to become a stop on the way, and the count says two.
|
||||
expect(screen.getAllByRole('button', { name: 'roadtrip.stop.makeService' })).toHaveLength(2)
|
||||
expect(screen.getByText('roadtrip.ride.pickup:09:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('reservations.span.pickup')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.ride.pickup:09:00')).toHaveClass('sr-only')
|
||||
expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-055: a ride is one block that opens its booking, between the two stops it joins', () => {
|
||||
const { shell } = renderTab(planner({ roadtripRoutes: routes({ days: [flightStage()] }) }))
|
||||
|
||||
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.ride.departure:13:20')).toBeInTheDocument()
|
||||
expect(screen.getByText('LH 2020')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 h 10 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.ride.departureFlight:13:20')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.ride.arrival:14:30')).toBeInTheDocument()
|
||||
// Kyoto is 1 and Munich is 2: the terminals between them take no number.
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
@@ -864,6 +866,21 @@ describe('MRoadtripTab', () => {
|
||||
expect(shell.openSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-066: a flight the drive reaches after take-off is said on the ride, where the stage only ever showed it on time', () => {
|
||||
const pinned = flightStage()
|
||||
const late = stage({
|
||||
...pinned,
|
||||
// The departure pinned an hour ahead for the check-in, the drive to it ten hours late.
|
||||
stops: pinned.stops.map((s, i) => (i === 1 ? { ...s, time: '12:20' } : s)),
|
||||
schedule: { ...pinned.schedule, warnings: [{ index: 1, code: 'late', minutes: 600 }] },
|
||||
})
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [late] }) }))
|
||||
|
||||
const mark = screen.getByRole('img', { name: /^roadtrip\.ride\.lateHintFlight:LH 2020,13:20,12:20,Hamburg Airport,22:20/ })
|
||||
expect(mark).toHaveTextContent('roadtrip.ride.missed')
|
||||
expect(within(mark).getByText('roadtrip.ride.reachedAt:22:20')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-056: a table hangs under its stop as a chip, one booked for no stop is listed under the day, and the night is neither', () => {
|
||||
const reservations = [
|
||||
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-05-02T19:30', assignment_id: 503 }),
|
||||
@@ -962,9 +979,9 @@ describe('MRoadtripTab', () => {
|
||||
it('FE-MOB-RTTAB-062: the stage leaves from the hotel and comes back to one, with no number and no kind on either', () => {
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [hotelStage()] }) }))
|
||||
|
||||
expect(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.stay.until:10:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.bookend.back:Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Hotel Alpenblick')).toHaveLength(2)
|
||||
expect(screen.getByText('roadtrip.bookend.checkOut').parentElement).toHaveTextContent('10:00')
|
||||
expect(screen.getByText('roadtrip.bookend.back')).toBeInTheDocument()
|
||||
// Only the two places offer to become a stop on the way, and the count says two.
|
||||
expect(screen.getAllByRole('button', { name: 'roadtrip.stop.makeService' })).toHaveLength(2)
|
||||
expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
|
||||
@@ -978,7 +995,7 @@ describe('MRoadtripTab', () => {
|
||||
it('FE-MOB-RTTAB-063: a tap opens the booking behind the night, or the stay for somebody who may not edit bookings', () => {
|
||||
const editor = planner({ roadtripRoutes: routes({ days: [hotelStage()] }), reservations: [hotelBooking] })
|
||||
const first = renderTab(editor)
|
||||
fireEvent.click(screen.getByText('roadtrip.bookend.back:Hotel Alpenblick'))
|
||||
fireEvent.click(screen.getByText('roadtrip.bookend.back'))
|
||||
expect(editor.setEditingReservation).toHaveBeenCalledWith(hotelBooking)
|
||||
expect(editor.setShowReservationModal).toHaveBeenCalledWith(true)
|
||||
expect(first.shell.openSheet).not.toHaveBeenCalled()
|
||||
@@ -990,7 +1007,7 @@ describe('MRoadtripTab', () => {
|
||||
can: (action: string) => action !== 'reservation_edit',
|
||||
})
|
||||
const { shell } = renderTab(dayEditor)
|
||||
fireEvent.click(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick'))
|
||||
fireEvent.click(screen.getByText('roadtrip.bookend.checkOut'))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('accommodation', { dayId: 2, accId: 5 })
|
||||
expect(dayEditor.setEditingReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1003,7 +1020,7 @@ describe('MRoadtripTab', () => {
|
||||
const { shell } = renderTab(reader)
|
||||
|
||||
expect(screen.queryByText('roadtrip.window.resume')).toBeNull()
|
||||
fireEvent.click(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick'))
|
||||
fireEvent.click(screen.getByText('roadtrip.bookend.checkOut'))
|
||||
expect(reader.handlePlaceClick).toHaveBeenCalledWith(900)
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1017,7 +1034,8 @@ describe('MRoadtripTab', () => {
|
||||
})
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [transfer] }) }))
|
||||
|
||||
expect(screen.getByText('roadtrip.bookend.back:Wallinga')).toBeInTheDocument()
|
||||
expect(screen.getByText('Wallinga')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.bookend.back')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/roadtrip\.day\.stopCount/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -286,10 +286,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'فتح الحجز',
|
||||
'roadtrip.ride.undated': 'الحجز {title} لا يقع في أي يوم من أيام هذه الرحلة، لذلك لا يستخدمه المسار.',
|
||||
'roadtrip.bookings.loose': 'محجوز أيضًا في هذا اليوم',
|
||||
'roadtrip.bookend.checkOut': 'تسجيل المغادرة · {name}',
|
||||
'roadtrip.bookend.from': 'من {name}',
|
||||
'roadtrip.bookend.back': 'العودة إلى {name}',
|
||||
'roadtrip.bookend.checkIn': 'تسجيل الوصول · {name}',
|
||||
'roadtrip.ride.departureFlight': 'الإقلاع {time}',
|
||||
'roadtrip.ride.duration': 'المدة حسب الحجز',
|
||||
'roadtrip.ride.checkIn': 'تسجيل الوصول',
|
||||
'roadtrip.ride.boarding': 'الصعود',
|
||||
'roadtrip.ride.missed': 'فاتتك',
|
||||
'roadtrip.ride.lateBy': 'متأخر {time}',
|
||||
'roadtrip.ride.reachedAt': 'الوصول {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} تُقلع في {departs}، وتسجيل الوصول حتى {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} تُبحر في {departs}، وتسجيل الوصول حتى {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} تغادر في {departs}، والصعود حتى {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'لا تصل الرحلة إلى {place} قبل {time}. {title} تُقلع في {departs}، وتسجيل الوصول حتى {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'لا تصل الرحلة إلى {place} قبل {time}. {title} تُبحر في {departs}، وتسجيل الوصول حتى {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'لا تصل الرحلة إلى {place} قبل {time}. {title} تغادر في {departs}، والصعود حتى {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'فاتتك الرحلة الجوية',
|
||||
'roadtrip.day.rideMissed.train': 'فاتك القطار',
|
||||
'roadtrip.day.rideMissed.ferry': 'فاتتك العبّارة',
|
||||
'roadtrip.day.rideMissed.cruise': 'فاتتك الرحلة البحرية',
|
||||
'roadtrip.day.rideMissed.bus': 'فاتتك الحافلة',
|
||||
'roadtrip.bookend.checkOut': 'تسجيل المغادرة',
|
||||
'roadtrip.bookend.from': 'بداية اليوم',
|
||||
'roadtrip.bookend.back': 'مبيت',
|
||||
'roadtrip.bookend.checkIn': 'تسجيل الوصول',
|
||||
'roadtrip.bookend.afterCheckOut': 'الانطلاق بعد موعد تسجيل المغادرة',
|
||||
'roadtrip.bookend.noVia': 'لا يمكن وضع نقطة عبور على الطريق من مكان إقامتك أو إليه. أضف توقفاً هناك بدلاً من ذلك.',
|
||||
};
|
||||
|
||||
@@ -292,10 +292,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'A reserva {title} não está em nenhum dia desta viagem, por isso o percurso não a considera.',
|
||||
'roadtrip.bookings.loose': 'Também reservado neste dia',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Saindo de {name}',
|
||||
'roadtrip.bookend.back': 'Volta para {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Partida {time}',
|
||||
'roadtrip.ride.duration': 'Duração segundo a reserva',
|
||||
'roadtrip.ride.checkIn': 'Check-in',
|
||||
'roadtrip.ride.boarding': 'Embarque',
|
||||
'roadtrip.ride.missed': 'Perdido',
|
||||
'roadtrip.ride.lateBy': '{time} de atraso',
|
||||
'roadtrip.ride.reachedAt': 'chega às {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} decola às {departs}, check-in até {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} zarpa às {departs}, check-in até {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} parte às {departs}, embarque até {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'O trajeto só chega a {place} às {time}. {title} decola às {departs}, check-in até {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'O trajeto só chega a {place} às {time}. {title} zarpa às {departs}, check-in até {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'O trajeto só chega a {place} às {time}. {title} parte às {departs}, embarque até {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Voo perdido',
|
||||
'roadtrip.day.rideMissed.train': 'Trem perdido',
|
||||
'roadtrip.day.rideMissed.ferry': 'Balsa perdida',
|
||||
'roadtrip.day.rideMissed.cruise': 'Cruzeiro perdido',
|
||||
'roadtrip.day.rideMissed.bus': 'Ônibus perdido',
|
||||
'roadtrip.bookend.checkOut': 'Check-out',
|
||||
'roadtrip.bookend.from': 'Início do dia',
|
||||
'roadtrip.bookend.back': 'Pernoite',
|
||||
'roadtrip.bookend.checkIn': 'Check-in',
|
||||
'roadtrip.bookend.afterCheckOut': 'Saída depois do check-out',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Não dá para colocar um ponto de passagem no trajeto de ida ou volta da sua hospedagem. Adicione uma parada ali.',
|
||||
|
||||
@@ -295,10 +295,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'La reserva {title} no és a cap dia d’aquest viatge, així que el trajecte no la té en compte.',
|
||||
'roadtrip.bookings.loose': 'També reservat aquest dia',
|
||||
'roadtrip.bookend.checkOut': 'Sortida · {name}',
|
||||
'roadtrip.bookend.from': 'Des de {name}',
|
||||
'roadtrip.bookend.back': 'Tornada a {name}',
|
||||
'roadtrip.bookend.checkIn': 'Entrada · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Sortida {time}',
|
||||
'roadtrip.ride.duration': 'Durada segons la reserva',
|
||||
'roadtrip.ride.checkIn': 'Check-in',
|
||||
'roadtrip.ride.boarding': 'Embarcament',
|
||||
'roadtrip.ride.missed': 'Perdut',
|
||||
'roadtrip.ride.lateBy': '{time} de retard',
|
||||
'roadtrip.ride.reachedAt': 'arriba a les {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} s’enlaira a les {departs}, check-in fins a les {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} salpa a les {departs}, check-in fins a les {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} surt a les {departs}, embarcament fins a les {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'El trajecte no arriba a {place} fins a les {time}. {title} s’enlaira a les {departs}, check-in fins a les {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'El trajecte no arriba a {place} fins a les {time}. {title} salpa a les {departs}, check-in fins a les {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'El trajecte no arriba a {place} fins a les {time}. {title} surt a les {departs}, embarcament fins a les {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Vol perdut',
|
||||
'roadtrip.day.rideMissed.train': 'Tren perdut',
|
||||
'roadtrip.day.rideMissed.ferry': 'Ferri perdut',
|
||||
'roadtrip.day.rideMissed.cruise': 'Creuer perdut',
|
||||
'roadtrip.day.rideMissed.bus': 'Autobús perdut',
|
||||
'roadtrip.bookend.checkOut': 'Sortida',
|
||||
'roadtrip.bookend.from': 'Inici del dia',
|
||||
'roadtrip.bookend.back': 'Pernoctació',
|
||||
'roadtrip.bookend.checkIn': 'Entrada',
|
||||
'roadtrip.bookend.afterCheckOut': 'Marxa després de l’hora de sortida',
|
||||
'roadtrip.bookend.noVia':
|
||||
'No es pot posar cap punt de pas al trajecte fins al teu allotjament o des d’ell. Afegeix-hi una parada.',
|
||||
|
||||
@@ -289,10 +289,28 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Otevřít rezervaci',
|
||||
'roadtrip.ride.undated': 'Rezervace {title} nepatří k žádnému dni této cesty, proto s ní jízda nepočítá.',
|
||||
'roadtrip.bookings.loose': 'Také rezervováno na tento den',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Z ubytování {name}',
|
||||
'roadtrip.bookend.back': 'Zpět do ubytování {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Odlet {time}',
|
||||
'roadtrip.ride.duration': 'Doba podle rezervace',
|
||||
'roadtrip.ride.checkIn': 'Check-in',
|
||||
'roadtrip.ride.boarding': 'Nástup',
|
||||
'roadtrip.ride.missed': 'Zmeškáno',
|
||||
'roadtrip.ride.lateBy': 'zpoždění {time}',
|
||||
'roadtrip.ride.reachedAt': 'až v {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} odlétá v {departs}, check-in do {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} vyplouvá v {departs}, check-in do {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} odjíždí v {departs}, nástup do {pin}.',
|
||||
'roadtrip.ride.lateHintFlight': '{place}: příjezd až v {time}. {title} odlétá v {departs}, check-in do {pin}.',
|
||||
'roadtrip.ride.lateHintShip': '{place}: příjezd až v {time}. {title} vyplouvá v {departs}, check-in do {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding': '{place}: příjezd až v {time}. {title} odjíždí v {departs}, nástup do {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Zmeškaný let',
|
||||
'roadtrip.day.rideMissed.train': 'Zmeškaný vlak',
|
||||
'roadtrip.day.rideMissed.ferry': 'Zmeškaný trajekt',
|
||||
'roadtrip.day.rideMissed.cruise': 'Zmeškaná plavba',
|
||||
'roadtrip.day.rideMissed.bus': 'Zmeškaný autobus',
|
||||
'roadtrip.bookend.checkOut': 'Check-out',
|
||||
'roadtrip.bookend.from': 'Začátek dne',
|
||||
'roadtrip.bookend.back': 'Nocleh',
|
||||
'roadtrip.bookend.checkIn': 'Check-in',
|
||||
'roadtrip.bookend.afterCheckOut': 'Odjezd až po check-outu',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Na trasu do ubytování ani z něj nelze přidat průjezdní bod. Přidejte tam místo toho zastávku.',
|
||||
|
||||
@@ -289,10 +289,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'Die Buchung {title} liegt auf keinem Tag dieser Reise, deshalb plant die Fahrt nicht mit ihr.',
|
||||
'roadtrip.bookings.loose': 'Außerdem an diesem Tag gebucht',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Ab {name}',
|
||||
'roadtrip.bookend.back': 'Zurück zu {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Abflug {time}',
|
||||
'roadtrip.ride.duration': 'Dauer laut Buchung',
|
||||
'roadtrip.ride.checkIn': 'Check-in',
|
||||
'roadtrip.ride.boarding': 'Einstieg',
|
||||
'roadtrip.ride.missed': 'Verpasst',
|
||||
'roadtrip.ride.lateBy': '{time} zu spät',
|
||||
'roadtrip.ride.reachedAt': 'erst {time} da',
|
||||
'roadtrip.ride.hintFlight': '{title} fliegt um {departs} ab, Check-in bis {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} legt um {departs} ab, Check-in bis {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} fährt um {departs} ab, Einstieg bis {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'Die Fahrt erreicht {place} erst um {time}. {title} fliegt um {departs} ab, Check-in bis {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'Die Fahrt erreicht {place} erst um {time}. {title} legt um {departs} ab, Check-in bis {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'Die Fahrt erreicht {place} erst um {time}. {title} fährt um {departs} ab, Einstieg bis {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Flug verpasst',
|
||||
'roadtrip.day.rideMissed.train': 'Zug verpasst',
|
||||
'roadtrip.day.rideMissed.ferry': 'Fähre verpasst',
|
||||
'roadtrip.day.rideMissed.cruise': 'Kreuzfahrt verpasst',
|
||||
'roadtrip.day.rideMissed.bus': 'Bus verpasst',
|
||||
'roadtrip.bookend.checkOut': 'Check-out',
|
||||
'roadtrip.bookend.from': 'Tagesstart',
|
||||
'roadtrip.bookend.back': 'Übernachtung',
|
||||
'roadtrip.bookend.checkIn': 'Check-in',
|
||||
'roadtrip.bookend.afterCheckOut': 'Abfahrt nach dem Check-out',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Auf der Fahrt zur oder von der Unterkunft geht kein Zwischenpunkt. Füge dort stattdessen einen Stopp hinzu.',
|
||||
|
||||
@@ -286,10 +286,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Open booking',
|
||||
'roadtrip.ride.undated': '{title} is on none of this trip’s days, so the drive does not use it.',
|
||||
'roadtrip.bookings.loose': 'Also booked this day',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'From {name}',
|
||||
'roadtrip.bookend.back': 'Back to {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Departure {time}',
|
||||
'roadtrip.ride.duration': 'Duration per the booking',
|
||||
'roadtrip.ride.checkIn': 'Check-in',
|
||||
'roadtrip.ride.boarding': 'Boarding',
|
||||
'roadtrip.ride.missed': 'Missed',
|
||||
'roadtrip.ride.lateBy': '{time} late',
|
||||
'roadtrip.ride.reachedAt': 'there at {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} departs at {departs}, check-in closes at {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} sails at {departs}, check-in closes at {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} departs at {departs}, boarding by {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'The drive reaches {place} at {time}. {title} departs at {departs}, check-in closes at {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'The drive reaches {place} at {time}. {title} sails at {departs}, check-in closes at {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'The drive reaches {place} at {time}. {title} departs at {departs}, boarding by {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Flight missed',
|
||||
'roadtrip.day.rideMissed.train': 'Train missed',
|
||||
'roadtrip.day.rideMissed.ferry': 'Ferry missed',
|
||||
'roadtrip.day.rideMissed.cruise': 'Cruise missed',
|
||||
'roadtrip.day.rideMissed.bus': 'Bus missed',
|
||||
'roadtrip.bookend.checkOut': 'Check-out',
|
||||
'roadtrip.bookend.from': 'Day start',
|
||||
'roadtrip.bookend.back': 'Overnight',
|
||||
'roadtrip.bookend.checkIn': 'Check-in',
|
||||
'roadtrip.bookend.afterCheckOut': 'Leaves after check-out',
|
||||
'roadtrip.bookend.noVia': 'No via point on the drive to or from your stay. Add a stop there instead.',
|
||||
};
|
||||
|
||||
@@ -293,10 +293,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'La reserva {title} no está en ningún día de este viaje, así que el trayecto no la tiene en cuenta.',
|
||||
'roadtrip.bookings.loose': 'También reservado este día',
|
||||
'roadtrip.bookend.checkOut': 'Salida · {name}',
|
||||
'roadtrip.bookend.from': 'Desde {name}',
|
||||
'roadtrip.bookend.back': 'Vuelta a {name}',
|
||||
'roadtrip.bookend.checkIn': 'Entrada · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Salida {time}',
|
||||
'roadtrip.ride.duration': 'Duración según la reserva',
|
||||
'roadtrip.ride.checkIn': 'Check-in',
|
||||
'roadtrip.ride.boarding': 'Embarque',
|
||||
'roadtrip.ride.missed': 'Perdido',
|
||||
'roadtrip.ride.lateBy': '{time} de retraso',
|
||||
'roadtrip.ride.reachedAt': 'llega a las {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} despega a las {departs}, check-in hasta las {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} zarpa a las {departs}, check-in hasta las {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} sale a las {departs}, embarque hasta las {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'El trayecto no llega a {place} hasta las {time}. {title} despega a las {departs}, check-in hasta las {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'El trayecto no llega a {place} hasta las {time}. {title} zarpa a las {departs}, check-in hasta las {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'El trayecto no llega a {place} hasta las {time}. {title} sale a las {departs}, embarque hasta las {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Vuelo perdido',
|
||||
'roadtrip.day.rideMissed.train': 'Tren perdido',
|
||||
'roadtrip.day.rideMissed.ferry': 'Ferry perdido',
|
||||
'roadtrip.day.rideMissed.cruise': 'Crucero perdido',
|
||||
'roadtrip.day.rideMissed.bus': 'Autobús perdido',
|
||||
'roadtrip.bookend.checkOut': 'Salida',
|
||||
'roadtrip.bookend.from': 'Inicio del día',
|
||||
'roadtrip.bookend.back': 'Pernoctación',
|
||||
'roadtrip.bookend.checkIn': 'Entrada',
|
||||
'roadtrip.bookend.afterCheckOut': 'Parte después de la hora de salida',
|
||||
'roadtrip.bookend.noVia':
|
||||
'No se puede poner un punto de paso en el trayecto hacia tu alojamiento o desde él. Añade una parada allí.',
|
||||
|
||||
@@ -295,10 +295,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'La réservation {title} n’est sur aucun jour de ce voyage, le trajet n’en tient donc pas compte.',
|
||||
'roadtrip.bookings.loose': 'Également réservé ce jour',
|
||||
'roadtrip.bookend.checkOut': 'Départ · {name}',
|
||||
'roadtrip.bookend.from': 'Depuis {name}',
|
||||
'roadtrip.bookend.back': 'Retour à {name}',
|
||||
'roadtrip.bookend.checkIn': 'Arrivée · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Départ {time}',
|
||||
'roadtrip.ride.duration': 'Durée selon la réservation',
|
||||
'roadtrip.ride.checkIn': 'Enregistrement',
|
||||
'roadtrip.ride.boarding': 'Embarquement',
|
||||
'roadtrip.ride.missed': 'Manqué',
|
||||
'roadtrip.ride.lateBy': '{time} de retard',
|
||||
'roadtrip.ride.reachedAt': 'arrivée à {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} décolle à {departs}, enregistrement jusqu’à {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} appareille à {departs}, enregistrement jusqu’à {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} part à {departs}, embarquement jusqu’à {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'Le trajet n’atteint {place} qu’à {time}. {title} décolle à {departs}, enregistrement jusqu’à {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'Le trajet n’atteint {place} qu’à {time}. {title} appareille à {departs}, enregistrement jusqu’à {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'Le trajet n’atteint {place} qu’à {time}. {title} part à {departs}, embarquement jusqu’à {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Vol manqué',
|
||||
'roadtrip.day.rideMissed.train': 'Train manqué',
|
||||
'roadtrip.day.rideMissed.ferry': 'Ferry manqué',
|
||||
'roadtrip.day.rideMissed.cruise': 'Croisière manquée',
|
||||
'roadtrip.day.rideMissed.bus': 'Bus manqué',
|
||||
'roadtrip.bookend.checkOut': 'Départ',
|
||||
'roadtrip.bookend.from': 'Début de journée',
|
||||
'roadtrip.bookend.back': 'Nuitée',
|
||||
'roadtrip.bookend.checkIn': 'Arrivée',
|
||||
'roadtrip.bookend.afterCheckOut': 'Part après l’heure limite de départ',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Pas de point de passage sur le trajet vers ou depuis votre hébergement. Ajoutez plutôt un arrêt à cet endroit.',
|
||||
|
||||
@@ -297,10 +297,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'Η κράτηση {title} δεν βρίσκεται σε καμία μέρα αυτού του ταξιδιού, οπότε η διαδρομή δεν τη λαμβάνει υπόψη.',
|
||||
'roadtrip.bookings.loose': 'Επίσης κρατημένο αυτή τη μέρα',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Από {name}',
|
||||
'roadtrip.bookend.back': 'Επιστροφή: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Αναχώρηση {time}',
|
||||
'roadtrip.ride.duration': 'Διάρκεια σύμφωνα με την κράτηση',
|
||||
'roadtrip.ride.checkIn': 'Check-in',
|
||||
'roadtrip.ride.boarding': 'Επιβίβαση',
|
||||
'roadtrip.ride.missed': 'Χάθηκε',
|
||||
'roadtrip.ride.lateBy': '{time} καθυστέρηση',
|
||||
'roadtrip.ride.reachedAt': 'άφιξη {time}',
|
||||
'roadtrip.ride.hintFlight': '{title}: αναχώρηση στις {departs}, check-in έως τις {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title}: απόπλους στις {departs}, check-in έως τις {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title}: αναχώρηση στις {departs}, επιβίβαση έως τις {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'{place}: άφιξη μόλις στις {time}. {title}: αναχώρηση στις {departs}, check-in έως τις {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'{place}: άφιξη μόλις στις {time}. {title}: απόπλους στις {departs}, check-in έως τις {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'{place}: άφιξη μόλις στις {time}. {title}: αναχώρηση στις {departs}, επιβίβαση έως τις {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Χαμένη πτήση',
|
||||
'roadtrip.day.rideMissed.train': 'Χαμένο τρένο',
|
||||
'roadtrip.day.rideMissed.ferry': 'Χαμένο φέρι',
|
||||
'roadtrip.day.rideMissed.cruise': 'Χαμένη κρουαζιέρα',
|
||||
'roadtrip.day.rideMissed.bus': 'Χαμένο λεωφορείο',
|
||||
'roadtrip.bookend.checkOut': 'Check-out',
|
||||
'roadtrip.bookend.from': 'Έναρξη ημέρας',
|
||||
'roadtrip.bookend.back': 'Διανυκτέρευση',
|
||||
'roadtrip.bookend.checkIn': 'Check-in',
|
||||
'roadtrip.bookend.afterCheckOut': 'Αναχώρηση μετά το check-out',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Δεν μπαίνει ενδιάμεσο σημείο στη διαδρομή προς ή από το κατάλυμα. Προσθέστε εκεί μια στάση.',
|
||||
|
||||
@@ -291,10 +291,28 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Foglalás megnyitása',
|
||||
'roadtrip.ride.undated': '{title}: a foglalás az utazás egyik napjára sem esik, ezért az útvonal nem számol vele.',
|
||||
'roadtrip.bookings.loose': 'Erre a napra még lefoglalva',
|
||||
'roadtrip.bookend.checkOut': 'Kijelentkezés · {name}',
|
||||
'roadtrip.bookend.from': 'Indulás: {name}',
|
||||
'roadtrip.bookend.back': 'Vissza: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Bejelentkezés · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Indulás {time}',
|
||||
'roadtrip.ride.duration': 'Időtartam a foglalás szerint',
|
||||
'roadtrip.ride.checkIn': 'Utasfelvétel',
|
||||
'roadtrip.ride.boarding': 'Beszállás',
|
||||
'roadtrip.ride.missed': 'Lekésve',
|
||||
'roadtrip.ride.lateBy': '{time} késés',
|
||||
'roadtrip.ride.reachedAt': 'érkezés csak {time}',
|
||||
'roadtrip.ride.hintFlight': '{title}: indulás {departs}, utasfelvétel {pin}-ig.',
|
||||
'roadtrip.ride.hintShip': '{title}: kifutás {departs}, utasfelvétel {pin}-ig.',
|
||||
'roadtrip.ride.hintBoarding': '{title}: indulás {departs}, beszállás {pin}-ig.',
|
||||
'roadtrip.ride.lateHintFlight': '{place}: érkezés csak {time}. {title}: indulás {departs}, utasfelvétel {pin}-ig.',
|
||||
'roadtrip.ride.lateHintShip': '{place}: érkezés csak {time}. {title}: kifutás {departs}, utasfelvétel {pin}-ig.',
|
||||
'roadtrip.ride.lateHintBoarding': '{place}: érkezés csak {time}. {title}: indulás {departs}, beszállás {pin}-ig.',
|
||||
'roadtrip.day.rideMissed.flight': 'Lekésett járat',
|
||||
'roadtrip.day.rideMissed.train': 'Lekésett vonat',
|
||||
'roadtrip.day.rideMissed.ferry': 'Lekésett komp',
|
||||
'roadtrip.day.rideMissed.cruise': 'Lekésett hajóút',
|
||||
'roadtrip.day.rideMissed.bus': 'Lekésett busz',
|
||||
'roadtrip.bookend.checkOut': 'Kijelentkezés',
|
||||
'roadtrip.bookend.from': 'Napkezdés',
|
||||
'roadtrip.bookend.back': 'Éjszakázás',
|
||||
'roadtrip.bookend.checkIn': 'Bejelentkezés',
|
||||
'roadtrip.bookend.afterCheckOut': 'Indulás kijelentkezés után',
|
||||
'roadtrip.bookend.noVia':
|
||||
'A szálláshoz vezető vagy onnan induló útra nem kerülhet köztes pont. Adj hozzá inkább egy megállót ott.',
|
||||
|
||||
@@ -294,10 +294,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'Pemesanan {title} tidak berada di hari mana pun dalam perjalanan ini, jadi perjalanan tidak memperhitungkannya.',
|
||||
'roadtrip.bookings.loose': 'Juga dipesan hari ini',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Dari {name}',
|
||||
'roadtrip.bookend.back': 'Kembali ke {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Berangkat {time}',
|
||||
'roadtrip.ride.duration': 'Durasi sesuai pemesanan',
|
||||
'roadtrip.ride.checkIn': 'Check-in',
|
||||
'roadtrip.ride.boarding': 'Boarding',
|
||||
'roadtrip.ride.missed': 'Terlewat',
|
||||
'roadtrip.ride.lateBy': 'terlambat {time}',
|
||||
'roadtrip.ride.reachedAt': 'baru tiba {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} berangkat pukul {departs}, check-in sampai {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} berlayar pukul {departs}, check-in sampai {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} berangkat pukul {departs}, boarding sampai {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'Perjalanan baru tiba di {place} pukul {time}. {title} berangkat pukul {departs}, check-in sampai {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'Perjalanan baru tiba di {place} pukul {time}. {title} berlayar pukul {departs}, check-in sampai {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'Perjalanan baru tiba di {place} pukul {time}. {title} berangkat pukul {departs}, boarding sampai {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Penerbangan terlewat',
|
||||
'roadtrip.day.rideMissed.train': 'Kereta terlewat',
|
||||
'roadtrip.day.rideMissed.ferry': 'Feri terlewat',
|
||||
'roadtrip.day.rideMissed.cruise': 'Kapal pesiar terlewat',
|
||||
'roadtrip.day.rideMissed.bus': 'Bus terlewat',
|
||||
'roadtrip.bookend.checkOut': 'Check-out',
|
||||
'roadtrip.bookend.from': 'Awal hari',
|
||||
'roadtrip.bookend.back': 'Menginap',
|
||||
'roadtrip.bookend.checkIn': 'Check-in',
|
||||
'roadtrip.bookend.afterCheckOut': 'Berangkat setelah check-out',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Titik lintas tidak bisa ditaruh di perjalanan ke atau dari penginapan Anda. Tambahkan perhentian di sana.',
|
||||
|
||||
@@ -294,10 +294,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'La prenotazione {title} non cade in nessun giorno di questo viaggio, quindi il percorso non la considera.',
|
||||
'roadtrip.bookings.loose': 'Prenotato anche questo giorno',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Da {name}',
|
||||
'roadtrip.bookend.back': 'Ritorno a {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Partenza {time}',
|
||||
'roadtrip.ride.duration': 'Durata secondo la prenotazione',
|
||||
'roadtrip.ride.checkIn': 'Check-in',
|
||||
'roadtrip.ride.boarding': 'Imbarco',
|
||||
'roadtrip.ride.missed': 'Perso',
|
||||
'roadtrip.ride.lateBy': '{time} di ritardo',
|
||||
'roadtrip.ride.reachedAt': 'arrivo alle {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} decolla alle {departs}, check-in entro le {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} salpa alle {departs}, check-in entro le {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} parte alle {departs}, imbarco entro le {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'Il viaggio arriva a {place} solo alle {time}. {title} decolla alle {departs}, check-in entro le {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'Il viaggio arriva a {place} solo alle {time}. {title} salpa alle {departs}, check-in entro le {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'Il viaggio arriva a {place} solo alle {time}. {title} parte alle {departs}, imbarco entro le {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Volo perso',
|
||||
'roadtrip.day.rideMissed.train': 'Treno perso',
|
||||
'roadtrip.day.rideMissed.ferry': 'Traghetto perso',
|
||||
'roadtrip.day.rideMissed.cruise': 'Crociera persa',
|
||||
'roadtrip.day.rideMissed.bus': 'Autobus perso',
|
||||
'roadtrip.bookend.checkOut': 'Check-out',
|
||||
'roadtrip.bookend.from': 'Inizio giornata',
|
||||
'roadtrip.bookend.back': 'Pernottamento',
|
||||
'roadtrip.bookend.checkIn': 'Check-in',
|
||||
'roadtrip.bookend.afterCheckOut': 'Partenza dopo il check-out',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Nessun punto di passaggio sul tragitto verso o dal tuo alloggio. Aggiungi invece una sosta in quel punto.',
|
||||
|
||||
@@ -288,10 +288,30 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': '予約を開く',
|
||||
'roadtrip.ride.undated': '{title} はこの旅行のどの日にも入っていないため、走行では使われません。',
|
||||
'roadtrip.bookings.loose': 'この日のその他の予約',
|
||||
'roadtrip.bookend.checkOut': 'チェックアウト · {name}',
|
||||
'roadtrip.bookend.from': '{name}から',
|
||||
'roadtrip.bookend.back': '{name}へ戻る',
|
||||
'roadtrip.bookend.checkIn': 'チェックイン · {name}',
|
||||
'roadtrip.ride.departureFlight': '出発 {time}',
|
||||
'roadtrip.ride.duration': '予約上の所要時間',
|
||||
'roadtrip.ride.checkIn': 'チェックイン',
|
||||
'roadtrip.ride.boarding': '乗車',
|
||||
'roadtrip.ride.missed': '乗り遅れ',
|
||||
'roadtrip.ride.lateBy': '{time}遅れ',
|
||||
'roadtrip.ride.reachedAt': '{time}着',
|
||||
'roadtrip.ride.hintFlight': '{title} は {departs} 発、チェックインは {pin} まで。',
|
||||
'roadtrip.ride.hintShip': '{title} は {departs} 出港、チェックインは {pin} まで。',
|
||||
'roadtrip.ride.hintBoarding': '{title} は {departs} 発、乗車は {pin} まで。',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'{place} に着くのは {time} です。{title} は {departs} 発、チェックインは {pin} まで。',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'{place} に着くのは {time} です。{title} は {departs} 出港、チェックインは {pin} まで。',
|
||||
'roadtrip.ride.lateHintBoarding': '{place} に着くのは {time} です。{title} は {departs} 発、乗車は {pin} まで。',
|
||||
'roadtrip.day.rideMissed.flight': '飛行機に乗り遅れ',
|
||||
'roadtrip.day.rideMissed.train': '列車に乗り遅れ',
|
||||
'roadtrip.day.rideMissed.ferry': 'フェリーに乗り遅れ',
|
||||
'roadtrip.day.rideMissed.cruise': 'クルーズに乗り遅れ',
|
||||
'roadtrip.day.rideMissed.bus': 'バスに乗り遅れ',
|
||||
'roadtrip.bookend.checkOut': 'チェックアウト',
|
||||
'roadtrip.bookend.from': '出発',
|
||||
'roadtrip.bookend.back': '宿泊',
|
||||
'roadtrip.bookend.checkIn': 'チェックイン',
|
||||
'roadtrip.bookend.afterCheckOut': 'チェックアウト後に出発',
|
||||
'roadtrip.bookend.noVia': '宿泊先との間の区間には経由地を置けません。代わりにそこへ立ち寄り先を追加してください。',
|
||||
};
|
||||
|
||||
@@ -285,10 +285,28 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': '예약 열기',
|
||||
'roadtrip.ride.undated': '{title}은(는) 이 여행의 어느 날에도 속하지 않아 주행에 반영되지 않습니다.',
|
||||
'roadtrip.bookings.loose': '이날의 다른 예약',
|
||||
'roadtrip.bookend.checkOut': '체크아웃 · {name}',
|
||||
'roadtrip.bookend.from': '{name}에서 출발',
|
||||
'roadtrip.bookend.back': '{name}(으)로 돌아가기',
|
||||
'roadtrip.bookend.checkIn': '체크인 · {name}',
|
||||
'roadtrip.ride.departureFlight': '출발 {time}',
|
||||
'roadtrip.ride.duration': '예약 기준 소요 시간',
|
||||
'roadtrip.ride.checkIn': '체크인',
|
||||
'roadtrip.ride.boarding': '탑승',
|
||||
'roadtrip.ride.missed': '놓침',
|
||||
'roadtrip.ride.lateBy': '{time} 늦음',
|
||||
'roadtrip.ride.reachedAt': '{time} 도착',
|
||||
'roadtrip.ride.hintFlight': '{title} {departs} 출발, 체크인 마감 {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} {departs} 출항, 체크인 마감 {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} {departs} 출발, 탑승 마감 {pin}.',
|
||||
'roadtrip.ride.lateHintFlight': '{place}에 {time}에야 도착합니다. {title} {departs} 출발, 체크인 마감 {pin}.',
|
||||
'roadtrip.ride.lateHintShip': '{place}에 {time}에야 도착합니다. {title} {departs} 출항, 체크인 마감 {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding': '{place}에 {time}에야 도착합니다. {title} {departs} 출발, 탑승 마감 {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': '항공편 놓침',
|
||||
'roadtrip.day.rideMissed.train': '기차 놓침',
|
||||
'roadtrip.day.rideMissed.ferry': '페리 놓침',
|
||||
'roadtrip.day.rideMissed.cruise': '크루즈 놓침',
|
||||
'roadtrip.day.rideMissed.bus': '버스 놓침',
|
||||
'roadtrip.bookend.checkOut': '체크아웃',
|
||||
'roadtrip.bookend.from': '하루 시작',
|
||||
'roadtrip.bookend.back': '숙박',
|
||||
'roadtrip.bookend.checkIn': '체크인',
|
||||
'roadtrip.bookend.afterCheckOut': '체크아웃 후 출발',
|
||||
'roadtrip.bookend.noVia': '숙소를 오가는 구간에는 경유지를 둘 수 없습니다. 대신 그곳에 정차 지점을 추가하세요.',
|
||||
};
|
||||
|
||||
@@ -293,10 +293,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'De boeking {title} valt op geen enkele dag van deze reis, dus de rit houdt er geen rekening mee.',
|
||||
'roadtrip.bookings.loose': 'Ook geboekt op deze dag',
|
||||
'roadtrip.bookend.checkOut': 'Uitchecken · {name}',
|
||||
'roadtrip.bookend.from': 'Vanaf {name}',
|
||||
'roadtrip.bookend.back': 'Terug naar {name}',
|
||||
'roadtrip.bookend.checkIn': 'Inchecken · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Vertrek {time}',
|
||||
'roadtrip.ride.duration': 'Duur volgens de boeking',
|
||||
'roadtrip.ride.checkIn': 'Inchecken',
|
||||
'roadtrip.ride.boarding': 'Instappen',
|
||||
'roadtrip.ride.missed': 'Gemist',
|
||||
'roadtrip.ride.lateBy': '{time} te laat',
|
||||
'roadtrip.ride.reachedAt': 'pas om {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} vertrekt om {departs}, inchecken tot {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} vaart af om {departs}, inchecken tot {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} vertrekt om {departs}, instappen tot {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'De rit bereikt {place} pas om {time}. {title} vertrekt om {departs}, inchecken tot {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'De rit bereikt {place} pas om {time}. {title} vaart af om {departs}, inchecken tot {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'De rit bereikt {place} pas om {time}. {title} vertrekt om {departs}, instappen tot {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Vlucht gemist',
|
||||
'roadtrip.day.rideMissed.train': 'Trein gemist',
|
||||
'roadtrip.day.rideMissed.ferry': 'Veerboot gemist',
|
||||
'roadtrip.day.rideMissed.cruise': 'Cruise gemist',
|
||||
'roadtrip.day.rideMissed.bus': 'Bus gemist',
|
||||
'roadtrip.bookend.checkOut': 'Uitchecken',
|
||||
'roadtrip.bookend.from': 'Dagstart',
|
||||
'roadtrip.bookend.back': 'Overnachting',
|
||||
'roadtrip.bookend.checkIn': 'Inchecken',
|
||||
'roadtrip.bookend.afterCheckOut': 'Vertrek na het uitchecken',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Geen tussenpunt op de rit naar of van je verblijf. Voeg daar in plaats daarvan een stop toe.',
|
||||
|
||||
@@ -290,10 +290,29 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Otwórz rezerwację',
|
||||
'roadtrip.ride.undated': 'Rezerwacja {title} nie przypada na żaden dzień tej podróży, więc trasa jej nie uwzględnia.',
|
||||
'roadtrip.bookings.loose': 'Zarezerwowane też tego dnia',
|
||||
'roadtrip.bookend.checkOut': 'Wymeldowanie · {name}',
|
||||
'roadtrip.bookend.from': 'Wyjazd: {name}',
|
||||
'roadtrip.bookend.back': 'Powrót: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Zameldowanie · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Wylot {time}',
|
||||
'roadtrip.ride.duration': 'Czas trwania według rezerwacji',
|
||||
'roadtrip.ride.checkIn': 'Odprawa',
|
||||
'roadtrip.ride.boarding': 'Wsiadanie',
|
||||
'roadtrip.ride.missed': 'Spóźnienie',
|
||||
'roadtrip.ride.lateBy': '{time} spóźnienia',
|
||||
'roadtrip.ride.reachedAt': 'dopiero o {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} odlatuje o {departs}, odprawa do {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} wypływa o {departs}, odprawa do {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} odjeżdża o {departs}, wsiadanie do {pin}.',
|
||||
'roadtrip.ride.lateHintFlight': '{place}: przyjazd dopiero o {time}. {title} odlatuje o {departs}, odprawa do {pin}.',
|
||||
'roadtrip.ride.lateHintShip': '{place}: przyjazd dopiero o {time}. {title} wypływa o {departs}, odprawa do {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'{place}: przyjazd dopiero o {time}. {title} odjeżdża o {departs}, wsiadanie do {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Spóźnienie na lot',
|
||||
'roadtrip.day.rideMissed.train': 'Spóźnienie na pociąg',
|
||||
'roadtrip.day.rideMissed.ferry': 'Spóźnienie na prom',
|
||||
'roadtrip.day.rideMissed.cruise': 'Spóźnienie na rejs',
|
||||
'roadtrip.day.rideMissed.bus': 'Spóźnienie na autobus',
|
||||
'roadtrip.bookend.checkOut': 'Wymeldowanie',
|
||||
'roadtrip.bookend.from': 'Początek dnia',
|
||||
'roadtrip.bookend.back': 'Nocleg',
|
||||
'roadtrip.bookend.checkIn': 'Zameldowanie',
|
||||
'roadtrip.bookend.afterCheckOut': 'Wyjazd po godzinie wymeldowania',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Na trasie do noclegu ani z niego nie można dodać punktu pośredniego. Dodaj tam zamiast tego postój.',
|
||||
|
||||
@@ -294,10 +294,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'Бронирование {title} не относится ни к одному дню этой поездки, поэтому маршрут его не учитывает.',
|
||||
'roadtrip.bookings.loose': 'Также забронировано в этот день',
|
||||
'roadtrip.bookend.checkOut': 'Выезд · {name}',
|
||||
'roadtrip.bookend.from': 'Отправление: {name}',
|
||||
'roadtrip.bookend.back': 'Возвращение: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Заезд · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Вылет {time}',
|
||||
'roadtrip.ride.duration': 'Длительность по бронированию',
|
||||
'roadtrip.ride.checkIn': 'Регистрация',
|
||||
'roadtrip.ride.boarding': 'Посадка',
|
||||
'roadtrip.ride.missed': 'Опоздание',
|
||||
'roadtrip.ride.lateBy': 'опоздание {time}',
|
||||
'roadtrip.ride.reachedAt': 'только в {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} вылетает в {departs}, регистрация до {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} отплывает в {departs}, регистрация до {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} отправляется в {departs}, посадка до {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'{place}: прибытие только в {time}. {title} вылетает в {departs}, регистрация до {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'{place}: прибытие только в {time}. {title} отплывает в {departs}, регистрация до {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'{place}: прибытие только в {time}. {title} отправляется в {departs}, посадка до {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Опоздание на рейс',
|
||||
'roadtrip.day.rideMissed.train': 'Опоздание на поезд',
|
||||
'roadtrip.day.rideMissed.ferry': 'Опоздание на паром',
|
||||
'roadtrip.day.rideMissed.cruise': 'Опоздание на круиз',
|
||||
'roadtrip.day.rideMissed.bus': 'Опоздание на автобус',
|
||||
'roadtrip.bookend.checkOut': 'Выезд',
|
||||
'roadtrip.bookend.from': 'Начало дня',
|
||||
'roadtrip.bookend.back': 'Ночёвка',
|
||||
'roadtrip.bookend.checkIn': 'Заезд',
|
||||
'roadtrip.bookend.afterCheckOut': 'Отъезд позже расчётного часа',
|
||||
'roadtrip.bookend.noVia':
|
||||
'На маршрут до места проживания или от него нельзя поставить промежуточную точку. Добавьте там остановку.',
|
||||
|
||||
@@ -289,10 +289,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Öppna bokning',
|
||||
'roadtrip.ride.undated': 'Bokningen {title} ligger inte på någon av resans dagar, så körningen tar inte med den.',
|
||||
'roadtrip.bookings.loose': 'Även bokat denna dag',
|
||||
'roadtrip.bookend.checkOut': 'Utcheckning · {name}',
|
||||
'roadtrip.bookend.from': 'Från {name}',
|
||||
'roadtrip.bookend.back': 'Tillbaka till {name}',
|
||||
'roadtrip.bookend.checkIn': 'Incheckning · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Avgång {time}',
|
||||
'roadtrip.ride.duration': 'Längd enligt bokningen',
|
||||
'roadtrip.ride.checkIn': 'Incheckning',
|
||||
'roadtrip.ride.boarding': 'Påstigning',
|
||||
'roadtrip.ride.missed': 'Missad',
|
||||
'roadtrip.ride.lateBy': '{time} sent',
|
||||
'roadtrip.ride.reachedAt': 'framme {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} avgår {departs}, incheckningen stänger {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} lägger ut {departs}, incheckningen stänger {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} avgår {departs}, påstigning senast {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'Körningen når {place} först {time}. {title} avgår {departs}, incheckningen stänger {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'Körningen når {place} först {time}. {title} lägger ut {departs}, incheckningen stänger {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'Körningen når {place} först {time}. {title} avgår {departs}, påstigning senast {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Missat flyg',
|
||||
'roadtrip.day.rideMissed.train': 'Missat tåg',
|
||||
'roadtrip.day.rideMissed.ferry': 'Missad färja',
|
||||
'roadtrip.day.rideMissed.cruise': 'Missad kryssning',
|
||||
'roadtrip.day.rideMissed.bus': 'Missad buss',
|
||||
'roadtrip.bookend.checkOut': 'Utcheckning',
|
||||
'roadtrip.bookend.from': 'Dagens start',
|
||||
'roadtrip.bookend.back': 'Övernattning',
|
||||
'roadtrip.bookend.checkIn': 'Incheckning',
|
||||
'roadtrip.bookend.afterCheckOut': 'Avresa efter utcheckning',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Ingen mellanpunkt på resan till eller från ditt boende. Lägg till ett stopp där i stället.',
|
||||
|
||||
@@ -292,10 +292,28 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'{title} rezervasyonu bu yolculuğun hiçbir gününde değil, bu yüzden sürüş onu hesaba katmıyor.',
|
||||
'roadtrip.bookings.loose': 'Bu gün için ayrıca rezerve edildi',
|
||||
'roadtrip.bookend.checkOut': 'Çıkış · {name}',
|
||||
'roadtrip.bookend.from': 'Başlangıç: {name}',
|
||||
'roadtrip.bookend.back': 'Dönüş: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Giriş · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Kalkış {time}',
|
||||
'roadtrip.ride.duration': 'Rezervasyona göre süre',
|
||||
'roadtrip.ride.checkIn': 'Check-in',
|
||||
'roadtrip.ride.boarding': 'Biniş',
|
||||
'roadtrip.ride.missed': 'Kaçırıldı',
|
||||
'roadtrip.ride.lateBy': '{time} geç',
|
||||
'roadtrip.ride.reachedAt': 'varış {time}',
|
||||
'roadtrip.ride.hintFlight': '{title}: kalkış {departs}, check-in en geç {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title}: hareket {departs}, check-in en geç {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title}: kalkış {departs}, biniş en geç {pin}.',
|
||||
'roadtrip.ride.lateHintFlight': '{place}: varış ancak {time}. {title}: kalkış {departs}, check-in en geç {pin}.',
|
||||
'roadtrip.ride.lateHintShip': '{place}: varış ancak {time}. {title}: hareket {departs}, check-in en geç {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding': '{place}: varış ancak {time}. {title}: kalkış {departs}, biniş en geç {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Uçuş kaçırıldı',
|
||||
'roadtrip.day.rideMissed.train': 'Tren kaçırıldı',
|
||||
'roadtrip.day.rideMissed.ferry': 'Feribot kaçırıldı',
|
||||
'roadtrip.day.rideMissed.cruise': 'Gemi turu kaçırıldı',
|
||||
'roadtrip.day.rideMissed.bus': 'Otobüs kaçırıldı',
|
||||
'roadtrip.bookend.checkOut': 'Çıkış',
|
||||
'roadtrip.bookend.from': 'Gün başlangıcı',
|
||||
'roadtrip.bookend.back': 'Konaklama',
|
||||
'roadtrip.bookend.checkIn': 'Giriş',
|
||||
'roadtrip.bookend.afterCheckOut': 'Çıkış saatinden sonra hareket',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Konaklama yerinize giden ya da oradan çıkan yola ara nokta konamaz. Bunun yerine oraya bir mola ekleyin.',
|
||||
|
||||
@@ -291,10 +291,29 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Відкрити бронювання',
|
||||
'roadtrip.ride.undated': 'Бронювання {title} не припадає на жоден день цієї подорожі, тому маршрут його не враховує.',
|
||||
'roadtrip.bookings.loose': 'Також заброньовано цього дня',
|
||||
'roadtrip.bookend.checkOut': 'Виїзд · {name}',
|
||||
'roadtrip.bookend.from': 'Відправлення: {name}',
|
||||
'roadtrip.bookend.back': 'Повернення: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Заїзд · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Виліт {time}',
|
||||
'roadtrip.ride.duration': 'Тривалість за бронюванням',
|
||||
'roadtrip.ride.checkIn': 'Реєстрація',
|
||||
'roadtrip.ride.boarding': 'Посадка',
|
||||
'roadtrip.ride.missed': 'Запізнення',
|
||||
'roadtrip.ride.lateBy': 'запізнення {time}',
|
||||
'roadtrip.ride.reachedAt': 'лише о {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} вилітає о {departs}, реєстрація до {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} відпливає о {departs}, реєстрація до {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} відправляється о {departs}, посадка до {pin}.',
|
||||
'roadtrip.ride.lateHintFlight': '{place}: прибуття лише о {time}. {title} вилітає о {departs}, реєстрація до {pin}.',
|
||||
'roadtrip.ride.lateHintShip': '{place}: прибуття лише о {time}. {title} відпливає о {departs}, реєстрація до {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'{place}: прибуття лише о {time}. {title} відправляється о {departs}, посадка до {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Запізнення на рейс',
|
||||
'roadtrip.day.rideMissed.train': 'Запізнення на поїзд',
|
||||
'roadtrip.day.rideMissed.ferry': 'Запізнення на пором',
|
||||
'roadtrip.day.rideMissed.cruise': 'Запізнення на круїз',
|
||||
'roadtrip.day.rideMissed.bus': 'Запізнення на автобус',
|
||||
'roadtrip.bookend.checkOut': 'Виїзд',
|
||||
'roadtrip.bookend.from': 'Початок дня',
|
||||
'roadtrip.bookend.back': 'Ночівля',
|
||||
'roadtrip.bookend.checkIn': 'Заїзд',
|
||||
'roadtrip.bookend.afterCheckOut': 'Від’їзд пізніше розрахункової години',
|
||||
'roadtrip.bookend.noVia':
|
||||
'На маршрут до місця проживання чи від нього не можна поставити проміжну точку. Додайте там зупинку.',
|
||||
|
||||
@@ -290,10 +290,31 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Mở đặt chỗ',
|
||||
'roadtrip.ride.undated': 'Đặt chỗ {title} không thuộc ngày nào của chuyến đi này, nên hành trình không tính đến nó.',
|
||||
'roadtrip.bookings.loose': 'Cũng đã đặt trong ngày này',
|
||||
'roadtrip.bookend.checkOut': 'Trả phòng · {name}',
|
||||
'roadtrip.bookend.from': 'Từ {name}',
|
||||
'roadtrip.bookend.back': 'Về lại {name}',
|
||||
'roadtrip.bookend.checkIn': 'Nhận phòng · {name}',
|
||||
'roadtrip.ride.departureFlight': 'Cất cánh {time}',
|
||||
'roadtrip.ride.duration': 'Thời lượng theo đặt chỗ',
|
||||
'roadtrip.ride.checkIn': 'Làm thủ tục',
|
||||
'roadtrip.ride.boarding': 'Lên tàu',
|
||||
'roadtrip.ride.missed': 'Lỡ chuyến',
|
||||
'roadtrip.ride.lateBy': 'trễ {time}',
|
||||
'roadtrip.ride.reachedAt': 'đến lúc {time}',
|
||||
'roadtrip.ride.hintFlight': '{title} cất cánh lúc {departs}, làm thủ tục đến {pin}.',
|
||||
'roadtrip.ride.hintShip': '{title} khởi hành lúc {departs}, làm thủ tục đến {pin}.',
|
||||
'roadtrip.ride.hintBoarding': '{title} khởi hành lúc {departs}, lên tàu đến {pin}.',
|
||||
'roadtrip.ride.lateHintFlight':
|
||||
'Chuyến đi chỉ tới {place} lúc {time}. {title} cất cánh lúc {departs}, làm thủ tục đến {pin}.',
|
||||
'roadtrip.ride.lateHintShip':
|
||||
'Chuyến đi chỉ tới {place} lúc {time}. {title} khởi hành lúc {departs}, làm thủ tục đến {pin}.',
|
||||
'roadtrip.ride.lateHintBoarding':
|
||||
'Chuyến đi chỉ tới {place} lúc {time}. {title} khởi hành lúc {departs}, lên tàu đến {pin}.',
|
||||
'roadtrip.day.rideMissed.flight': 'Lỡ chuyến bay',
|
||||
'roadtrip.day.rideMissed.train': 'Lỡ chuyến tàu',
|
||||
'roadtrip.day.rideMissed.ferry': 'Lỡ chuyến phà',
|
||||
'roadtrip.day.rideMissed.cruise': 'Lỡ chuyến du thuyền',
|
||||
'roadtrip.day.rideMissed.bus': 'Lỡ chuyến xe buýt',
|
||||
'roadtrip.bookend.checkOut': 'Trả phòng',
|
||||
'roadtrip.bookend.from': 'Bắt đầu ngày',
|
||||
'roadtrip.bookend.back': 'Nghỉ đêm',
|
||||
'roadtrip.bookend.checkIn': 'Nhận phòng',
|
||||
'roadtrip.bookend.afterCheckOut': 'Khởi hành sau giờ trả phòng',
|
||||
'roadtrip.bookend.noVia':
|
||||
'Không thể đặt điểm trung gian trên chặng đi hoặc về nơi lưu trú. Hãy thêm một điểm dừng ở đó.',
|
||||
|
||||
@@ -274,10 +274,28 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': '開啟預訂',
|
||||
'roadtrip.ride.undated': '{title} 不在本次旅行的任何一天,因此行程不會用到它。',
|
||||
'roadtrip.bookings.loose': '當天的其他預訂',
|
||||
'roadtrip.bookend.checkOut': '退房 · {name}',
|
||||
'roadtrip.bookend.from': '從 {name} 出發',
|
||||
'roadtrip.bookend.back': '返回 {name}',
|
||||
'roadtrip.bookend.checkIn': '入住 · {name}',
|
||||
'roadtrip.ride.departureFlight': '起飛 {time}',
|
||||
'roadtrip.ride.duration': '預訂時長',
|
||||
'roadtrip.ride.checkIn': '辦理登記',
|
||||
'roadtrip.ride.boarding': '上車',
|
||||
'roadtrip.ride.missed': '錯過',
|
||||
'roadtrip.ride.lateBy': '晚到 {time}',
|
||||
'roadtrip.ride.reachedAt': '{time} 才到',
|
||||
'roadtrip.ride.hintFlight': '{title} {departs} 起飛,{pin} 前辦理登記。',
|
||||
'roadtrip.ride.hintShip': '{title} {departs} 開船,{pin} 前辦理登記。',
|
||||
'roadtrip.ride.hintBoarding': '{title} {departs} 發車,{pin} 前上車。',
|
||||
'roadtrip.ride.lateHintFlight': '行程 {time} 才抵達 {place}。{title} {departs} 起飛,{pin} 前辦理登記。',
|
||||
'roadtrip.ride.lateHintShip': '行程 {time} 才抵達 {place}。{title} {departs} 開船,{pin} 前辦理登記。',
|
||||
'roadtrip.ride.lateHintBoarding': '行程 {time} 才抵達 {place}。{title} {departs} 發車,{pin} 前上車。',
|
||||
'roadtrip.day.rideMissed.flight': '錯過航班',
|
||||
'roadtrip.day.rideMissed.train': '錯過火車',
|
||||
'roadtrip.day.rideMissed.ferry': '錯過渡輪',
|
||||
'roadtrip.day.rideMissed.cruise': '錯過郵輪',
|
||||
'roadtrip.day.rideMissed.bus': '錯過巴士',
|
||||
'roadtrip.bookend.checkOut': '退房',
|
||||
'roadtrip.bookend.from': '出發',
|
||||
'roadtrip.bookend.back': '住宿',
|
||||
'roadtrip.bookend.checkIn': '入住',
|
||||
'roadtrip.bookend.afterCheckOut': '退房後才出發',
|
||||
'roadtrip.bookend.noVia': '往返住宿地點的路段無法加入途經點。請改在那裡新增一個停靠點。',
|
||||
};
|
||||
|
||||
@@ -275,10 +275,28 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': '打开预订',
|
||||
'roadtrip.ride.undated': '{title} 不在本次旅行的任何一天,因此行程不会用到它。',
|
||||
'roadtrip.bookings.loose': '当天的其他预订',
|
||||
'roadtrip.bookend.checkOut': '退房 · {name}',
|
||||
'roadtrip.bookend.from': '从 {name} 出发',
|
||||
'roadtrip.bookend.back': '返回 {name}',
|
||||
'roadtrip.bookend.checkIn': '入住 · {name}',
|
||||
'roadtrip.ride.departureFlight': '起飞 {time}',
|
||||
'roadtrip.ride.duration': '预订时长',
|
||||
'roadtrip.ride.checkIn': '办理登记',
|
||||
'roadtrip.ride.boarding': '上车',
|
||||
'roadtrip.ride.missed': '错过',
|
||||
'roadtrip.ride.lateBy': '晚到 {time}',
|
||||
'roadtrip.ride.reachedAt': '{time} 才到',
|
||||
'roadtrip.ride.hintFlight': '{title} {departs} 起飞,{pin} 前办理登记。',
|
||||
'roadtrip.ride.hintShip': '{title} {departs} 开船,{pin} 前办理登记。',
|
||||
'roadtrip.ride.hintBoarding': '{title} {departs} 发车,{pin} 前上车。',
|
||||
'roadtrip.ride.lateHintFlight': '行程 {time} 才到达 {place}。{title} {departs} 起飞,{pin} 前办理登记。',
|
||||
'roadtrip.ride.lateHintShip': '行程 {time} 才到达 {place}。{title} {departs} 开船,{pin} 前办理登记。',
|
||||
'roadtrip.ride.lateHintBoarding': '行程 {time} 才到达 {place}。{title} {departs} 发车,{pin} 前上车。',
|
||||
'roadtrip.day.rideMissed.flight': '错过航班',
|
||||
'roadtrip.day.rideMissed.train': '错过火车',
|
||||
'roadtrip.day.rideMissed.ferry': '错过渡轮',
|
||||
'roadtrip.day.rideMissed.cruise': '错过邮轮',
|
||||
'roadtrip.day.rideMissed.bus': '错过巴士',
|
||||
'roadtrip.bookend.checkOut': '退房',
|
||||
'roadtrip.bookend.from': '出发',
|
||||
'roadtrip.bookend.back': '住宿',
|
||||
'roadtrip.bookend.checkIn': '入住',
|
||||
'roadtrip.bookend.afterCheckOut': '退房后才出发',
|
||||
'roadtrip.bookend.noVia': '往返住处的路段不能添加途经点。请改为在那里添加一个停靠点。',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user