fix(bookings): booking cards always show the calendar date of their linked day

Phone booking and transport cards printed only the title of the linked day,
so a long title cut off in the field left no date at all. They now print the
date beside the title like the desktop card, as a span that never shrinks
while the title is truncated, and a day range takes the full width with the
time under it. The desktop card gets the same non-shrinking date.

A day without a date of its own (a trip planned without dates) now borrows
the booking's start or end date, on the desktop cards, the list, the detail
dialog, the shared page and the phone alike.

Refs #2590
This commit is contained in:
Maurice
2026-10-12 10:16:01 +02:00
parent 7cb96c3d74
commit 4ee5e3fa96
10 changed files with 151 additions and 17 deletions
@@ -1,4 +1,4 @@
// FE-PLANNER-BKFACTS-001 to FE-PLANNER-BKFACTS-020
// FE-PLANNER-BKFACTS-001 to FE-PLANNER-BKFACTS-022
import { describe, it, expect } from 'vitest';
import { buildDay, buildReservation } from '../../../../tests/helpers/factories';
import type { ReservationEndpoint } from '../../../types';
@@ -43,6 +43,27 @@ describe('bookingFacts: day', () => {
expect(f.day).toEqual({ label: 'dayplan.dayN#3', date: null, range: false });
});
it('FE-PLANNER-BKFACTS-021: a day without a date borrows the booking date (#2590)', () => {
const d4 = buildDay({ id: 704, day_number: 4, date: null, title: 'Korea Arrival' });
const one = bookingFacts(buildReservation({ day_id: 703, reservation_time: '2025-06-02T15:35' }), ctx({ days: [d1, d2, d3, d4] }));
expect(one.day).toEqual({ label: 'dayplan.dayN#3', date: 'Mon, Jun 2', range: false });
const range = bookingFacts(
buildReservation({ day_id: 703, end_day_id: 704, reservation_time: '2025-06-02T15:35', reservation_end_time: '2025-06-03T09:55' }),
ctx({ days: [d1, d2, d3, d4] }),
);
expect(range.day).toEqual({ label: 'dayplan.dayN#3 → Korea Arrival', date: 'Jun 2 → Jun 3', range: true });
});
it('FE-PLANNER-BKFACTS-022: a dated day keeps its own date over the booking date, and one date is not doubled', () => {
expect(bookingFacts(buildReservation({ day_id: 701, reservation_time: '2025-06-05T10:00' }), ctx()).day?.date).toBe('Sun, Jun 1');
const d4 = buildDay({ id: 704, day_number: 4, date: null, title: null });
const f = bookingFacts(
buildReservation({ day_id: 703, end_day_id: 704, reservation_time: '2025-06-02T08:00', reservation_end_time: '2025-06-02T22:00' }),
ctx({ days: [d1, d2, d3, d4] }),
);
expect(f.day?.date).toBe('Mon, Jun 2');
});
it('FE-PLANNER-BKFACTS-005: without a day the booking dates stand in, a range when they differ', () => {
expect(bookingFacts(buildReservation({ reservation_time: '2025-06-02T10:00' }), ctx()).day)
.toEqual({ label: 'Mon, Jun 2', date: null, range: false });
@@ -41,6 +41,25 @@ export function formatDay(date: string, locale: string, weekday = true): string
return fmtDate(date, locale, weekday)
}
/**
* The calendar date beside a linked day or day range, with a weekday when it is
* a single date. A day without a date of its own (a trip planned without dates)
* borrows the booking's own start or end date, so a date is never missing when
* the booking knows one.
*/
export function linkedDayDate(
start: Day,
end: Day | undefined,
booking: { start: string | null; end: string | null },
locale: string,
separator = ' → ',
): string | null {
const first = start.date || booking.start
const last = end && end.id !== start.id ? end.date || booking.end : null
const dates = [first, last !== first ? last : null].filter(Boolean) as string[]
return dates.length ? dates.map(d => fmtDate(d, locale, dates.length === 1)).join(separator) : null
}
/** Everything a card, a row and the detail pane print about one booking, formatted once. */
export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
const { t, locale, timeFormat } = c
@@ -55,8 +74,7 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
if (span.start) {
const range = !!span.end && span.end.id !== span.start.id
const label = range ? `${dayName(span.start)} → ${dayName(span.end!)}` : dayName(span.start)
const dates = [span.start.date, range ? span.end!.date : null].filter(Boolean) as string[]
day = { label, date: dates.length ? dates.map(d => fmtDate(d, locale, dates.length === 1)).join(' → ') : null, range }
day = { label, date: linkedDayDate(span.start, span.end, { start: startDt.date, end: endDt.date }, locale), range }
} else if (startDt.date) {
const range = !!endDt.date && endDt.date !== startDt.date
const label = range ? `${fmtDate(startDt.date, locale)} → ${fmtDate(endDt.date!, locale)}` : fmtDate(startDt.date, locale)
@@ -0,0 +1,29 @@
// FE-PLANNER-BKPARTS-001 to FE-PLANNER-BKPARTS-002
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { WhenFields } from './bookingParts';
import type { BookingFacts } from './bookingFacts';
function facts(day: BookingFacts['day'], time: string | null = '15:35 → 09:55'): BookingFacts {
return {
day, time, startTime: null, endTime: null, startDate: null, endDate: null,
cells: [], endpoints: [], legCodes: [], place: null, accommodation: null, linked: null, url: null, isHotel: false,
};
}
describe('WhenFields', () => {
it('FE-PLANNER-BKPARTS-001: a long day title is cut, the date beside it never shrinks (#2590)', () => {
render(<WhenFields facts={facts({ label: 'Germany Departure Flight with a much longer custom title → Korea Arrival', date: 'Oct 21 → Oct 22', range: true })} />);
const title = screen.getByText('Germany Departure Flight with a much longer custom title → Korea Arrival');
const date = screen.getByText('Oct 21 → Oct 22');
expect(title).toHaveClass('truncate');
expect(date).toHaveClass('flex-none');
expect(title.contains(date)).toBe(false);
});
it('FE-PLANNER-BKPARTS-002: a day without a date prints its label alone', () => {
render(<WhenFields facts={facts({ label: 'Day 3', date: null, range: false }, null)} />);
expect(screen.getByText('Day 3')).toBeInTheDocument();
});
});
@@ -65,8 +65,13 @@ export function WhenFields({ facts }: { facts: BookingFacts }) {
<div className={`flex gap-2 ${facts.day?.range ? 'flex-col' : ''}`}>
{facts.day && (
<Field label={t('reservations.date')} className="flex-[1.4]">
{facts.day.label}
{facts.day.date && <span className="ms-1.5 font-medium text-content-faint">{facts.day.date}</span>}
{facts.day.date ? (
// The date never shrinks; a long day title is cut instead.
<span className="flex min-w-0 items-center justify-center gap-1.5">
<span className="min-w-0 truncate">{facts.day.label}</span>
<span className="flex-none font-medium text-content-faint">{facts.day.date}</span>
</span>
) : facts.day.label}
</Field>
)}
{facts.time && <Field label={t('reservations.time')} className="flex-1" tabular>{facts.time}</Field>}
@@ -129,7 +129,7 @@ function BookingCard({ res, planner, canEdit, compact }: {
planner.setShowReservationModal(true)
}
const { dayValue, timeValue } = cardWhen(res, startDay, endDay, t, locale, timeFormat)
const { dayValue, dayDate, dayRange, timeValue } = cardWhen(res, startDay, endDay, t, locale, timeFormat)
return (
<div className="mt-2 overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
@@ -146,6 +146,8 @@ function BookingCard({ res, planner, canEdit, compact }: {
<div className="px-3 pb-3 pt-[9px]">
<CardWhenAndCode
dayValue={dayValue}
dayDate={dayDate}
dayRange={dayRange}
timeValue={timeValue}
code={res.confirmation_number}
card={card}
@@ -1,4 +1,4 @@
// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-007
// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-008
import { fireEvent, render, screen } from '@testing-library/react';
import { Hotel, Plane } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest';
@@ -119,4 +119,26 @@ describe('MReservationCardParts', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }));
expect(card.confirmDelete).toHaveBeenCalledOnce();
});
it('FE-COMP-MRESCARD-008: the date sits beside the day title and never shrinks, a range stacks the time under it (#2590)', () => {
const { container } = render(
<CardWhenAndCode
dayValue="Germany Departure Flight – Korea Arrival"
dayDate="Oct 21 – Oct 22"
dayRange
timeValue="15:35 – 09:55"
code={null}
card={cardState()}
onOpen={vi.fn()}
t={t}
/>
);
const title = screen.getByText('Germany Departure Flight – Korea Arrival');
const date = screen.getByText('Oct 21 – Oct 22');
expect(title).toHaveClass('truncate');
expect(date).toHaveClass('flex-none');
expect(title.contains(date)).toBe(false);
expect(container.querySelector('button > div')).toHaveClass('flex-col');
});
});
@@ -73,10 +73,14 @@ export function CardActions({ onEdit, onDelete, t }: { onEdit: () => void; onDel
/**
* Date and time row, followed by the booking code. The code sits outside the
* row button so its reveal can be its own control.
* row button so its reveal can be its own control. The calendar date never
* shrinks; a long day title is cut instead, and a day range takes the full
* width with the time under it.
*/
export function CardWhenAndCode({
dayValue,
dayDate = null,
dayRange = false,
timeValue,
code,
card,
@@ -84,6 +88,8 @@ export function CardWhenAndCode({
t,
}: {
dayValue: string;
dayDate?: string | null;
dayRange?: boolean;
timeValue: string;
code: string | null | undefined;
card: CardState;
@@ -93,9 +99,14 @@ export function CardWhenAndCode({
return (
<>
<button type="button" onClick={onOpen} className="block w-full text-start">
<div className="flex gap-2">
<div className={`flex gap-2 ${dayRange ? 'flex-col' : ''}`}>
<Field label={t('reservations.date')} className="flex-[1.4]">
{dayValue}
{dayDate ? (
<span className="flex min-w-0 items-center justify-center gap-1.5">
<span className="min-w-0 truncate">{dayValue}</span>
<span className="flex-none font-medium text-m-faint">{dayDate}</span>
</span>
) : dayValue}
</Field>
<Field label={t('reservations.time')} className="flex-1" tabular>
{timeValue}
@@ -130,7 +130,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
planner.setShowTransportModal(true)
}
const { dayValue, timeValue } = cardWhen(res, startDay, endDay, t, locale, timeFormat)
const { dayValue, dayDate, dayRange, timeValue } = cardWhen(res, startDay, endDay, t, locale, timeFormat)
return (
<div className="mt-2 overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
@@ -158,6 +158,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
<div className="px-3 pb-3 pt-[9px]">
<CardWhenAndCode
dayValue={dayValue}
dayDate={dayDate}
dayRange={dayRange}
timeValue={timeValue}
code={res.confirmation_number}
card={card}
@@ -1,5 +1,6 @@
import { formatTime, splitReservationDateTime } from '../../../../utils/formatters'
import type { Day, Reservation, TranslationFn } from '../../../../types'
import { linkedDayDate } from '../../../../components/Planner/bookings/bookingFacts'
/**
* Transport view-model — the real-data counterpart to the demo's `trsSecs`
@@ -45,8 +46,8 @@ export function parseTransportMeta(res: Reservation): TransportMeta {
/**
* The date and time cells of a phone booking or transport card: the day (or day
* range) the booking sits on, else its own date, and its time or time range. A
* dash stands in for whichever is missing.
* range) the booking sits on with its calendar date beside it, else its own
* date, and its time or time range. A dash stands in for whichever is missing.
*/
export function cardWhen(
res: Reservation,
@@ -55,7 +56,7 @@ export function cardWhen(
t: TranslationFn,
locale: string,
timeFormat: string,
): { dayValue: string; timeValue: string } {
): { dayValue: string; dayDate: string | null; dayRange: boolean; timeValue: string } {
const startDt = splitReservationDateTime(res.reservation_time)
const endDt = splitReservationDateTime(res.reservation_end_time)
const fmtDate = (date: string) =>
@@ -64,10 +65,14 @@ export function cardWhen(
const timeValue = startDt.time
? `${formatTime(startDt.time, locale, timeFormat)}${endDt.time ? ` – ${formatTime(endDt.time, locale, timeFormat)}` : ''}`
: '—'
const rangeEnd = startDay && endDay && endDay.id !== startDay.id ? endDay : undefined
const dayValue = startDay
? `${dayLabel(startDay)}${endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''}`
? `${dayLabel(startDay)}${rangeEnd ? ` – ${dayLabel(rangeEnd)}` : ''}`
: startDt.date
? fmtDate(startDt.date)
: '—'
return { dayValue, timeValue }
// The same date the desktop card prints beside the day, so a long day title
// can never hide when the booking actually is.
const dayDate = startDay ? linkedDayDate(startDay, endDay, { start: startDt.date, end: endDt.date }, locale, ' – ') : null
return { dayValue, dayDate, dayRange: !!rangeEnd, timeValue }
}
@@ -162,18 +162,24 @@ describe('transportsModel — cardWhen', () => {
const r = res({ reservation_time: '2026-07-01T09:05', reservation_end_time: '2026-07-03T18:30' });
expect(cardWhen(r, day1, day3, t, 'en-US', '24h')).toEqual({
dayValue: 'dayplan.dayN:1 – Coast',
dayDate: 'Jul 1 – Jul 3',
dayRange: true,
timeValue: '09:05 – 18:30',
});
expect(cardWhen(r, day1, day1, t, 'en-US', '24h').dayValue).toBe('dayplan.dayN:1');
expect(cardWhen(r, day1, day1, t, 'en-US', '24h')).toMatchObject({ dayValue: 'dayplan.dayN:1', dayDate: 'Wed, Jul 1', dayRange: false });
});
it('FE-MOB-TRPM-016: without a day the booking date stands in, then a dash', () => {
expect(cardWhen(res({ reservation_time: '2026-07-04' }), undefined, undefined, t, 'en-US', '24h')).toEqual({
dayValue: 'Jul 4',
dayDate: null,
dayRange: false,
timeValue: '—',
});
expect(cardWhen(res({ reservation_time: null }), undefined, undefined, t, 'en-US', '24h')).toEqual({
dayValue: '—',
dayDate: null,
dayRange: false,
timeValue: '—',
});
});
@@ -182,4 +188,17 @@ describe('transportsModel — cardWhen', () => {
const r = res({ reservation_time: '2026-07-01T14:00', reservation_end_time: null });
expect(cardWhen(r, day1, undefined, t, 'en-US', '12h').timeValue).toBe('2:00 PM');
});
it('FE-MOB-TRPM-018: a day without a date shows the booking date beside its title (#2590)', () => {
const departure = buildDay({ id: 200, day_number: 1, title: 'Germany Departure Flight', date: null });
const arrival = buildDay({ id: 201, day_number: 2, title: 'Korea Arrival', date: null });
const r = res({ reservation_time: '2026-10-21T15:35', reservation_end_time: '2026-10-22T09:55' });
expect(cardWhen(r, departure, arrival, t, 'en-US', '24h')).toEqual({
dayValue: 'Germany Departure Flight – Korea Arrival',
dayDate: 'Oct 21 – Oct 22',
dayRange: true,
timeValue: '15:35 – 09:55',
});
expect(cardWhen(res({ reservation_time: null }), departure, undefined, t, 'en-US', '24h').dayDate).toBeNull();
});
});