mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user