diff --git a/e2e/helpers/trip.ts b/e2e/helpers/trip.ts
index 42fa481..684b207 100644
--- a/e2e/helpers/trip.ts
+++ b/e2e/helpers/trip.ts
@@ -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 };
diff --git a/e2e/trip-timeline.test.ts b/e2e/trip-timeline.test.ts
new file mode 100644
index 0000000..c5383fb
--- /dev/null
+++ b/e2e/trip-timeline.test.ts
@@ -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();
+});
diff --git a/src/lib/components/TourBadge.svelte b/src/lib/components/TourBadge.svelte
new file mode 100644
index 0000000..0ea7320
--- /dev/null
+++ b/src/lib/components/TourBadge.svelte
@@ -0,0 +1,66 @@
+
+
+
+ {#if logoUrl}
+

+ {:else}
+
+ {operatorName.slice(0, 1).toUpperCase()}
+
+ {/if}
+ {#if showName}
+
{operatorName}{tourName ? `: ${tourName}` : ''}
+ {/if}
+
diff --git a/src/lib/components/TourTimelineCard.svelte b/src/lib/components/TourTimelineCard.svelte
new file mode 100644
index 0000000..38f521f
--- /dev/null
+++ b/src/lib/components/TourTimelineCard.svelte
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+ {statusConfig[plan.status].label}
+
+ {#if onEdit}
+
+ {/if}
+ {#if onDelete}
+
+ {/if}
+
+
+
+ {#if hasStart || hasEnd}
+
+
+
Start
+
+ {#if tour.start_date}
+ {formatDate(tour.start_date)} {formatTime(tour.start_time)}
+ {:else}
+ {formatTime(tour.start_time)}
+ {/if}
+
+
+
+
End
+
+ {#if tour.end_date}
+ {formatDate(tour.end_date)} {formatTime(tour.end_time)}
+ {:else}
+ {formatTime(tour.end_time)}
+ {/if}
+
+
+
+ {/if}
+
diff --git a/src/lib/components/TripList.svelte b/src/lib/components/TripList.svelte
index 35bfcb6..ab3a61c 100644
--- a/src/lib/components/TripList.svelte
+++ b/src/lib/components/TripList.svelte
@@ -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');
-
+
{title}
- {#if trips.length > 0}
+ {#if tripList.length > 0}
-{#if trips.length === 0}
+{#if tripList.length === 0}
{emptyMessage}
{:else if view === 'tile'}
- {#each trips as trip}
+ {#each tripList as trip}
{/each}
{:else}
-
+
{/if}
diff --git a/src/lib/timeline.test.ts b/src/lib/timeline.test.ts
new file mode 100644
index 0000000..ead7177
--- /dev/null
+++ b/src/lib/timeline.test.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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);
+ });
+});
diff --git a/src/lib/timeline.ts b/src/lib/timeline.ts
new file mode 100644
index 0000000..e6a2366
--- /dev/null
+++ b/src/lib/timeline.ts
@@ -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;
+ 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();
+ 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();
+ 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 };
+}
diff --git a/src/routes/(protected)/trips/[id]/+page.svelte b/src/routes/(protected)/trips/[id]/+page.svelte
index ec39012..40389e2 100644
--- a/src/routes/(protected)/trips/[id]/+page.svelte
+++ b/src/routes/(protected)/trips/[id]/+page.svelte
@@ -15,6 +15,8 @@
import ChecklistCard from '$lib/components/ChecklistCard.svelte';
import PackageTourCard from '$lib/components/PackageTourCard.svelte';
import AddPlanMenu, { type AddPlanMenuItem } from '$lib/components/AddPlanMenu.svelte';
+ import TourBadge from '$lib/components/TourBadge.svelte';
+ import TourTimelineCard from '$lib/components/TourTimelineCard.svelte';
import { PLAN_TYPE_DEFINITIONS, type PlanTypeId } from '$lib/components/plan-types.js';
import PlanCard from '$lib/components/PlanCard.svelte';
import FlightCard from '$lib/components/FlightCard.svelte';
@@ -22,6 +24,7 @@
import PrivateVehicleCard from '$lib/components/PrivateVehicleCard.svelte';
import LodgingCard from '$lib/components/LodgingCard.svelte';
import TravellerChip from '$lib/components/TravellerChip.svelte';
+ import { buildTripTimeline } from '$lib/timeline.js';
let { data, form } = $props();
let trip = $derived(form?.trip ?? data.trip);
@@ -89,6 +92,12 @@
let togglingChecklistItemId = $state(null);
let togglingChecklistItemChecked = $state<'0' | '1'>('0');
let addingChildToPlanId = $state(null);
+ let planView = $state<'types' | 'timeline'>('types');
+
+ const planViewOptions = [
+ { id: 'types', label: 'By type' },
+ { id: 'timeline', label: 'Timeline' }
+ ] as const;
let transportationLocationOptions = $derived(
plans
@@ -132,11 +141,118 @@
});
}
+ function formatTimelineDate(d: string) {
+ return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
+ weekday: 'short',
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ });
+ }
+
+ function formatTimelineTime(t: string | null) {
+ if (!t) return 'All day';
+ return t.slice(0, 5);
+ }
+
+ 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 toggleChecklistItem(itemId: string | number, nextChecked: boolean) {
togglingChecklistItemId = String(itemId);
togglingChecklistItemChecked = nextChecked ? '1' : '0';
setTimeout(() => checklistToggleFormRef?.requestSubmit(), 0);
}
+
+ const flightBookingByPlanId = $derived(
+ new Map(flightBookings.map((booking) => [booking.plan_id, booking]))
+ );
+ const privateVehicleByPlanId = $derived(
+ new Map(privateVehicles.map((vehicle) => [vehicle.plan_id, vehicle]))
+ );
+ const otherTransportByPlanId = $derived(
+ new Map(otherTransports.map((transport) => [transport.plan_id, transport]))
+ );
+ const lodgingByPlanId = $derived(new Map(lodgings.map((lodging) => [lodging.plan_id, lodging])));
+ const activityByPlanId = $derived(
+ new Map(activities.map((activity) => [activity.plan_id, activity]))
+ );
+ const restaurantByPlanId = $derived(
+ new Map(restaurants.map((restaurant) => [restaurant.plan_id, restaurant]))
+ );
+ const packingListByPlanId = $derived(new Map(packingLists.map((list) => [list.plan_id, list])));
+ const todoByPlanId = $derived(new Map(todos.map((list) => [list.plan_id, list])));
+ const tourByPlanId = $derived(new Map(packageTours.map((tour) => [tour.plan_id, tour])));
+ const byTypeTourContextByPlanId = $derived(
+ (() => {
+ const dayContextByPlanId = new Map<
+ string,
+ {
+ tour: (typeof packageTours)[number];
+ dayNumber: number;
+ dayTitle: string | null;
+ dayDate: string | null;
+ }
+ >();
+ for (const tour of packageTours) {
+ for (const day of tour.days) {
+ dayContextByPlanId.set(day.plan_id, {
+ tour,
+ dayNumber: day.day_number,
+ dayTitle: day.title ?? null,
+ dayDate: tour.start_date ? addDaysToDate(tour.start_date, day.day_number - 1) : null
+ });
+ }
+ }
+
+ const contextByPlanId = new Map<
+ string,
+ {
+ tour: (typeof packageTours)[number];
+ dayNumber: number | null;
+ dayTitle: string | null;
+ dayDate: string | null;
+ }
+ >();
+ for (const plan of plans) {
+ if (!plan.parent_id) continue;
+ const dayContext = dayContextByPlanId.get(plan.parent_id);
+ if (dayContext) {
+ contextByPlanId.set(plan.id, dayContext);
+ continue;
+ }
+
+ const parentTour = tourByPlanId.get(plan.parent_id);
+ if (parentTour) {
+ contextByPlanId.set(plan.id, {
+ tour: parentTour,
+ dayNumber: null,
+ dayTitle: null,
+ dayDate: null
+ });
+ }
+ }
+ return contextByPlanId;
+ })()
+ );
+
+ const timeline = $derived(
+ buildTripTimeline({
+ plans,
+ flightBookings,
+ privateVehicles,
+ otherTransports,
+ lodgings,
+ activities,
+ restaurants,
+ packingLists,
+ todos,
+ packageTours
+ })
+ );
@@ -543,366 +659,1251 @@
onAddTodo={() => (showAddTodo = true)}
/>
{:else}
-
-
-
- Destinations
-
-
- {#each plans.filter((p) => p.type === 'destination') as plan (plan.id)}
- {@const formId = `remove-plan-${plan.id}`}
- {@const submitForm = () => {
- const form = document.getElementById(formId) as HTMLFormElement;
- form?.requestSubmit();
- }}
-