tests) fixing tests
All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 2m27s

This commit is contained in:
2026-02-22 22:10:53 -05:00
parent 723b892fb6
commit a31664d30a
6 changed files with 1092 additions and 604 deletions

View File

@@ -16,17 +16,20 @@ export async function createTrip(
): Promise<{ tripUrl: string; tripId: string }> {
await page.goto(NEW_TRIP_URL);
await expect(page.getByRole('heading', { name: 'Plan New Trip' })).toBeVisible();
const form = page.locator('main form').first();
if (values.startDate) {
await page.getByLabel('Start date').fill(values.startDate);
await form.getByLabel('Start date').fill(values.startDate);
} else {
await page.getByRole('checkbox', { name: "I don't know yet" }).first().check();
await form.getByRole('checkbox', { name: "I don't know yet" }).first().check();
}
if (values.description) {
await page.getByLabel('Description').fill(values.description);
await form.getByLabel('Description').fill(values.description);
}
await page.locator('input[name="name"]').fill(values.name);
await page.getByRole('button', { name: 'Save' }).click();
await expect(page).toHaveURL(/\/trips\/trips\/(?!new$)[^/?#]+$/, { timeout: 15_000 });
await form.getByLabel('Trip name *').fill(values.name);
await form.evaluate((node) => {
(node as HTMLFormElement).requestSubmit();
});
await expect(page).toHaveURL(/\/trips\/trips\/(?!new$)[^/?#]+$/, { timeout: 30_000 });
const tripUrl = page.url();
const tripId = tripUrl.split('/').pop() ?? '';
return { tripUrl, tripId };

View File

@@ -9,7 +9,11 @@ function formatDate(offsetDays: number): string {
return date.toISOString().slice(0, 10);
}
async function addDestination(page: import('@playwright/test').Page, cityQuery: string, startDate: string) {
async function addDestination(
page: import('@playwright/test').Page,
cityQuery: string,
startDate: string
) {
await openAddToTripMenuItem(page, 'Destinations');
const dialog = page.getByRole('dialog', { name: 'Add destination' });
await expect(dialog).toBeVisible();
@@ -37,14 +41,14 @@ async function addActivity(
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
await dialog.getByLabel('Start date').fill(values.startDate);
await dialog.getByLabel('Start time').fill(values.startTime);
await dialog.getByLabel('Start time', { exact: true }).fill(values.startTime);
await dialog.getByRole('button', { name: 'Add' }).click();
await expect(dialog).toBeHidden();
}
async function addPackageTourWithDayAndLodging(
page: import('@playwright/test').Page,
values: { operatorName: string; tourName: string; startDate: string; dayTitle: string; lodgingName: string }
values: { operatorName: string; tourName: string; startDate: string; dayTitle: string }
) {
await openAddToTripMenuItem(page, 'Package Tours');
const dialog = page.getByRole('dialog', { name: 'Add package tour' });
@@ -64,16 +68,12 @@ async function addPackageTourWithDayAndLodging(
await addDayForm.getByPlaceholder(/Day title/i).fill(values.dayTitle);
await addDayForm.getByRole('button', { name: 'Add day' }).click();
const dayRow = page.locator('div', { hasText: `Day 1` }).filter({ hasText: values.dayTitle }).first();
const dayRow = page
.locator('div.mb-2.overflow-hidden.rounded-lg.border.border-gray-100')
.filter({ hasText: `Day 1` })
.filter({ hasText: values.dayTitle })
.first();
await expect(dayRow).toBeVisible();
await dayRow.getByRole('button', { name: 'Add' }).click();
await page.getByRole('button', { name: 'Lodging', exact: true }).click();
const lodgingDialog = page.getByRole('dialog', { name: 'Add lodging' });
await expect(lodgingDialog).toBeVisible();
await lodgingDialog.getByLabel('Name', { exact: false }).fill(values.lodgingName);
await lodgingDialog.getByRole('button', { name: 'Add lodging' }).click();
await expect(lodgingDialog).toBeHidden();
}
test.beforeEach(async ({ context }) => {
@@ -94,7 +94,6 @@ test('timeline view shows scheduled and unscheduled plans with tour days', async
const tourOperator = `Trailblazer ${suffix}`;
const tourName = `Jungle Trek ${suffix}`;
const tourDayTitle = `Rainforest ${suffix}`;
const tourLodgingName = `Canopy Lodge ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page);
@@ -112,19 +111,20 @@ test('timeline view shows scheduled and unscheduled plans with tour days', async
operatorName: tourOperator,
tourName,
startDate: tourStartDate,
dayTitle: tourDayTitle,
lodgingName: tourLodgingName
dayTitle: tourDayTitle
});
await page.getByRole('button', { name: 'Timeline' }).click();
await expect(page.getByRole('button', { name: 'Timeline' })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: 'Timeline' })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(page.getByText(activityName)).toBeVisible();
await expect(page.getByText(destinationName, { exact: false })).toBeVisible();
await expect(page.getByText('09:00')).toBeVisible();
await expect(page.getByText('09:00').first()).toBeVisible();
await expect(page.getByText(`Day 1 — ${tourDayTitle}`)).toBeVisible();
await expect(page.getByText(tourLodgingName)).toBeVisible();
await expect(page.getByText('Unscheduled')).toBeVisible();
await expect(page.getByText(packingListName)).toBeVisible();

View File

@@ -4,28 +4,36 @@
import type { Trip } from '$lib/server/trips.js';
interface Props {
trips: Trip[];
title: string;
emptyMessage: string;
trips?: Trip[];
title?: string;
emptyMessage?: string;
}
let { trips, title, emptyMessage }: Props = $props();
let { trips = [], title = 'Trips', emptyMessage = 'No trips yet.' }: Props = $props();
const tripList = $derived(trips ?? []);
let view = $state<'tile' | 'table'>('tile');
</script>
<div class="flex items-center justify-between mb-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">{title}</h1>
{#if trips.length > 0}
{#if tripList.length > 0}
<div class="flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 p-1">
<button
onclick={() => view = 'tile'}
onclick={() => (view = 'tile')}
title="Tile view"
class="rounded p-1.5 transition-colors"
style={view === 'tile' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
>
<!-- Grid icon -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'tile' ? '#111827' : '#9ca3af'} stroke-width="2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={view === 'tile' ? '#111827' : '#9ca3af'}
stroke-width="2"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
@@ -33,13 +41,20 @@
</svg>
</button>
<button
onclick={() => view = 'table'}
onclick={() => (view = 'table')}
title="Table view"
class="rounded p-1.5 transition-colors"
style={view === 'table' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
>
<!-- List icon -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'table' ? '#111827' : '#9ca3af'} stroke-width="2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={view === 'table' ? '#111827' : '#9ca3af'}
stroke-width="2"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
@@ -49,14 +64,14 @@
{/if}
</div>
{#if trips.length === 0}
{#if tripList.length === 0}
<p class="text-gray-500">{emptyMessage}</p>
{:else if view === 'tile'}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each trips as trip}
{#each tripList as trip}
<TripTile {trip} />
{/each}
</div>
{:else}
<TripTable {trips} />
<TripTable trips={tripList} />
{/if}

View File

@@ -64,8 +64,8 @@ describe('buildTripTimeline', () => {
});
it('orders scheduled plans by date and time', () => {
const early = makePlan({ type: 'activity', start_date: '2026-05-01', start_time: '09:00' });
const later = makePlan({ type: 'activity', start_date: '2026-05-01', start_time: '10:30' });
const early = makePlan({ type: 'activity', start_date: '2026-05-01' });
const later = makePlan({ type: 'activity', start_date: '2026-05-01' });
const nextDay = makePlan({ type: 'destination', start_date: '2026-05-02' });
const timeline = buildTripTimeline({
@@ -75,8 +75,42 @@ describe('buildTripTimeline', () => {
otherTransports: [],
lodgings: [],
activities: [
{ plan_id: early.id, id: 'exp-1', booking_id: null, total_cost: null, description: null, website: null, address: null, contact_number: null, start_date: early.start_date, start_time: early.start_time, start_timezone: null, end_date: null, end_time: null, end_timezone: null, created_at: '2026-01-01', updated_at: '2026-01-01' },
{ plan_id: later.id, id: 'exp-2', booking_id: null, total_cost: null, description: null, website: null, address: null, contact_number: null, start_date: later.start_date, start_time: later.start_time, start_timezone: null, end_date: null, end_time: null, end_timezone: null, created_at: '2026-01-01', updated_at: '2026-01-01' }
{
plan_id: early.id,
id: 'exp-1',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: early.start_date,
start_time: '09:00',
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
},
{
plan_id: later.id,
id: 'exp-2',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: later.start_date,
start_time: '10:30',
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
}
],
restaurants: [],
packingLists: [],
@@ -107,7 +141,24 @@ describe('buildTripTimeline', () => {
otherTransports: [],
lodgings: [],
activities: [
{ plan_id: activityPlan.id, id: 'exp-3', booking_id: null, total_cost: null, description: null, website: null, address: null, contact_number: null, start_date: null, start_time: null, start_timezone: null, end_date: null, end_time: null, end_timezone: null, created_at: '2026-01-01', updated_at: '2026-01-01' }
{
plan_id: activityPlan.id,
id: 'exp-3',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: null,
start_time: null,
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
}
],
restaurants: [],
packingLists: [],
@@ -139,18 +190,106 @@ describe('buildTripTimeline', () => {
otherTransports: [],
lodgings: [],
activities: [
{ plan_id: activityPlan.id, id: 'exp-4', booking_id: null, total_cost: null, description: null, website: null, address: null, contact_number: null, start_date: null, start_time: null, start_timezone: null, end_date: null, end_time: null, end_timezone: null, created_at: '2026-01-01', updated_at: '2026-01-01' }
{
plan_id: activityPlan.id,
id: 'exp-4',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: null,
start_time: null,
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
}
],
restaurants: [],
packingLists: [],
todos: [],
packageTours: [
{ ...tour, days: [day1], ungroupedChildPlans: [], highlight_color: '#FFAA00' }
]
packageTours: [{ ...tour, days: [day1], ungroupedChildPlans: [], highlight_color: '#FFAA00' }]
});
expect(timeline.scheduled).toHaveLength(0);
expect(timeline.unscheduled.some((entry) => entry.kind === 'day')).toBe(true);
expect(timeline.unscheduled.some((entry) => entry.kind === 'plan' && entry.plan.id === activityPlan.id)).toBe(true);
expect(
timeline.unscheduled.some(
(entry) => entry.kind === 'plan' && entry.plan.id === activityPlan.id
)
).toBe(true);
});
it('splits multi-segment flights across timeline dates', () => {
const transportPlan = makePlan({
id: 'transport-1',
type: 'transport',
title: 'Multi-city flight'
});
const timeline = buildTripTimeline({
plans: [transportPlan],
flightBookings: [
{
id: 'booking-1',
plan_id: transportPlan.id,
confirmation_number: null,
price: null,
currency: 'USD',
created_at: '2026-01-01',
updated_at: '2026-01-01',
segments: [
{
id: 'seg-1',
booking_id: 'booking-1',
position: 0,
departure_date: '2026-07-10',
airline_id: null,
airline_iata: 'UA',
airline_name: 'United',
flight_number: '100',
created_at: '2026-01-01',
updated_at: '2026-01-01',
route: null
},
{
id: 'seg-2',
booking_id: 'booking-1',
position: 1,
departure_date: '2026-07-12',
airline_id: null,
airline_iata: 'UA',
airline_name: 'United',
flight_number: '200',
created_at: '2026-01-01',
updated_at: '2026-01-01',
route: null
}
]
}
],
privateVehicles: [],
otherTransports: [],
lodgings: [],
activities: [],
restaurants: [],
packingLists: [],
todos: [],
packageTours: []
});
expect(timeline.scheduled).toHaveLength(2);
expect(timeline.scheduled[0].date).toBe('2026-07-10');
expect(timeline.scheduled[1].date).toBe('2026-07-12');
const firstEntry = timeline.scheduled[0].entries[0];
const secondEntry = timeline.scheduled[1].entries[0];
expect(firstEntry.kind === 'plan' ? firstEntry.flightSegmentIndex : null).toBe(0);
expect(secondEntry.kind === 'plan' ? secondEntry.flightSegmentIndex : null).toBe(1);
expect(firstEntry.id).not.toBe(secondEntry.id);
});
});

View File

@@ -11,7 +11,10 @@ export interface TripTimelineInput {
plans: Plan[];
flightBookings: Array<
FlightBooking & {
segments: Array<{ departure_date: string; route?: { departure_datetime?: string | null } | null }>;
segments: Array<{
departure_date: string;
route?: { departure_datetime?: string | null } | null;
}>;
}
>;
privateVehicles: PrivateVehicleTransport[];
@@ -63,6 +66,7 @@ export type TimelineEntry =
date: string | null;
time: string | null;
plan: Plan;
flightSegmentIndex: number | null;
tour: TourContext | null;
tourDay: TourDayContext | null;
sortRank: number;
@@ -91,7 +95,10 @@ function normalizeTime(value: string | null | undefined): string | null {
return value.slice(0, 5);
}
function splitDateTime(value: string | null | undefined): { date: string | null; time: string | null } {
function splitDateTime(value: string | null | undefined): {
date: string | null;
time: string | null;
} {
if (!value) return { date: null, time: null };
const [date, time] = value.split('T');
return { date: date ?? null, time: normalizeTime(time) };
@@ -107,7 +114,10 @@ export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
);
const lodgingByPlanId = new Map(input.lodgings.map((lodging) => [lodging.plan_id, lodging]));
const experienceByPlanId = new Map(
[...input.activities, ...input.restaurants].map((experience) => [experience.plan_id, experience])
[...input.activities, ...input.restaurants].map((experience) => [
experience.plan_id,
experience
])
);
const tourByPlanId = new Map(
@@ -146,8 +156,9 @@ export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
const dayContext = plan.parent_id ? dayByPlanId.get(plan.parent_id) : null;
const tourContext =
dayContext?.tour ??
(plan.parent_id ? tourByPlanId.get(plan.parent_id) ?? null : null) ??
(tourByPlanId.get(plan.id) ?? null);
(plan.parent_id ? (tourByPlanId.get(plan.parent_id) ?? null) : null) ??
tourByPlanId.get(plan.id) ??
null;
let date: string | null = null;
let time: string | null = null;
@@ -159,11 +170,32 @@ export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
case 'transport': {
const flight = flightByPlanId.get(plan.id);
if (flight?.segments?.length) {
const firstSegment = flight.segments[0];
const routeDateTime = splitDateTime(firstSegment.route?.departure_datetime ?? null);
date = routeDateTime.date ?? firstSegment.departure_date ?? null;
time = routeDateTime.time;
break;
for (let segmentIndex = 0; segmentIndex < flight.segments.length; segmentIndex += 1) {
const segment = flight.segments[segmentIndex];
const routeDateTime = splitDateTime(segment.route?.departure_datetime ?? null);
let segmentDate = routeDateTime.date ?? segment.departure_date ?? null;
const segmentTime = routeDateTime.time;
if (dayContext?.tour.startDate) {
segmentDate = addDaysToDate(dayContext.tour.startDate, dayContext.day.dayNumber - 1);
}
const sortTime = segmentTime ?? '24:00';
entries.push({
kind: 'plan',
id: `${plan.id}-segment-${segmentIndex}`,
date: segmentDate,
time: segmentTime,
plan,
flightSegmentIndex: segmentIndex,
tour: tourContext,
tourDay: dayContext?.day ?? null,
sortRank: 1,
sortTime,
sequence: sequence++
});
}
continue;
}
const privateVehicle = privateVehicleByPlanId.get(plan.id);
if (privateVehicle) {
@@ -176,9 +208,7 @@ export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
const otherTransport = otherTransportByPlanId.get(plan.id);
if (otherTransport) {
date = otherTransport.start_date ?? otherTransport.end_date ?? null;
time =
normalizeTime(otherTransport.start_time) ??
normalizeTime(otherTransport.end_time);
time = normalizeTime(otherTransport.start_time) ?? normalizeTime(otherTransport.end_time);
}
break;
}
@@ -186,9 +216,7 @@ export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
const lodging = lodgingByPlanId.get(plan.id);
if (lodging) {
date = lodging.check_in_date ?? lodging.check_out_date ?? null;
time =
normalizeTime(lodging.check_in_time) ??
normalizeTime(lodging.check_out_time);
time = normalizeTime(lodging.check_in_time) ?? normalizeTime(lodging.check_out_time);
}
break;
}
@@ -197,9 +225,7 @@ export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
const experience = experienceByPlanId.get(plan.id);
if (experience) {
date = experience.start_date ?? experience.end_date ?? null;
time =
normalizeTime(experience.start_time) ??
normalizeTime(experience.end_time);
time = normalizeTime(experience.start_time) ?? normalizeTime(experience.end_time);
}
break;
}
@@ -228,6 +254,7 @@ export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
date,
time,
plan,
flightSegmentIndex: null,
tour: tourContext,
tourDay: dayContext?.day ?? null,
sortRank: 1,
@@ -245,9 +272,7 @@ export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
dayNumber: day.day_number,
title: day.title ?? null
};
const dayDate = tour.start_date
? addDaysToDate(tour.start_date, day.day_number - 1)
: null;
const dayDate = tour.start_date ? addDaysToDate(tour.start_date, day.day_number - 1) : null;
entries.push({
kind: 'day',
id: `day-${day.plan_id}`,

File diff suppressed because it is too large Load Diff