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} + {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/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(); - }} -
{ - return ({ update }) => { - update(); - }; - }} - class="contents" +
+

Plans

+
+ {#each planViewOptions as option} + {/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(); - }} -
{ - 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 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 = entry.time ? formatTimelineTime(entry.time) : ''} + {@const hasMatchingDayHeader = + !!entry.tourDay && + group.entries.some( + (candidate) => + candidate.kind === 'day' && + candidate.tour.tourId === entry.tour?.tourId && + candidate.day.dayNumber === entry.tourDay?.dayNumber + )} + {@const showInlineTourContext = + !!entry.tour && plan.type !== 'tour' && !hasMatchingDayHeader} + {@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 showInlineTourContext} +
+ + + {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} + {@const timelineFlightBooking = + entry.flightSegmentIndex == null + ? flightBooking + : { + ...flightBooking, + segments: [ + flightBooking.segments[entry.flightSegmentIndex] + ].filter(Boolean) + }} + + (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} + {@const timelineFlightBooking = + entry.flightSegmentIndex == null + ? flightBooking + : { + ...flightBooking, + segments: [ + flightBooking.segments[entry.flightSegmentIndex] + ].filter(Boolean) + }} + + (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} - - {/if} - {/each} -
+ {/each} +
+
+ {/if}
- {/if} - - - {#if activities.length > 0} + {:else} +

- Attractions & Activities + Destinations

- {#each activities as activity (activity.id)} - {@const plan = plans.find((p) => p.id === activity.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 packageTourContext = byTypeTourContextByPlanId.get(plan.id)} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; }} - { - return ({ update }) => { - update(); - }; - }} - class="contents" + class="contents" + > + +
- - (editingActivity = { plan, experience: activity })} - onDelete={submitForm} - /> - - {/if} + {#if packageTourContext} +
+ + + {packageTourContext.tour.operator_name}{packageTourContext.tour.tour_name + ? `: ${packageTourContext.tour.tour_name}` + : ''} + + {#if packageTourContext.dayNumber != null} + + • Day {packageTourContext.dayNumber}{packageTourContext.dayTitle + ? ` — ${packageTourContext.dayTitle}` + : ''}{packageTourContext.dayDate + ? ` · ${formatTimelineDate(packageTourContext.dayDate)}` + : ''} + + {/if} +
+ {/if} + +
+ {/each}
- {/if} - - {#if restaurants.length > 0} -
-

- Restaurants -

-
- {#each restaurants as restaurant (restaurant.id)} - {@const plan = plans.find((p) => p.id === restaurant.plan_id)} - {#if plan} - {@const formId = `remove-plan-${plan.id}`} - {@const submitForm = () => { - const form = document.getElementById(formId) as HTMLFormElement; - form?.requestSubmit(); - }} -
{ - return ({ update }) => { - update(); - }; + + {#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)} + {@const packageTourContext = byTypeTourContextByPlanId.get(plan.id)} + {#if plan} + {@const formId = `remove-plan-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); }} - class="contents" - > - - (editingRestaurant = { plan, experience: restaurant })} - onDelete={submitForm} - /> - - {/if} - {/each} -
-
- {/if} - - - {#if packingLists.length > 0} -
-

- Packing Lists -

-
- {#each packingLists as list (list.id)} - {@const plan = plans.find((p) => p.id === list.plan_id)} - {#if plan} - {@const formId = `remove-plan-${plan.id}`} - {@const submitForm = () => { - const form = document.getElementById(formId) as HTMLFormElement; - form?.requestSubmit(); - }} -
{ - return ({ update }) => { - update(); - }; - }} - class="contents" - > - - (editingPackingList = { plan, list })} - onDelete={submitForm} - onToggleItem={toggleChecklistItem} - /> - - {/if} - {/each} -
-
- {/if} - - - {#if todos.length > 0} -
-

To-dos

-
- {#each todos as list (list.id)} - {@const plan = plans.find((p) => p.id === list.plan_id)} - {#if plan} - {@const formId = `remove-plan-${plan.id}`} - {@const submitForm = () => { - const form = document.getElementById(formId) as HTMLFormElement; - form?.requestSubmit(); - }} -
{ - return ({ update }) => { - update(); - }; - }} - class="contents" - > - - (editingTodo = { plan, list })} - onDelete={submitForm} - onToggleItem={toggleChecklistItem} - /> - - {/if} - {/each} -
-
- {/if} - - - {#if lodgings.length > 0} -
-

- Lodgings -

-
- {#each lodgings as lodging (lodging.id)} - {@const plan = plans.find((p) => p.id === lodging.plan_id)} - {#if plan} - {@const formId = `remove-plan-${plan.id}`} - {@const submitForm = () => { - const form = document.getElementById(formId) as HTMLFormElement; - form?.requestSubmit(); - }} -
{ - return ({ update }) => { - update(); - }; - }} - class="contents" - > - - (editingLodging = lodging)} - onDelete={submitForm} - /> - - {/if} - {/each} -
-
- {/if} - - - {#if packageTours.length > 0} -
-

- Package Tours -

-
- {#each packageTours as tour (tour.id)} - {@const plan = plans.find((p) => p.id === tour.plan_id)} - {#if plan} - {@const formId = `remove-plan-${plan.id}`} - {@const submitForm = () => { - const f = document.getElementById(formId) as HTMLFormElement; - f?.requestSubmit(); - }} -
{ - return ({ update }) => { - update(); - }; - }} - class="contents" - > - - (editingTour = tour)} - onDelete={submitForm} - onAddTransportation={() => { - addingChildToPlanId = plan.id; - showAddTransportation = true; + { + return ({ update }) => { + update(); + }; }} - onAddLodging={() => { - addingChildToPlanId = plan.id; - showAddLodging = true; - }} - onAddTransportationToDay={(dayPlanId) => { - addingChildToPlanId = dayPlanId; - showAddTransportation = true; - }} - onAddLodgingToDay={(dayPlanId) => { - addingChildToPlanId = dayPlanId; - showAddLodging = true; - }} - /> - - {/if} - {/each} + class="contents" + > + +
+ {#if packageTourContext} +
+ + + {packageTourContext.tour.operator_name}{packageTourContext.tour + .tour_name + ? `: ${packageTourContext.tour.tour_name}` + : ''} + + {#if packageTourContext.dayNumber != null} + + • Day {packageTourContext.dayNumber}{packageTourContext.dayTitle + ? ` — ${packageTourContext.dayTitle}` + : ''}{packageTourContext.dayDate + ? ` · ${formatTimelineDate(packageTourContext.dayDate)}` + : ''} + + {/if} +
+ {/if} + {#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} +
-
+ {/if} + + + {#if activities.length > 0} +
+

+ Attractions & Activities +

+
+ {#each activities as activity (activity.id)} + {@const plan = plans.find((p) => p.id === activity.plan_id)} + {#if plan} + {@const formId = `remove-plan-${plan.id}`} + {@const packageTourContext = byTypeTourContextByPlanId.get(plan.id)} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + +
+ {#if packageTourContext} +
+ + + {packageTourContext.tour.operator_name}{packageTourContext.tour + .tour_name + ? `: ${packageTourContext.tour.tour_name}` + : ''} + + {#if packageTourContext.dayNumber != null} + + • Day {packageTourContext.dayNumber}{packageTourContext.dayTitle + ? ` — ${packageTourContext.dayTitle}` + : ''}{packageTourContext.dayDate + ? ` · ${formatTimelineDate(packageTourContext.dayDate)}` + : ''} + + {/if} +
+ {/if} + (editingActivity = { plan, experience: activity })} + onDelete={submitForm} + /> +
+
+ {/if} + {/each} +
+
+ {/if} + + + {#if restaurants.length > 0} +
+

+ Restaurants +

+
+ {#each restaurants as restaurant (restaurant.id)} + {@const plan = plans.find((p) => p.id === restaurant.plan_id)} + {#if plan} + {@const formId = `remove-plan-${plan.id}`} + {@const packageTourContext = byTypeTourContextByPlanId.get(plan.id)} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + +
+ {#if packageTourContext} +
+ + + {packageTourContext.tour.operator_name}{packageTourContext.tour + .tour_name + ? `: ${packageTourContext.tour.tour_name}` + : ''} + + {#if packageTourContext.dayNumber != null} + + • Day {packageTourContext.dayNumber}{packageTourContext.dayTitle + ? ` — ${packageTourContext.dayTitle}` + : ''}{packageTourContext.dayDate + ? ` · ${formatTimelineDate(packageTourContext.dayDate)}` + : ''} + + {/if} +
+ {/if} + (editingRestaurant = { plan, experience: restaurant })} + onDelete={submitForm} + /> +
+
+ {/if} + {/each} +
+
+ {/if} + + + {#if packingLists.length > 0} +
+

+ Packing Lists +

+
+ {#each packingLists as list (list.id)} + {@const plan = plans.find((p) => p.id === list.plan_id)} + {#if plan} + {@const formId = `remove-plan-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingPackingList = { plan, list })} + onDelete={submitForm} + onToggleItem={toggleChecklistItem} + /> + + {/if} + {/each} +
+
+ {/if} + + + {#if todos.length > 0} +
+

+ To-dos +

+
+ {#each todos as list (list.id)} + {@const plan = plans.find((p) => p.id === list.plan_id)} + {#if plan} + {@const formId = `remove-plan-${plan.id}`} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingTodo = { plan, list })} + onDelete={submitForm} + onToggleItem={toggleChecklistItem} + /> + + {/if} + {/each} +
+
+ {/if} + + + {#if lodgings.length > 0} +
+

+ Lodgings +

+
+ {#each lodgings as lodging (lodging.id)} + {@const plan = plans.find((p) => p.id === lodging.plan_id)} + {#if plan} + {@const formId = `remove-plan-${plan.id}`} + {@const packageTourContext = byTypeTourContextByPlanId.get(plan.id)} + {@const submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + +
+ {#if packageTourContext} +
+ + + {packageTourContext.tour.operator_name}{packageTourContext.tour + .tour_name + ? `: ${packageTourContext.tour.tour_name}` + : ''} + + {#if packageTourContext.dayNumber != null} + + • Day {packageTourContext.dayNumber}{packageTourContext.dayTitle + ? ` — ${packageTourContext.dayTitle}` + : ''}{packageTourContext.dayDate + ? ` · ${formatTimelineDate(packageTourContext.dayDate)}` + : ''} + + {/if} +
+ {/if} + (editingLodging = lodging)} + onDelete={submitForm} + /> +
+
+ {/if} + {/each} +
+
+ {/if} + + + {#if packageTours.length > 0} +
+

+ Package Tours +

+
+ {#each packageTours as tour (tour.id)} + {@const plan = plans.find((p) => p.id === tour.plan_id)} + {#if plan} + {@const formId = `remove-plan-${plan.id}`} + {@const submitForm = () => { + const f = document.getElementById(formId) as HTMLFormElement; + f?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingTour = tour)} + onDelete={submitForm} + onAddTransportation={() => { + addingChildToPlanId = plan.id; + showAddTransportation = true; + }} + onAddLodging={() => { + addingChildToPlanId = plan.id; + showAddLodging = true; + }} + onAddTransportationToDay={(dayPlanId) => { + addingChildToPlanId = dayPlanId; + showAddTransportation = true; + }} + onAddLodgingToDay={(dayPlanId) => { + addingChildToPlanId = dayPlanId; + showAddLodging = true; + }} + /> + + {/if} + {/each} +
+
+ {/if} {/if} {/if} {/if}
+ +