diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5093b9f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `src/`: SvelteKit application source. +- `src/routes/`: File-based routes and server handlers (e.g., `+page.server.ts`). +- `src/lib/`: Shared UI and server modules (`components/`, `server/`, `assets/`). +- `src/tests/`: Test helpers and stubs; unit tests live alongside code and match `src/**/*.test.ts`. +- `static/`: Public assets served as-is. +- `build/`: Production build output (generated). +- `trips.db`: Local SQLite database for development. + +## Build, Test, and Development Commands +- `npm run dev`: Start the Vite dev server. +- `npm run build`: Create a production build. +- `npm run preview`: Serve the production build locally. +- `npm run check`: Typecheck with `svelte-check`. +- `npm run lint`: Run ESLint on `src`. +- `npm run format`: Format with Prettier (Svelte + Tailwind plugins). +- `npm run test`: Run Vitest once. +- `npm run test:watch`: Watch mode for Vitest. +- `npm run test:coverage`: Coverage for `src/lib/server/**/*.ts` (db layer excluded). + +## Coding Style & Naming Conventions +- Indentation: tabs (see `.prettierrc`). +- Quotes: single quotes; trailing commas disabled. +- Svelte components use `.svelte`; TypeScript modules use `.ts`. +- Routes follow SvelteKit conventions (e.g., `+page.svelte`, `+layout.server.ts`). +- Use `$lib` alias for `src/lib` imports. + +## Testing Guidelines +- Framework: Vitest with `node` environment. +- Test file pattern: `src/**/*.test.ts`. +- Keep tests near the code they cover; use `src/tests/stubs` for runtime stubbing. + +## Commit & Pull Request Guidelines +- Commit messages currently follow a light “scope) message” pattern, e.g., + - `trips) adding package-tours option` + - `trip) adding support for lodgings` +- PRs should include a clear description of intent, testing performed (or why not), and any relevant screenshots for UI changes. + +## Configuration & Environment +- Copy `.env.example` to `.env` for local secrets and auth settings. +- SQLite data lives in `trips.db`; avoid committing local data changes unless intentional. diff --git a/src/lib/components/AddFlightForm.svelte b/src/lib/components/AddFlightForm.svelte new file mode 100644 index 0000000..4711377 --- /dev/null +++ b/src/lib/components/AddFlightForm.svelte @@ -0,0 +1,663 @@ + + +
{ + return ({ result, update }) => { + update(); + if (result.type === 'success') { + reset(); + onSuccess?.(); + } + }; + }} +> + {#if parentPlanId} + + {/if} + {#if dayId != null} + + + {/if} + + +
+
+
+ + +
+
+ +
+ + +
+
+
+ + {#if showStatus} +
+ Status +
+ {#each [{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' }, { value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' }, { value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }] as opt} + + {/each} +
+
+ {/if} + + {#if people.length > 0 && tripTravellerIds.length > 0} +
+ Passengers +
+ {#each people.filter((p) => tripTravellerIds.includes(p.id)) as person} + + {/each} +
+
+ {/if} + {#each selectedPassengers as personId} + + {/each} + +
+
+ Transportation segments + +
+ + {#each segments as segment, segmentIndex (segmentIndex)} +
+
+ Segment {segmentIndex + 1} + {#if segments.length > 1} + + {/if} +
+ +
+
+ + +
+ +
+ +
+ { + searchAirlines(e.currentTarget.value); + activeSearchField = { segmentIndex, field: 'airline' }; + }} + placeholder="Search airline or enter code" + class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> + {#if loadingAirlines && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'airline'} +
+ + + + +
+ {/if} + {#if airlines.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'airline'} +
    + {#each airlines as airline} +
  • + +
  • + {/each} +
+ {/if} +
+ {#if segment.airlineId} + + {/if} + {#if segment.airlineIata} + + {/if} + {#if segment.airlineName} + + {/if} +
+ +
+ + +
+ +
+
+ +
+ { + searchAirports(e.currentTarget.value); + activeSearchField = { segmentIndex, field: 'departure' }; + }} + placeholder="Code or search" + class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> + {#if loadingAirports && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'departure'} +
+ + + + +
+ {/if} + {#if airports.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'departure'} +
    + {#each airports as airport} +
  • + +
  • + {/each} +
+ {/if} +
+ {#if segment.departureAirportId} + + {/if} + {#if segment.departureAirportCode} + + {/if} +
+ +
+ +
+ { + searchAirports(e.currentTarget.value); + activeSearchField = { segmentIndex, field: 'arrival' }; + }} + placeholder="Code or search" + class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> + {#if loadingAirports && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'arrival'} +
+ + + + +
+ {/if} + {#if airports.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'arrival'} +
    + {#each airports as airport} +
  • + +
  • + {/each} +
+ {/if} +
+ {#if segment.arrivalAirportId} + + {/if} + {#if segment.arrivalAirportCode} + + {/if} +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ {/each} +
+
+ +
+ {#if onBack} + + {/if} + + +
+
diff --git a/src/lib/components/AddFlightModal.svelte b/src/lib/components/AddFlightModal.svelte index 66222b2..0a92217 100644 --- a/src/lib/components/AddFlightModal.svelte +++ b/src/lib/components/AddFlightModal.svelte @@ -1,39 +1,7 @@ -{#if open} - -
e.key === 'Escape' && handleClose()} - >
- - - -{/if} + diff --git a/src/lib/components/AddOtherTransportationForm.svelte b/src/lib/components/AddOtherTransportationForm.svelte new file mode 100644 index 0000000..698b1f4 --- /dev/null +++ b/src/lib/components/AddOtherTransportationForm.svelte @@ -0,0 +1,243 @@ + + +
{ + return ({ result, update }) => { + update(); + if (result.type === 'success') { + reset(); + onSuccess?.(); + } + }; + }} +> + {#if parentPlanId} + + {/if} + {#if dayId != null} + + + {/if} + + + +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + {#if showStatus} +
+ Status +
+ {#each [{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' }, { value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' }, { value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }] as opt (opt.value)} + + {/each} +
+
+ {/if} +
+ +
+
+ {#if onBack} + + {/if} +
+
+ + +
+
+
diff --git a/src/lib/components/AddPlanMenu.svelte b/src/lib/components/AddPlanMenu.svelte new file mode 100644 index 0000000..bc6fc9c --- /dev/null +++ b/src/lib/components/AddPlanMenu.svelte @@ -0,0 +1,96 @@ + + +
+ + + {#if open} +
(open = false)} + onkeydown={(e) => e.key === 'Escape' && (open = false)} + >
+
+ {#each items as item (item.id)} + {#if item.dividerBefore} +
+ {/if} + + {/each} +
+ {/if} +
diff --git a/src/lib/components/AddPrivateVehicleForm.svelte b/src/lib/components/AddPrivateVehicleForm.svelte new file mode 100644 index 0000000..10cc555 --- /dev/null +++ b/src/lib/components/AddPrivateVehicleForm.svelte @@ -0,0 +1,312 @@ + + +
{ + return ({ result, update }) => { + update(); + if (result.type === 'success') { + reset(); + onSuccess?.(); + } + }; + }} +> + {#if parentPlanId} + + {/if} + {#if dayId != null} + + + {/if} + + +
+ {#if showStatus} +
+ Status +
+ {#each [{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' }, { value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' }, { value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }] as opt (opt.value)} + + {/each} +
+
+ {/if} + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ {#if onBack} + + {/if} +
+
+ + +
+
+
diff --git a/src/lib/components/AddTransportationModal.svelte b/src/lib/components/AddTransportationModal.svelte new file mode 100644 index 0000000..d420a46 --- /dev/null +++ b/src/lib/components/AddTransportationModal.svelte @@ -0,0 +1,161 @@ + + +{#if open} +
e.key === 'Escape' && handleClose()} + >
+ + +{/if} diff --git a/src/lib/components/ChecklistCard.svelte b/src/lib/components/ChecklistCard.svelte new file mode 100644 index 0000000..834b4b6 --- /dev/null +++ b/src/lib/components/ChecklistCard.svelte @@ -0,0 +1,114 @@ + + +
+
+
+
+

{plan.title}

+ {#if isTemplate} + + Template + + {:else} + + {statusConfig[plan.status ?? 'idea'].label} + + {/if} +
+
+
+ {#if onEdit} + + {/if} + {#if onDelete} + + {/if} +
+
+ +
+ {#if items.length === 0} +

No items yet.

+ {:else} + {#each items as item (`${item.id}`)} + + {/each} + {/if} +
+
diff --git a/src/lib/components/ChecklistModal.svelte b/src/lib/components/ChecklistModal.svelte new file mode 100644 index 0000000..3e27ae1 --- /dev/null +++ b/src/lib/components/ChecklistModal.svelte @@ -0,0 +1,243 @@ + + +{#if open} +
e.key === 'Escape' && handleClose()} + >
+ + +{/if} diff --git a/src/lib/components/EditFlightForm.svelte b/src/lib/components/EditFlightForm.svelte new file mode 100644 index 0000000..0d270eb --- /dev/null +++ b/src/lib/components/EditFlightForm.svelte @@ -0,0 +1,638 @@ + + +
{ + return ({ result, update }) => { + update(); + if (result.type === 'success') { + resetSearch(); + onSuccess?.(); + } + }; + }} +> + + + +
+
+
+ + +
+
+ +
+ + +
+
+
+ + {#if showStatus} +
+ Status +
+ {#each [{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' }, { value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' }, { value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }] as opt} + + {/each} +
+
+ {/if} + + {#if people.length > 0 && tripTravellerIds.length > 0} +
+ Passengers +
+ {#each people.filter((p) => tripTravellerIds.includes(p.id)) as person} + + {/each} +
+
+ {/if} + {#each selectedPassengers as personId} + + {/each} + +
+
+ Transportation segments + +
+ + {#each segments as segment, segmentIndex (segmentIndex)} +
+
+ Segment {segmentIndex + 1} + {#if segments.length > 1} + + {/if} +
+ +
+
+ + +
+ +
+ +
+ { + searchAirlines(e.currentTarget.value); + activeSearchField = { segmentIndex, field: 'airline' }; + }} + placeholder="Search airline or enter code" + class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> + {#if loadingAirlines && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'airline'} +
+ + + + +
+ {/if} + {#if airlines.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'airline'} +
    + {#each airlines as airline} +
  • + +
  • + {/each} +
+ {/if} +
+ {#if segment.airlineId} + + {/if} + {#if segment.airlineIata} + + {/if} + {#if segment.airlineName} + + {/if} +
+ +
+ + +
+ +
+
+ +
+ { + searchAirports(e.currentTarget.value); + activeSearchField = { segmentIndex, field: 'departure' }; + }} + placeholder="Code or search" + class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> + {#if loadingAirports && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'departure'} +
+ + + + +
+ {/if} + {#if airports.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'departure'} +
    + {#each airports as airport} +
  • + +
  • + {/each} +
+ {/if} +
+ {#if segment.departureAirportId} + + {/if} + {#if segment.departureAirportCode} + + {/if} +
+ +
+ +
+ { + searchAirports(e.currentTarget.value); + activeSearchField = { segmentIndex, field: 'arrival' }; + }} + placeholder="Code or search" + class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> + {#if loadingAirports && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'arrival'} +
+ + + + +
+ {/if} + {#if airports.length > 0 && activeSearchField?.segmentIndex === segmentIndex && activeSearchField?.field === 'arrival'} +
    + {#each airports as airport} +
  • + +
  • + {/each} +
+ {/if} +
+ {#if segment.arrivalAirportId} + + {/if} + {#if segment.arrivalAirportCode} + + {/if} +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ {/each} +
+
+ +
+ + +
+
diff --git a/src/lib/components/EditFlightModal.svelte b/src/lib/components/EditFlightModal.svelte index b988709..3977542 100644 --- a/src/lib/components/EditFlightModal.svelte +++ b/src/lib/components/EditFlightModal.svelte @@ -1,40 +1,8 @@ -{#if open && flightBooking} - -
e.key === 'Escape' && handleClose()} - >
- - - -{/if} + diff --git a/src/lib/components/EditOtherTransportationForm.svelte b/src/lib/components/EditOtherTransportationForm.svelte new file mode 100644 index 0000000..9db2a7f --- /dev/null +++ b/src/lib/components/EditOtherTransportationForm.svelte @@ -0,0 +1,212 @@ + + +
{ + return ({ result, update }) => { + update(); + if (result.type === 'success') onSuccess?.(); + }; + }} +> + + + + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + {#if showStatus} +
+ Status +
+ {#each [{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' }, { value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' }, { value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }] as opt (opt.value)} + + {/each} +
+
+ {/if} +
+ +
+ + +
+
diff --git a/src/lib/components/EditPrivateVehicleForm.svelte b/src/lib/components/EditPrivateVehicleForm.svelte new file mode 100644 index 0000000..8e7dbf1 --- /dev/null +++ b/src/lib/components/EditPrivateVehicleForm.svelte @@ -0,0 +1,275 @@ + + +
{ + return ({ result, update }) => { + update(); + if (result.type === 'success') { + onSuccess?.(); + } + }; + }} +> + + + +
+ {#if showStatus} +
+ Status +
+ {#each [{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' }, { value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' }, { value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }] as opt (opt.value)} + + {/each} +
+
+ {/if} + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
diff --git a/src/lib/components/EditTransportationModal.svelte b/src/lib/components/EditTransportationModal.svelte new file mode 100644 index 0000000..6fab449 --- /dev/null +++ b/src/lib/components/EditTransportationModal.svelte @@ -0,0 +1,152 @@ + + +{#if open && transportation} +
e.key === 'Escape' && onclose()} + >
+ + +{/if} diff --git a/src/lib/components/ExperienceCard.svelte b/src/lib/components/ExperienceCard.svelte new file mode 100644 index 0000000..4935bb0 --- /dev/null +++ b/src/lib/components/ExperienceCard.svelte @@ -0,0 +1,139 @@ + + +
+
+
+
+

{plan.title}

+ {#if isTemplate} + + Template + + {:else} + + {statusConfig[plan.status ?? 'idea'].label} + + {/if} +
+ {#if experience.description} +

{experience.description}

+ {/if} +
+
+ {#if onEdit} + + {/if} + {#if onDelete} + + {/if} +
+
+ + {#if experience.start_date || experience.start_time || experience.end_date || experience.end_time} +
+
+

Start

+

+ {formatDate(experience.start_date)} {formatTime(experience.start_time)} +

+ {#if experience.start_timezone} +

{experience.start_timezone}

+ {/if} +
+
+

End

+

+ {formatDate(experience.end_date)} {formatTime(experience.end_time)} +

+ {#if experience.end_timezone} +

{experience.end_timezone}

+ {/if} +
+
+ {/if} + + {#if experience.address || experience.website || experience.contact_number} +
+ {#if experience.address}

{experience.address}

{/if} + {#if experience.website}

{experience.website}

{/if} + {#if experience.contact_number}

{experience.contact_number}

{/if} +
+ {/if} + + {#if experience.booking_id || experience.total_cost != null} +
+ {#if experience.booking_id}Booking: {experience.booking_id}{/if} + {#if experience.total_cost != null}Total: {experience.total_cost.toFixed(2)}{/if} +
+ {/if} +
diff --git a/src/lib/components/ExperienceModal.svelte b/src/lib/components/ExperienceModal.svelte new file mode 100644 index 0000000..38151b2 --- /dev/null +++ b/src/lib/components/ExperienceModal.svelte @@ -0,0 +1,356 @@ + + +{#if open} +
e.key === 'Escape' && handleClose()} + >
+ + +{/if} diff --git a/src/lib/components/FlightCard.svelte b/src/lib/components/FlightCard.svelte index e882d60..3018b58 100644 --- a/src/lib/components/FlightCard.svelte +++ b/src/lib/components/FlightCard.svelte @@ -46,7 +46,9 @@ // Derive the airline IATA from the first segment (for the header label) const airlineName = $derived( - flightBooking.segments[0]?.airline_name || flightBooking.segments[0]?.airline_iata || 'Flight' + flightBooking.segments[0]?.airline_name || + flightBooking.segments[0]?.airline_iata || + 'Transportation' ); // True when every segment is operated by the same airline — logo goes in the header @@ -132,7 +134,7 @@ type="button" onclick={onEdit} class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600" - aria-label="Edit flight" + aria-label="Edit transportation" > 1}
- {segment.airline_name || segment.airline_iata || 'Flight'} + {segment.airline_name || segment.airline_iata || 'Transportation'} {segment.flight_number}
{/if} diff --git a/src/lib/components/OtherTransportCard.svelte b/src/lib/components/OtherTransportCard.svelte new file mode 100644 index 0000000..fa5529f --- /dev/null +++ b/src/lib/components/OtherTransportCard.svelte @@ -0,0 +1,140 @@ + + +
+
+
+
+

{plan.title}

+ {#if isTemplate} + + Template + + {:else} + + {statusConfig[plan.status ?? 'idea'].label} + + {/if} +
+ {#if plan.notes} +

{plan.notes}

+ {/if} +
+
+ {#if onEdit} + + {/if} + {#if onDelete} + + {/if} +
+
+ + {#if otherTransport.start_date || otherTransport.start_time || otherTransport.end_date || otherTransport.end_time} +
+
+

Start

+

+ {formatDate(otherTransport.start_date)} + {formatTime(otherTransport.start_time)} +

+ {#if otherTransport.start_timezone} +

{otherTransport.start_timezone}

+ {/if} +
+
+

End

+

+ {formatDate(otherTransport.end_date)} + {formatTime(otherTransport.end_time)} +

+ {#if otherTransport.end_timezone} +

{otherTransport.end_timezone}

+ {/if} +
+
+ {/if} +
diff --git a/src/lib/components/PackageTourCard.svelte b/src/lib/components/PackageTourCard.svelte index c052210..c8985d4 100644 --- a/src/lib/components/PackageTourCard.svelte +++ b/src/lib/components/PackageTourCard.svelte @@ -2,6 +2,8 @@ import { base } from '$app/paths'; import { enhance } from '$app/forms'; import { SvelteSet } from 'svelte/reactivity'; + import AddPlanMenu, { type AddPlanMenuItem } from '$lib/components/AddPlanMenu.svelte'; + import { PLAN_TYPE_MAP } from '$lib/components/plan-types.js'; import type { Plan } from '$lib/server/plans.js'; import type { PackageTour, TourDay, ChildPlanSummary } from '$lib/server/package-tours.js'; @@ -15,8 +17,10 @@ }; onEdit?: () => void; onDelete?: () => void; + onAddTransportation?: () => void; onAddFlight?: () => void; onAddLodging?: () => void; + onAddTransportationToDay?: (dayPlanId: string) => void; onAddFlightToDay?: (dayPlanId: string) => void; onAddLodgingToDay?: (dayPlanId: string) => void; } @@ -26,12 +30,17 @@ tour, onEdit, onDelete, + onAddTransportation, onAddFlight, onAddLodging, + onAddTransportationToDay, onAddFlightToDay, onAddLodgingToDay }: Props = $props(); + const addTransport = $derived(onAddTransportation ?? onAddFlight); + const addTransportToDay = $derived(onAddTransportationToDay ?? onAddFlightToDay); + // Collapsible days — all expanded by default let expandedDays = new SvelteSet(tour.days.map((d) => d.plan_id)); @@ -43,35 +52,46 @@ } } - // Per-day "Add to day" dropdown state - let openDayMenu = $state(null); - let dayMenuOpenUp = $state(false); - - function toggleDayMenu(e: MouseEvent, dayPlanId: string) { - if (openDayMenu === dayPlanId) { - openDayMenu = null; - return; + function dayAddMenuItems(dayPlanId: string): AddPlanMenuItem[] { + const items: AddPlanMenuItem[] = []; + if (addTransportToDay) { + items.push({ + id: `${dayPlanId}-transport`, + label: 'Transportation', + icon: PLAN_TYPE_MAP.transport.icon, + onclick: () => addTransportToDay?.(dayPlanId) + }); } - const btn = e.currentTarget as HTMLElement; - const rect = btn.getBoundingClientRect(); - dayMenuOpenUp = rect.bottom + 96 > window.innerHeight; - openDayMenu = dayPlanId; + if (onAddLodgingToDay) { + items.push({ + id: `${dayPlanId}-lodging`, + label: 'Lodging', + icon: PLAN_TYPE_MAP.lodging.icon, + onclick: () => onAddLodgingToDay?.(dayPlanId) + }); + } + return items; } - // Top-level "Add to tour" dropdown - let showAddMenu = $state(false); - let addMenuOpenUp = $state(false); - - function toggleAddMenu(e: MouseEvent) { - if (showAddMenu) { - showAddMenu = false; - return; + function tourAddMenuItems(): AddPlanMenuItem[] { + const items: AddPlanMenuItem[] = []; + if (addTransport) { + items.push({ + id: 'tour-transport', + label: 'Transportation', + icon: PLAN_TYPE_MAP.transport.icon, + onclick: () => addTransport?.() + }); } - const btn = e.currentTarget as HTMLElement; - const rect = btn.getBoundingClientRect(); - const dropdownHeight = 96; - addMenuOpenUp = rect.bottom + dropdownHeight > window.innerHeight; - showAddMenu = true; + if (onAddLodging) { + items.push({ + id: 'tour-lodging', + label: 'Lodging', + icon: PLAN_TYPE_MAP.lodging.icon, + onclick: () => onAddLodging?.() + }); + } + return items; } // Inline add-day form @@ -128,7 +148,7 @@ icon: '' }, transport: { - label: 'Flight', + label: 'Transportation', icon: '' }, lodging: { @@ -389,88 +409,14 @@ {/if} - {#if onAddFlightToDay || onAddLodgingToDay} -
- - {#if openDayMenu === day.plan_id} -
(openDayMenu = null)} - onkeydown={(e) => e.key === 'Escape' && (openDayMenu = null)} - >
-
- {#if onAddFlightToDay} - - {/if} - {#if onAddLodgingToDay} - - {/if} -
- {/if} -
+ {#if addTransportToDay || onAddLodgingToDay} + {/if} @@ -739,87 +685,9 @@ {/if} - {#if onAddFlight || onAddLodging} -
- - {#if showAddMenu} -
(showAddMenu = false)} - onkeydown={(e) => e.key === 'Escape' && (showAddMenu = false)} - >
-
- {#if onAddFlight} - - {/if} - {#if onAddLodging} - - {/if} -
- {/if} + {#if addTransport || onAddLodging} +
+
{/if}
diff --git a/src/lib/components/PrivateVehicleCard.svelte b/src/lib/components/PrivateVehicleCard.svelte new file mode 100644 index 0000000..caa6c40 --- /dev/null +++ b/src/lib/components/PrivateVehicleCard.svelte @@ -0,0 +1,160 @@ + + +
+
+
+
+

Private vehicle

+ + {statusConfig[plan.status].label} + +
+
Road transfer
+
+
+ {#if onEdit} + + {/if} + {#if onDelete} + + {/if} +
+
+ +
+
+

From

+

{privateVehicle.start_address}

+
+ + + + +
+

To

+

{privateVehicle.end_address}

+
+
+ + {#if privateVehicle.departure_date || privateVehicle.departure_time || privateVehicle.arrival_date || privateVehicle.arrival_time} +
+
+

Departure

+ {#if privateVehicle.departure_date || privateVehicle.departure_time} +

+ {#if privateVehicle.departure_date} + {formatDate(privateVehicle.departure_date)} + {/if} + {#if privateVehicle.departure_time} + {#if privateVehicle.departure_date} + · + {/if} + {formatTime(privateVehicle.departure_time)} + {/if} +

+ {#if privateVehicle.departure_timezone} +

({tzLabel(privateVehicle.departure_timezone)})

+ {/if} + {:else} +

+ {/if} +
+
+

Arrival

+ {#if privateVehicle.arrival_date || privateVehicle.arrival_time} +

+ {#if privateVehicle.arrival_date} + {formatDate(privateVehicle.arrival_date)} + {/if} + {#if privateVehicle.arrival_time} + {#if privateVehicle.arrival_date} + · + {/if} + {formatTime(privateVehicle.arrival_time)} + {/if} +

+ {#if privateVehicle.arrival_timezone} +

({tzLabel(privateVehicle.arrival_timezone)})

+ {/if} + {:else} +

+ {/if} +
+
+ {/if} +
diff --git a/src/lib/components/TripWelcome.svelte b/src/lib/components/TripWelcome.svelte index 395d66c..f457e0c 100644 --- a/src/lib/components/TripWelcome.svelte +++ b/src/lib/components/TripWelcome.svelte @@ -1,110 +1,43 @@
@@ -145,10 +78,10 @@
- {#each actions as action} + {#each PLAN_TYPE_DEFINITIONS as action (action.id)}
{/if} - +
-

- Transport & lodging -

+

Day Plans

{#each dayPlanList as plan (plan.id)} {#if plan.type === 'lodging'} -
submitDeletePlan(plan.id)} />
- {:else} - - {#if editingPlanId === plan.id} - { - return ({ result, update }) => { - update(); - if (result.type === 'success') cancelEditPlan(); - }; + {:else if plan.type === 'transport'} +
+ - -
- - -
- - -
-
- - {:else} -
-
- - Transport - - {plan.title} - {#if plan.notes} -

{plan.notes}

- {/if} -
-
- -
({ update }) => update()}> - - -
-
-
- {/if} + otherTransport={{ + id: plan.id, + plan_id: String(plan.id), + start_date: plan.start_date ?? null, + start_time: plan.start_time ?? null, + start_timezone: plan.start_timezone ?? null, + end_date: plan.end_date ?? null, + end_time: plan.end_time ?? null, + end_timezone: plan.end_timezone ?? null, + created_at: '', + updated_at: '' + }} + onEdit={() => startEditPlan(plan)} + onDelete={() => submitDeletePlan(plan.id)} + /> +
+ {:else if plan.type === 'activity' || plan.type === 'restaurant'} +
+ startEditPlan(plan)} + onDelete={() => submitDeletePlan(plan.id)} + /> +
+ {:else if plan.type === 'packing' || plan.type === 'todo'} +
+ { + try { + const parsed = JSON.parse(plan.items_json ?? '[]'); + if (!Array.isArray(parsed)) return []; + return parsed.map((item, index) => ({ + id: `${plan.id}-${index}`, + content: typeof item === 'string' ? item : String(item?.content ?? ''), + is_checked: + typeof item === 'object' && item && 'is_checked' in item + ? Number(item.is_checked) + : 0 + })); + } catch { + return []; + } + })()} + onEdit={() => startEditPlan(plan)} + onDelete={() => submitDeletePlan(plan.id)} + /> +
{/if} {/each} - {#if addPlanFor?.dayId === day.id && addPlanFor?.type === 'transport'} -
{ - return ({ result, update }) => { - update(); - if (result.type === 'success') cancelAddPlan(); - }; - }} - class="mb-2 rounded-md border border-dashed border-gray-200 bg-gray-50/30 p-3" - > - - -

Add transport

-
- - -
- - -
-
-
- {:else} -
- - -
+ {#if addingTransportDayId !== day.id && addLodgingDayId !== day.id && addingExperienceDay?.dayId !== day.id && addingChecklistDay?.dayId !== day.id} + {/if}
diff --git a/src/routes/(protected)/trips/[id]/+page.server.ts b/src/routes/(protected)/trips/[id]/+page.server.ts index e00b933..f81e2fa 100644 --- a/src/routes/(protected)/trips/[id]/+page.server.ts +++ b/src/routes/(protected)/trips/[id]/+page.server.ts @@ -8,6 +8,16 @@ import { removeTravellerFromTrip } from '$lib/server/travellers.js'; import { createFlight, updateFlight, getFlightBookingsForTrip } from '$lib/server/flights.js'; +import { + createPrivateVehicle, + updatePrivateVehicle, + getPrivateVehiclesForTrip +} from '$lib/server/private-vehicles.js'; +import { + createOtherTransport, + updateOtherTransport, + getOtherTransportsForTrip +} from '$lib/server/other-transports.js'; import { createLodging, updateLodging, getLodgingsForTrip } from '$lib/server/lodgings.js'; import { createPackageTour, @@ -17,6 +27,17 @@ import { updateTourDay, cloneTemplateDaysToTour } from '$lib/server/package-tours.js'; +import { + createExperience, + updateExperience, + getExperiencesForTrip +} from '$lib/server/experiences.js'; +import { + createChecklist, + updateChecklist, + getChecklistsForTrip, + toggleChecklistItem +} from '$lib/server/checklists.js'; import type { PageServerLoad, Actions } from './$types'; export const load: PageServerLoad = async (event) => { @@ -31,8 +52,14 @@ export const load: PageServerLoad = async (event) => { const travellers = getTravellersForTrip(trip.id, userId); const people = getPeopleForUser(userId); const flightBookings = getFlightBookingsForTrip(trip.id, userId); + const privateVehicles = getPrivateVehiclesForTrip(trip.id, userId); + const otherTransports = getOtherTransportsForTrip(trip.id, userId); const lodgings = getLodgingsForTrip(trip.id, userId); const packageTours = getPackageToursForTrip(trip.id, userId); + const activities = getExperiencesForTrip(trip.id, userId, 'activity'); + const restaurants = getExperiencesForTrip(trip.id, userId, 'restaurant'); + const packingLists = getChecklistsForTrip(trip.id, userId, 'packing'); + const todos = getChecklistsForTrip(trip.id, userId, 'todo'); return { trip, @@ -42,8 +69,14 @@ export const load: PageServerLoad = async (event) => { travellerCount: travellers.length, people, flightBookings, + privateVehicles, + otherTransports, lodgings, - packageTours + packageTours, + activities, + restaurants, + packingLists, + todos }; }; @@ -194,12 +227,94 @@ export const actions: Actions = { if (!trip) return fail(404, { error: 'Trip not found' }); const data = await event.request.formData(); + const transportationType = ((data.get('transportation_type') as string)?.trim() || 'flight') as + | 'flight' + | 'private_vehicle' + | 'other'; const parentPlanId = (data.get('parent_plan_id') as string)?.trim() || undefined; + const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed'; + + if (transportationType === 'private_vehicle') { + const startAddress = (data.get('start_address') as string)?.trim(); + const endAddress = (data.get('end_address') as string)?.trim(); + const departureDate = (data.get('departure_date') as string)?.trim() || undefined; + const departureTime = (data.get('departure_time') as string)?.trim() || undefined; + const departureTimezone = (data.get('departure_timezone') as string)?.trim() || undefined; + const arrivalDate = (data.get('arrival_date') as string)?.trim() || undefined; + const arrivalTime = (data.get('arrival_time') as string)?.trim() || undefined; + const arrivalTimezone = (data.get('arrival_timezone') as string)?.trim() || undefined; + const startPlanId = (data.get('start_plan_id') as string)?.trim() || undefined; + const endPlanId = (data.get('end_plan_id') as string)?.trim() || undefined; + + if (!startAddress || !endAddress) { + return fail(400, { error: 'Start and end addresses are required' }); + } + + try { + createPrivateVehicle({ + tripId: trip.id, + userId, + parentId: parentPlanId, + status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea', + startAddress, + endAddress, + departureDate, + departureTime, + departureTimezone, + arrivalDate, + arrivalTime, + arrivalTimezone, + startPlanId, + endPlanId + }); + return { success: true }; + } catch (err) { + return fail(400, { + error: + err instanceof Error ? err.message : 'Failed to create private vehicle transportation' + }); + } + } + + if (transportationType === 'other') { + const title = (data.get('title') as string)?.trim(); + const notes = (data.get('notes') as string)?.trim() || undefined; + const startDate = (data.get('start_date') as string)?.trim() || undefined; + const startTime = (data.get('start_time') as string)?.trim() || undefined; + const startTimezone = (data.get('start_timezone') as string)?.trim() || undefined; + const endDate = (data.get('end_date') as string)?.trim() || undefined; + const endTime = (data.get('end_time') as string)?.trim() || undefined; + const endTimezone = (data.get('end_timezone') as string)?.trim() || undefined; + + if (!title) return fail(400, { error: 'Title is required' }); + + try { + createOtherTransport({ + tripId: trip.id, + userId, + parentId: parentPlanId, + status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea', + title, + notes, + startDate, + startTime, + startTimezone, + endDate, + endTime, + endTimezone + }); + return { success: true }; + } catch (err) { + return fail(400, { + error: err instanceof Error ? err.message : 'Failed to create transportation' + }); + } + } + const confirmationNumber = (data.get('confirmation_number') as string)?.trim() || undefined; const priceRaw = (data.get('price') as string)?.trim(); const price = priceRaw ? parseFloat(priceRaw) : undefined; const currency = (data.get('currency') as string)?.trim() || 'USD'; - const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed'; const passengerIds = data.getAll('passenger_ids[]') as string[]; // Parse segments - form data comes as segments[0][field], segments[1][field], etc. @@ -316,6 +431,91 @@ export const actions: Actions = { if (!trip) return fail(404, { error: 'Trip not found' }); const data = await event.request.formData(); + const transportationType = ((data.get('transportation_type') as string)?.trim() || 'flight') as + | 'flight' + | 'private_vehicle' + | 'other'; + const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed'; + + if (transportationType === 'private_vehicle') { + const privateVehicleId = (data.get('private_vehicle_id') as string)?.trim(); + const startAddress = (data.get('start_address') as string)?.trim(); + const endAddress = (data.get('end_address') as string)?.trim(); + const departureDate = (data.get('departure_date') as string)?.trim() || undefined; + const departureTime = (data.get('departure_time') as string)?.trim() || undefined; + const departureTimezone = (data.get('departure_timezone') as string)?.trim() || undefined; + const arrivalDate = (data.get('arrival_date') as string)?.trim() || undefined; + const arrivalTime = (data.get('arrival_time') as string)?.trim() || undefined; + const arrivalTimezone = (data.get('arrival_timezone') as string)?.trim() || undefined; + const startPlanId = (data.get('start_plan_id') as string)?.trim() || undefined; + const endPlanId = (data.get('end_plan_id') as string)?.trim() || undefined; + + if (!privateVehicleId) return fail(400, { error: 'Private vehicle ID is required' }); + if (!startAddress || !endAddress) { + return fail(400, { error: 'Start and end addresses are required' }); + } + + try { + updatePrivateVehicle({ + privateVehicleId, + userId, + status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea', + startAddress, + endAddress, + departureDate, + departureTime, + departureTimezone, + arrivalDate, + arrivalTime, + arrivalTimezone, + startPlanId, + endPlanId + }); + return { success: true }; + } catch (err) { + return fail(400, { + error: + err instanceof Error ? err.message : 'Failed to update private vehicle transportation' + }); + } + } + + if (transportationType === 'other') { + const otherTransportId = (data.get('other_transport_id') as string)?.trim(); + const title = (data.get('title') as string)?.trim(); + const notes = (data.get('notes') as string)?.trim() || undefined; + const startDate = (data.get('start_date') as string)?.trim() || undefined; + const startTime = (data.get('start_time') as string)?.trim() || undefined; + const startTimezone = (data.get('start_timezone') as string)?.trim() || undefined; + const endDate = (data.get('end_date') as string)?.trim() || undefined; + const endTime = (data.get('end_time') as string)?.trim() || undefined; + const endTimezone = (data.get('end_timezone') as string)?.trim() || undefined; + + if (!otherTransportId) return fail(400, { error: 'Other transportation ID is required' }); + if (!title) return fail(400, { error: 'Title is required' }); + + try { + updateOtherTransport({ + otherTransportId, + userId, + status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea', + title, + notes, + startDate, + startTime, + startTimezone, + endDate, + endTime, + endTimezone + }); + return { success: true }; + } catch (err) { + return fail(400, { + error: err instanceof Error ? err.message : 'Failed to update transportation' + }); + } + } + const bookingId = (data.get('booking_id') as string)?.trim(); if (!bookingId) return fail(400, { error: 'Booking ID is required' }); @@ -323,7 +523,6 @@ export const actions: Actions = { const priceRaw = (data.get('price') as string)?.trim(); const price = priceRaw ? parseFloat(priceRaw) : undefined; const currency = (data.get('currency') as string)?.trim() || 'USD'; - const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed'; const passengerIds = data.getAll('passenger_ids[]') as string[]; const segments: Array<{ @@ -534,6 +733,206 @@ export const actions: Actions = { } }, + addExperience: async (event) => { + const session = await event.locals.auth(); + const userId = session?.user?.id; + if (!userId) return fail(401, { error: 'Not authenticated' }); + + const trip = getTripById(event.params.id, userId); + if (!trip) return fail(404, { error: 'Trip not found' }); + + const formData = await event.request.formData(); + const type = (formData.get('type') as string)?.trim() as 'activity' | 'restaurant'; + if (type !== 'activity' && type !== 'restaurant') + return fail(400, { error: 'Invalid plan type' }); + + const name = (formData.get('name') as string)?.trim(); + if (!name) return fail(400, { error: 'Name is required' }); + + const str = (key: string) => (formData.get(key) as string)?.trim() || undefined; + const num = (key: string) => { + const v = str(key); + return v ? parseFloat(v) : undefined; + }; + const parentPlanId = str('parent_plan_id'); + const status = formData.get('status') as string as 'idea' | 'tentative' | 'confirmed'; + + try { + createExperience({ + tripId: trip.id, + userId, + type, + name, + parentId: parentPlanId, + status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea', + bookingId: str('booking_id'), + totalCost: num('total_cost'), + description: str('description'), + website: str('website'), + address: str('address'), + contactNumber: str('contact_number'), + startDate: str('start_date'), + startTime: str('start_time'), + startTimezone: str('start_timezone'), + endDate: str('end_date'), + endTime: str('end_time'), + endTimezone: str('end_timezone') + }); + return { success: true }; + } catch (err) { + return fail(400, { + error: err instanceof Error ? err.message : 'Failed to create plan' + }); + } + }, + + editExperience: async (event) => { + const session = await event.locals.auth(); + const userId = session?.user?.id; + if (!userId) return fail(401, { error: 'Not authenticated' }); + + const trip = getTripById(event.params.id, userId); + if (!trip) return fail(404, { error: 'Trip not found' }); + + const formData = await event.request.formData(); + const experienceId = (formData.get('experience_id') as string)?.trim(); + if (!experienceId) return fail(400, { error: 'Plan ID is required' }); + + const name = (formData.get('name') as string)?.trim(); + if (!name) return fail(400, { error: 'Name is required' }); + + const str = (key: string) => (formData.get(key) as string)?.trim() || undefined; + const num = (key: string) => { + const v = str(key); + return v ? parseFloat(v) : undefined; + }; + const status = formData.get('status') as string as 'idea' | 'tentative' | 'confirmed'; + + try { + updateExperience({ + experienceId, + userId, + name, + status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea', + bookingId: str('booking_id'), + totalCost: num('total_cost'), + description: str('description'), + website: str('website'), + address: str('address'), + contactNumber: str('contact_number'), + startDate: str('start_date'), + startTime: str('start_time'), + startTimezone: str('start_timezone'), + endDate: str('end_date'), + endTime: str('end_time'), + endTimezone: str('end_timezone') + }); + return { success: true }; + } catch (err) { + return fail(400, { + error: err instanceof Error ? err.message : 'Failed to update plan' + }); + } + }, + + addChecklist: async (event) => { + const session = await event.locals.auth(); + const userId = session?.user?.id; + if (!userId) return fail(401, { error: 'Not authenticated' }); + + const trip = getTripById(event.params.id, userId); + if (!trip) return fail(404, { error: 'Trip not found' }); + + const formData = await event.request.formData(); + const type = (formData.get('type') as string)?.trim() as 'packing' | 'todo'; + if (type !== 'packing' && type !== 'todo') return fail(400, { error: 'Invalid plan type' }); + + const name = (formData.get('name') as string)?.trim(); + if (!name) return fail(400, { error: 'Name is required' }); + const parentPlanId = (formData.get('parent_plan_id') as string)?.trim() || undefined; + const status = formData.get('status') as string as 'idea' | 'tentative' | 'confirmed'; + const items = formData + .getAll('items[]') + .map((item) => String(item).trim()) + .filter(Boolean); + + try { + createChecklist({ + tripId: trip.id, + userId, + type, + name, + parentId: parentPlanId, + status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea', + items + }); + return { success: true }; + } catch (err) { + return fail(400, { + error: err instanceof Error ? err.message : 'Failed to create checklist' + }); + } + }, + + editChecklist: async (event) => { + const session = await event.locals.auth(); + const userId = session?.user?.id; + if (!userId) return fail(401, { error: 'Not authenticated' }); + + const trip = getTripById(event.params.id, userId); + if (!trip) return fail(404, { error: 'Trip not found' }); + + const formData = await event.request.formData(); + const checklistId = (formData.get('checklist_id') as string)?.trim(); + if (!checklistId) return fail(400, { error: 'Checklist ID is required' }); + const name = (formData.get('name') as string)?.trim(); + if (!name) return fail(400, { error: 'Name is required' }); + const status = formData.get('status') as string as 'idea' | 'tentative' | 'confirmed'; + const items = formData + .getAll('items[]') + .map((item) => String(item).trim()) + .filter(Boolean) + .map((content) => ({ content, isChecked: false })); + + try { + updateChecklist({ + checklistId, + userId, + name, + status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea', + items + }); + return { success: true }; + } catch (err) { + return fail(400, { + error: err instanceof Error ? err.message : 'Failed to update checklist' + }); + } + }, + + toggleChecklistItem: async (event) => { + const session = await event.locals.auth(); + const userId = session?.user?.id; + if (!userId) return fail(401, { error: 'Not authenticated' }); + + const trip = getTripById(event.params.id, userId); + if (!trip) return fail(404, { error: 'Trip not found' }); + + const formData = await event.request.formData(); + const itemId = (formData.get('item_id') as string)?.trim(); + if (!itemId) return fail(400, { error: 'Checklist item ID is required' }); + const isChecked = (formData.get('is_checked') as string)?.trim() === '1'; + + try { + toggleChecklistItem({ itemId, userId, isChecked }); + return { success: true }; + } catch (err) { + return fail(400, { + error: err instanceof Error ? err.message : 'Failed to update checklist item' + }); + } + }, + addPackageTour: async (event) => { const session = await event.locals.auth(); const userId = session?.user?.id; diff --git a/src/routes/(protected)/trips/[id]/+page.svelte b/src/routes/(protected)/trips/[id]/+page.svelte index 5e5da10..ec39012 100644 --- a/src/routes/(protected)/trips/[id]/+page.svelte +++ b/src/routes/(protected)/trips/[id]/+page.svelte @@ -3,15 +3,23 @@ import TripWelcome from '$lib/components/TripWelcome.svelte'; import AddDestinationModal from '$lib/components/AddDestinationModal.svelte'; import AddTravellerModal from '$lib/components/AddTravellerModal.svelte'; - import AddFlightModal from '$lib/components/AddFlightModal.svelte'; - import EditFlightModal from '$lib/components/EditFlightModal.svelte'; + import AddTransportationModal from '$lib/components/AddTransportationModal.svelte'; + import EditTransportationModal from '$lib/components/EditTransportationModal.svelte'; import AddLodgingModal from '$lib/components/AddLodgingModal.svelte'; import EditLodgingModal from '$lib/components/EditLodgingModal.svelte'; import AddPackageTourModal from '$lib/components/AddPackageTourModal.svelte'; import EditPackageTourModal from '$lib/components/EditPackageTourModal.svelte'; + import ExperienceModal from '$lib/components/ExperienceModal.svelte'; + import ExperienceCard from '$lib/components/ExperienceCard.svelte'; + import ChecklistModal from '$lib/components/ChecklistModal.svelte'; + import ChecklistCard from '$lib/components/ChecklistCard.svelte'; import PackageTourCard from '$lib/components/PackageTourCard.svelte'; + import AddPlanMenu, { type AddPlanMenuItem } from '$lib/components/AddPlanMenu.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'; + import OtherTransportCard from '$lib/components/OtherTransportCard.svelte'; + import PrivateVehicleCard from '$lib/components/PrivateVehicleCard.svelte'; import LodgingCard from '$lib/components/LodgingCard.svelte'; import TravellerChip from '$lib/components/TravellerChip.svelte'; @@ -24,85 +32,96 @@ let people = $derived(data.people ?? []); let tripTravellerIds = $derived(travellers.map((t) => t.id)); let flightBookings = $derived(data.flightBookings ?? []); + let privateVehicles = $derived(data.privateVehicles ?? []); + let otherTransports = $derived(data.otherTransports ?? []); let lodgings = $derived(data.lodgings ?? []); let packageTours = $derived(data.packageTours ?? []); + let activities = $derived(data.activities ?? []); + let restaurants = $derived(data.restaurants ?? []); + let packingLists = $derived(data.packingLists ?? []); + let todos = $derived(data.todos ?? []); let editing = $state(false); let showAddDestination = $state(false); let showAddTraveller = $state(false); - let showAddFlight = $state(false); + let showAddTransportation = $state(false); let showAddLodging = $state(false); let showAddPackageTour = $state(false); - let showAddMenu = $state(false); - let editingFlight = $state<(typeof flightBookings)[0] | null>(null); - let editingFlightPlan = $derived( - editingFlight ? (plans.find((p) => p.id === editingFlight!.plan_id) ?? null) : null - ); + let showAddActivity = $state(false); + let showAddRestaurant = $state(false); + let showAddPackingList = $state(false); + let showAddTodo = $state(false); + let editingTransportation = $state< + | { + type: 'flight'; + planStatus: 'idea' | 'tentative' | 'confirmed'; + flightBooking: (typeof flightBookings)[0]; + } + | { + type: 'private_vehicle'; + planStatus: 'idea' | 'tentative' | 'confirmed'; + privateVehicle: (typeof privateVehicles)[0]; + } + | { + type: 'other'; + planStatus: 'idea' | 'tentative' | 'confirmed'; + planTitle: string; + planNotes: string | null; + otherTransport: (typeof otherTransports)[0]; + } + | null + >(null); let editingLodging = $state<(typeof lodgings)[0] | null>(null); let editingTour = $state<(typeof packageTours)[0] | null>(null); + let editingActivity = $state<{ + plan: (typeof plans)[0]; + experience: (typeof activities)[0]; + } | null>(null); + let editingRestaurant = $state<{ + plan: (typeof plans)[0]; + experience: (typeof restaurants)[0]; + } | null>(null); + let editingPackingList = $state<{ + plan: (typeof plans)[0]; + list: (typeof packingLists)[0]; + } | null>(null); + let editingTodo = $state<{ plan: (typeof plans)[0]; list: (typeof todos)[0] } | null>(null); + let checklistToggleFormRef = $state(null); + let togglingChecklistItemId = $state(null); + let togglingChecklistItemChecked = $state<'0' | '1'>('0'); let addingChildToPlanId = $state(null); - const menuItems = [ + let transportationLocationOptions = $derived( + plans + .filter((plan) => plan.type !== 'transport' && plan.type !== 'day') + .map((plan) => ({ id: plan.id, label: plan.title })) + ); + + const addPlanHandlers: Partial void>> = { + destination: () => (showAddDestination = true), + activity: () => (showAddActivity = true), + transport: () => (showAddTransportation = true), + lodging: () => (showAddLodging = true), + restaurant: () => (showAddRestaurant = true), + packageTour: () => (showAddPackageTour = true), + packingList: () => (showAddPackingList = true), + todo: () => (showAddTodo = true) + }; + + const addToTripMenuItems = $derived([ { - label: 'Destinations', - icon: ``, - onclick: () => { - showAddDestination = true; - showAddMenu = false; - } + id: 'travellers', + label: 'Travellers', + icon: ``, + onclick: () => (showAddTraveller = true) }, - { - label: 'Attractions & Activities', - icon: ``, - onclick: () => { - showAddMenu = false; - } - }, - { - label: 'Transportation', - icon: ``, - onclick: () => { - showAddFlight = true; - showAddMenu = false; - } - }, - { - label: 'Lodgings', - icon: ``, - onclick: () => { - showAddLodging = true; - showAddMenu = false; - } - }, - { - label: 'Restaurants', - icon: ``, - onclick: () => { - showAddMenu = false; - } - }, - { - label: 'Package Tours', - icon: ``, - onclick: () => { - showAddPackageTour = true; - showAddMenu = false; - } - }, - { - label: 'Packing List', - icon: ``, - onclick: () => { - showAddMenu = false; - } - }, - { - label: 'To-dos', - icon: ``, - onclick: () => { - showAddMenu = false; - } - } - ]; + ...PLAN_TYPE_DEFINITIONS.map((definition, index) => ({ + id: definition.id, + label: definition.label, + icon: definition.icon, + dividerBefore: index === 0, + onclick: () => addPlanHandlers[definition.id]?.() + })) + ]); function formatDate(d: string | null) { if (!d) return 'TBD'; @@ -112,6 +131,12 @@ year: 'numeric' }); } + + function toggleChecklistItem(itemId: string | number, nextChecked: boolean) { + togglingChecklistItemId = String(itemId); + togglingChecklistItemChecked = nextChecked ? '1' : '0'; + setTimeout(() => checklistToggleFormRef?.requestSubmit(), 0); + } @@ -125,23 +150,24 @@ {people} {tripTravellerIds} /> - { - showAddFlight = false; + showAddTransportation = false; addingChildToPlanId = null; }} {people} {tripTravellerIds} + planOptions={transportationLocationOptions} parentPlanId={addingChildToPlanId ?? undefined} /> - (editingFlight = null)} + (editingTransportation = null)} {people} {tripTravellerIds} + planOptions={transportationLocationOptions} /> + { + showAddActivity = false; + addingChildToPlanId = null; + }} + planType="activity" + mode="add" + formAction="?/addExperience" + parentPlanId={addingChildToPlanId ?? undefined} +/> + { + showAddRestaurant = false; + addingChildToPlanId = null; + }} + planType="restaurant" + mode="add" + formAction="?/addExperience" + parentPlanId={addingChildToPlanId ?? undefined} +/> + (editingActivity = null)} + planType="activity" + mode="edit" + formAction="?/editExperience" + idValue={editingActivity?.experience.id} + initial={editingActivity + ? { + name: editingActivity.plan.title, + status: editingActivity.plan.status, + ...editingActivity.experience + } + : null} +/> + (editingRestaurant = null)} + planType="restaurant" + mode="edit" + formAction="?/editExperience" + idValue={editingRestaurant?.experience.id} + initial={editingRestaurant + ? { + name: editingRestaurant.plan.title, + status: editingRestaurant.plan.status, + ...editingRestaurant.experience + } + : null} +/> + { + showAddPackingList = false; + addingChildToPlanId = null; + }} + checklistType="packing" + mode="add" + formAction="?/addChecklist" + parentPlanId={addingChildToPlanId ?? undefined} +/> + { + showAddTodo = false; + addingChildToPlanId = null; + }} + checklistType="todo" + mode="add" + formAction="?/addChecklist" + parentPlanId={addingChildToPlanId ?? undefined} +/> + (editingPackingList = null)} + checklistType="packing" + mode="edit" + formAction="?/editChecklist" + idValue={editingPackingList?.list.id} + initial={editingPackingList + ? { + name: editingPackingList.plan.title, + status: editingPackingList.plan.status, + items: editingPackingList.list.items.map((item) => ({ content: item.content })) + } + : null} +/> + (editingTodo = null)} + checklistType="todo" + mode="edit" + formAction="?/editChecklist" + idValue={editingTodo?.list.id} + initial={editingTodo + ? { + name: editingTodo.plan.title, + status: editingTodo.plan.status, + items: editingTodo.list.items.map((item) => ({ content: item.content })) + } + : null} +/> +
+ ({ update }) => + update()} + class="hidden" + aria-hidden="true" +> + + +
@@ -183,84 +326,13 @@

{trip.name}

{#if planCount > 0 || travellerCount > 0} -
- - - {#if showAddMenu} -
(showAddMenu = false)} - onkeydown={(e) => e.key === 'Escape' && (showAddMenu = false)} - >
-
- - -
- {#each menuItems as item} - - {/each} -
- {/if} -
+ {/if}
- - {#if flightBookings.length > 0} + + {#if flightBookings.length > 0 || privateVehicles.length > 0 || otherTransports.length > 0}

Transportation

- {#each flightBookings as flightBooking (flightBooking.id)} - {@const plan = plans.find((p) => p.id === flightBooking.plan_id)} + {#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 = () => { @@ -523,10 +602,83 @@ class="contents" > - + (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 submitForm = () => { + const form = document.getElementById(formId) as HTMLFormElement; + form?.requestSubmit(); + }} +
{ + return ({ update }) => { + update(); + }; + }} + class="contents" + > + + (editingFlight = flightBooking)} + experience={activity} + onEdit={() => (editingActivity = { plan, experience: activity })} onDelete={submitForm} /> @@ -536,6 +688,126 @@
{/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(); + }; + }} + 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}
@@ -608,17 +880,17 @@ {tour} onEdit={() => (editingTour = tour)} onDelete={submitForm} - onAddFlight={() => { + onAddTransportation={() => { addingChildToPlanId = plan.id; - showAddFlight = true; + showAddTransportation = true; }} onAddLodging={() => { addingChildToPlanId = plan.id; showAddLodging = true; }} - onAddFlightToDay={(dayPlanId) => { + onAddTransportationToDay={(dayPlanId) => { addingChildToPlanId = dayPlanId; - showAddFlight = true; + showAddTransportation = true; }} onAddLodgingToDay={(dayPlanId) => { addingChildToPlanId = dayPlanId;