From 723b892fb61a598330501c5f4bc3e38c6a08ed0b Mon Sep 17 00:00:00 2001 From: AI Agent Date: Mon, 23 Feb 2026 01:16:49 +0000 Subject: [PATCH] trip) add timeline view to trip page --- e2e/trip-timeline.test.ts | 131 +++ src/lib/components/TourBadge.svelte | 66 ++ src/lib/components/TourTimelineCard.svelte | 109 +++ src/lib/timeline.test.ts | 156 ++++ src/lib/timeline.ts | 296 ++++++ .../(protected)/trips/[id]/+page.svelte | 873 ++++++++++++++++-- 6 files changed, 1542 insertions(+), 89 deletions(-) create mode 100644 e2e/trip-timeline.test.ts create mode 100644 src/lib/components/TourBadge.svelte create mode 100644 src/lib/components/TourTimelineCard.svelte create mode 100644 src/lib/timeline.test.ts create mode 100644 src/lib/timeline.ts diff --git a/e2e/trip-timeline.test.ts b/e2e/trip-timeline.test.ts new file mode 100644 index 0000000..23dba8f --- /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').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 } +) { + 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', { 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 }) => { + 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}`; + const tourLodgingName = `Canopy Lodge ${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, + lodgingName: tourLodgingName + }); + + 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')).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(); +}); 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} + {operatorName} + {: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/timeline.test.ts b/src/lib/timeline.test.ts new file mode 100644 index 0000000..6ddd52a --- /dev/null +++ b/src/lib/timeline.test.ts @@ -0,0 +1,156 @@ +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', start_time: '09:00' }); + const later = makePlan({ type: 'activity', start_date: '2026-05-01', start_time: '10:30' }); + 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: 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' } + ], + 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); + }); +}); diff --git a/src/lib/timeline.ts b/src/lib/timeline.ts new file mode 100644 index 0000000..fa719f9 --- /dev/null +++ b/src/lib/timeline.ts @@ -0,0 +1,296 @@ +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; + 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) { + const firstSegment = flight.segments[0]; + const routeDateTime = splitDateTime(firstSegment.route?.departure_datetime ?? null); + date = routeDateTime.date ?? firstSegment.departure_date ?? null; + time = routeDateTime.time; + break; + } + 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, + 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..9c9cc46 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,50 @@ }); } + 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 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 timeline = $derived( + buildTripTimeline({ + plans, + flightBookings, + privateVehicles, + otherTransports, + lodgings, + activities, + restaurants, + packingLists, + todos, + packageTours + }) + ); @@ -543,106 +591,752 @@ 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(); - }} -
{ - return ({ update }) => { - update(); - }; - }} - class="contents" +
+

Plans

