Compare commits

..

6 Commits

Author SHA1 Message Date
Maurice eea977f364 refactor(pdf): call the markdown row directly 2026-10-24 09:51:44 +02:00
Maurice 3e3d51082e refactor(pdf): the markdown rows and styles in a module of their own; the shared stop's markdown test beside its component 2026-10-24 09:51:44 +02:00
Maurice 7e189acd71 fix(journey): a journey's created_at is an epoch number 2026-10-24 09:51:44 +02:00
Maurice d33b3a81b1 fix: journey year helper takes what a journey row carries; PDF markdown test without casts 2026-10-24 09:51:43 +02:00
Maurice bd91321720 fix: test strings, logical sides in the PDF markdown styles, one export per markdown module 2026-10-24 09:51:43 +02:00
Maurice 36b890d97d fix: four reported bugs that dev still had
- Dashboard: a browser that names no time zone (Chrome on Windows for a zone
  it cannot map) crashed the world clocks and the whole dashboard. One helper
  now reads the browser zone and every reader picks its fallback; a stored
  zone the browser does not know is left out instead of throwing (#2694).
- Phone: a leg of a multi-leg train or flight showed only its route, and its
  sheet showed the first leg's seat. The day plan subtitle is one helper for
  desktop and phone now, so a leg names its own train or flight, platform and
  seat, translated instead of the hardcoded "Gl."/"Sitz"; the sheet opened
  from a leg shows that leg's number, seat, platform and times (#2701). The
  PDF export translates platform and seat too.
- Journey list: the year badge named the year a journey was written, not the
  years of its trips; a trip over New Year shows both (#2685).
- A place's description and notes are markdown in the planner but printed as
  raw syntax in the PDF export and on a shared trip. Both render it now through
  the app's sanitized markdown pipeline, raw HTML stays text (#2692).

Closes #2694
Closes #2701
Closes #2685
Closes #2692
2026-10-24 09:51:43 +02:00
28 changed files with 468 additions and 90 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2953,
"src/components/Planner/DayPlanSidebar.tsx": 2936,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157,
"src/components/Planner/PlaceInspector.tsx": 1290,
@@ -1,4 +1,4 @@
// FE-COMP-TZHOOK-001 to -007: the world clocks both dashboards share.
// FE-COMP-TZHOOK-001 to -009: the world clocks both dashboards share.
import { act, renderHook, waitFor } from '@testing-library/react';
import { useSettingsStore } from '../../store/settingsStore';
@@ -97,4 +97,27 @@ describe('useDashboardTimezones', () => {
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
expect(updateSetting).not.toHaveBeenCalled();
});
// #2694: Chrome on Windows names no zone for a system zone it cannot map.
it('FE-COMP-TZHOOK-008: a browser that names no zone still gets the default clocks', () => {
const original = Intl.DateTimeFormat.prototype.resolvedOptions;
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (
this: Intl.DateTimeFormat
) {
return { ...original.call(this), timeZone: undefined as unknown as string };
});
try {
const { result } = renderHook(() => useDashboardTimezones('en'));
expect(result.current.zones).toEqual(['Europe/London', 'Asia/Tokyo']);
expect(() => result.current.zones.map((tz) => result.current.timeIn(tz))).not.toThrow();
} finally {
spy.mockRestore();
}
});
it('FE-COMP-TZHOOK-009: a stored zone this browser does not know is left out instead of crashing the clocks', () => {
seedSettings(['Europe/Berlin', 'Mars/Olympus_Mons', '']);
const { result } = renderHook(() => useDashboardTimezones('en'));
expect(result.current.zones).toEqual(['Europe/Berlin']);
});
});
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useSettingsStore } from '../../store/settingsStore';
import { browserTimeZone, isValidTimeZone } from '../../utils/localDate';
const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo'];
@@ -38,13 +39,15 @@ export function shortZone(tz: string): string {
* clocks tick every 30 seconds, `adding` toggles the zone picker.
*/
export function useDashboardTimezones(locale: string) {
const home = Intl.DateTimeFormat().resolvedOptions().timeZone;
const home = browserTimeZone();
const [now, setNow] = useState(() => new Date());
const isLoaded = useSettingsStore((s) => s.isLoaded);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const stored = useSettingsStore((s) => s.settings.dashboard_timezones);
// Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
const zones = stored ?? [home, ...DEFAULT_ZONES];
// A browser that names no zone (#2694) or a zone this browser does not know
// would throw while rendering a clock, so such entries are left out.
const zones = (stored ?? [home, ...DEFAULT_ZONES]).filter(isValidTimeZone);
const setZones = (next: string[]) => {
updateSetting('dashboard_timezones', next).catch(() => {});
};
+15 -2
View File
@@ -397,6 +397,19 @@ describe('downloadTripPDF', () => {
expect(iframe!.srcdoc).toContain('€')
})
// #2692: a place's description and notes print as the planner shows them, not as raw markdown.
it('FE-COMP-TRIPPDF-016c: renders markdown in a place description, sanitized', async () => {
const description = ['**Colosseum** tickets', '', '| Gate | Time |', '| --- | --- |', '| A | 9:00 |', '', '<script>alert(1)</script>'].join('\n')
const place = { ...placeWithDetails, description }
const md = { ...richArgs, places: [place], assignments: { '10': [{ ...assignmentForDay, place }] } as typeof richArgs.assignments }
await downloadTripPDF(md)
const html = getIframe()?.srcdoc ?? ''
expect(html).toContain('<strong>Colosseum</strong>')
expect(html).toContain('<td>9:00</td>')
expect(html).not.toContain('**Colosseum**')
expect(html).not.toContain('<script>alert(1)</script>')
})
it('FE-COMP-TRIPPDF-016b: formats price chip and totals in the trip currency, not EUR', async () => {
const usdArgs = {
...richArgs,
@@ -796,7 +809,7 @@ describe('downloadTripPDF remaining branches', () => {
}]))
const html = srcdoc()
expect(html).toContain('ICE 599 · Gl. 7 · Seat 21A · FRA → BER')
expect(html).toContain('ICE 599 · reservations.meta.platform 7 · reservations.meta.seat 21A · FRA → BER')
})
it('FE-W5PDF-006: a multi-leg train renders one line per leg', async () => {
@@ -811,7 +824,7 @@ describe('downloadTripPDF remaining branches', () => {
}]))
const html = srcdoc()
expect(html).toContain('IC 1 · Gl. 3 · BER → MUC')
expect(html).toContain('IC 1 · reservations.meta.platform 3 · BER → MUC')
expect(html).toContain('EC 2 · MUC → ZRH')
})
+11 -8
View File
@@ -24,6 +24,7 @@ import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs'
import { isServiceStopType } from '../Roadtrip/roadtripModel'
import { onlyMyPlan } from './pdfScope'
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
/**
* Every day starts a new page by default. On a trip of short days that prints
@@ -231,6 +232,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
let hasTransportNotes = false
const loc = _locale || undefined
const tr = _t || (k => k)
const markdown = createPdfMarkdown()
// The store read is the fallback, not the source: a caller that forgets the
// prop still prints the reader's own format instead of silently reverting.
const is12h = (_timeFormat || useSettingsStore.getState().settings.time_format || '24h') === '12h'
@@ -439,8 +441,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
const source = (usesStationRoute(r.type) ? getTrainLegs(r) : getFlightLegs(r))[l.index]
subtitleLines = [[
l.airline, l.flight_number, l.train_number,
l.platform ? `Gl. ${l.platform}` : '',
l.seat ? `Seat ${l.seat}` : '',
l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
l.seat ? `${tr('reservations.meta.seat')} ${l.seat}` : '',
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
source?.confirmation_number,
].filter(Boolean).join(' · ')].filter(Boolean)
@@ -471,7 +473,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
// Multi-leg: one line per leg so every train number + segment route shows,
// with the segment's own booking code where it has one (#1943).
subtitleLines = legs.map(l =>
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
[l.train_number, l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
l.confirmation_number]
.filter(Boolean).join(' · '))
@@ -479,7 +481,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
} else {
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
const route = stops.length >= 2 ? stops.join(' → ') : ''
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
subtitle = [meta.train_number, meta.platform ? `${tr('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${tr('reservations.meta.seat')} ${meta.seat}` : '', route].filter(Boolean).join(' · ')
}
}
else if (r.type === 'car') {
@@ -587,10 +589,10 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
</div>
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
${place.description ? markdown.row(place.description) : ''}
${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''}
${place.notes ? markdown.row(place.notes) : ''}
${item.data.notes ? markdown.row(item.data.notes) : ''}
</div>
</div>`
}).join('')
@@ -836,7 +838,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
.info-spacer { width: 13px; flex-shrink: 0; }
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
.info-text.muted { color: #94a3b8; }
.info-text.italic { font-style: italic; }
${PDF_MARKDOWN_CSS}
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
@@ -930,6 +932,7 @@ ${tripMapHtml}
${daysHtml}
${pluginSectionsHtml}
</body></html>`
markdown.dispose()
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
const overlay = document.createElement('div')
+30
View File
@@ -0,0 +1,30 @@
import { createMarkdownHtml } from '../shared/markdownHtml';
/**
* A place's description and notes are markdown in the app, so the export
* renders them the same way instead of printing the raw syntax (#2692): one
* info row per text, the HTML from the app's own sanitized pipeline.
*/
export function createPdfMarkdown(): { row: (text: string) => string; dispose: () => void } {
const markdown = createMarkdownHtml();
return {
row: (text) =>
`<div class="info-row"><span class="info-spacer"></span><div class="info-text muted md">${markdown.html(text)}</div></div>`,
dispose: markdown.dispose,
};
}
/** The markdown's own spacing, lists, tables and links at the export's small size. */
export const PDF_MARKDOWN_CSS = `
.info-text.md { min-width: 0; overflow-wrap: anywhere; }
.md > :first-child { margin-top: 0; }
.md > :last-child { margin-bottom: 0; }
.md p, .md ul, .md ol, .md table, .md blockquote, .md pre { margin: 0 0 4px; }
.md ul, .md ol { padding-inline-start: 14px; }
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-size: 1.1em; font-weight: 700; margin: 4px 0 2px; }
.md table { border-collapse: collapse; }
.md th, .md td { border: 1px solid #e2e8f0; padding: 2px 5px; text-align: start; }
.md code { font-family: ui-monospace, monospace; font-size: 8.5px; }
.md a { color: inherit; text-decoration: underline; }
.md blockquote { border-inline-start: 2px solid #e2e8f0; padding-inline-start: 6px; }
`;
@@ -43,6 +43,7 @@ import './dayPlanRows.css'
import { TypeTile } from './bookings/bookingParts'
import { typeInfo } from './bookings/bookingsModel'
import { transportSubtitle } from './transportDetailModel'
import { dayHeadStays } from './dayHeadStays'
import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import {
@@ -2166,24 +2167,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
// Subtitle aus Metadaten zusammensetzen
let subtitle = ''
if (res.__leg) {
// One leg of a multi-leg flight/train — show this segment's own detail.
const parts = res.type === 'train'
? [res.__leg.train_number, res.__leg.platform ? `${t('reservations.meta.platform')} ${res.__leg.platform}` : '', res.__leg.seat ? `${t('reservations.meta.seat')} ${res.__leg.seat}` : ''].filter(Boolean)
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
if (res.__leg.from || res.__leg.to)
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
subtitle = parts.join(', ')
} else if (res.type === 'flight') {
const parts = [meta.airline, meta.flight_number].filter(Boolean)
if (meta.departure_airport || meta.arrival_airport)
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
subtitle = parts.join(', ')
} else if (res.type === 'train') {
subtitle = [meta.train_number, meta.platform ? `${t('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${t('reservations.meta.seat')} ${meta.seat}` : ''].filter(Boolean).join(', ')
}
// A leg of a multi-leg flight or train shows its own detail (#2701).
const subtitle = transportSubtitle(res, t) ?? ''
// A transit journey (#1065) renders its itinerary inline —
// line badges in their colors instead of a plain subtitle,
@@ -23,3 +23,65 @@ export function bookingDayLabel(date: string, locale: string): string {
export function segmentCodeLabel(leg: { from?: string | null; to?: string | null }, t: Translate): string {
return [leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode');
}
/** The per-leg detail the day plan's leg rows carry (utils/dayMerge's `__leg`). */
export interface TransportLegDetail {
index: number;
from?: string | null;
to?: string | null;
airline?: string | null;
flight_number?: string | null;
train_number?: string | null;
platform?: string | null;
seat?: string | null;
}
/**
* The subtitle of a transport row in the day plan, desktop and phone alike. A
* leg of a multi-leg train or flight names its own train or flight, platform
* and seat (#2701); a whole booking reads them off its metadata. Null for the
* other transport types, which each view labels its own way.
*/
export function transportSubtitle(
res: { type: string; metadata?: unknown; __leg?: TransportLegDetail | null },
t: Translate
): string | null {
const meta = parseMeta(res.metadata);
const leg = res.__leg;
const trainParts = (d: { train_number?: unknown; platform?: unknown; seat?: unknown }) => [
str(d.train_number),
str(d.platform) ? `${t('reservations.meta.platform')} ${str(d.platform)}` : '',
str(d.seat) ? `${t('reservations.meta.seat')} ${str(d.seat)}` : '',
];
if (leg) {
const parts = res.type === 'train' ? trainParts(leg) : [str(leg.airline), str(leg.flight_number)];
if (leg.from || leg.to) parts.push([leg.from, leg.to].filter(Boolean).join(' → '));
return parts.filter(Boolean).join(', ');
}
if (res.type === 'flight') {
const parts = [str(meta.airline), str(meta.flight_number)];
if (meta.departure_airport || meta.arrival_airport) {
parts.push([str(meta.departure_airport), str(meta.arrival_airport)].filter(Boolean).join(' → '));
}
return parts.filter(Boolean).join(', ');
}
if (res.type === 'train') return trainParts(meta).filter(Boolean).join(', ');
return null;
}
function str(value: unknown): string {
return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
}
function parseMeta(metadata: unknown): Record<string, unknown> {
let meta = metadata;
// An earlier bug could double-encode the metadata; reading it twice heals that.
for (let i = 0; i < 2 && typeof meta === 'string'; i++) {
try {
meta = JSON.parse(meta || '{}');
} catch {
meta = {};
}
}
return meta && typeof meta === 'object' ? (meta as Record<string, unknown>) : {};
}
@@ -11,6 +11,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
import { pluginsApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useIsPhone } from '../../mobile/useIsPhone'
import { browserTimeZone } from '../../utils/localDate'
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
@@ -379,7 +380,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
timeFormat: settings.time_format,
distanceUnit: settings.distance_unit,
temperatureUnit: settings.temperature_unit,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timezone: browserTimeZone() ?? 'UTC',
blurBookingCodes: Boolean(settings.blur_booking_codes),
},
tokens: readThemeTokens(),
@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { createMarkdownHtml } from './markdownHtml';
import { SafeMarkdown } from './SafeMarkdown';
describe('SafeMarkdown (#2692)', () => {
it('FE-COMP-SAFEMD-001: renders GitHub markdown with kept line breaks and safe links', () => {
render(<SafeMarkdown>{'# Title\nfirst\nsecond\n\n[site](https://example.com)'}</SafeMarkdown>);
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument();
expect(document.querySelector('br')).not.toBeNull();
const link = screen.getByRole('link', { name: 'site' });
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
});
it('FE-COMP-SAFEMD-002: shows raw HTML as text and never as an element', () => {
render(<SafeMarkdown>{'Room <TBD> <img src=x onerror=alert(1)>'}</SafeMarkdown>);
expect(document.querySelector('img')).toBeNull();
expect(screen.getByText(/Room <TBD>/)).toBeInTheDocument();
});
});
describe('createMarkdownHtml (#2692)', () => {
it('FE-COMP-SAFEMD-003: returns the sanitized HTML, the same for the same text', () => {
const markdown = createMarkdownHtml();
try {
const html = markdown.html('**bold** and <script>alert(1)</script>');
expect(html).toContain('<strong>bold</strong>');
expect(html).not.toContain('<script>');
expect(markdown.html('**bold** and <script>alert(1)</script>')).toBe(html);
expect(markdown.html('- a\n- b')).toContain('<li>a</li>');
} finally {
markdown.dispose();
}
});
});
@@ -0,0 +1,22 @@
import Markdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from './markdownSanitize';
/**
* Text a user wrote in markdown (a place's description, its notes), rendered
* the way the planner shows it: GitHub flavour, line breaks kept, raw HTML
* shown as text, the rest sanitized, links in a new tab without an opener.
* Safe for pages strangers see, like a shared trip (#2692).
*/
export function SafeMarkdown({ children }: { children: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={sanitizedMarkdownPlugins}
components={sanitizedMarkdownComponents}
>
{children}
</Markdown>
);
}
@@ -0,0 +1,27 @@
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { SafeMarkdown } from './SafeMarkdown';
/**
* Turns markdown into the HTML {@link SafeMarkdown} renders, for output built as
* a string such as the PDF export (#2692). It renders through the same
* pipeline into a detached node, so the sanitizer is the one the app uses and
* no second markdown library is bundled. Call it outside a render and dispose
* of it when done; the same text is rendered once.
*/
export function createMarkdownHtml(): { html: (text: string) => string; dispose: () => void } {
const host = document.createElement('div');
const root = createRoot(host);
const done = new Map<string, string>();
return {
html: (text) => {
const known = done.get(text);
if (known !== undefined) return known;
flushSync(() => root.render(<SafeMarkdown>{text}</SafeMarkdown>));
done.set(text, host.innerHTML);
return host.innerHTML;
},
dispose: () => root.unmount(),
};
}
@@ -224,7 +224,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
drag={dragFor(row)}
onOpen={() => {
if (editing) tl.editTransport(row.res)
else shell.openSheet('transport', { reservationId: row.res.id })
else shell.openSheet('transport', { reservationId: row.res.id, legIndex: row.res.__leg?.index })
}}
/>
{dayScheduleFor('reservation', row.res.id)}
@@ -265,7 +265,7 @@ export function TransportRow({ res, dayId, chrome, reorder, drag, onOpen }: {
const time = start
? `${fmtTime(start, chrome)}${phase === 'single' && end ? ` – ${fmtTime(end, chrome)}` : ''}`
: ''
const sub = transportSubtitle(res)
const sub = transportSubtitle(res, chrome.t)
return (
<div
@@ -5,6 +5,7 @@ import {
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
import { transportSubtitle as dayPlanTransportSubtitle, type TransportLegDetail } from '../../../../components/Planner/transportDetailModel'
import { projectDayItinerary } from '../../../../components/Map/dayTourProjection'
import type { Accommodation, Assignment, Day, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../types'
@@ -16,7 +17,7 @@ import type { Accommodation, Assignment, Day, DayNote, Place, Reservation, Route
/** A day-timeline transport row can be a synthetic per-leg expansion of a multi-leg booking. */
export type TransportEntry = Reservation & {
__leg?: { index: number; total: number; from: string | null; to: string | null }
__leg?: TransportLegDetail & { total: number }
}
export interface TransitMeta {
@@ -48,26 +49,8 @@ export function getTransitMeta(res: Reservation): TransitMeta | null {
}
/** Same subtitle language as the desktop day plan: airline/number, train/platform/seat, leg detail. */
export function transportSubtitle(res: TransportEntry): string {
const meta = parseReservationMeta(res) as Record<string, string | undefined>
if (res.__leg) {
const parts = [res.__leg.from, res.__leg.to].filter(Boolean).length
? [[res.__leg.from, res.__leg.to].filter(Boolean).join(' → ')]
: []
return parts.join(' · ')
}
if (res.type === 'flight') {
const parts = [meta.airline, meta.flight_number].filter(Boolean) as string[]
if (meta.departure_airport || meta.arrival_airport) {
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
}
return parts.join(' · ')
}
if (res.type === 'train') {
return [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : '']
.filter(Boolean).join(' · ')
}
return res.location || ''
export function transportSubtitle(res: TransportEntry, t: TranslationFn): string {
return dayPlanTransportSubtitle(res, t) ?? (res.location || '')
}
const sameCoord = (a: [number, number], b: [number, number]): boolean =>
@@ -6,7 +6,7 @@ import { useTranslation } from '../../../../i18n'
import { useSettingsStore } from '../../../../store/settingsStore'
import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
import { splitReservationDateTime } from '../../../../utils/formatters'
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../../../utils/flightLegs'
import { getFlightLegs, getTrainLegs, usesStationRoute, type FlightLeg, type TrainLeg } from '../../../../utils/flightLegs'
import { openFile } from '../../../../utils/fileDownload'
import { runsOnDay } from '../../../../utils/reservationRoutes'
import { filesFor } from '../../../../utils/reservationFiles'
@@ -19,6 +19,8 @@ import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripShe
interface TransportSheetPayload {
reservationId?: number
/** The leg of a multi-leg train or flight the day plan row stood for (#2701). */
legIndex?: number
}
interface TransitLeg {
@@ -118,24 +120,38 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
const to = (res.endpoints || []).find(e => e.role === 'to')
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
const depTime = from?.local_time || startTime
const arrTime = to?.local_time || endTime
const subParts: string[] = []
if (meta.airline) subParts.push(meta.airline)
if (meta.flight_number) subParts.push(meta.flight_number)
if (meta.train_number) subParts.push(meta.train_number)
if (from?.name && to?.name) subParts.push(`${from.name} → ${to.name}`)
else if (date) subParts.push(bookingDayLabel(date, locale))
const seat = meta.seat
const platform = meta.platform
const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
const bookingDepTime = from?.local_time || startTime
const bookingArrTime = to?.local_time || endTime
// Per-segment booking codes (#1943), only on a real stopover booking: the
// single-leg fallback would just echo the booking's own code shown below.
const routeLegs = res.type === 'flight' ? getFlightLegs(res) : usesStationRoute(res.type) ? getTrainLegs(res) : []
const isFlight = res.type === 'flight'
const routeLegs: (FlightLeg | TrainLeg)[] = isFlight ? getFlightLegs(res) : usesStationRoute(res.type) ? getTrainLegs(res) : []
const legCodes = routeLegs.length > 1 ? routeLegs.filter(l => l.confirmation_number) : []
// Opened from one leg's row, the sheet shows that leg's own number, seat,
// platform and times rather than the first leg's (#2701).
const leg = routeLegs.length > 1 && payload.legIndex != null ? routeLegs[payload.legIndex] ?? null : null
const legDetail = (leg ?? {}) as Partial<FlightLeg & TrainLeg>
const depTime = leg ? leg.dep_time || '' : bookingDepTime
const arrTime = leg ? leg.arr_time || '' : bookingArrTime
const depLabel = (leg ? leg.from : from?.name) || t('reservations.time')
const arrLabel = (leg ? leg.to : to?.name) || t('reservations.time')
const subParts: string[] = []
const airline = leg ? legDetail.airline : meta.airline
const flightNumber = leg ? legDetail.flight_number : meta.flight_number
const trainNumber = leg ? legDetail.train_number : meta.train_number
if (airline) subParts.push(airline)
if (flightNumber) subParts.push(flightNumber)
if (trainNumber) subParts.push(trainNumber)
if (leg && (leg.from || leg.to)) subParts.push([leg.from, leg.to].filter(Boolean).join(' → '))
else if (from?.name && to?.name) subParts.push(`${from.name} → ${to.name}`)
else if (date) subParts.push(bookingDayLabel(date, locale))
// A flight's legs carry no seat or platform of their own, so those stay the booking's.
const seat = leg && !isFlight ? legDetail.seat : meta.seat
const platform = leg && !isFlight ? legDetail.platform : meta.platform
const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
const resFiles = filesFor(res, planner.files || [])
@@ -186,10 +202,10 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
{(depTime || arrTime || seat || platform) && (
<div className="mt-[14px] flex gap-[6px]">
{depTime && (
<StatBox value={displayTime(depTime, locale, timeFormat)} label={from?.name || t('reservations.time')} />
<StatBox value={displayTime(depTime, locale, timeFormat)} label={depLabel} />
)}
{arrTime && (
<StatBox value={displayTime(arrTime, locale, timeFormat)} label={to?.name || t('reservations.time')} />
<StatBox value={displayTime(arrTime, locale, timeFormat)} label={arrLabel} />
)}
{platform && <StatBox value={platform} label={t('reservations.meta.platform')} />}
{seat && <StatBox value={seat} label={t('reservations.meta.seat')} />}
+2 -1
View File
@@ -9,6 +9,7 @@ import {
import type { Journey } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourney } from './journey/useJourney'
import { journeyYearLabel } from './journey/journeyModel'
import HelpAnchor from '../components/Help/HelpAnchor'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell'
import { EditorField, INPUT } from '../components/shared/dialogParts'
@@ -438,7 +439,7 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
<span className="absolute top-3.5 start-3.5 z-[2] inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-white text-[11px] font-semibold"
style={{ background: 'rgba(255,255,255,0.18)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid rgba(255,255,255,0.26)' }}>
<Calendar size={12} strokeWidth={2.2} />
{new Date(j.created_at).getFullYear()}
{journeyYearLabel(j)}
</span>
{lifecycle !== 'live' && (
<span className="absolute top-3.5 end-3.5 z-[2] px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide text-white"
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { journeyYearLabel } from './journeyModel';
describe('journeyYearLabel (#2685)', () => {
it('FE-PAGE-JOURNEY-YEAR-001: names the year of the trips, not the year the journey was written', () => {
expect(
journeyYearLabel({ trip_date_min: '2024-09-12', trip_date_max: '2024-09-20', created_at: '2026-03-01 10:00:00' })
).toBe('2024');
});
it('FE-PAGE-JOURNEY-YEAR-002: a trip over New Year names both years', () => {
expect(
journeyYearLabel({ trip_date_min: '2024-12-28', trip_date_max: '2025-01-04', created_at: '2026-03-01 10:00:00' })
).toBe('2024/2025');
});
it('FE-PAGE-JOURNEY-YEAR-003: one known date is enough, and without any it falls back to when it was written', () => {
expect(
journeyYearLabel({ trip_date_min: null, trip_date_max: '2023-05-02', created_at: '2026-03-01 10:00:00' })
).toBe('2023');
expect(journeyYearLabel({ trip_date_min: null, trip_date_max: null, created_at: '2026-03-01 10:00:00' })).toBe(
'2026'
);
});
});
+13
View File
@@ -0,0 +1,13 @@
/** The year a journey card names: the years its trips cover, not the year it was written (#2685). */
export function journeyYearLabel(journey: {
trip_date_min?: string | null;
trip_date_max?: string | null;
created_at?: string | number | null;
}): string {
// The dates are calendar dates, so the year is read from the text, not through a Date in some zone.
const from = journey.trip_date_min?.slice(0, 4);
const to = journey.trip_date_max?.slice(0, 4);
if (from && to && to !== from) return `${from}/${to}`;
if (from || to) return (from || to) as string;
return journey.created_at ? String(new Date(journey.created_at).getFullYear()) : '';
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { SharedPlaceDetails } from './SharedPlaceDetails';
/** FE-PAGE-SHARED-PLACE-001: a stop's description and notes on a shared trip, read as the planner shows them (#2692). */
describe('SharedPlaceDetails', () => {
it('FE-PAGE-SHARED-PLACE-001: renders markdown in the description and notes, and keeps raw HTML as text', () => {
render(
<SharedPlaceDetails
place={{
name: 'Market',
address: 'Market Square 1',
description: [
'## Opening',
'',
'| Day | Hours |',
'| --- | --- |',
'| Sat | 8 to 14 |',
'',
'<img src=x onerror=alert(1)>',
].join('\n'),
notes: ['- fruit', '- bread'].join('\n'),
}}
assignmentNotes="**Bring** cash"
/>
);
expect(screen.getByRole('heading', { name: 'Opening' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: '8 to 14' })).toBeInTheDocument();
expect(screen.getByText('Bring').tagName).toBe('STRONG');
expect(screen.getByText('fruit').tagName).toBe('LI');
expect(document.querySelector('img[src="x"]')).toBeNull();
expect(screen.queryByText('## Opening')).toBeNull();
});
});
@@ -3,6 +3,7 @@ import { useTranslation } from '../../i18n'
import { fs } from '../../components/shared/DialogShell'
import { Tooltip } from '../../components/shared/Tooltip'
import { WhiteBadge } from '../../components/Planner/planParts'
import { SafeMarkdown } from '../../components/shared/SafeMarkdown'
import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps'
import { formatDurationMinutes, isHttpUrl, plannedStayMinutes } from './sharedTripModel'
@@ -61,7 +62,7 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl
</div>
)}
{place.description && (
<div className="whitespace-pre-wrap text-content-secondary [overflow-wrap:anywhere]" style={fs(12, 'body')}>{place.description}</div>
<div className="collab-note-md-full text-content-secondary [overflow-wrap:anywhere]" style={fs(12, 'body')}><SafeMarkdown>{place.description}</SafeMarkdown></div>
)}
{dayNote && <NoteLine label={t('places.assignmentNotes')} text={dayNote} strong />}
{placeNote && <NoteLine label={t('places.formNotes')} text={placeNote} />}
@@ -98,7 +99,7 @@ function NoteLine({ label, text, strong = false }: { label: string; text: string
<Tooltip label={label} placement="top">
<div className={`flex items-start gap-1.5 rounded-lg bg-surface-secondary px-2 py-1.5 ${strong ? 'text-content-secondary' : 'text-content-muted'}`} style={fs(11.5, 'body')}>
<FileText size={11} strokeWidth={2} className="mt-0.5 flex-none text-content-faint" aria-label={label} />
<span className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]">{text}</span>
<div className="collab-note-md-full min-w-0 [overflow-wrap:anywhere]"><SafeMarkdown>{text}</SafeMarkdown></div>
</div>
</Tooltip>
)
+2 -1
View File
@@ -21,6 +21,7 @@ import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
import { assistantRepo } from '../repo/assistantRepo';
import { OnlineOnlyError } from '../repo/onlineOnly';
import type { Assignment } from '../types';
import { browserTimeZone } from '../utils/localDate';
import { randomId } from '../utils/randomId';
import type { RouteProfileFocus } from '../utils/routeGeometry';
import { assistantScopeOf, assistantTargetOf, type AssistantChatKey } from './assistantChatKey';
@@ -331,7 +332,7 @@ function errorCodeOf(err: unknown): AssistantErrorCode {
function clientContext() {
return {
locale: useSettingsStore.getState().settings.language || 'en',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
timeZone: browserTimeZone() ?? 'UTC',
};
}
+2 -1
View File
@@ -18,6 +18,7 @@ import { forgetServerLanguage } from './settingsStore'
import { forgetResumeRoute } from '../utils/resumeRoute'
import { markSignedOut, clearSignedOut } from '../utils/signedOut'
import { forgetPushDeviceOnLogout, resyncPushSubscription } from '../push/webPush'
import { browserTimeZone } from '../utils/localDate'
/** How long a logout waits for the queued changes to go out. */
const LOGOUT_FLUSH_MS = 5000
@@ -154,7 +155,7 @@ export const useAuthStore = create<AuthState>()(
hasMapsKey: false,
hasAmapKey: false,
placesProvider: 'auto',
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
serverTimezone: browserTimeZone() ?? '',
appRequireMfa: false,
tripRemindersEnabled: false,
placesPhotosEnabled: true,
+24 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { isLocalToday, localIsoDate, localMinutes } from './localDate'
import { browserTimeZone, isLocalToday, isValidTimeZone, localIsoDate, localMinutes } from './localDate'
/**
* "Today" must be the user's local calendar date. `toISOString()` is the UTC
@@ -66,3 +66,26 @@ describe('isLocalToday', () => {
expect(isLocalToday(undefined)).toBe(false)
})
})
describe('browserTimeZone and isValidTimeZone (#2694)', () => {
it('names the browser zone, and nothing when the browser names none', () => {
expect(browserTimeZone()).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone)
const original = Intl.DateTimeFormat.prototype.resolvedOptions
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (this: Intl.DateTimeFormat) {
return { ...original.call(this), timeZone: undefined as unknown as string }
})
try {
expect(browserTimeZone()).toBeUndefined()
} finally {
spy.mockRestore()
}
})
it('accepts the zones Intl knows and refuses the rest', () => {
expect(isValidTimeZone('Europe/Berlin')).toBe(true)
expect(isValidTimeZone('UTC')).toBe(true)
expect(isValidTimeZone('Mars/Olympus_Mons')).toBe(false)
expect(isValidTimeZone('')).toBe(false)
expect(isValidTimeZone(undefined)).toBe(false)
})
})
+21
View File
@@ -27,3 +27,24 @@ export function localMinutes(d: Date = new Date()): number {
export function isLocalToday(date: string | null | undefined): boolean {
return !!date && date.slice(0, 10) === localIsoDate()
}
/**
* The browser's IANA time zone, or undefined when it cannot name one. Chrome
* on Windows answers undefined for a system zone it cannot map, such as
* Alberta's permanent UTC-6 (#2694), so every reader picks its own fallback.
*/
export function browserTimeZone(): string | undefined {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone as string | undefined
return typeof tz === 'string' && tz ? tz : undefined
}
/** Whether `Intl` accepts the zone; formatting with one it rejects throws a RangeError. */
export function isValidTimeZone(tz: unknown): tz is string {
if (typeof tz !== 'string' || !tz) return false
try {
new Intl.DateTimeFormat(undefined, { timeZone: tz })
return true
} catch {
return false
}
}
@@ -193,7 +193,7 @@ describe('TransportRow', () => {
expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument()
expect(container.textContent).toContain('08:15 – 18:40')
expect(screen.getByText('Lufthansa · LH714 · MUC → HND')).toBeInTheDocument()
expect(screen.getByText('Lufthansa, LH714, MUC → HND')).toBeInTheDocument()
})
it('FE-MOB-PLROW-014: a multi-day span shows only the day-relevant time', () => {
@@ -234,7 +234,7 @@ describe('TransportRow', () => {
} as unknown as TransportEntry
render(<TransportRow {...base} res={train} />)
expect(screen.getByText('ICE 599 · Gl. 7 · Sitz 21A')).toBeInTheDocument()
expect(screen.getByText('ICE 599, reservations.meta.platform 7, reservations.meta.seat 21A')).toBeInTheDocument()
})
it('FE-MOB-PLROW-018: an untimed booking of an unknown type still renders its title', () => {
@@ -7,7 +7,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-TRSH-001 to FE-MOB-TRSH-033
// FE-MOB-TRSH-001 to FE-MOB-TRSH-035
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
@@ -442,4 +442,34 @@ describe('MTransportSheet', () => {
expect(planner.handleSelectDay).toHaveBeenCalledWith(3, false)
})
})
// #2701: a leg row opens the sheet on that leg, not on the booking's first one.
describe('opened from one leg of a multi-leg train', () => {
const TRAIN = {
...FLIGHT, id: 8, type: 'train', title: 'Takayama to Kyoto', confirmation_number: null,
metadata: JSON.stringify({
train_number: 'Hida 6', seat: '10-4C',
legs: [
{ from: 'Takayama', to: 'Nagoya', train_number: 'Hida 6', seat: '10-4C', platform: '1', dep_time: '09:40', arr_time: '12:10' },
{ from: 'Nagoya', to: 'Kyoto', train_number: 'Nozomi 67', seat: '4-5A', platform: '14', dep_time: '12:35', arr_time: '13:10' },
],
}),
} as unknown as Reservation
it("FE-MOB-TRSH-034: shows the second leg's own train, seat, platform and times", () => {
render(<MTransportSheet planner={makePlanner({ reservations: [TRAIN] })} shell={makeShell({ sheet: { id: 'transport', payload: { reservationId: 8, legIndex: 1 } } })} />)
expect(screen.getByText(/Nozomi 67/)).toBeInTheDocument()
expect(screen.getByText('4-5A')).toBeInTheDocument()
expect(screen.getByText('14')).toBeInTheDocument()
expect(screen.getByText('12:35')).toBeInTheDocument()
expect(screen.getByText('13:10')).toBeInTheDocument()
expect(screen.queryByText('10-4C')).not.toBeInTheDocument()
})
it('FE-MOB-TRSH-035: without a leg the sheet still shows the booking as a whole', () => {
render(<MTransportSheet planner={makePlanner({ reservations: [TRAIN] })} shell={makeShell({ sheet: { id: 'transport', payload: { reservationId: 8 } } })} />)
expect(screen.getByText('10-4C')).toBeInTheDocument()
expect(screen.queryByText('4-5A')).not.toBeInTheDocument()
})
})
})
@@ -128,14 +128,17 @@ describe('planTimelineModel — metadata parsing', () => {
})
describe('planTimelineModel — transportSubtitle', () => {
// The labels stay keys, so the assertions read what the subtitle puts where.
const tr: TranslationFn = (key) => key
it('FE-MOB-PTLM-006: renders a synthetic leg as from → to', () => {
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 0, total: 2, from: 'FRA', to: 'IST' } } as TransportEntry
expect(transportSubtitle(res)).toBe('FRA → IST')
expect(transportSubtitle(res, tr)).toBe('FRA → IST')
})
it('FE-MOB-PTLM-007: renders a leg without endpoints as an empty subtitle', () => {
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 1, total: 2, from: null, to: null } } as TransportEntry
expect(transportSubtitle(res)).toBe('')
expect(transportSubtitle(res, tr)).toBe('')
})
it('FE-MOB-PTLM-008: joins airline, flight number and airports for a flight', () => {
@@ -143,25 +146,42 @@ describe('planTimelineModel — transportSubtitle', () => {
type: 'flight',
metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123', departure_airport: 'FRA', arrival_airport: 'HND' }),
})
expect(transportSubtitle(res)).toBe('LH · LH123 · FRA → HND')
expect(transportSubtitle(res, tr)).toBe('LH, LH123, FRA → HND')
})
it('FE-MOB-PTLM-009: omits the airport pair when neither airport is known', () => {
const res = buildReservation({ type: 'flight', metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123' }) })
expect(transportSubtitle(res)).toBe('LH · LH123')
expect(transportSubtitle(res, tr)).toBe('LH, LH123')
})
it('FE-MOB-PTLM-010: labels platform and seat for a train', () => {
const full = buildReservation({
type: 'train', metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '32A' }),
})
expect(transportSubtitle(full)).toBe('ICE 599 · Gl. 7 · Sitz 32A')
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }))).toBe('ICE 599')
expect(transportSubtitle(full, tr)).toBe('ICE 599, reservations.meta.platform 7, reservations.meta.seat 32A')
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }), tr)).toBe('ICE 599')
})
// #2701: a leg names its own train, platform and seat, not the first leg's.
it('FE-MOB-PTLM-011b: a train leg carries its own number, platform and seat', () => {
const res = {
...buildReservation({ type: 'train', metadata: JSON.stringify({ train_number: 'Hida 6', seat: '10-4C' }) }),
__leg: { index: 1, total: 2, from: 'Nagoya', to: 'Kyoto', train_number: 'Nozomi 67', platform: '14', seat: '4-5A' },
} as TransportEntry
expect(transportSubtitle(res, tr)).toBe('Nozomi 67, reservations.meta.platform 14, reservations.meta.seat 4-5A, Nagoya → Kyoto')
})
it('FE-MOB-PTLM-011c: a flight leg carries its own airline and flight number', () => {
const res = {
...buildReservation({ type: 'flight' }),
__leg: { index: 1, total: 2, from: 'IST', to: 'HND', airline: 'TK', flight_number: 'TK52' },
} as TransportEntry
expect(transportSubtitle(res, tr)).toBe('TK, TK52, IST → HND')
})
it('FE-MOB-PTLM-011: falls back to the location for any other type', () => {
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }))).toBe('Central Station')
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }))).toBe('')
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }), tr)).toBe('Central Station')
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }), tr)).toBe('')
})
})