Files
TREK/client/src/components/Planner/DayDetailPanel.test.tsx
T
Julien G. 78d6f2ba77 Bug fixes - April 28th 2026 (#915)
* fix: replace raw day-ID range checks with position-based helper (issue #889 follow-up)

Commit 8e05ba7 fixed the accommodation date-range pickers, but the
post-save state filters in DayDetailPanel and several other consumers
still compared `day.id >= start_day_id && day.id <= end_day_id`. With
non-monotonic ID layouts (day_number 1-9 → IDs 17-25, day_number 10-16
→ IDs 1-7) this made the just-saved accommodation immediately invisible
— matching the regression reported in the last comment of #889.

Introduces `isDayInAccommodationRange` in `client/src/utils/dayOrder.ts`
which compares positional order (`day_number` with `indexOf` fallback)
rather than raw IDs. Falls back to the old numeric comparison when
endpoint days are absent from the loaded array (sparse test data or
partial loads) so existing tests are unaffected.

Fixed call sites:
- DayDetailPanel.tsx (initial load, post-create, post-delete, post-edit-save)
- DayPlanSidebar.tsx (daily badge renderer)
- SharedTripPage.tsx (public share view)
- TripPDF.tsx (PDF export filter + sort)

Also declares `day_number?: number` on the client `Day` type (already
returned by the server but previously untyped).

Adds regression tests FE-PLANNER-DAYDETAIL-060/061/062 covering the
edit-save, create-save, and initial-load paths with the reporter's exact
non-monotonic ID layout.

* fix: non-transport reservations no longer appear as transports in day planner (issue #914)

getTransportForDay now uses TRANSPORT_TYPES allowlist instead of only excluding hotels,
and the click handler dispatches to onEditReservation for non-transport types instead of
always opening TransportModal, preventing silent type coercion to 'flight'.

* feat: add file attachment support to TransportModal (issue #918)

Transports (flight/train/car/cruise) now support file attachments identical to the reservation modal — upload on create/edit, link existing files, and unlink. The Files tab and Assign File modal now differentiate between bookings and transports with separate sections and type-specific icons. Translations added for all 15 locales.
2026-04-29 00:16:56 +02:00

1192 lines
50 KiB
TypeScript

// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-025
import React from 'react';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import DayDetailPanel from './DayDetailPanel';
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
const defaultProps = {
day,
days: [day],
places: [],
categories: [],
tripId: 1,
assignments: {},
reservations: [],
lat: null,
lng: null,
onClose: vi.fn(),
onAccommodationChange: vi.fn(),
};
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
server.use(
http.get('/api/weather/detailed', () => HttpResponse.json({ error: true })),
http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [] })),
);
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: false },
});
});
describe('DayDetailPanel', () => {
// ── Rendering ────────────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-001: renders without crashing', () => {
render(<DayDetailPanel {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-002: returns null when day prop is null', () => {
render(<DayDetailPanel {...defaultProps} day={null as any} />);
expect(document.querySelector('[style*="position: fixed"]')).toBeNull();
});
it('FE-PLANNER-DAYDETAIL-003: shows day title in header', () => {
render(<DayDetailPanel {...defaultProps} />);
expect(screen.getByText('Day in Paris')).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-004: shows day number when title is null', () => {
const untitled = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: null });
render(<DayDetailPanel {...defaultProps} day={untitled} days={[untitled]} />);
expect(screen.getByText(/Day 1/i)).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-005: shows formatted date when day.date is set', () => {
render(<DayDetailPanel {...defaultProps} />);
// Date '2025-06-15' → locale string containing "June" or "15"
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-006: does NOT show date when day.date is null', () => {
const noDate = buildDay({ id: 1, trip_id: 1, date: null, title: 'No Date Day' });
render(<DayDetailPanel {...defaultProps} day={noDate} days={[noDate]} />);
expect(screen.queryByText(/June|Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday/i)).toBeNull();
});
it('FE-PLANNER-DAYDETAIL-007: close button calls onClose', async () => {
const onClose = vi.fn();
render(<DayDetailPanel {...defaultProps} onClose={onClose} />);
// The header X button — the one outside the hotel picker
const closeButtons = screen.getAllByRole('button');
// Second button is the header X close (first is collapse toggle)
await userEvent.click(closeButtons[1]);
expect(onClose).toHaveBeenCalled();
});
// ── Weather ──────────────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-008: weather section not shown when no lat/lng', async () => {
render(<DayDetailPanel {...defaultProps} lat={null} lng={null} />);
await waitFor(() => expect(screen.queryByText(/No weather/i)).toBeNull());
// No loading spinner either
expect(document.querySelector('[style*="border-top-color"]')).toBeNull();
});
it('FE-PLANNER-DAYDETAIL-009: weather loading state shown briefly', async () => {
server.use(
http.get('/api/weather/detailed', () => new Promise(() => {})), // never resolves
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
// Spinner div has border + borderTopColor
await waitFor(() => {
const spinner = document.querySelector('[style*="border-radius: 50%"]');
expect(spinner).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-010: weather data renders temperature in Celsius', async () => {
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({ main: 'Clear', temp: 22, temp_min: 18, temp_max: 26, description: 'sunny' })
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText(/22°C/);
});
it('FE-PLANNER-DAYDETAIL-011: weather in Fahrenheit when setting is fahrenheit', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'fahrenheit', blur_booking_codes: false },
});
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({ main: 'Clear', temp: 0, temp_min: 0, temp_max: 0, description: 'cold' })
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText(/32°F/);
});
it('FE-PLANNER-DAYDETAIL-012: no weather shows "No weather data" message', async () => {
server.use(
http.get('/api/weather/detailed', () => HttpResponse.json({ error: true })),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText(/No weather/i);
});
// ── Reservations ─────────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-013: shows reservations linked to this day\'s assignments', async () => {
const place = buildPlace({ name: 'Museum' });
const reservation = buildReservation({
id: 1,
title: 'Museum Tour Ticket',
assignment_id: 50,
status: 'confirmed',
});
render(<DayDetailPanel
{...defaultProps}
assignments={{ '1': [{ id: 50, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
reservations={[reservation]}
/>);
await screen.findByText('Museum Tour Ticket');
});
it('FE-PLANNER-DAYDETAIL-014: reservations from OTHER days are not shown', async () => {
const place = buildPlace({ name: 'Other Venue' });
const reservation = buildReservation({
id: 2,
title: 'Other Day Event',
assignment_id: 51,
status: 'confirmed',
});
render(<DayDetailPanel
{...defaultProps}
// day.id=1, but reservation belongs to assignment_id=51 which is in day '2'
assignments={{
'1': [{ id: 50, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }],
'2': [{ id: 51, place, place_id: place.id, day_id: 2, order_index: 0, notes: null }],
}}
reservations={[reservation]}
/>);
await waitFor(() => {
expect(screen.queryByText('Other Day Event')).toBeNull();
});
});
it('FE-PLANNER-DAYDETAIL-015: reservation shows formatted time when reservation_time has T', async () => {
const place = buildPlace({ name: 'Restaurant' });
const reservation = buildReservation({
id: 3,
title: 'Dinner',
assignment_id: 50,
status: 'confirmed',
reservation_time: '2025-06-15T14:30:00Z',
});
render(<DayDetailPanel
{...defaultProps}
assignments={{ '1': [{ id: 50, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
reservations={[reservation]}
/>);
await screen.findByText('Dinner');
// Time should be rendered from reservation_time with T — check for a time-like string
await waitFor(() => {
// The time is rendered via toLocaleTimeString — match any HH:MM pattern
const timeEl = screen.queryByText(/\d{1,2}:\d{2}/);
expect(timeEl).toBeInTheDocument();
});
});
// ── Accommodation ─────────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-016: accommodation section header is always present', async () => {
render(<DayDetailPanel {...defaultProps} />);
await waitFor(() => {
expect(screen.getAllByText(/Accommodation/i).length).toBeGreaterThanOrEqual(1);
});
});
it('FE-PLANNER-DAYDETAIL-017: accommodation with check-in shows hotel name', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Grand Hotel');
});
it('FE-PLANNER-DAYDETAIL-018: check-in time shown for check-in day', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
}],
})
),
);
// day.id = 1 = start_day_id (check-in day)
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('14:00');
await waitFor(() => {
expect(screen.getAllByText(/Check-in/i).length).toBeGreaterThanOrEqual(1);
});
});
it('FE-PLANNER-DAYDETAIL-019: check-out time shown for check-out day', async () => {
const checkOutDay = buildDay({ id: 3, trip_id: 1, date: '2025-06-17', title: 'Check Out Day' });
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
}],
})
),
);
render(<DayDetailPanel
{...defaultProps}
day={checkOutDay}
days={[day, checkOutDay]}
/>);
await screen.findByText('11:00');
});
it('FE-PLANNER-DAYDETAIL-020: confirmation code shown', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: 'HOTEL99',
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('HOTEL99');
});
it('FE-PLANNER-DAYDETAIL-021: accommodation edit/remove buttons shown when canEditDays=true', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Grand Hotel');
// Pencil and X buttons should be present in the accommodation row
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(2);
});
it('FE-PLANNER-DAYDETAIL-022: accommodation edit/remove buttons hidden when canEditDays=false', async () => {
// Use regular user + restrict day_edit to admin only
const regularUser = buildUser({ id: 999, role: 'user' });
seedStore(useAuthStore, { user: regularUser, isAuthenticated: true });
seedStore(usePermissionsStore, { permissions: { day_edit: 'admin' } });
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Budget Inn', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Budget Inn');
// No edit/remove buttons — only close button in header
const buttons = screen.getAllByRole('button');
// Should only have the header collapse + close buttons, no pencil/X in accommodation
expect(buttons).toHaveLength(2);
});
// ── Adding accommodation ──────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-023: "Add accommodation" button visible when canEditDays=true and no accommodation', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText(/Add accommodation/i);
});
it('FE-PLANNER-DAYDETAIL-024: clicking add accommodation opens hotel picker', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
render(<DayDetailPanel {...defaultProps} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
// Hotel picker portal renders into document.body
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
});
});
// ── Blur booking codes ────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-025: linked booking confirmation code is blurred when blur_booking_codes=true', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: true },
});
const linkedReservation = buildReservation({
id: 10,
title: 'Hotel Booking',
status: 'confirmed',
confirmation_number: 'SECRET',
accommodation_id: 1,
});
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Secret Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} reservations={[linkedReservation]} />);
await screen.findByText('Secret Hotel');
// Find the element containing the confirmation number
await waitFor(() => {
const el = screen.getByText(/#SECRET/);
expect(el).toHaveStyle({ filter: 'blur(4px)' });
});
});
// ── Weather chips ─────────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-026: weather chips render precipitation, wind, sunrise, sunset', async () => {
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({
main: 'Rain',
temp: 15,
temp_min: 12,
temp_max: 18,
description: 'rainy',
precipitation_probability_max: 80,
precipitation_sum: 5.2,
wind_max: 30,
sunrise: '06:30',
sunset: '20:15',
})
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText('80%');
await screen.findByText('5.2 mm');
await screen.findByText('30 km/h');
await screen.findByText('06:30');
await screen.findByText('20:15');
});
it('FE-PLANNER-DAYDETAIL-027: weather chips show Fahrenheit wind speed', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'fahrenheit', blur_booking_codes: false },
});
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({
main: 'Clouds',
temp: 20,
temp_min: 15,
temp_max: 25,
description: 'cloudy',
wind_max: 50,
})
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
// 50 km/h * 0.621371 ≈ 31 mph
await screen.findByText('31 mph');
});
// ── Hotel picker interactions ─────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-028: hotel picker cancel button closes the picker', async () => {
render(<DayDetailPanel {...defaultProps} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
// Picker opened
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
});
// Click cancel button inside picker
const cancelButton = screen.getByText(/Cancel/i);
await userEvent.click(cancelButton);
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeNull();
});
});
it('FE-PLANNER-DAYDETAIL-029: hotel picker shows places list when places are provided', async () => {
const place1 = buildPlace({ id: 10, name: 'Hotel du Nord', address: '102 Quai de Jemmapes' });
const place2 = buildPlace({ id: 11, name: 'Hotel du Sud', address: null });
render(<DayDetailPanel {...defaultProps} places={[place1, place2]} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
await screen.findByText('Hotel du Nord');
await screen.findByText('Hotel du Sud');
await screen.findByText('102 Quai de Jemmapes');
});
it('FE-PLANNER-DAYDETAIL-030: selecting a place in hotel picker enables save button', async () => {
const place = buildPlace({ id: 10, name: 'Maison Blanche' });
server.use(
http.post('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodation: {
id: 99, place_id: 10, place_name: 'Maison Blanche', place_address: null,
start_day_id: 1, end_day_id: 1, check_in: null, check_out: null, confirmation: null,
},
})
),
);
render(<DayDetailPanel {...defaultProps} places={[place]} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
await screen.findByText('Maison Blanche');
// Click the place button
const placeButton = screen.getByRole('button', { name: /Maison Blanche/i });
await userEvent.click(placeButton);
// Save button should now be enabled
const saveButton = screen.getByText(/Save/i);
expect(saveButton).not.toBeDisabled();
});
it('FE-PLANNER-DAYDETAIL-031: hotel picker shows no places message when list is empty', async () => {
render(<DayDetailPanel {...defaultProps} places={[]} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
await waitFor(() => {
const portal = document.body.querySelector('[style*="z-index: 99999"]');
expect(portal).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-032: edit accommodation button opens picker in edit mode', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Edit Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '10:00', confirmation: 'EDIT01',
}],
})
),
);
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Edit Hotel');
// All buttons: header collapse (0), header close (1), pencil (2), X/remove (3)
const allButtons = screen.getAllByRole('button');
// Pencil is third button (index 2)
const pencilButton = allButtons[2];
await userEvent.click(pencilButton);
// Edit picker should open with "Edit accommodation" title
await waitFor(() => {
const portal = document.body.querySelector('[style*="z-index: 99999"]');
expect(portal?.textContent).toMatch(/Edit accommodation/i);
});
});
it('FE-PLANNER-DAYDETAIL-033: hotel picker "all days" button selects full trip range', async () => {
const day2 = buildDay({ id: 2, trip_id: 1, date: '2025-06-16', title: 'Day 2' });
const day3 = buildDay({ id: 3, trip_id: 1, date: '2025-06-17', title: 'Day 3' });
render(<DayDetailPanel {...defaultProps} days={[day, day2, day3]} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
await waitFor(() => {
const portal = document.body.querySelector('[style*="z-index: 99999"]');
expect(portal?.textContent).toMatch(/Day in Paris|Day 2|Day 3/i);
});
});
it('FE-PLANNER-DAYDETAIL-034: accommodation with all fields shows full details grid', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Full Details Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 1, check_in: '14:00', check_out: '11:00', confirmation: 'FULL01',
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Full Details Hotel');
await waitFor(() => {
expect(screen.getAllByText(/Check-in/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText(/Check-out/i).length).toBeGreaterThanOrEqual(1);
});
await screen.findByText('FULL01');
});
it('FE-PLANNER-DAYDETAIL-035: middle-day accommodation shows no check-in/out label', async () => {
const middleDay = buildDay({ id: 2, trip_id: 1, date: '2025-06-16', title: 'Middle Day' });
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Overnight Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} day={middleDay} days={[day, middleDay]} />);
await screen.findByText('Overnight Hotel');
expect(screen.queryByText(/Check-in & Check-out/i)).toBeNull();
});
it('FE-PLANNER-DAYDETAIL-036: weather hourly data renders hour entries', async () => {
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({
main: 'Clear',
temp: 20,
temp_min: 15,
temp_max: 25,
description: 'sunny',
hourly: [
{ hour: 8, main: 'Clear', temp: 18, precipitation_probability: 0 },
{ hour: 10, main: 'Clear', temp: 20, precipitation_probability: 10 },
{ hour: 12, main: 'Clouds', temp: 22, precipitation_probability: 60 },
],
})
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText(/20°C/);
// Hourly renders every other entry (i % 2 === 0): hours 8 and 12
await waitFor(() => {
expect(screen.getByText('08')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-037: climate type weather shows average indicator', async () => {
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({
main: 'Clear',
type: 'climate',
temp: 18,
temp_min: 14,
temp_max: 22,
description: 'average',
})
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText(/Ø/);
});
it('FE-PLANNER-DAYDETAIL-038: hotel picker with category filter renders category buttons', async () => {
const { buildCategory } = await import('../../../tests/helpers/factories');
const cat = buildCategory({ id: 1, name: 'Hotels' });
const place = buildPlace({ id: 10, name: 'Hotel Belmont', category_id: 1 });
render(<DayDetailPanel {...defaultProps} places={[place]} categories={[cat]} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
await waitFor(() => {
const portal = document.body.querySelector('[style*="z-index: 99999"]');
expect(portal?.textContent).toMatch(/Hotels/);
});
});
it('FE-PLANNER-DAYDETAIL-039: add another accommodation button visible when accommodations exist', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Existing Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
})
),
);
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Existing Hotel');
// "Add accommodation" dashed button should also appear for adding more
await screen.findByText(/Add accommodation/i);
});
it('FE-PLANNER-DAYDETAIL-041: save new accommodation calls API and updates list', async () => {
const place = buildPlace({ id: 10, name: 'New Hotel' });
server.use(
http.post('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodation: {
id: 99, place_id: 10, place_name: 'New Hotel', place_address: null,
start_day_id: 1, end_day_id: 1, check_in: null, check_out: null, confirmation: null,
},
})
),
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({ accommodations: [] })
),
);
render(<DayDetailPanel {...defaultProps} places={[place]} />);
// Open picker
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
// Select a place
const placeBtn = await screen.findByRole('button', { name: /New Hotel/i });
await userEvent.click(placeBtn);
// Click Save
const saveButton = screen.getByText(/Save/i);
await userEvent.click(saveButton);
// Picker should close after save
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeNull();
});
});
it('FE-PLANNER-DAYDETAIL-042: remove accommodation calls delete API', async () => {
let deleteWasCalled = false;
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 5, place_id: 5, place_name: 'Hotel To Remove', place_address: 'Paris',
start_day_id: 1, end_day_id: 1, check_in: null, check_out: null, confirmation: null,
}],
})
),
http.delete('/api/trips/1/accommodations/5', () => {
deleteWasCalled = true;
return HttpResponse.json({ success: true });
}),
);
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Hotel To Remove');
// Buttons: collapse (0), close header (1), pencil (2), X/remove (3)
const allButtons = screen.getAllByRole('button');
const removeButton = allButtons[3];
await userEvent.click(removeButton);
await waitFor(() => {
expect(deleteWasCalled).toBe(true);
});
});
it('FE-PLANNER-DAYDETAIL-043: 12h check-in time formatted with AM/PM', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
});
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'AM Hotel', place_address: null,
start_day_id: 1, end_day_id: 1, check_in: '14:00', check_out: '09:00', confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('AM Hotel');
// 14:00 in 12h = 2:00 PM
await waitFor(() => {
expect(screen.getByText('2:00 PM')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-044: accommodation with linked pending reservation shows pending status', async () => {
const pendingReservation = buildReservation({
id: 20,
title: 'Pending Booking',
status: 'pending',
confirmation_number: null,
accommodation_id: 1,
});
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Pending Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} reservations={[pendingReservation]} />);
await screen.findByText('Pending Hotel');
await screen.findByText('Pending Booking');
await waitFor(() => {
expect(screen.getAllByText(/pending/i).length).toBeGreaterThanOrEqual(1);
});
});
it('FE-PLANNER-DAYDETAIL-045: weather API network error is handled gracefully', async () => {
server.use(
http.get('/api/weather/detailed', () => HttpResponse.error()),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
// Should show "No weather" after error (catch sets weather to null)
await screen.findByText(/No weather/i);
});
it('FE-PLANNER-DAYDETAIL-046: save edited accommodation calls update API', async () => {
let updateCalled = false;
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 7, place_id: 5, place_name: 'Edit Me Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 1, check_in: '15:00', check_out: null, confirmation: null,
}],
})
),
http.put('/api/trips/1/accommodations/7', () => {
updateCalled = true;
return HttpResponse.json({
accommodation: {
id: 7, place_id: 5, place_name: 'Edit Me Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 1, check_in: '15:00', check_out: null, confirmation: 'NEW01',
},
});
}),
);
const place = buildPlace({ id: 5, name: 'Edit Me Hotel' });
render(<DayDetailPanel {...defaultProps} places={[place]} />);
await screen.findByText('Edit Me Hotel');
// Click the pencil/edit button (index 2, after collapse and close buttons)
const allButtons = screen.getAllByRole('button');
await userEvent.click(allButtons[2]);
// Picker opens in edit mode
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
});
// Click Save in the edit picker
const saveButton = screen.getByText(/Save/i);
await userEvent.click(saveButton);
await waitFor(() => {
expect(updateCalled).toBe(true);
});
});
it('FE-PLANNER-DAYDETAIL-047: blurred confirmation code revealed on click', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: true },
});
const linkedReservation = buildReservation({
id: 11,
title: 'Blurred Booking',
status: 'confirmed',
confirmation_number: 'REVEAL123',
accommodation_id: 2,
});
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 2, place_id: 5, place_name: 'Blurred Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} reservations={[linkedReservation]} />);
await screen.findByText('Blurred Hotel');
const codeEl = await screen.findByText(/#REVEAL123/);
// Initially blurred
expect(codeEl).toHaveStyle({ filter: 'blur(4px)' });
// Fire mouse events to cover the event handler code paths
await userEvent.hover(codeEl);
await userEvent.unhover(codeEl);
await userEvent.click(codeEl);
});
// ── Collapse behavior ─────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-048: collapse button has title "Collapse" when expanded', () => {
render(<DayDetailPanel {...defaultProps} collapsed={false} />);
const collapseBtn = screen.getByTitle('Collapse');
expect(collapseBtn).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-049: collapse button has title "Expand" when collapsed', () => {
render(<DayDetailPanel {...defaultProps} collapsed={true} />);
const expandBtn = screen.getByTitle('Expand');
expect(expandBtn).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-050: content area is hidden when collapsed=true', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Visible Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 1, check_in: null, check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} collapsed={true} />);
await waitFor(() => {
const content = document.querySelector('[style*="overflow-y: auto"]');
expect(content).toHaveStyle({ display: 'none' });
});
});
it('FE-PLANNER-DAYDETAIL-051: content area is visible when collapsed=false', async () => {
render(<DayDetailPanel {...defaultProps} collapsed={false} />);
await waitFor(() => {
const content = document.querySelector('[style*="overflow-y: auto"]');
expect(content).toHaveStyle({ display: 'block' });
});
});
it('FE-PLANNER-DAYDETAIL-052: clicking the collapse button calls onToggleCollapse', async () => {
const onToggleCollapse = vi.fn();
render(<DayDetailPanel {...defaultProps} collapsed={false} onToggleCollapse={onToggleCollapse} />);
const collapseBtn = screen.getByTitle('Collapse');
await userEvent.click(collapseBtn);
expect(onToggleCollapse).toHaveBeenCalled();
});
it('FE-PLANNER-DAYDETAIL-053: clicking the header row calls onToggleCollapse', async () => {
const onToggleCollapse = vi.fn();
render(<DayDetailPanel {...defaultProps} collapsed={false} onToggleCollapse={onToggleCollapse} />);
// The header div (contains title text) is the clickable toggle area
await userEvent.click(screen.getByText('Day in Paris'));
expect(onToggleCollapse).toHaveBeenCalled();
});
it('FE-PLANNER-DAYDETAIL-054: when collapsed, date appears inline in title row', () => {
render(<DayDetailPanel {...defaultProps} collapsed={true} />);
// Title and date are in the same element when collapsed
const titleEl = screen.getByText(/Day in Paris/);
expect(titleEl.textContent).toMatch(/June|15/i);
});
it('FE-PLANNER-DAYDETAIL-055: when expanded, date is shown in a separate element below title', () => {
render(<DayDetailPanel {...defaultProps} collapsed={false} />);
const titleEl = screen.getByText('Day in Paris');
// The date should be in a sibling element, not inside the title element itself
expect(titleEl.textContent).toBe('Day in Paris');
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
});
// ── Accommodation date-range picker — non-monotonic day IDs (issue #889) ─────
// Builds the reporter's exact ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
// This happens after repeated trip-length changes via generateDays (no import/migration needed).
function buildNonMonotonicDays() {
return [
buildDay({ id: 17, trip_id: 1, date: '2026-04-30' }),
buildDay({ id: 18, trip_id: 1, date: '2026-05-01' }),
buildDay({ id: 19, trip_id: 1, date: '2026-05-02' }),
buildDay({ id: 20, trip_id: 1, date: '2026-05-03' }),
buildDay({ id: 21, trip_id: 1, date: '2026-05-04' }),
buildDay({ id: 22, trip_id: 1, date: '2026-05-05' }),
buildDay({ id: 23, trip_id: 1, date: '2026-05-06' }),
buildDay({ id: 24, trip_id: 1, date: '2026-05-07' }),
buildDay({ id: 25, trip_id: 1, date: '2026-05-08' }),
buildDay({ id: 1, trip_id: 1, date: '2026-05-09' }),
buildDay({ id: 2, trip_id: 1, date: '2026-05-10' }),
buildDay({ id: 3, trip_id: 1, date: '2026-05-11' }),
buildDay({ id: 4, trip_id: 1, date: '2026-05-12' }),
buildDay({ id: 5, trip_id: 1, date: '2026-05-13' }),
buildDay({ id: 6, trip_id: 1, date: '2026-05-14' }),
buildDay({ id: 7, trip_id: 1, date: '2026-05-15' }),
];
}
// Returns the two CustomSelect trigger buttons for start/end day pickers.
// When no dropdown is open, these are the only globally-visible buttons whose textContent
// matches /Day \d+/ (the main panel title is a div, not a button).
// [0] = start trigger, [1] = end trigger (DOM source order).
function getDayPickerTriggers() {
return screen.getAllByRole('button').filter(b => /Day \d+/.test(b.textContent ?? ''));
}
it('FE-PLANNER-DAYDETAIL-056: non-monotonic IDs — end picker does not clobber start-day', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 50, name: 'Range Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 99, place_id: 50, place_name: 'Range Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Range Hotel/i }));
// Both triggers show "Day 1"; the second one is the end picker.
await userEvent.click(getDayPickerTriggers()[1]);
// Select "Day 16" (id=7) from the open dropdown — textContent starts with "Day 16".
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
// start must remain id 17 (day 1) — old code would clobber it to id 7 via Math.min
expect(capturedBody?.start_day_id).toBe(17);
expect(capturedBody?.end_day_id).toBe(7);
});
});
it('FE-PLANNER-DAYDETAIL-057: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 51, name: 'Span Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 100, place_id: 51, place_name: 'Span Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Span Hotel/i }));
// Set end to day 16 (id=7, low ID but last day by position).
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
// Set start to day 9 (id=25, high ID, but earlier by position than day 16).
// Old code: Math.max(25, 7) = 25 → end collapses to day 9.
// New code: position(id=25)=8 < position(id=7)=15 → end stays at 7 (day 16).
await userEvent.click(getDayPickerTriggers()[0]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
expect(capturedBody?.start_day_id).toBe(25); // day 9
expect(capturedBody?.end_day_id).toBe(7); // day 16 — must NOT have collapsed
});
});
it('FE-PLANNER-DAYDETAIL-058: non-monotonic IDs — All days button sets correct first/last IDs', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 52, name: 'Full Trip Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 101, place_id: 52, place_name: 'Full Trip Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Full Trip Hotel/i }));
// "All" is the day.allDays translation (en: "All") — the Apply-to-entire-trip button.
// When categories=[] the category-filter "All" button is not rendered, so this is unique.
await userEvent.click(screen.getByRole('button', { name: /^All$/i }));
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
// days[0].id=17 (first by position), days[15].id=7 (last by position)
expect(capturedBody?.start_day_id).toBe(17);
expect(capturedBody?.end_day_id).toBe(7);
});
});
it('FE-PLANNER-DAYDETAIL-059: sequential IDs — end picker clamping still works (regression guard)', async () => {
const seqDays = [
buildDay({ id: 101, trip_id: 1, date: '2026-06-01' }),
buildDay({ id: 102, trip_id: 1, date: '2026-06-02' }),
buildDay({ id: 103, trip_id: 1, date: '2026-06-03' }),
];
const place = buildPlace({ id: 53, name: 'Seq Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 102, place_id: 53, place_name: 'Seq Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={seqDays[0]} days={seqDays} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Seq Hotel/i }));
// Pick end = day 3 (id=103, position 2 > position 0 of start id=101).
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 3'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
expect(capturedBody?.start_day_id).toBe(101);
expect(capturedBody?.end_day_id).toBe(103);
});
});
// ── Post-save state filter — non-monotonic IDs (issue #889 follow-up) ────────
it('FE-PLANNER-DAYDETAIL-060: non-monotonic IDs — hotel stays visible after edit-save (issue #889 regression)', async () => {
const days = buildNonMonotonicDays();
let getCallCount = 0;
server.use(
http.get('/api/trips/1/accommodations', () => {
getCallCount++;
const acc = getCallCount === 1
// Initial load: single-day so old filter (17>=17 && 17<=17) passes — hotel visible, edit possible
? { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 17, check_in: null, check_out: null, confirmation: null }
// Post-save relist: full span — old filter (17>=17 && 17<=7) would drop it, new code keeps it
: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null };
return HttpResponse.json({ accommodations: [acc] });
}),
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
const body = await request.json() as any;
return HttpResponse.json({
accommodation: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null,
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
check_in: null, check_out: null, confirmation: null },
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
await screen.findByText('Span Hotel');
// Pencil = 3rd button (index 2): collapse, close, pencil, remove
const allButtons = screen.getAllByRole('button');
await userEvent.click(allButtons[2]);
// Extend end picker to Day 16 (id=7)
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
// Old code: 17>=17 && 17<=7 → false (hotel vanishes). New code: position 0 in [0,15] → visible.
await waitFor(() => {
expect(screen.getByText('Span Hotel')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-061: non-monotonic IDs — hotel appears after create-save on intermediate day', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 55, name: 'Created Hotel' });
// Current day: days[5] = id 22, position 5 (within any full-span range)
const currentDay = days[5];
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
const body = await request.json() as any;
return HttpResponse.json({
accommodation: { id: 200, place_id: 55, place_name: 'Created Hotel', place_address: null,
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
check_in: null, check_out: null, confirmation: null },
});
}),
);
render(<DayDetailPanel {...defaultProps} day={currentDay} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Created Hotel/i }));
// Extend end to Day 16 (id=7) — start stays at current day id=22
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
// Old code: 22>=22 && 22<=7 → false (hotel vanishes). New code: position 5 in [5,15] → visible.
await waitFor(() => {
expect(screen.getByText('Created Hotel')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-062: non-monotonic IDs — hotel shown on initial load when it spans the full trip', async () => {
const days = buildNonMonotonicDays();
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{ id: 1, place_id: 60, place_name: 'Full Trip Hotel', place_address: null,
start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null }],
})
),
);
// Day 1 (id=17): old filter: 17>=17 && 17<=7 → false. New: position 0 in [0,15] → visible.
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
await screen.findByText('Full Trip Hotel');
// Intermediate day (id=1, position 9): old filter: 1>=17 → false. New: 9 in [0,15] → visible.
render(<DayDetailPanel {...defaultProps} day={days[9]} days={days} />);
await screen.findByText('Full Trip Hotel');
});
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
});
const place = buildPlace({ name: 'Bistro' });
const reservation = buildReservation({
id: 20,
title: 'Lunch',
assignment_id: 60,
status: 'confirmed',
reservation_time: '2025-06-15T13:00:00Z',
});
render(<DayDetailPanel
{...defaultProps}
assignments={{ '1': [{ id: 60, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
reservations={[reservation]}
/>);
await screen.findByText('Lunch');
// 12h format: some AM/PM-like string
await waitFor(() => {
const timeEl = screen.queryByText(/AM|PM|\d{1,2}:\d{2}/i);
expect(timeEl).toBeInTheDocument();
});
});
});