+
+ {#each planViewOptions as option} + {/each}
- - {#if flightBookings.length > 0 || privateVehicles.length > 0 || otherTransports.length > 0} + {#if planView === 'timeline'} +
+ {#if timeline.scheduled.length === 0} +

No scheduled plans yet.

+ {:else} + {#each timeline.scheduled as group (group.date)} +
+
+

+ {formatTimelineDate(group.date)} +

+
+
+
+ {#each group.entries as entry (entry.id)} + {#if entry.kind === 'day'} +
+
+ +

+ Day {entry.day.dayNumber}{entry.day.title ? ` — ${entry.day.title}` : ''} +

+ + {entry.tour.operatorName}{entry.tour.tourName ? `: ${entry.tour.tourName}` : ''} + +
+
+ {:else} + {@const plan = entry.plan} + {@const timeLabel = formatTimelineTime(entry.time)} + {@const flightBooking = flightBookingByPlanId.get(plan.id)} + {@const privateVehicle = privateVehicleByPlanId.get(plan.id)} + {@const otherTransport = otherTransportByPlanId.get(plan.id)} + {@const lodging = lodgingByPlanId.get(plan.id)} + {@const activity = activityByPlanId.get(plan.id)} + {@const restaurant = restaurantByPlanId.get(plan.id)} + {@const packingList = packingListByPlanId.get(plan.id)} + {@const todoList = todoByPlanId.get(plan.id)} + {@const tour = tourByPlanId.get(plan.id)} +
+
+ {timeLabel} +
+
+ {#if entry.tour && plan.type !== 'tour'} +
+ + + {entry.tour.operatorName}{entry.tour.tourName ? `: ${entry.tour.tourName}` : ''} + + {#if entry.tourDay} + + • Day {entry.tourDay.dayNumber}{entry.tourDay.title ? ` — ${entry.tourDay.title}` : ''} + + {/if} +
+ {/if} +
+ {#if plan.type === 'destination'} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + + + {:else if plan.type === 'transport'} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + {#if flightBooking} + + (editingTransportation = { + type: 'flight', + flightBooking, + planStatus: plan.status + })} + onDelete={submitForm} + /> + {:else if privateVehicle} + + (editingTransportation = { + type: 'private_vehicle', + privateVehicle, + planStatus: plan.status + })} + onDelete={submitForm} + /> + {:else if otherTransport} + + (editingTransportation = { + type: 'other', + otherTransport, + planStatus: plan.status, + planTitle: plan.title, + planNotes: plan.notes + })} + onDelete={submitForm} + /> + {/if} + + {:else if plan.type === 'activity' && activity} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingActivity = { plan, experience: activity })} + onDelete={submitForm} + /> + + {:else if plan.type === 'restaurant' && restaurant} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingRestaurant = { plan, experience: restaurant })} + onDelete={submitForm} + /> + + {:else if plan.type === 'lodging' && lodging} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingLodging = lodging)} + onDelete={submitForm} + /> + + {:else if plan.type === 'packing' && packingList} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingPackingList = { plan, list: packingList })} + onDelete={submitForm} + onToggleItem={toggleChecklistItem} + /> + + {:else if plan.type === 'todo' && todoList} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingTodo = { plan, list: todoList })} + onDelete={submitForm} + onToggleItem={toggleChecklistItem} + /> + + {:else if plan.type === 'tour' && tour} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingTour = tour)} + onDelete={submitForm} + /> + + {/if} +
+
+
+ {/if} + {/each} +
+
+ {/each} + {/if} + + {#if timeline.unscheduled.length > 0} +
+
+

Unscheduled

+
+
+
+ {#each timeline.unscheduled as entry (entry.id)} + {#if entry.kind === 'day'} +
+
+ +

+ Day {entry.day.dayNumber}{entry.day.title ? ` — ${entry.day.title}` : ''} +

+ + {entry.tour.operatorName}{entry.tour.tourName ? `: ${entry.tour.tourName}` : ''} + + Date TBD +
+
+ {:else} + {@const plan = entry.plan} + {@const timeLabel = entry.time ? formatTimelineTime(entry.time) : 'TBD'} + {@const flightBooking = flightBookingByPlanId.get(plan.id)} + {@const privateVehicle = privateVehicleByPlanId.get(plan.id)} + {@const otherTransport = otherTransportByPlanId.get(plan.id)} + {@const lodging = lodgingByPlanId.get(plan.id)} + {@const activity = activityByPlanId.get(plan.id)} + {@const restaurant = restaurantByPlanId.get(plan.id)} + {@const packingList = packingListByPlanId.get(plan.id)} + {@const todoList = todoByPlanId.get(plan.id)} + {@const tour = tourByPlanId.get(plan.id)} +
+
+ {timeLabel} +
+
+ {#if entry.tour && plan.type !== 'tour'} +
+ + + {entry.tour.operatorName}{entry.tour.tourName ? `: ${entry.tour.tourName}` : ''} + + {#if entry.tourDay} + + • Day {entry.tourDay.dayNumber}{entry.tourDay.title ? ` — ${entry.tourDay.title}` : ''} + + {/if} +
+ {/if} +
+ {#if plan.type === 'destination'} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + + + {:else if plan.type === 'transport'} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + {#if flightBooking} + + (editingTransportation = { + type: 'flight', + flightBooking, + planStatus: plan.status + })} + onDelete={submitForm} + /> + {:else if privateVehicle} + + (editingTransportation = { + type: 'private_vehicle', + privateVehicle, + planStatus: plan.status + })} + onDelete={submitForm} + /> + {:else if otherTransport} + + (editingTransportation = { + type: 'other', + otherTransport, + planStatus: plan.status, + planTitle: plan.title, + planNotes: plan.notes + })} + onDelete={submitForm} + /> + {/if} + + {:else if plan.type === 'activity' && activity} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingActivity = { plan, experience: activity })} + onDelete={submitForm} + /> + + {:else if plan.type === 'restaurant' && restaurant} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingRestaurant = { plan, experience: restaurant })} + onDelete={submitForm} + /> + + {:else if plan.type === 'lodging' && lodging} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingLodging = lodging)} + onDelete={submitForm} + /> + + {:else if plan.type === 'packing' && packingList} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingPackingList = { plan, list: packingList })} + onDelete={submitForm} + onToggleItem={toggleChecklistItem} + /> + + {:else if plan.type === 'todo' && todoList} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingTodo = { plan, list: todoList })} + onDelete={submitForm} + onToggleItem={toggleChecklistItem} + /> + + {:else if plan.type === 'tour' && tour} + {@const formId = `remove-plan-timeline-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingTour = tour)} + onDelete={submitForm} + /> + + {/if} +
+
+
+ {/if} + {/each} +
+
+ {/if} +
+ {:else} +

- Transportation + Destinations

- {#each plans.filter((p) => p.type === 'transport') as plan (plan.id)} - {@const flightBooking = flightBookings.find((b) => b.plan_id === plan.id)} - {@const privateVehicle = privateVehicles.find((pv) => pv.plan_id === plan.id)} - {@const otherTransport = otherTransports.find((ot) => ot.plan_id === plan.id)} - {#if plan} - {@const formId = `remove-plan-${plan.id}`} - {@const submitForm = () => { - const form = document.getElementById(formId) as HTMLFormElement; - form?.requestSubmit(); + {#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(); + }} +
{ + return ({ update }) => { + update(); + }; }} - { - return ({ update }) => { - update(); - }; + class="contents" + > + + + + {/each} +
+
+ + + {#if flightBookings.length > 0 || privateVehicles.length > 0 || otherTransports.length > 0} +
+

+ Transportation +

+
+ {#each plans.filter((p) => p.type === 'transport') as plan (plan.id)} + {@const flightBooking = flightBookings.find((b) => b.plan_id === plan.id)} + {@const privateVehicle = privateVehicles.find((pv) => pv.plan_id === plan.id)} + {@const otherTransport = otherTransports.find((ot) => ot.plan_id === plan.id)} + {#if plan} + {@const formId = `remove-plan-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); }} - class="contents" - > - - {#if flightBooking} - - (editingTransportation = { - type: 'flight', - flightBooking, - planStatus: plan.status - })} - onDelete={submitForm} - /> - {:else if privateVehicle} - - (editingTransportation = { - type: 'private_vehicle', - privateVehicle, - planStatus: plan.status - })} - onDelete={submitForm} - /> - {:else if otherTransport} - - (editingTransportation = { - type: 'other', - otherTransport, - planStatus: plan.status, - planTitle: plan.title, - planNotes: plan.notes - })} - onDelete={submitForm} - /> - {/if} - - {/if} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + {#if flightBooking} + + (editingTransportation = { + type: 'flight', + flightBooking, + planStatus: plan.status + })} + onDelete={submitForm} + /> + {:else if privateVehicle} + + (editingTransportation = { + type: 'private_vehicle', + privateVehicle, + planStatus: plan.status + })} + onDelete={submitForm} + /> + {:else if otherTransport} + + (editingTransportation = { + type: 'other', + otherTransport, + planStatus: plan.status, + planTitle: plan.title, + planNotes: plan.notes + })} + onDelete={submitForm} + /> + {/if} + + {/if} {/each}
@@ -903,6 +1597,7 @@
{/if} + {/if} {/if} {/if}