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 @@
+
+
+
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()}
- >
-
-
-
-
-
-
Add flight
-
-
-
-
-
-
-
-
-
-
-
-{/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 @@
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {buttonLabel}
+ {#if showChevron}
+
+
+
+ {/if}
+
+
+ {#if open}
+
(open = false)}
+ onkeydown={(e) => e.key === 'Escape' && (open = false)}
+ >
+
+ {#each items as item (item.id)}
+ {#if item.dividerBefore}
+
+ {/if}
+ select(item)}
+ class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
+ >
+ {#if item.icon}{@html item.icon}{/if}
+ {item.label}
+
+ {/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)}
+
+
+ {opt.label}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+ Start from existing plan
+
+ handleStartPlanChange((e.target as HTMLSelectElement).value)}
+ class="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"
+ >
+ None
+ {#each planOptions as option (option.id)}
+ {option.label}
+ {/each}
+
+
+
+
+ End at existing plan
+
+ handleEndPlanChange((e.target as HTMLSelectElement).value)}
+ class="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"
+ >
+ None
+ {#each planOptions as option (option.id)}
+ {option.label}
+ {/each}
+
+
+
+
+
+
+ Start address *
+
+
+
+
+
+
+ End address *
+
+
+
+
+
+
+
+
+
+
+
+ {#if onBack}
+
+ Back
+
+ {/if}
+
+
+
+ Cancel
+
+
+ {submitLabel}
+
+
+
+
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()}
+ >
+
+
+
+
+ {isTemplate ? 'Add transportation (template)' : 'Add transportation'}
+
+
+
+
+
+
+
+
+
+ {#if !transportationType}
+
+
+
Transportation type
+
Choose how you're traveling.
+
+ {#each visibleTransportationTypes as type (type.id)}
+
(transportationType = type.id)}
+ class="flex w-full items-center gap-3 rounded-md border border-gray-300 bg-white px-4 py-3 text-left text-sm text-gray-800 hover:border-blue-400 hover:bg-blue-50/50"
+ >
+ {@html type.icon}
+
+
{type.label}
+
{type.description}
+
+
+ {/each}
+
+
+
+
+
+ Cancel
+
+
+ {:else if transportationType === 'flight'}
+
(transportationType = '')}
+ />
+ {:else if transportationType === 'private_vehicle'}
+ (transportationType = '')}
+ />
+ {:else if transportationType === 'other'}
+ (transportationType = '')}
+ />
+ {/if}
+
+{/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}`)}
+
+ onToggleItem?.(item.id, !item.is_checked)}
+ class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-70"
+ />
+ {item.content}
+
+ {/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()}
+ >
+
+
+
+
+ {mode === 'add' ? 'Add' : 'Edit'}
+ {isTemplate ? `${typeLabel} (template)` : typeLabel}
+
+
+
+
+
+
+
+
+
+
{
+ return ({ result, update }) => {
+ update();
+ if (result.type === 'success') handleClose();
+ };
+ }}
+ >
+ {#if parentPlanId}
+
+ {/if}
+ {#if isTemplate && dayId != null}
+
+ {/if}
+ {#if mode === 'edit' && idValue != null}
+
+ {/if}
+
+
+
+
+
+ Name *
+
+
+
+
+
+
+ Items
+
+ Add item
+
+
+
+ {#each items as item, index (`${index}-${item.content}`)}
+
+
+ removeItem(index)}
+ class="rounded-md border border-gray-300 px-2.5 py-2 text-xs text-gray-600 hover:bg-gray-50"
+ >
+ Remove
+
+
+ {/each}
+
+ {#each items as item, index (`hidden-${index}-${item.content}`)}
+
+ {/each}
+
+
+ {#if !isTemplate}
+
+
Status
+
+ {#each [{ value: 'idea', label: 'Idea' }, { value: 'tentative', label: 'Tentative' }, { value: 'confirmed', label: 'Confirmed' }] as opt (opt.value)}
+
+
+ {opt.label}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+ Cancel
+
+
+ {mode === 'add' ? 'Add' : 'Save changes'}
+
+
+
+
+{/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?.();
+ }
+ };
+ }}
+>
+
+
+
+
+
+
+
+ Confirmation number
+
+
+
+
+
Price
+
+
+ USD
+ EUR
+ GBP
+ CAD
+ JPY
+ AUD
+
+
+
+
+
+
+ {#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.label}
+
+ {/each}
+
+
+ {/if}
+
+ {#if people.length > 0 && tripTravellerIds.length > 0}
+
+
Passengers
+
+ {#each people.filter((p) => tripTravellerIds.includes(p.id)) as person}
+
+ togglePassenger(person.id)}
+ class="sr-only"
+ />
+ {person.first_name}
+ {person.last_name}
+
+ {/each}
+
+
+ {/if}
+ {#each selectedPassengers as personId}
+
+ {/each}
+
+
+
+ Transportation segments
+
+ + Add segment
+
+
+
+ {#each segments as segment, segmentIndex (segmentIndex)}
+
+
+ Segment {segmentIndex + 1}
+ {#if segments.length > 1}
+ removeSegment(segmentIndex)}
+ class="text-xs text-red-600 hover:text-red-700"
+ >
+ Remove
+
+ {/if}
+
+
+
+
+ Departure date
+
+
+
+
+
Airline
+
+
{
+ 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}
+
+ selectAirline(segmentIndex, airline)}
+ class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
+ >
+ {airline.name}
+ {#if airline.iata_code}
+ ({airline.iata_code})
+ {/if}
+
+
+ {/each}
+
+ {/if}
+
+ {#if segment.airlineId}
+
+ {/if}
+ {#if segment.airlineIata}
+
+ {/if}
+ {#if segment.airlineName}
+
+ {/if}
+
+
+
+ Flight number
+
+
+
+
+
+
Departure airport
+
+
{
+ 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}
+
+ selectAirport(segmentIndex, 'departure', airport)}
+ class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
+ >
+
+
{airport.name}
+
+ {airport.iata_code || airport.icao_code} • {airport.city}, {airport.country}
+
+
+
+
+ {/each}
+
+ {/if}
+
+ {#if segment.departureAirportId}
+
+ {/if}
+ {#if segment.departureAirportCode}
+
+ {/if}
+
+
+
+
Arrival airport
+
+
{
+ 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}
+
+ selectAirport(segmentIndex, 'arrival', airport)}
+ class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
+ >
+
+
{airport.name}
+
+ {airport.iata_code || airport.icao_code} • {airport.city}, {airport.country}
+
+
+
+
+ {/each}
+
+ {/if}
+
+ {#if segment.arrivalAirportId}
+
+ {/if}
+ {#if segment.arrivalAirportCode}
+
+ {/if}
+
+
+
+
+
+
+ {/each}
+
+
+
+
+
+ Cancel
+
+
+ {submitLabel}
+
+
+
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()}
- >
-
-
-
-
-
-
Edit flight
-
-
-
-
-
-
-
-
-
-
{
- return ({ result, update }) => {
- update();
- if (result.type === 'success') handleClose();
- };
- }}
- >
-
-
-
-
-
-
-
- Confirmation number
-
-
-
-
-
Price
-
-
- USD
- EUR
- GBP
- CAD
- JPY
- AUD
-
-
-
-
-
-
-
-
-
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.label}
-
- {/each}
-
-
-
-
- {#if people.length > 0 && tripTravellerIds.length > 0}
-
-
Passengers
-
- {#each people.filter((p) => tripTravellerIds.includes(p.id)) as person}
-
- togglePassenger(person.id)}
- class="sr-only"
- />
- {person.first_name}
- {person.last_name}
-
- {/each}
-
-
- {/if}
- {#each selectedPassengers as personId}
-
- {/each}
-
-
-
-
- Flight segments
-
- + Add segment
-
-
-
- {#each segments as segment, segmentIndex (segmentIndex)}
-
-
- Segment {segmentIndex + 1}
- {#if segments.length > 1}
- removeSegment(segmentIndex)}
- class="text-xs text-red-600 hover:text-red-700"
- >
- Remove
-
- {/if}
-
-
-
-
-
- Departure date
-
-
-
-
-
-
Airline
-
-
{
- 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}
-
- selectAirline(segmentIndex, airline)}
- class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
- >
- {airline.name}
- {#if airline.iata_code}
- ({airline.iata_code})
- {/if}
-
-
- {/each}
-
- {/if}
-
- {#if segment.airlineId}
-
- {/if}
- {#if segment.airlineIata}
-
- {/if}
- {#if segment.airlineName}
-
- {/if}
-
-
-
-
- Flight number
-
-
-
-
-
-
-
Departure airport
-
-
{
- 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}
-
- selectAirport(segmentIndex, 'departure', airport)}
- class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
- >
-
-
{airport.name}
-
- {airport.iata_code || airport.icao_code} • {airport.city}, {airport.country}
-
-
-
-
- {/each}
-
- {/if}
-
- {#if segment.departureAirportId}
-
- {/if}
- {#if segment.departureAirportCode}
-
- {/if}
-
-
-
-
Arrival airport
-
-
{
- 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}
-
- selectAirport(segmentIndex, 'arrival', airport)}
- class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
- >
-
-
{airport.name}
-
- {airport.iata_code || airport.icao_code} • {airport.city}, {airport.country}
-
-
-
-
- {/each}
-
- {/if}
-
- {#if segment.arrivalAirportId}
-
- {/if}
- {#if segment.arrivalAirportCode}
-
- {/if}
-
-
-
-
-
-
-
- {/each}
-
-
-
-
-
-
- Cancel
-
-
- Save changes
-
-
-
-
-{/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?.();
+ };
+ }}
+>
+
+
+
+
+
+
+
+ Title *
+
+
+
+
+ Note
+
+
+
+
+
+
+
+ {#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)}
+
+
+ {opt.label}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+ Cancel
+
+
+ {submitLabel}
+
+
+
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)}
+
+
+ {opt.label}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+ Start from existing plan
+
+ handleStartPlanChange((e.target as HTMLSelectElement).value)}
+ class="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"
+ >
+ None
+ {#each planOptions as option (option.id)}
+ {option.label}
+ {/each}
+
+
+
+
+ End at existing plan
+
+ handleEndPlanChange((e.target as HTMLSelectElement).value)}
+ class="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"
+ >
+ None
+ {#each planOptions as option (option.id)}
+ {option.label}
+ {/each}
+
+
+
+
+
+
+ Start address *
+
+
+
+
+
+
+ End address *
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {submitLabel}
+
+
+
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()}
+ >
+
+
+
+
+ {isTemplate ? 'Edit transportation (template)' : 'Edit transportation'}:
+ {transportationTypeLabel}
+
+
+
+
+
+
+
+
+
+ {#if transportation.type === 'flight'}
+
+ {:else if transportation.type === 'private_vehicle'}
+
+ {:else if transportation.type === 'other'}
+
+ {/if}
+
+{/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()}
+ >
+
+
+
+
+ {mode === 'add' ? 'Add' : 'Edit'} {isTemplate ? `${typeLabel} (template)` : typeLabel}
+
+
+
+
+
+
+
+
+
+
{
+ return ({ result, update }) => {
+ update();
+ if (result.type === 'success') handleClose();
+ };
+ }}
+ >
+ {#if parentPlanId}
+
+ {/if}
+ {#if isTemplate && dayId != null}
+
+ {/if}
+ {#if mode === 'edit' && idValue != null}
+
+ {/if}
+
+
+
+
+
+ Name *
+
+
+
+
+
+
+
+
+
+ Address
+
+
+
+
+
+
+
+
+ Description
+
+
+
+ {#if !isTemplate}
+
+
Status
+
+ {#each [{ value: 'idea', label: 'Idea' }, { value: 'tentative', label: 'Tentative' }, { value: 'confirmed', label: 'Confirmed' }] as opt (opt.value)}
+
+
+ {opt.label}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+ Cancel
+
+
+ {mode === 'add' ? 'Add' : 'Save changes'}
+
+
+
+
+{/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}
-
-
toggleDayMenu(e, day.plan_id)}
- class="flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 hover:text-gray-700"
- >
-
-
-
- Add
-
- {#if openDayMenu === day.plan_id}
-
(openDayMenu = null)}
- onkeydown={(e) => e.key === 'Escape' && (openDayMenu = null)}
- >
-
- {#if onAddFlightToDay}
-
{
- openDayMenu = null;
- onAddFlightToDay?.(day.plan_id);
- }}
- class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
- >
-
-
-
- Flight
-
- {/if}
- {#if onAddLodgingToDay}
-
{
- openDayMenu = null;
- onAddLodgingToDay?.(day.plan_id);
- }}
- class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
- >
-
-
-
-
- Lodging
-
- {/if}
-
- {/if}
-
+ {#if addTransportToDay || onAddLodgingToDay}
+
{/if}
@@ -739,87 +685,9 @@
{/if}
- {#if onAddFlight || onAddLodging}
-
-
-
-
-
- Add to tour
-
- {#if showAddMenu}
-
(showAddMenu = false)}
- onkeydown={(e) => e.key === 'Escape' && (showAddMenu = false)}
- >
-
- {#if onAddFlight}
-
{
- showAddMenu = false;
- onAddFlight?.();
- }}
- class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
- >
-
-
-
- Flight
-
- {/if}
- {#if onAddLodging}
-
{
- showAddMenu = false;
- onAddLodging?.();
- }}
- class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
- >
-
-
-
-
- Lodging
-
- {/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)}
addHandlers[action.id]?.()}
+ style="background:{action.chipColor};"
class="flex flex-col items-start gap-3 rounded-xl border border-gray-100 p-5 text-left transition-all hover:scale-[1.02] hover:shadow-md"
>
{@html action.icon}
diff --git a/src/lib/components/plan-types.ts b/src/lib/components/plan-types.ts
new file mode 100644
index 0000000..bde1c38
--- /dev/null
+++ b/src/lib/components/plan-types.ts
@@ -0,0 +1,89 @@
+export type PlanTypeId =
+ | 'destination'
+ | 'activity'
+ | 'transport'
+ | 'lodging'
+ | 'restaurant'
+ | 'packageTour'
+ | 'packingList'
+ | 'todo';
+
+export interface PlanTypeDefinition {
+ id: PlanTypeId;
+ label: string;
+ shortLabel: string;
+ description: string;
+ icon: string;
+ chipColor: string;
+}
+
+export const PLAN_TYPE_DEFINITIONS: PlanTypeDefinition[] = [
+ {
+ id: 'destination',
+ label: 'Destinations',
+ shortLabel: 'Destination',
+ description: "Add the places you're visiting",
+ chipColor: '#EFF6FF',
+ icon: ` `
+ },
+ {
+ id: 'activity',
+ label: 'Attractions & Activities',
+ shortLabel: 'Activity',
+ description: 'Things to see and do',
+ chipColor: '#F0FDF4',
+ icon: ` `
+ },
+ {
+ id: 'transport',
+ label: 'Transportation',
+ shortLabel: 'Transportation',
+ description: 'Flights, trains, car hire and more',
+ chipColor: '#FFF7ED',
+ icon: ` `
+ },
+ {
+ id: 'lodging',
+ label: 'Lodgings',
+ shortLabel: 'Lodging',
+ description: 'Hotels, rentals and accommodation',
+ chipColor: '#FDF4FF',
+ icon: ` `
+ },
+ {
+ id: 'restaurant',
+ label: 'Restaurants',
+ shortLabel: 'Restaurant',
+ description: 'Places to eat and drink',
+ chipColor: '#FFF1F2',
+ icon: ` `
+ },
+ {
+ id: 'packageTour',
+ label: 'Package Tours',
+ shortLabel: 'Tour',
+ description: 'Pre-arranged tours and excursions',
+ chipColor: '#FFFBEB',
+ icon: ` `
+ },
+ {
+ id: 'packingList',
+ label: 'Packing List',
+ shortLabel: 'Packing List',
+ description: 'Everything you need to bring',
+ chipColor: '#F0FDFA',
+ icon: ` `
+ },
+ {
+ id: 'todo',
+ label: 'To-dos',
+ shortLabel: 'To-do',
+ description: 'Visas, documents and tasks',
+ chipColor: '#F8FAFC',
+ icon: ` `
+ }
+];
+
+export const PLAN_TYPE_MAP = Object.fromEntries(
+ PLAN_TYPE_DEFINITIONS.map((definition) => [definition.id, definition])
+) as Record;
diff --git a/src/lib/components/transportation-types.ts b/src/lib/components/transportation-types.ts
new file mode 100644
index 0000000..b5c9b2a
--- /dev/null
+++ b/src/lib/components/transportation-types.ts
@@ -0,0 +1,37 @@
+export type TransportationTypeId = 'flight' | 'private_vehicle' | 'other';
+
+export interface TransportationTypeDefinition {
+ id: TransportationTypeId;
+ label: string;
+ description: string;
+ icon: string;
+ enabled: boolean;
+}
+
+export const TRANSPORTATION_TYPE_DEFINITIONS: TransportationTypeDefinition[] = [
+ {
+ id: 'flight',
+ label: 'Flight',
+ description: 'Commercial or private flight itinerary',
+ enabled: true,
+ icon: ` `
+ },
+ {
+ id: 'private_vehicle',
+ label: 'Private vehicle',
+ description: 'Drive or ride by personal vehicle',
+ enabled: true,
+ icon: ` `
+ },
+ {
+ id: 'other',
+ label: 'Other',
+ description: 'Ground, ferry, or custom transportation',
+ enabled: true,
+ icon: ` `
+ }
+];
+
+export const TRANSPORTATION_TYPE_MAP = Object.fromEntries(
+ TRANSPORTATION_TYPE_DEFINITIONS.map((definition) => [definition.id, definition])
+) as Record;
diff --git a/src/lib/server/admin/data.ts b/src/lib/server/admin/data.ts
index fcd868f..fdeddf6 100644
--- a/src/lib/server/admin/data.ts
+++ b/src/lib/server/admin/data.ts
@@ -428,9 +428,15 @@ export function deleteOperatorTourDay(id: number): void {
}
}
-// --- Operator Tour Day Plans (transport / lodging templates per day) ---
+// --- Operator Tour Day Plans (template plans per day) ---
-export type OperatorTourDayPlanType = 'transport' | 'lodging';
+export type OperatorTourDayPlanType =
+ | 'transport'
+ | 'lodging'
+ | 'activity'
+ | 'restaurant'
+ | 'packing'
+ | 'todo';
export interface OperatorTourDayPlan {
id: number;
@@ -449,6 +455,23 @@ export interface OperatorTourDayPlan {
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
+ // Transport-only template details (other transport mechanism)
+ transport_kind?: string | null;
+ start_date?: string | null;
+ start_time?: string | null;
+ start_timezone?: string | null;
+ end_date?: string | null;
+ end_time?: string | null;
+ end_timezone?: string | null;
+ // Activity/restaurant fields
+ booking_id?: string | null;
+ total_cost?: number | null;
+ description?: string | null;
+ website?: string | null;
+ address?: string | null;
+ contact_number?: string | null;
+ // Packing/todo fields
+ items_json?: string | null;
}
export function listPlansForOperatorTour(tourId: number): OperatorTourDayPlan[] {
@@ -474,7 +497,31 @@ export function createOperatorTourDayPlan(
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
- }
+ },
+ transportFields?: {
+ transport_kind?: string | null;
+ start_date?: string | null;
+ start_time?: string | null;
+ start_timezone?: string | null;
+ end_date?: string | null;
+ end_time?: string | null;
+ end_timezone?: string | null;
+ },
+ experienceFields?: {
+ booking_id?: string | null;
+ total_cost?: number | null;
+ description?: string | null;
+ website?: string | null;
+ address?: string | null;
+ contact_number?: string | null;
+ start_date?: string | null;
+ start_time?: string | null;
+ start_timezone?: string | null;
+ end_date?: string | null;
+ end_time?: string | null;
+ end_timezone?: string | null;
+ },
+ checklistItemsJson?: string | null
): OperatorTourDayPlan {
const maxPos = db.get<{ pos: number }>(
'SELECT COALESCE(MAX(position), -1) + 1 as pos FROM operator_tour_day_plans WHERE operator_tour_day_id = ?',
@@ -483,32 +530,68 @@ export function createOperatorTourDayPlan(
const position = maxPos?.pos ?? 0;
const l = lodgingFields;
- const cols =
- type === 'lodging' && l
- ? 'operator_tour_day_id, type, title, notes, position, chain, address_line1, address_line2, city_name, country, country_code, postal_code'
- : 'operator_tour_day_id, type, title, notes, position';
- const vals =
- type === 'lodging' && l
- ? [
- dayId,
- type,
- title.trim(),
- notes?.trim() || null,
- position,
- l.chain?.trim() || null,
- l.address_line1?.trim() || null,
- l.address_line2?.trim() || null,
- l.city_name?.trim() || null,
- l.country?.trim() || null,
- l.country_code?.trim() || null,
- l.postal_code?.trim() || null
- ]
- : [dayId, type, title.trim(), notes?.trim() || null, position];
+ const t = transportFields;
+ let cols = 'operator_tour_day_id, type, title, notes, position';
+ let vals: Array = [
+ dayId,
+ type,
+ title.trim(),
+ notes?.trim() || null,
+ position
+ ];
+ if (type === 'lodging' && l) {
+ cols += ', chain, address_line1, address_line2, city_name, country, country_code, postal_code';
+ vals = [
+ ...vals,
+ l.chain?.trim() || null,
+ l.address_line1?.trim() || null,
+ l.address_line2?.trim() || null,
+ l.city_name?.trim() || null,
+ l.country?.trim() || null,
+ l.country_code?.trim() || null,
+ l.postal_code?.trim() || null
+ ];
+ }
+ if (type === 'transport') {
+ cols +=
+ ', transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone';
+ vals = [
+ ...vals,
+ t?.transport_kind?.trim() || 'other',
+ t?.start_date?.trim() || null,
+ t?.start_time?.trim() || null,
+ t?.start_timezone?.trim() || null,
+ t?.end_date?.trim() || null,
+ t?.end_time?.trim() || null,
+ t?.end_timezone?.trim() || null
+ ];
+ }
+ if (type === 'activity' || type === 'restaurant') {
+ const e = experienceFields;
+ cols +=
+ ', booking_id, total_cost, description, website, address, contact_number, start_date, start_time, start_timezone, end_date, end_time, end_timezone';
+ vals = [
+ ...vals,
+ e?.booking_id?.trim() || null,
+ e?.total_cost ?? null,
+ e?.description?.trim() || null,
+ e?.website?.trim() || null,
+ e?.address?.trim() || null,
+ e?.contact_number?.trim() || null,
+ e?.start_date?.trim() || null,
+ e?.start_time?.trim() || null,
+ e?.start_timezone?.trim() || null,
+ e?.end_date?.trim() || null,
+ e?.end_time?.trim() || null,
+ e?.end_timezone?.trim() || null
+ ];
+ }
+ if (type === 'packing' || type === 'todo') {
+ cols += ', items_json';
+ vals = [...vals, checklistItemsJson?.trim() || null];
+ }
const placeholders = vals.map(() => '?').join(', ');
- db.run(
- `INSERT INTO operator_tour_day_plans (${cols}) VALUES (${placeholders})`,
- vals
- );
+ db.run(`INSERT INTO operator_tour_day_plans (${cols}) VALUES (${placeholders})`, vals);
return db.get(
'SELECT * FROM operator_tour_day_plans WHERE id = last_insert_rowid()'
)!;
@@ -526,14 +609,28 @@ export function updateOperatorTourDayPlan(
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
+ transport_kind?: string | null;
+ start_date?: string | null;
+ start_time?: string | null;
+ start_timezone?: string | null;
+ end_date?: string | null;
+ end_time?: string | null;
+ end_timezone?: string | null;
+ booking_id?: string | null;
+ total_cost?: number | null;
+ description?: string | null;
+ website?: string | null;
+ address?: string | null;
+ contact_number?: string | null;
+ items_json?: string | null;
}
): void {
const setClauses: string[] = [];
const values: (string | null | number)[] = [];
- const optional = (key: string, v: string | null | undefined) => {
+ const optional = (key: string, v: string | number | null | undefined) => {
if (v !== undefined) {
setClauses.push(`${key} = ?`);
- values.push(v === null ? null : (typeof v === 'string' ? v.trim() : v));
+ values.push(v === null ? null : typeof v === 'string' ? v.trim() : v);
}
};
optional('title', updates.title);
@@ -545,13 +642,24 @@ export function updateOperatorTourDayPlan(
optional('country', updates.country ?? undefined);
optional('country_code', updates.country_code ?? undefined);
optional('postal_code', updates.postal_code ?? undefined);
+ optional('transport_kind', updates.transport_kind ?? undefined);
+ optional('start_date', updates.start_date ?? undefined);
+ optional('start_time', updates.start_time ?? undefined);
+ optional('start_timezone', updates.start_timezone ?? undefined);
+ optional('end_date', updates.end_date ?? undefined);
+ optional('end_time', updates.end_time ?? undefined);
+ optional('end_timezone', updates.end_timezone ?? undefined);
+ optional('booking_id', updates.booking_id ?? undefined);
+ optional('total_cost', updates.total_cost ?? undefined);
+ optional('description', updates.description ?? undefined);
+ optional('website', updates.website ?? undefined);
+ optional('address', updates.address ?? undefined);
+ optional('contact_number', updates.contact_number ?? undefined);
+ optional('items_json', updates.items_json ?? undefined);
if (setClauses.length === 0) return;
setClauses.push("updated_at = datetime('now')");
values.push(id);
- db.run(
- `UPDATE operator_tour_day_plans SET ${setClauses.join(', ')} WHERE id = ?`,
- values
- );
+ db.run(`UPDATE operator_tour_day_plans SET ${setClauses.join(', ')} WHERE id = ?`, values);
}
export function deleteOperatorTourDayPlan(id: number): void {
diff --git a/src/lib/server/checklists.ts b/src/lib/server/checklists.ts
new file mode 100644
index 0000000..7d8ff9a
--- /dev/null
+++ b/src/lib/server/checklists.ts
@@ -0,0 +1,161 @@
+import { randomUUID } from 'crypto';
+import { db } from './db/index.js';
+import type { PlanStatus } from './plans.js';
+import { createPlan } from './plans.js';
+
+export type ChecklistType = 'packing' | 'todo';
+
+export interface Checklist {
+ id: string;
+ plan_id: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ChecklistItem {
+ id: string;
+ checklist_id: string;
+ content: string;
+ is_checked: number;
+ position: number;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ChecklistWithItems extends Checklist {
+ items: ChecklistItem[];
+}
+
+export interface CreateChecklistInput {
+ tripId: string;
+ userId: string;
+ type: ChecklistType;
+ name: string;
+ parentId?: string;
+ status?: PlanStatus;
+ items?: string[];
+}
+
+export interface UpdateChecklistItemInput {
+ id?: string;
+ content: string;
+ isChecked?: boolean;
+}
+
+export interface UpdateChecklistInput {
+ checklistId: string;
+ userId: string;
+ name: string;
+ status?: PlanStatus;
+ items: UpdateChecklistItemInput[];
+}
+
+export function createChecklist(input: CreateChecklistInput): ChecklistWithItems {
+ const plan = createPlan({
+ tripId: input.tripId,
+ userId: input.userId,
+ type: input.type,
+ title: input.name.trim(),
+ parentId: input.parentId ?? null,
+ status: input.status ?? 'idea'
+ });
+
+ const checklistId = randomUUID();
+ db.run(`INSERT INTO checklists (id, plan_id) VALUES (?, ?)`, [checklistId, plan.id]);
+
+ const cleanItems = (input.items ?? []).map((item) => item.trim()).filter(Boolean);
+ cleanItems.forEach((content, index) => {
+ db.run(
+ `INSERT INTO checklist_items (id, checklist_id, content, is_checked, position)
+ VALUES (?, ?, ?, 0, ?)`,
+ [randomUUID(), checklistId, content, index]
+ );
+ });
+
+ const checklist = db.get('SELECT * FROM checklists WHERE id = ?', [checklistId])!;
+ return {
+ ...checklist,
+ items: db.all(
+ 'SELECT * FROM checklist_items WHERE checklist_id = ? ORDER BY position ASC, created_at ASC',
+ [checklistId]
+ )
+ };
+}
+
+export function getChecklistsForTrip(
+ tripId: string,
+ userId: string,
+ type?: ChecklistType
+): ChecklistWithItems[] {
+ const params: unknown[] = [tripId, userId];
+ let typeFilter = '';
+ if (type) {
+ typeFilter = ' AND p.type = ?';
+ params.push(type);
+ }
+
+ const checklists = db.all(
+ `SELECT c.* FROM checklists c
+ JOIN plans p ON p.id = c.plan_id
+ WHERE p.trip_id = ? AND p.user_id = ?${typeFilter}
+ ORDER BY p.position ASC, c.created_at ASC`,
+ params
+ );
+
+ return checklists.map((checklist) => ({
+ ...checklist,
+ items: db.all(
+ 'SELECT * FROM checklist_items WHERE checklist_id = ? ORDER BY position ASC, created_at ASC',
+ [checklist.id]
+ )
+ }));
+}
+
+export function updateChecklist(input: UpdateChecklistInput): void {
+ const existing = db.get<{ checklist_id: string; plan_id: string }>(
+ `SELECT c.id as checklist_id, c.plan_id
+ FROM checklists c
+ JOIN plans p ON p.id = c.plan_id
+ WHERE c.id = ? AND p.user_id = ?`,
+ [input.checklistId, input.userId]
+ );
+ if (!existing) throw new Error('Checklist not found or not authorized');
+
+ db.run(`UPDATE plans SET status = ?, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
+ input.status ?? 'idea',
+ input.name.trim(),
+ existing.plan_id
+ ]);
+
+ db.run('DELETE FROM checklist_items WHERE checklist_id = ?', [input.checklistId]);
+ const cleanItems = input.items
+ .map((item) => ({ content: item.content.trim(), isChecked: item.isChecked }))
+ .filter((item) => item.content.length > 0);
+ cleanItems.forEach((item, index) => {
+ db.run(
+ `INSERT INTO checklist_items (id, checklist_id, content, is_checked, position)
+ VALUES (?, ?, ?, ?, ?)`,
+ [randomUUID(), input.checklistId, item.content, item.isChecked ? 1 : 0, index]
+ );
+ });
+}
+
+export function toggleChecklistItem(input: {
+ itemId: string;
+ userId: string;
+ isChecked: boolean;
+}): void {
+ const row = db.get<{ id: string }>(
+ `SELECT ci.id
+ FROM checklist_items ci
+ JOIN checklists c ON c.id = ci.checklist_id
+ JOIN plans p ON p.id = c.plan_id
+ WHERE ci.id = ? AND p.user_id = ?`,
+ [input.itemId, input.userId]
+ );
+ if (!row) throw new Error('Checklist item not found or not authorized');
+ db.run(`UPDATE checklist_items SET is_checked = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
+ input.isChecked ? 1 : 0,
+ input.itemId
+ ]);
+}
diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts
index 5d9418f..f573cdf 100644
--- a/src/lib/server/db/migrations.ts
+++ b/src/lib/server/db/migrations.ts
@@ -211,6 +211,100 @@ export function runMigrations(db: Database): void {
)
`);
+ // Private vehicle transportation details - links to a transport plan
+ db.run(`
+ CREATE TABLE IF NOT EXISTS private_vehicles (
+ id TEXT PRIMARY KEY,
+ plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
+ start_address TEXT NOT NULL,
+ end_address TEXT NOT NULL,
+ departure_date TEXT,
+ departure_time TEXT,
+ departure_timezone TEXT,
+ arrival_date TEXT,
+ arrival_time TEXT,
+ arrival_timezone TEXT,
+ start_plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL,
+ end_plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
+ for (const col of [
+ 'departure_date TEXT',
+ 'departure_time TEXT',
+ 'departure_timezone TEXT',
+ 'arrival_date TEXT',
+ 'arrival_time TEXT',
+ 'arrival_timezone TEXT'
+ ]) {
+ try {
+ db.run(`ALTER TABLE private_vehicles ADD COLUMN ${col}`);
+ } catch {
+ /* already exists */
+ }
+ }
+
+ // Other transportation details - links to a transport plan
+ db.run(`
+ CREATE TABLE IF NOT EXISTS other_transports (
+ id TEXT PRIMARY KEY,
+ plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
+ start_date TEXT,
+ start_time TEXT,
+ start_timezone TEXT,
+ end_date TEXT,
+ end_time TEXT,
+ end_timezone TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
+
+ // Activity / restaurant details - links to a plan
+ db.run(`
+ CREATE TABLE IF NOT EXISTS experience_plans (
+ id TEXT PRIMARY KEY,
+ plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
+ booking_id TEXT,
+ total_cost REAL,
+ description TEXT,
+ website TEXT,
+ address TEXT,
+ contact_number TEXT,
+ start_date TEXT,
+ start_time TEXT,
+ start_timezone TEXT,
+ end_date TEXT,
+ end_time TEXT,
+ end_timezone TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
+
+ // Checklist master table (packing/todo details come from linked plan.type)
+ db.run(`
+ CREATE TABLE IF NOT EXISTS checklists (
+ id TEXT PRIMARY KEY,
+ plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
+
+ db.run(`
+ CREATE TABLE IF NOT EXISTS checklist_items (
+ id TEXT PRIMARY KEY,
+ checklist_id TEXT NOT NULL REFERENCES checklists(id) ON DELETE CASCADE,
+ content TEXT NOT NULL,
+ is_checked INTEGER NOT NULL DEFAULT 0,
+ position INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
+
// Lodgings table - links to a plan
db.run(`
CREATE TABLE IF NOT EXISTS lodgings (
@@ -330,12 +424,12 @@ export function runMigrations(db: Database): void {
)
`);
- // Template plans (transport/lodging) attached to operator tour days
+ // Template plans attached to operator tour days
db.run(`
CREATE TABLE IF NOT EXISTS operator_tour_day_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_tour_day_id INTEGER NOT NULL REFERENCES operator_tour_days(id) ON DELETE CASCADE,
- type TEXT NOT NULL CHECK (type IN ('transport', 'lodging')),
+ type TEXT NOT NULL CHECK (type IN ('transport', 'lodging', 'activity', 'restaurant', 'packing', 'todo')),
title TEXT NOT NULL,
notes TEXT,
position INTEGER NOT NULL DEFAULT 0,
@@ -344,6 +438,65 @@ export function runMigrations(db: Database): void {
)
`);
+ // Migration: expand operator_tour_day_plans `type` CHECK constraint if it still only supports transport/lodging.
+ try {
+ const schema = db.get<{ sql: string }>(
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'operator_tour_day_plans'`
+ );
+ const sql = schema?.sql?.toLowerCase() ?? '';
+ if (sql && !sql.includes("'activity'")) {
+ db.run(`
+ CREATE TABLE operator_tour_day_plans__new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ operator_tour_day_id INTEGER NOT NULL REFERENCES operator_tour_days(id) ON DELETE CASCADE,
+ type TEXT NOT NULL CHECK (type IN ('transport', 'lodging', 'activity', 'restaurant', 'packing', 'todo')),
+ title TEXT NOT NULL,
+ notes TEXT,
+ position INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ chain TEXT,
+ address_line1 TEXT,
+ address_line2 TEXT,
+ city_name TEXT,
+ country TEXT,
+ country_code TEXT,
+ postal_code TEXT,
+ transport_kind TEXT,
+ start_date TEXT,
+ start_time TEXT,
+ start_timezone TEXT,
+ end_date TEXT,
+ end_time TEXT,
+ end_timezone TEXT,
+ booking_id TEXT,
+ total_cost REAL,
+ description TEXT,
+ website TEXT,
+ address TEXT,
+ contact_number TEXT,
+ items_json TEXT
+ )
+ `);
+ db.run(`
+ INSERT INTO operator_tour_day_plans__new (
+ id, operator_tour_day_id, type, title, notes, position, created_at, updated_at,
+ chain, address_line1, address_line2, city_name, country, country_code, postal_code,
+ transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone
+ )
+ SELECT
+ id, operator_tour_day_id, type, title, notes, position, created_at, updated_at,
+ chain, address_line1, address_line2, city_name, country, country_code, postal_code,
+ transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone
+ FROM operator_tour_day_plans
+ `);
+ db.run('DROP TABLE operator_tour_day_plans');
+ db.run('ALTER TABLE operator_tour_day_plans__new RENAME TO operator_tour_day_plans');
+ }
+ } catch {
+ /* best-effort migration */
+ }
+
// Lodging-specific fields for operator_tour_day_plans (type=lodging) — same shape as trip lodgings
for (const col of [
'chain TEXT',
@@ -352,7 +505,21 @@ export function runMigrations(db: Database): void {
'city_name TEXT',
'country TEXT',
'country_code TEXT',
- 'postal_code TEXT'
+ 'postal_code TEXT',
+ 'transport_kind TEXT',
+ 'start_date TEXT',
+ 'start_time TEXT',
+ 'start_timezone TEXT',
+ 'end_date TEXT',
+ 'end_time TEXT',
+ 'end_timezone TEXT',
+ 'booking_id TEXT',
+ 'total_cost REAL',
+ 'description TEXT',
+ 'website TEXT',
+ 'address TEXT',
+ 'contact_number TEXT',
+ 'items_json TEXT'
]) {
try {
db.run(`ALTER TABLE operator_tour_day_plans ADD COLUMN ${col}`);
@@ -360,6 +527,15 @@ export function runMigrations(db: Database): void {
/* already exists */
}
}
+ try {
+ db.run(
+ `UPDATE operator_tour_day_plans
+ SET transport_kind = 'other'
+ WHERE type = 'transport' AND (transport_kind IS NULL OR TRIM(transport_kind) = '')`
+ );
+ } catch {
+ /* best-effort migration */
+ }
// Countries table — editable list for admin (used by country selector)
db.run(`
diff --git a/src/lib/server/experiences.ts b/src/lib/server/experiences.ts
new file mode 100644
index 0000000..b9a6042
--- /dev/null
+++ b/src/lib/server/experiences.ts
@@ -0,0 +1,164 @@
+import { randomUUID } from 'crypto';
+import { db } from './db/index.js';
+import type { PlanStatus } from './plans.js';
+import { createPlan } from './plans.js';
+
+export type ExperienceType = 'activity' | 'restaurant';
+
+export interface ExperiencePlan {
+ id: string;
+ plan_id: string;
+ booking_id: string | null;
+ total_cost: number | null;
+ description: string | null;
+ website: string | null;
+ address: string | null;
+ contact_number: string | null;
+ start_date: string | null;
+ start_time: string | null;
+ start_timezone: string | null;
+ end_date: string | null;
+ end_time: string | null;
+ end_timezone: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateExperienceInput {
+ tripId: string;
+ userId: string;
+ type: ExperienceType;
+ name: string;
+ parentId?: string;
+ status?: PlanStatus;
+ bookingId?: string;
+ totalCost?: number;
+ description?: string;
+ website?: string;
+ address?: string;
+ contactNumber?: string;
+ startDate?: string;
+ startTime?: string;
+ startTimezone?: string;
+ endDate?: string;
+ endTime?: string;
+ endTimezone?: string;
+}
+
+export interface UpdateExperienceInput {
+ experienceId: string;
+ userId: string;
+ name: string;
+ status?: PlanStatus;
+ bookingId?: string;
+ totalCost?: number;
+ description?: string;
+ website?: string;
+ address?: string;
+ contactNumber?: string;
+ startDate?: string;
+ startTime?: string;
+ startTimezone?: string;
+ endDate?: string;
+ endTime?: string;
+ endTimezone?: string;
+}
+
+export function createExperience(input: CreateExperienceInput): ExperiencePlan {
+ const plan = createPlan({
+ tripId: input.tripId,
+ userId: input.userId,
+ type: input.type,
+ title: input.name.trim(),
+ notes: input.description?.trim() || null,
+ parentId: input.parentId ?? null,
+ status: input.status ?? 'idea'
+ });
+
+ const experienceId = randomUUID();
+ db.run(
+ `INSERT INTO experience_plans (
+ id, plan_id, booking_id, total_cost, description, website, address, contact_number,
+ start_date, start_time, start_timezone, end_date, end_time, end_timezone
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ experienceId,
+ plan.id,
+ input.bookingId?.trim() || null,
+ input.totalCost ?? null,
+ input.description?.trim() || null,
+ input.website?.trim() || null,
+ input.address?.trim() || null,
+ input.contactNumber?.trim() || null,
+ input.startDate?.trim() || null,
+ input.startTime?.trim() || null,
+ input.startTimezone?.trim() || null,
+ input.endDate?.trim() || null,
+ input.endTime?.trim() || null,
+ input.endTimezone?.trim() || null
+ ]
+ );
+
+ return db.get('SELECT * FROM experience_plans WHERE id = ?', [experienceId])!;
+}
+
+export function updateExperience(input: UpdateExperienceInput): void {
+ const existing = db.get<{ plan_id: string; user_id: string }>(
+ `SELECT ep.plan_id, p.user_id
+ FROM experience_plans ep
+ JOIN plans p ON p.id = ep.plan_id
+ WHERE ep.id = ? AND p.user_id = ?`,
+ [input.experienceId, input.userId]
+ );
+ if (!existing) throw new Error('Plan not found or not authorized');
+
+ db.run(`UPDATE plans SET status = ?, title = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
+ input.status ?? 'idea',
+ input.name.trim(),
+ input.description?.trim() || null,
+ existing.plan_id
+ ]);
+
+ db.run(
+ `UPDATE experience_plans
+ SET booking_id = ?, total_cost = ?, description = ?, website = ?, address = ?, contact_number = ?,
+ start_date = ?, start_time = ?, start_timezone = ?, end_date = ?, end_time = ?, end_timezone = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?`,
+ [
+ input.bookingId?.trim() || null,
+ input.totalCost ?? null,
+ input.description?.trim() || null,
+ input.website?.trim() || null,
+ input.address?.trim() || null,
+ input.contactNumber?.trim() || null,
+ input.startDate?.trim() || null,
+ input.startTime?.trim() || null,
+ input.startTimezone?.trim() || null,
+ input.endDate?.trim() || null,
+ input.endTime?.trim() || null,
+ input.endTimezone?.trim() || null,
+ input.experienceId
+ ]
+ );
+}
+
+export function getExperiencesForTrip(
+ tripId: string,
+ userId: string,
+ type?: ExperienceType
+): ExperiencePlan[] {
+ const params: unknown[] = [tripId, userId];
+ let typeFilter = '';
+ if (type) {
+ typeFilter = ' AND p.type = ?';
+ params.push(type);
+ }
+ return db.all(
+ `SELECT ep.* FROM experience_plans ep
+ JOIN plans p ON p.id = ep.plan_id
+ WHERE p.trip_id = ? AND p.user_id = ?${typeFilter}
+ ORDER BY p.position ASC, ep.created_at ASC`,
+ params
+ );
+}
diff --git a/src/lib/server/other-transports.ts b/src/lib/server/other-transports.ts
new file mode 100644
index 0000000..7fa0e83
--- /dev/null
+++ b/src/lib/server/other-transports.ts
@@ -0,0 +1,137 @@
+import { randomUUID } from 'crypto';
+import { db } from './db/index.js';
+import type { PlanStatus } from './plans.js';
+
+export interface OtherTransport {
+ id: string;
+ plan_id: string;
+ start_date: string | null;
+ start_time: string | null;
+ start_timezone: string | null;
+ end_date: string | null;
+ end_time: string | null;
+ end_timezone: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateOtherTransportInput {
+ tripId: string;
+ userId: string;
+ parentId?: string;
+ status?: PlanStatus;
+ title: string;
+ notes?: string;
+ startDate?: string;
+ startTime?: string;
+ startTimezone?: string;
+ endDate?: string;
+ endTime?: string;
+ endTimezone?: string;
+}
+
+export interface UpdateOtherTransportInput {
+ otherTransportId: string;
+ userId: string;
+ status?: PlanStatus;
+ title: string;
+ notes?: string;
+ startDate?: string;
+ startTime?: string;
+ startTimezone?: string;
+ endDate?: string;
+ endTime?: string;
+ endTimezone?: string;
+}
+
+export function createOtherTransport(input: CreateOtherTransportInput): OtherTransport {
+ const planId = randomUUID();
+ const maxPos = db.get<{ pos: number }>(
+ `SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE trip_id = ? AND user_id = ?`,
+ [input.tripId, input.userId]
+ );
+ const position = maxPos?.pos ?? 0;
+
+ db.run(
+ `INSERT INTO plans (id, trip_id, user_id, type, status, title, notes, parent_id, position)
+ VALUES (?, ?, ?, 'transport', ?, ?, ?, ?, ?)`,
+ [
+ planId,
+ input.tripId,
+ input.userId,
+ input.status ?? 'idea',
+ input.title.trim(),
+ input.notes?.trim() || null,
+ input.parentId ?? null,
+ position
+ ]
+ );
+
+ const otherTransportId = randomUUID();
+ db.run(
+ `INSERT INTO other_transports (
+ id, plan_id,
+ start_date, start_time, start_timezone,
+ end_date, end_time, end_timezone
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ otherTransportId,
+ planId,
+ input.startDate ?? null,
+ input.startTime ?? null,
+ input.startTimezone ?? null,
+ input.endDate ?? null,
+ input.endTime ?? null,
+ input.endTimezone ?? null
+ ]
+ );
+
+ return db.get('SELECT * FROM other_transports WHERE id = ?', [otherTransportId])!;
+}
+
+export function updateOtherTransport(input: UpdateOtherTransportInput): void {
+ const existing = db.get(
+ `SELECT ot.*, p.user_id FROM other_transports ot
+ JOIN plans p ON p.id = ot.plan_id
+ WHERE ot.id = ? AND p.user_id = ?`,
+ [input.otherTransportId, input.userId]
+ );
+ if (!existing) throw new Error('Other transportation not found or not authorized');
+
+ db.run(
+ `UPDATE plans SET status = ?, title = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
+ [
+ input.status ?? 'idea',
+ input.title.trim(),
+ input.notes?.trim() || null,
+ existing.plan_id
+ ]
+ );
+
+ db.run(
+ `UPDATE other_transports
+ SET start_date = ?, start_time = ?, start_timezone = ?,
+ end_date = ?, end_time = ?, end_timezone = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?`,
+ [
+ input.startDate ?? null,
+ input.startTime ?? null,
+ input.startTimezone ?? null,
+ input.endDate ?? null,
+ input.endTime ?? null,
+ input.endTimezone ?? null,
+ input.otherTransportId
+ ]
+ );
+}
+
+export function getOtherTransportsForTrip(tripId: string, userId: string): OtherTransport[] {
+ return db.all(
+ `SELECT ot.* FROM other_transports ot
+ JOIN plans p ON p.id = ot.plan_id
+ WHERE p.trip_id = ? AND p.user_id = ? AND p.type = 'transport'
+ ORDER BY p.position ASC, ot.created_at ASC`,
+ [tripId, userId]
+ );
+}
diff --git a/src/lib/server/package-tours.ts b/src/lib/server/package-tours.ts
index b29d378..d7bdc37 100644
--- a/src/lib/server/package-tours.ts
+++ b/src/lib/server/package-tours.ts
@@ -2,6 +2,9 @@ import { db } from './db/index.js';
import { randomUUID } from 'crypto';
import type { PlanStatus } from './plans.js';
import { createPlan } from './plans.js';
+import { createOtherTransport } from './other-transports.js';
+import { createExperience } from './experiences.js';
+import { createChecklist } from './checklists.js';
import { listDaysForOperatorTour, listPlansForOperatorTour } from './admin/data.js';
export interface PackageTour {
@@ -323,15 +326,81 @@ export function cloneTemplateDaysToTour(input: {
});
const dayPlans = allDayPlans.filter((p) => p.operator_tour_day_id === day.id);
for (const dp of dayPlans) {
- createPlan({
- tripId: input.tripId,
- userId: input.userId,
- type: dp.type,
- title: dp.title,
- notes: dp.notes ?? undefined,
- parentId: newDay.plan_id,
- status: 'idea'
- });
+ if (dp.type === 'transport') {
+ createOtherTransport({
+ tripId: input.tripId,
+ userId: input.userId,
+ parentId: newDay.plan_id,
+ status: 'idea',
+ title: dp.title,
+ notes: dp.notes ?? undefined,
+ startDate: dp.start_date ?? undefined,
+ startTime: dp.start_time ?? undefined,
+ startTimezone: dp.start_timezone ?? undefined,
+ endDate: dp.end_date ?? undefined,
+ endTime: dp.end_time ?? undefined,
+ endTimezone: dp.end_timezone ?? undefined
+ });
+ } else if (dp.type === 'activity' || dp.type === 'restaurant') {
+ createExperience({
+ tripId: input.tripId,
+ userId: input.userId,
+ type: dp.type,
+ name: dp.title,
+ parentId: newDay.plan_id,
+ status: 'idea',
+ bookingId: dp.booking_id ?? undefined,
+ totalCost: dp.total_cost ?? undefined,
+ description: dp.description ?? dp.notes ?? undefined,
+ website: dp.website ?? undefined,
+ address: dp.address ?? undefined,
+ contactNumber: dp.contact_number ?? undefined,
+ startDate: dp.start_date ?? undefined,
+ startTime: dp.start_time ?? undefined,
+ startTimezone: dp.start_timezone ?? undefined,
+ endDate: dp.end_date ?? undefined,
+ endTime: dp.end_time ?? undefined,
+ endTimezone: dp.end_timezone ?? undefined
+ });
+ } else if (dp.type === 'packing' || dp.type === 'todo') {
+ let items: string[] = [];
+ try {
+ const parsed = JSON.parse(dp.items_json ?? '[]');
+ if (Array.isArray(parsed)) {
+ items = parsed
+ .map((item) =>
+ typeof item === 'string'
+ ? item
+ : typeof item === 'object' && item && 'content' in item
+ ? String(item.content)
+ : ''
+ )
+ .map((value) => value.trim())
+ .filter(Boolean);
+ }
+ } catch {
+ items = [];
+ }
+ createChecklist({
+ tripId: input.tripId,
+ userId: input.userId,
+ type: dp.type,
+ name: dp.title,
+ parentId: newDay.plan_id,
+ status: 'idea',
+ items
+ });
+ } else {
+ createPlan({
+ tripId: input.tripId,
+ userId: input.userId,
+ type: dp.type,
+ title: dp.title,
+ notes: dp.notes ?? undefined,
+ parentId: newDay.plan_id,
+ status: 'idea'
+ });
+ }
}
}
}
diff --git a/src/lib/server/private-vehicles.ts b/src/lib/server/private-vehicles.ts
new file mode 100644
index 0000000..3ddde56
--- /dev/null
+++ b/src/lib/server/private-vehicles.ts
@@ -0,0 +1,161 @@
+import { randomUUID } from 'crypto';
+import { db } from './db/index.js';
+import type { PlanStatus } from './plans.js';
+
+export interface PrivateVehicleTransport {
+ id: string;
+ plan_id: string;
+ start_address: string;
+ end_address: string;
+ departure_date: string | null;
+ departure_time: string | null;
+ departure_timezone: string | null;
+ arrival_date: string | null;
+ arrival_time: string | null;
+ arrival_timezone: string | null;
+ start_plan_id: string | null;
+ end_plan_id: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreatePrivateVehicleInput {
+ tripId: string;
+ userId: string;
+ parentId?: string;
+ status?: PlanStatus;
+ startAddress: string;
+ endAddress: string;
+ departureDate?: string;
+ departureTime?: string;
+ departureTimezone?: string;
+ arrivalDate?: string;
+ arrivalTime?: string;
+ arrivalTimezone?: string;
+ startPlanId?: string;
+ endPlanId?: string;
+}
+
+export interface UpdatePrivateVehicleInput {
+ privateVehicleId: string;
+ userId: string;
+ status?: PlanStatus;
+ startAddress: string;
+ endAddress: string;
+ departureDate?: string;
+ departureTime?: string;
+ departureTimezone?: string;
+ arrivalDate?: string;
+ arrivalTime?: string;
+ arrivalTimezone?: string;
+ startPlanId?: string;
+ endPlanId?: string;
+}
+
+function buildTitle(startAddress: string, endAddress: string): string {
+ return `Private vehicle: ${startAddress} to ${endAddress}`;
+}
+
+export function createPrivateVehicle(input: CreatePrivateVehicleInput): PrivateVehicleTransport {
+ const planId = randomUUID();
+ const maxPos = db.get<{ pos: number }>(
+ `SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE trip_id = ? AND user_id = ?`,
+ [input.tripId, input.userId]
+ );
+ const position = maxPos?.pos ?? 0;
+
+ db.run(
+ `INSERT INTO plans (id, trip_id, user_id, type, status, title, parent_id, position)
+ VALUES (?, ?, ?, 'transport', ?, ?, ?, ?)`,
+ [
+ planId,
+ input.tripId,
+ input.userId,
+ input.status ?? 'idea',
+ buildTitle(input.startAddress, input.endAddress),
+ input.parentId ?? null,
+ position
+ ]
+ );
+
+ const privateVehicleId = randomUUID();
+ db.run(
+ `INSERT INTO private_vehicles (
+ id, plan_id, start_address, end_address,
+ departure_date, departure_time, departure_timezone,
+ arrival_date, arrival_time, arrival_timezone,
+ start_plan_id, end_plan_id
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ privateVehicleId,
+ planId,
+ input.startAddress,
+ input.endAddress,
+ input.departureDate ?? null,
+ input.departureTime ?? null,
+ input.departureTimezone ?? null,
+ input.arrivalDate ?? null,
+ input.arrivalTime ?? null,
+ input.arrivalTimezone ?? null,
+ input.startPlanId ?? null,
+ input.endPlanId ?? null
+ ]
+ );
+
+ return db.get('SELECT * FROM private_vehicles WHERE id = ?', [
+ privateVehicleId
+ ])!;
+}
+
+export function updatePrivateVehicle(input: UpdatePrivateVehicleInput): void {
+ const privateVehicle = db.get(
+ `SELECT pv.*, p.user_id FROM private_vehicles pv
+ JOIN plans p ON p.id = pv.plan_id
+ WHERE pv.id = ? AND p.user_id = ?`,
+ [input.privateVehicleId, input.userId]
+ );
+ if (!privateVehicle)
+ throw new Error('Private vehicle transportation not found or not authorized');
+
+ db.run(`UPDATE plans SET status = ?, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
+ input.status ?? 'idea',
+ buildTitle(input.startAddress, input.endAddress),
+ privateVehicle.plan_id
+ ]);
+
+ db.run(
+ `UPDATE private_vehicles
+ SET start_address = ?, end_address = ?,
+ departure_date = ?, departure_time = ?, departure_timezone = ?,
+ arrival_date = ?, arrival_time = ?, arrival_timezone = ?,
+ start_plan_id = ?, end_plan_id = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?`,
+ [
+ input.startAddress,
+ input.endAddress,
+ input.departureDate ?? null,
+ input.departureTime ?? null,
+ input.departureTimezone ?? null,
+ input.arrivalDate ?? null,
+ input.arrivalTime ?? null,
+ input.arrivalTimezone ?? null,
+ input.startPlanId ?? null,
+ input.endPlanId ?? null,
+ input.privateVehicleId
+ ]
+ );
+}
+
+export function getPrivateVehiclesForTrip(
+ tripId: string,
+ userId: string
+): PrivateVehicleTransport[] {
+ return db.all(
+ `SELECT pv.* FROM private_vehicles pv
+ JOIN plans p ON p.id = pv.plan_id
+ WHERE p.trip_id = ? AND p.user_id = ? AND p.type = 'transport'
+ ORDER BY p.position ASC, pv.created_at ASC`,
+ [tripId, userId]
+ );
+}
diff --git a/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts b/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts
index 9084417..82df5b9 100644
--- a/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts
+++ b/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts
@@ -84,13 +84,52 @@ export const actions: Actions = {
const dayId = parseInt(formData.get('dayId') as string);
const type = (formData.get('type') as string)?.trim();
const get = (k: string) => (formData.get(k) as string)?.trim() || undefined;
- const title = get('name') || get('title');
+ const transportationType = (get('transportation_type') || get('transport_kind') || 'other') as
+ | 'other'
+ | 'flight'
+ | 'private_vehicle';
if (isNaN(dayId)) return fail(400, { error: 'Invalid day ID' });
- if (type !== 'transport' && type !== 'lodging') return fail(400, { error: 'Invalid plan type' });
- if (!title) return fail(400, { error: 'Name is required' });
+ if (
+ type !== 'transport' &&
+ type !== 'lodging' &&
+ type !== 'activity' &&
+ type !== 'restaurant' &&
+ type !== 'packing' &&
+ type !== 'todo'
+ )
+ return fail(400, { error: 'Invalid plan type' });
+ let title = get('name') || get('title');
const notes = get('notes') ?? undefined;
+ let transportFields:
+ | {
+ transport_kind: string;
+ start_date: string | null;
+ start_time: string | null;
+ start_timezone: string | null;
+ end_date: string | null;
+ end_time: string | null;
+ end_timezone: string | null;
+ }
+ | undefined;
+ let experienceFields:
+ | {
+ booking_id: string | null;
+ total_cost: number | null;
+ description: string | null;
+ website: string | null;
+ address: string | null;
+ contact_number: string | null;
+ start_date: string | null;
+ start_time: string | null;
+ start_timezone: string | null;
+ end_date: string | null;
+ end_time: string | null;
+ end_timezone: string | null;
+ }
+ | undefined;
+ let checklistItemsJson: string | null | undefined;
const lodgingFields =
type === 'lodging'
? {
@@ -103,14 +142,103 @@ export const actions: Actions = {
postal_code: get('postal_code') ?? null
}
: undefined;
+ if (type === 'transport') {
+ if (transportationType === 'flight') {
+ const segmentIndices = new Set();
+ for (const key of formData.keys()) {
+ const match = key.match(/^segments\[(\d+)\]/);
+ if (match) segmentIndices.add(parseInt(match[1]));
+ }
+ const sorted = Array.from(segmentIndices).sort((a, b) => a - b);
+ const first = sorted[0];
+ const last = sorted[sorted.length - 1];
+ const firstAirline =
+ (first != null && get(`segments[${first}][airline_name]`)) ||
+ (first != null && get(`segments[${first}][airline_iata]`)) ||
+ 'Flight';
+ const firstNumber = (first != null && get(`segments[${first}][flight_number]`)) || '';
+ if (!title) title = `${firstAirline} ${firstNumber}`.trim();
+ const depDate =
+ (first != null && get(`segments[${first}][departure_date]`)) ||
+ (first != null && get(`segments[${first}][departure_datetime]`)?.split('T')[0]) ||
+ null;
+ const depTime =
+ (first != null && get(`segments[${first}][departure_datetime]`)?.split('T')[1]) || null;
+ const arrDate =
+ (last != null && get(`segments[${last}][arrival_datetime]`)?.split('T')[0]) || null;
+ const arrTime =
+ (last != null && get(`segments[${last}][arrival_datetime]`)?.split('T')[1]) || null;
+ transportFields = {
+ transport_kind: 'flight',
+ start_date: depDate,
+ start_time: depTime,
+ start_timezone: null,
+ end_date: arrDate,
+ end_time: arrTime,
+ end_timezone: null
+ };
+ } else if (transportationType === 'private_vehicle') {
+ const startAddress = get('start_address');
+ const endAddress = get('end_address');
+ if (!title) title = `Private vehicle: ${startAddress ?? 'Start'} to ${endAddress ?? 'End'}`;
+ transportFields = {
+ transport_kind: 'private_vehicle',
+ start_date: get('departure_date') ?? null,
+ start_time: get('departure_time') ?? null,
+ start_timezone: get('departure_timezone') ?? null,
+ end_date: get('arrival_date') ?? null,
+ end_time: get('arrival_time') ?? null,
+ end_timezone: get('arrival_timezone') ?? null
+ };
+ } else {
+ transportFields = {
+ transport_kind: 'other',
+ start_date: get('start_date') ?? null,
+ start_time: get('start_time') ?? null,
+ start_timezone: get('start_timezone') ?? null,
+ end_date: get('end_date') ?? null,
+ end_time: get('end_time') ?? null,
+ end_timezone: get('end_timezone') ?? null
+ };
+ }
+ }
+ if (type === 'activity' || type === 'restaurant') {
+ const totalCostRaw = get('total_cost');
+ const totalCost = totalCostRaw ? parseFloat(totalCostRaw) : null;
+ experienceFields = {
+ booking_id: get('booking_id') ?? null,
+ total_cost: totalCost != null && Number.isFinite(totalCost) ? totalCost : null,
+ description: get('description') ?? null,
+ website: get('website') ?? null,
+ address: get('address') ?? null,
+ contact_number: get('contact_number') ?? null,
+ start_date: get('start_date') ?? null,
+ start_time: get('start_time') ?? null,
+ start_timezone: get('start_timezone') ?? null,
+ end_date: get('end_date') ?? null,
+ end_time: get('end_time') ?? null,
+ end_timezone: get('end_timezone') ?? null
+ };
+ }
+ if (type === 'packing' || type === 'todo') {
+ const items = formData
+ .getAll('items[]')
+ .map((item) => String(item).trim())
+ .filter(Boolean);
+ checklistItemsJson = JSON.stringify(items);
+ }
+ if (!title) return fail(400, { error: 'Name is required' });
try {
data.createOperatorTourDayPlan(
dayId,
- type as 'transport' | 'lodging',
+ type as 'transport' | 'lodging' | 'activity' | 'restaurant' | 'packing' | 'todo',
title,
notes,
- lodgingFields
+ lodgingFields,
+ transportFields,
+ experienceFields,
+ checklistItemsJson
);
return { success: true };
} catch (err) {
@@ -125,10 +253,25 @@ export const actions: Actions = {
const id = parseInt(formData.get('id') as string);
if (isNaN(id)) return fail(400, { error: 'Invalid plan ID' });
- const title = (formData.get('name') as string)?.trim() || (formData.get('title') as string)?.trim();
+ const title =
+ (formData.get('name') as string)?.trim() || (formData.get('title') as string)?.trim();
if (!title) return fail(400, { error: 'Name is required' });
const get = (k: string) => (formData.get(k) as string)?.trim() || undefined;
+ const transportationType = (get('transportation_type') || get('transport_kind')) as
+ | 'other'
+ | 'flight'
+ | 'private_vehicle'
+ | undefined;
+ let checklistItemsJson: string | undefined;
+ const type = (formData.get('type') as string)?.trim();
+ if (type === 'packing' || type === 'todo') {
+ const items = formData
+ .getAll('items[]')
+ .map((item) => String(item).trim())
+ .filter(Boolean);
+ checklistItemsJson = JSON.stringify(items);
+ }
try {
data.updateOperatorTourDayPlan(id, {
title,
@@ -139,7 +282,26 @@ export const actions: Actions = {
city_name: get('city_name') ?? null,
country: get('country') ?? null,
country_code: get('country_code') ?? null,
- postal_code: get('postal_code') ?? null
+ postal_code: get('postal_code') ?? null,
+ transport_kind: transportationType ?? get('transport_kind') ?? undefined,
+ start_date: get('start_date') ?? undefined,
+ start_time: get('start_time') ?? undefined,
+ start_timezone: get('start_timezone') ?? undefined,
+ end_date: get('end_date') ?? undefined,
+ end_time: get('end_time') ?? undefined,
+ end_timezone: get('end_timezone') ?? undefined,
+ booking_id: get('booking_id') ?? undefined,
+ total_cost: (() => {
+ const raw = get('total_cost');
+ if (!raw) return undefined;
+ const parsed = parseFloat(raw);
+ return Number.isFinite(parsed) ? parsed : undefined;
+ })(),
+ description: get('description') ?? undefined,
+ website: get('website') ?? undefined,
+ address: get('address') ?? undefined,
+ contact_number: get('contact_number') ?? undefined,
+ items_json: checklistItemsJson
});
return { success: true };
} catch (err) {
diff --git a/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.svelte b/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.svelte
index 22af709..5d962b1 100644
--- a/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.svelte
+++ b/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.svelte
@@ -1,7 +1,16 @@
({ update }) => update()}
+ use:enhance={() =>
+ ({ update }) =>
+ update()}
class="hidden"
aria-hidden="true"
>
@@ -119,6 +172,108 @@
formAction="?/editDayPlan"
/>
+ (editingTransportPlan = null)}
+ variant="template"
+ formAction="?/editDayPlan"
+ idFieldName="id"
+ idValue={editingTransportPlan?.id}
+ transportation={editingTransportPlan
+ ? {
+ type: 'other',
+ planStatus: 'idea',
+ planTitle: editingTransportPlan.title,
+ planNotes: editingTransportPlan.notes,
+ transportKind: editingTransportPlan.transport_kind ?? 'other',
+ otherTransport: {
+ id: editingTransportPlan.id,
+ plan_id: editingTransportPlan.id,
+ start_date: editingTransportPlan.start_date ?? null,
+ start_time: editingTransportPlan.start_time ?? null,
+ start_timezone: editingTransportPlan.start_timezone ?? null,
+ end_date: editingTransportPlan.end_date ?? null,
+ end_time: editingTransportPlan.end_time ?? null,
+ end_timezone: editingTransportPlan.end_timezone ?? null,
+ created_at: '',
+ updated_at: ''
+ }
+ }
+ : null}
+/>
+ (addingExperienceDay = null)}
+ variant="template"
+ planType={addingExperienceDay?.type ?? 'activity'}
+ mode="add"
+ formAction="?/addDayPlan"
+ dayId={addingExperienceDay?.dayId}
+/>
+ (editingExperiencePlan = null)}
+ variant="template"
+ planType={(editingExperiencePlan?.type as 'activity' | 'restaurant') ?? 'activity'}
+ mode="edit"
+ formAction="?/editDayPlan"
+ idFieldName="id"
+ idValue={editingExperiencePlan?.id}
+ initial={editingExperiencePlan
+ ? {
+ name: editingExperiencePlan.title,
+ description: editingExperiencePlan.description ?? editingExperiencePlan.notes,
+ booking_id: editingExperiencePlan.booking_id,
+ total_cost: editingExperiencePlan.total_cost,
+ website: editingExperiencePlan.website,
+ address: editingExperiencePlan.address,
+ contact_number: editingExperiencePlan.contact_number,
+ start_date: editingExperiencePlan.start_date,
+ start_time: editingExperiencePlan.start_time,
+ start_timezone: editingExperiencePlan.start_timezone,
+ end_date: editingExperiencePlan.end_date,
+ end_time: editingExperiencePlan.end_time,
+ end_timezone: editingExperiencePlan.end_timezone
+ }
+ : null}
+/>
+ (addingChecklistDay = null)}
+ variant="template"
+ checklistType={addingChecklistDay?.type ?? 'packing'}
+ mode="add"
+ formAction="?/addDayPlan"
+ dayId={addingChecklistDay?.dayId}
+/>
+ (editingChecklistPlan = null)}
+ variant="template"
+ checklistType={(editingChecklistPlan?.type as 'packing' | 'todo') ?? 'packing'}
+ mode="edit"
+ formAction="?/editDayPlan"
+ idFieldName="id"
+ idValue={editingChecklistPlan?.id}
+ initial={editingChecklistPlan
+ ? {
+ name: editingChecklistPlan.title,
+ items: (() => {
+ try {
+ const parsed = JSON.parse(editingChecklistPlan.items_json ?? '[]');
+ return Array.isArray(parsed)
+ ? parsed.map((item) => ({
+ content: typeof item === 'string' ? item : String(item?.content ?? '')
+ }))
+ : [];
+ } catch {
+ return [];
+ }
+ })()
+ }
+ : null}
+/>
+
+
{data.tour.name} Itinerary — Admin — Trips
@@ -136,8 +298,9 @@
Tour Operators
›
- {data.operator.name} {data.operator.name}
›
{data.tour.name} — Itinerary
@@ -282,14 +445,11 @@
{/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'}
+
+
-
-
-
-
-
-
- Save
-
-
- Cancel
-
-
-
-
- {:else}
-
-
-
- Transport
-
-
{plan.title}
- {#if plan.notes}
-
{plan.notes}
- {/if}
-
-
-
startEditPlan(plan)}
- class="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
- title="Edit"
- >
-
-
-
-
-
-
({ update }) => update()}>
-
- {
- if (!confirm('Remove this plan from the day?')) e.preventDefault();
- }}
- class="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-red-600"
- title="Remove"
- >
-
-
-
-
-
-
-
-
-
-
- {/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
-
-
-
-
-
- Add
-
-
- Cancel
-
-
-
-
- {:else}
-
- openAddPlan(day.id, 'transport')}
- class="rounded border border-dashed border-gray-200 px-2 py-1.5 text-xs text-gray-500 hover:border-sky-300 hover:bg-sky-50/50 hover:text-sky-700"
- >
- + Transport
-
- openAddLodging(day.id)}
- class="rounded border border-dashed border-gray-200 px-2 py-1.5 text-xs text-gray-500 hover:border-amber-300 hover:bg-amber-50/50 hover:text-amber-700"
- >
- + Lodging
-
-
+ {#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}
-
-
(showAddMenu = !showAddMenu)}
- class="flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
- >
-
-
-
-
- Add to trip
-
-
-
-
-
- {#if showAddMenu}
-
(showAddMenu = false)}
- onkeydown={(e) => e.key === 'Escape' && (showAddMenu = false)}
- >
-
-
-
{
- showAddTraveller = true;
- showAddMenu = false;
- }}
- class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
- >
-
- Travellers
-
-
- {#each menuItems as item}
-
- {@html item.icon}
- {item.label}
-
- {/each}
-
- {/if}
-
+
{/if}
(editing = true)}
@@ -462,8 +534,13 @@
tripName={trip.name}
onAddDestination={() => (showAddDestination = true)}
onAddTraveller={() => (showAddTraveller = true)}
- onAddFlight={() => (showAddFlight = true)}
+ onAddActivity={() => (showAddActivity = true)}
+ onAddTransportation={() => (showAddTransportation = true)}
onAddLodging={() => (showAddLodging = true)}
+ onAddRestaurant={() => (showAddRestaurant = true)}
+ onAddPackageTour={() => (showAddPackageTour = true)}
+ onAddPackingList={() => (showAddPackingList = true)}
+ onAddTodo={() => (showAddTodo = true)}
/>
{:else}
@@ -496,15 +573,17 @@
-
- {#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;