trip) add timeline view to trip page (#48)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m21s

Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #48
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
This commit was merged in pull request #48.
This commit is contained in:
2026-02-23 03:17:44 +00:00
committed by shaun
parent d43889c7b0
commit ae39f7961a
8 changed files with 2299 additions and 358 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 };

131
e2e/trip-timeline.test.ts Normal file
View File

@@ -0,0 +1,131 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser, ensureSelfProfile } from './helpers/auth.js';
import { addPackingList, createTrip, openAddToTripMenuItem, uniqueSuffix } from './helpers/trip.js';
function formatDate(offsetDays: number): string {
const date = new Date();
date.setDate(date.getDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
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();
await dialog.getByLabel('City', { exact: false }).fill(cityQuery);
const cityOption = dialog.locator('ul button').filter({ hasText: cityQuery }).first();
await expect(cityOption).toBeVisible();
await cityOption.click();
await dialog.getByLabel('Arrival').fill(startDate);
await dialog.getByRole('button', { name: 'Add to trip' }).click();
await expect(dialog).toBeHidden();
}
async function addActivity(
page: import('@playwright/test').Page,
values: { name: string; startDate: string; startTime: string }
) {
await openAddToTripMenuItem(page, 'Attractions & Activities');
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.getByRole('heading', { name: /Attraction & activity/i }) })
.first();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
await dialog.getByLabel('Start date').fill(values.startDate);
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 }
) {
await openAddToTripMenuItem(page, 'Package Tours');
const dialog = page.getByRole('dialog', { name: 'Add package tour' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('Tour operator', { exact: false }).fill(values.operatorName);
await dialog.getByLabel('Tour name', { exact: false }).fill(values.tourName);
await dialog.locator('input[name="start_date"]').fill(values.startDate);
await dialog.getByRole('button', { name: 'Add tour' }).click();
await expect(dialog).toBeHidden();
await expect(page.getByText(values.operatorName)).toBeVisible();
await page.getByRole('button', { name: 'Add day' }).click();
const addDayForm = page.locator('form[action="?/addTourDay"]');
await expect(addDayForm).toBeVisible();
await addDayForm.getByPlaceholder(/Day title/i).fill(values.dayTitle);
await addDayForm.getByRole('button', { name: 'Add day' }).click();
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();
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('timeline view shows scheduled and unscheduled plans with tour days', async ({ page }) => {
const suffix = uniqueSuffix();
const startDate = formatDate(10);
const activityDate = formatDate(11);
const destinationDate = formatDate(12);
const tourStartDate = formatDate(13);
const tripName = `E2E Timeline Trip ${suffix}`;
const activityName = `Waterfall hike ${suffix}`;
const destinationName = 'Tokyo';
const packingListName = `Timeline essentials ${suffix}`;
const tourOperator = `Trailblazer ${suffix}`;
const tourName = `Jungle Trek ${suffix}`;
const tourDayTitle = `Rainforest ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page);
await createTrip(page, {
name: tripName,
startDate,
description: 'Timeline view coverage'
});
await addDestination(page, destinationName, destinationDate);
await addActivity(page, { name: activityName, startDate: activityDate, startTime: '09:00' });
await addPackingList(page, { name: packingListName, items: ['Passport', 'Sunscreen'] });
await addPackageTourWithDayAndLodging(page, {
operatorName: tourOperator,
tourName,
startDate: tourStartDate,
dayTitle: tourDayTitle
});
await page.getByRole('button', { name: 'Timeline' }).click();
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').first()).toBeVisible();
await expect(page.getByText(`Day 1 — ${tourDayTitle}`)).toBeVisible();
await expect(page.getByText('Unscheduled')).toBeVisible();
await expect(page.getByText(packingListName)).toBeVisible();
});

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { base } from '$app/paths';
interface Props {
operatorName: string;
tourName?: string | null;
highlightColor?: string | null;
size?: 'sm' | 'md';
showName?: boolean;
}
let {
operatorName,
tourName = null,
highlightColor = null,
size = 'sm',
showName = true
}: Props = $props();
function logoSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, '');
}
let logoVariant = $state<'square' | 'wide' | 'none'>('square');
const squareLogoUrl = $derived(
`${base}/package-tour-logos/${logoSlug(operatorName)}_square.svg`
);
const wideLogoUrl = $derived(
`${base}/package-tour-logos/${logoSlug(operatorName)}_wide.svg`
);
const logoUrl = $derived(
logoVariant === 'square' ? squareLogoUrl : logoVariant === 'wide' ? wideLogoUrl : null
);
const sizeClass = $derived(size === 'md' ? 'h-6 w-6' : 'h-5 w-5');
const textSizeClass = $derived(size === 'md' ? 'text-xs' : 'text-[10px]');
function handleLogoError() {
logoVariant = logoVariant === 'square' ? 'wide' : 'none';
}
</script>
<div class="flex items-center gap-2 text-xs text-gray-500">
{#if logoUrl}
<img
src={logoUrl}
alt={operatorName}
class="{sizeClass} rounded-md object-contain"
on:error={handleLogoError}
/>
{:else}
<span
class="{sizeClass} {textSizeClass} flex items-center justify-center rounded-md border border-gray-200 font-semibold"
style="background-color: {highlightColor ?? '#F3F4F6'}; color: {highlightColor ? '#111827' : '#6B7280'}"
>
{operatorName.slice(0, 1).toUpperCase()}
</span>
{/if}
{#if showName}
<span class="truncate">{operatorName}{tourName ? `: ${tourName}` : ''}</span>
{/if}
</div>

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import TourBadge from '$lib/components/TourBadge.svelte';
import type { Plan } from '$lib/server/plans.js';
import type { PackageTour } from '$lib/server/package-tours.js';
interface Props {
plan: Plan;
tour: PackageTour & { highlight_color: string | null };
onEdit?: () => void;
onDelete?: () => void;
}
let { plan, tour, onEdit, onDelete }: Props = $props();
function formatDate(d: string | null): string {
if (!d) return '';
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
function formatTime(t: string | null): string {
if (!t) return '';
return t.slice(0, 5);
}
const statusConfig = {
idea: { label: 'Idea', class: 'bg-gray-100 text-gray-600' },
tentative: { label: 'Tentative', class: 'bg-yellow-100 text-yellow-800' },
confirmed: { label: 'Confirmed', class: 'bg-green-100 text-green-800' }
};
const hasStart = $derived(tour.start_date || tour.start_time);
const hasEnd = $derived(tour.end_date || tour.end_time);
</script>
<div
class="rounded-xl border-2 bg-white p-4 shadow-sm"
style="border-color: {tour.highlight_color ?? '#E5E7EB'}"
>
<div class="flex items-start justify-between gap-3">
<TourBadge
operatorName={tour.operator_name}
tourName={tour.tour_name}
highlightColor={tour.highlight_color}
size="md"
/>
<div class="flex shrink-0 items-center gap-1">
<span
class="rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[plan.status].class}"
>
{statusConfig[plan.status].label}
</span>
{#if onEdit}
<button
type="button"
onclick={onEdit}
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Edit tour"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/if}
{#if onDelete}
<button
type="button"
onclick={onDelete}
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
aria-label="Remove tour"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</div>
</div>
{#if hasStart || hasEnd}
<div class="mt-3 grid grid-cols-2 gap-4 border-t border-gray-100 pt-3">
<div>
<p class="text-xs font-medium tracking-wide text-gray-400 uppercase">Start</p>
<p class="mt-0.5 text-sm text-gray-900">
{#if tour.start_date}
{formatDate(tour.start_date)} {formatTime(tour.start_time)}
{:else}
{formatTime(tour.start_time)}
{/if}
</p>
</div>
<div>
<p class="text-xs font-medium tracking-wide text-gray-400 uppercase">End</p>
<p class="mt-0.5 text-sm text-gray-900">
{#if tour.end_date}
{formatDate(tour.end_date)} {formatTime(tour.end_time)}
{:else}
{formatTime(tour.end_time)}
{/if}
</p>
</div>
</div>
{/if}
</div>

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}

295
src/lib/timeline.test.ts Normal file
View File

@@ -0,0 +1,295 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { buildTripTimeline } from './timeline.js';
import type { Plan } from './server/plans.js';
import type { PackageTour, TourDay } from './server/package-tours.js';
let planCounter = 0;
function makePlan(overrides: Partial<Plan>): Plan {
planCounter += 1;
return {
id: overrides.id ?? `plan-${planCounter}`,
trip_id: 'trip-1',
user_id: 'user-1',
type: overrides.type ?? 'destination',
status: overrides.status ?? 'idea',
parent_id: overrides.parent_id ?? null,
title: overrides.title ?? 'Plan',
notes: overrides.notes ?? null,
city_id: overrides.city_id ?? null,
city_name: overrides.city_name ?? null,
country: overrides.country ?? null,
country_code: overrides.country_code ?? null,
start_date: overrides.start_date ?? null,
end_date: overrides.end_date ?? null,
position: overrides.position ?? 0,
created_at: overrides.created_at ?? '2026-01-01T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-01-01T00:00:00Z'
};
}
function makeTour(overrides: Partial<PackageTour>): PackageTour {
return {
id: overrides.id ?? 'tour-1',
plan_id: overrides.plan_id ?? 'tour-plan-1',
operator_name: overrides.operator_name ?? 'Adventure Co',
tour_name: overrides.tour_name ?? 'Rainforest',
confirmation_number: overrides.confirmation_number ?? null,
start_date: overrides.start_date ?? null,
start_time: overrides.start_time ?? null,
start_timezone: overrides.start_timezone ?? null,
end_date: overrides.end_date ?? null,
end_time: overrides.end_time ?? null,
end_timezone: overrides.end_timezone ?? null,
price: overrides.price ?? null,
currency: overrides.currency ?? 'USD',
created_at: overrides.created_at ?? '2026-01-01T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-01-01T00:00:00Z'
};
}
function makeTourDay(overrides: Partial<TourDay>): TourDay {
return {
plan_id: overrides.plan_id ?? 'day-plan-1',
day_number: overrides.day_number ?? 1,
title: overrides.title ?? null,
notes: overrides.notes ?? null,
childPlans: overrides.childPlans ?? []
};
}
describe('buildTripTimeline', () => {
beforeEach(() => {
planCounter = 0;
});
it('orders scheduled plans by date and time', () => {
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({
plans: [nextDay, later, early],
flightBookings: [],
privateVehicles: [],
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: '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: [],
todos: [],
packageTours: []
});
expect(timeline.scheduled).toHaveLength(2);
expect(timeline.scheduled[0].date).toBe('2026-05-01');
const ids = timeline.scheduled[0].entries
.filter((entry) => entry.kind === 'plan')
.map((entry) => (entry.kind === 'plan' ? entry.plan.id : ''));
expect(ids).toEqual([early.id, later.id]);
expect(timeline.scheduled[1].date).toBe('2026-05-02');
});
it('maps tour day items to tour start date offsets', () => {
const tourPlan = makePlan({ id: 'tour-plan-1', type: 'tour' });
const day1 = makeTourDay({ plan_id: 'day-1', day_number: 1, title: 'Arrival' });
const day2 = makeTourDay({ plan_id: 'day-2', day_number: 2, title: 'Hike' });
const activityPlan = makePlan({ id: 'activity-1', type: 'activity', parent_id: day2.plan_id });
const tour = makeTour({ plan_id: tourPlan.id, start_date: '2027-01-01' });
const timeline = buildTripTimeline({
plans: [tourPlan, activityPlan],
flightBookings: [],
privateVehicles: [],
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'
}
],
restaurants: [],
packingLists: [],
todos: [],
packageTours: [
{ ...tour, days: [day1, day2], ungroupedChildPlans: [], highlight_color: '#FFAA00' }
]
});
const day2Group = timeline.scheduled.find((group) => group.date === '2027-01-02');
expect(day2Group).toBeTruthy();
const dayEntries = day2Group?.entries.filter((entry) => entry.kind === 'day') ?? [];
expect(dayEntries).toHaveLength(1);
const planEntries = day2Group?.entries.filter((entry) => entry.kind === 'plan') ?? [];
expect(planEntries).toHaveLength(1);
expect(planEntries[0].kind === 'plan' ? planEntries[0].tourDay?.dayNumber : null).toBe(2);
});
it('puts tour days without a start date into unscheduled', () => {
const tourPlan = makePlan({ id: 'tour-plan-2', type: 'tour' });
const day1 = makeTourDay({ plan_id: 'day-3', day_number: 1, title: 'Intro' });
const activityPlan = makePlan({ id: 'activity-2', type: 'activity', parent_id: day1.plan_id });
const tour = makeTour({ id: 'tour-2', plan_id: tourPlan.id, start_date: null });
const timeline = buildTripTimeline({
plans: [tourPlan, activityPlan],
flightBookings: [],
privateVehicles: [],
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'
}
],
restaurants: [],
packingLists: [],
todos: [],
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);
});
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);
});
});

321
src/lib/timeline.ts Normal file
View File

@@ -0,0 +1,321 @@
import type { Plan } from '$lib/server/plans.js';
import type { FlightBooking } from '$lib/server/flights.js';
import type { PrivateVehicleTransport } from '$lib/server/private-vehicles.js';
import type { OtherTransport } from '$lib/server/other-transports.js';
import type { Lodging } from '$lib/server/lodgings.js';
import type { ExperiencePlan } from '$lib/server/experiences.js';
import type { ChecklistWithItems } from '$lib/server/checklists.js';
import type { PackageTour, TourDay, ChildPlanSummary } from '$lib/server/package-tours.js';
export interface TripTimelineInput {
plans: Plan[];
flightBookings: Array<
FlightBooking & {
segments: Array<{
departure_date: string;
route?: { departure_datetime?: string | null } | null;
}>;
}
>;
privateVehicles: PrivateVehicleTransport[];
otherTransports: OtherTransport[];
lodgings: Array<Lodging & { planStatus?: string }>;
activities: ExperiencePlan[];
restaurants: ExperiencePlan[];
packingLists: ChecklistWithItems[];
todos: ChecklistWithItems[];
packageTours: Array<
PackageTour & {
days: TourDay[];
ungroupedChildPlans: ChildPlanSummary[];
highlight_color: string | null;
}
>;
}
export interface TourContext {
planId: string;
tourId: string;
operatorName: string;
tourName: string | null;
startDate: string | null;
startTime: string | null;
highlightColor: string | null;
}
export interface TourDayContext {
planId: string;
dayNumber: number;
title: string | null;
}
export type TimelineEntry =
| {
kind: 'day';
id: string;
date: string | null;
tour: TourContext;
day: TourDayContext;
sortRank: number;
sortTime: string;
sequence: number;
}
| {
kind: 'plan';
id: string;
date: string | null;
time: string | null;
plan: Plan;
flightSegmentIndex: number | null;
tour: TourContext | null;
tourDay: TourDayContext | null;
sortRank: number;
sortTime: string;
sequence: number;
};
export interface TimelineGroup {
date: string;
entries: TimelineEntry[];
}
export interface TripTimeline {
scheduled: TimelineGroup[];
unscheduled: TimelineEntry[];
}
function addDaysToDate(date: string, offset: number): string {
const [year, month, day] = date.split('-').map(Number);
const ts = Date.UTC(year, month - 1, day + offset);
return new Date(ts).toISOString().slice(0, 10);
}
function normalizeTime(value: string | null | undefined): string | null {
if (!value) return null;
return value.slice(0, 5);
}
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) };
}
export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
const flightByPlanId = new Map(input.flightBookings.map((booking) => [booking.plan_id, booking]));
const privateVehicleByPlanId = new Map(
input.privateVehicles.map((vehicle) => [vehicle.plan_id, vehicle])
);
const otherTransportByPlanId = new Map(
input.otherTransports.map((transport) => [transport.plan_id, transport])
);
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
])
);
const tourByPlanId = new Map(
input.packageTours.map((tour) => [
tour.plan_id,
{
planId: tour.plan_id,
tourId: tour.id,
operatorName: tour.operator_name,
tourName: tour.tour_name ?? null,
startDate: tour.start_date ?? null,
startTime: tour.start_time ?? null,
highlightColor: tour.highlight_color ?? null
} satisfies TourContext
])
);
const dayByPlanId = new Map<string, { day: TourDayContext; tour: TourContext }>();
for (const tour of input.packageTours) {
const tourContext = tourByPlanId.get(tour.plan_id);
if (!tourContext) continue;
for (const day of tour.days) {
dayByPlanId.set(day.plan_id, {
day: { planId: day.plan_id, dayNumber: day.day_number, title: day.title ?? null },
tour: tourContext
});
}
}
const entries: TimelineEntry[] = [];
let sequence = 0;
for (const plan of input.plans) {
if (plan.type === 'day') continue;
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;
let date: string | null = null;
let time: string | null = null;
switch (plan.type) {
case 'destination':
date = plan.start_date ?? plan.end_date ?? null;
break;
case 'transport': {
const flight = flightByPlanId.get(plan.id);
if (flight?.segments?.length) {
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) {
date = privateVehicle.departure_date ?? privateVehicle.arrival_date ?? null;
time =
normalizeTime(privateVehicle.departure_time) ??
normalizeTime(privateVehicle.arrival_time);
break;
}
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);
}
break;
}
case 'lodging': {
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);
}
break;
}
case 'activity':
case 'restaurant': {
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);
}
break;
}
case 'tour': {
if (tourContext) {
date = tourContext.startDate;
time = normalizeTime(tourContext.startTime);
}
break;
}
case 'packing':
case 'todo':
break;
default:
break;
}
if (dayContext?.tour.startDate) {
date = addDaysToDate(dayContext.tour.startDate, dayContext.day.dayNumber - 1);
}
const sortTime = time ?? '24:00';
entries.push({
kind: 'plan',
id: plan.id,
date,
time,
plan,
flightSegmentIndex: null,
tour: tourContext,
tourDay: dayContext?.day ?? null,
sortRank: 1,
sortTime,
sequence: sequence++
});
}
for (const tour of input.packageTours) {
const tourContext = tourByPlanId.get(tour.plan_id);
if (!tourContext) continue;
for (const day of tour.days) {
const dayContext: TourDayContext = {
planId: day.plan_id,
dayNumber: day.day_number,
title: day.title ?? null
};
const dayDate = tour.start_date ? addDaysToDate(tour.start_date, day.day_number - 1) : null;
entries.push({
kind: 'day',
id: `day-${day.plan_id}`,
date: dayDate,
tour: tourContext,
day: dayContext,
sortRank: 0,
sortTime: '00:00',
sequence: sequence++
});
}
}
const scheduledEntries = entries.filter((entry) => entry.date);
const unscheduledEntries = entries
.filter((entry) => !entry.date)
.sort((a, b) => {
if (a.sortRank !== b.sortRank) return a.sortRank - b.sortRank;
if (a.sortTime !== b.sortTime) return a.sortTime.localeCompare(b.sortTime);
return a.sequence - b.sequence;
});
scheduledEntries.sort((a, b) => {
if (a.date !== b.date) return (a.date ?? '').localeCompare(b.date ?? '');
if (a.sortRank !== b.sortRank) return a.sortRank - b.sortRank;
if (a.sortTime !== b.sortTime) return a.sortTime.localeCompare(b.sortTime);
return a.sequence - b.sequence;
});
const grouped = new Map<string, TimelineEntry[]>();
for (const entry of scheduledEntries) {
const date = entry.date as string;
const list = grouped.get(date);
if (list) list.push(entry);
else grouped.set(date, [entry]);
}
const scheduled: TimelineGroup[] = Array.from(grouped.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, groupEntries]) => ({
date,
entries: groupEntries
}));
return { scheduled, unscheduled: unscheduledEntries };
}

File diff suppressed because it is too large Load Diff