Import package tour activities and align day-indexed lodging scheduling
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m22s

This commit is contained in:
2026-02-24 21:37:22 -05:00
parent 1b4720be17
commit 4af96244ba
7 changed files with 156 additions and 49 deletions

View File

@@ -474,6 +474,7 @@ export interface OperatorTourDayPlan {
contact_number?: string | null;
// Packing/todo fields
items_json?: string | null;
is_optional?: number | null;
}
export function listPlansForOperatorTour(tourId: number): OperatorTourDayPlan[] {
@@ -525,7 +526,8 @@ export function createOperatorTourDayPlan(
end_time?: string | null;
end_timezone?: string | null;
},
checklistItemsJson?: string | null
checklistItemsJson?: string | null,
isOptional?: boolean
): 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 = ?',
@@ -535,13 +537,14 @@ export function createOperatorTourDayPlan(
const l = lodgingFields;
const t = transportFields;
let cols = 'operator_tour_day_id, type, title, notes, position';
let cols = 'operator_tour_day_id, type, title, notes, position, is_optional';
let vals: Array<string | number | null> = [
dayId,
type,
title.trim(),
notes?.trim() || null,
position
position,
isOptional ? 1 : 0
];
if (type === 'lodging' && l) {
cols += ', chain, address_line1, address_line2, city_name, country, country_code, postal_code';

View File

@@ -23,6 +23,7 @@ interface GAdventureComponent {
end_location?: { id?: string; href?: string; name?: string };
accommodation_dossier?: { id: string; href: string; name: string };
transport_dossier?: { id: string; href: string; name: string };
activity_dossier?: { id?: string; href?: string; name?: string };
}
interface GAdventureItineraryDay {
@@ -30,6 +31,9 @@ interface GAdventureItineraryDay {
label?: string;
summary?: string;
components?: GAdventureComponent[];
optional_activities?: Array<{
activity_dossier?: { id?: string; href?: string; name?: string };
}>;
}
// Accommodation dossier detail response (from following accommodation_dossier href)
@@ -160,11 +164,30 @@ async function dayPlansFromComponents(components: GAdventureComponent[]): Promis
if (details) lodgingFields = lodgingFieldsFromDossier(details);
}
plans.push({ type: 'lodging', title, notes: notes ?? undefined, lodgingFields });
} else if (type === 'ACTIVITY') {
const title = c.activity_dossier?.name?.trim() || c.summary?.trim() || 'Activity';
const notes = c.summary?.trim() || null;
plans.push({ type: 'activity', title, notes: notes ?? undefined, isOptional: false });
}
}
return plans;
}
function optionalActivityPlansFromDay(day: GAdventureItineraryDay): TourDayPlan[] {
const plans: TourDayPlan[] = [];
for (const opt of day.optional_activities ?? []) {
const title = opt.activity_dossier?.name?.trim();
if (!title) continue;
plans.push({
type: 'activity',
title,
notes: 'Optional activity',
isOptional: true
});
}
return plans;
}
export const GAdventuresProvider: TourProvider = {
name: 'G Adventures',
@@ -203,7 +226,9 @@ export const GAdventuresProvider: TourProvider = {
const days: TourDay[] = [];
for (const d of rawDays) {
const dayPlans = await dayPlansFromComponents(d.components ?? []);
const includedDayPlans = await dayPlansFromComponents(d.components ?? []);
const optionalActivityPlans = optionalActivityPlansFromDay(d);
const dayPlans = [...includedDayPlans, ...optionalActivityPlans];
days.push({
dayNumber: d.day,
title: d.label ?? '',

View File

@@ -31,9 +31,10 @@ export interface TourDayPlanTransportFields {
/** A transport or lodging plan attached to a day (from provider components) */
export interface TourDayPlan {
type: 'transport' | 'lodging';
type: 'transport' | 'lodging' | 'activity';
title: string;
notes?: string | null;
isOptional?: boolean;
/** When type is 'lodging', structured address/location from provider */
lodgingFields?: TourDayPlanLodgingFields;
/** When type is 'transport', structured movement details from provider */

View File

@@ -507,7 +507,8 @@ export function runMigrations(db: Database): void {
website TEXT,
address TEXT,
contact_number TEXT,
items_json TEXT
items_json TEXT,
is_optional INTEGER NOT NULL DEFAULT 0
)
`);
db.run(`
@@ -515,13 +516,13 @@ export function runMigrations(db: Database): void {
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,
start_location, end_location
start_location, end_location, is_optional
)
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,
NULL as start_location, NULL as end_location
NULL as start_location, NULL as end_location, 0 as is_optional
FROM operator_tour_day_plans
`);
db.run('DROP TABLE operator_tour_day_plans');
@@ -555,7 +556,8 @@ export function runMigrations(db: Database): void {
'website TEXT',
'address TEXT',
'contact_number TEXT',
'items_json TEXT'
'items_json TEXT',
'is_optional INTEGER NOT NULL DEFAULT 0'
]) {
try {
db.run(`ALTER TABLE operator_tour_day_plans ADD COLUMN ${col}`);

View File

@@ -5,6 +5,7 @@ import { createPlan } from './plans.js';
import { createOtherTransport } from './other-transports.js';
import { createExperience } from './experiences.js';
import { createChecklist } from './checklists.js';
import { createLodging } from './lodgings.js';
import { listDaysForOperatorTour, listPlansForOperatorTour } from './admin/data.js';
export interface PackageTour {
@@ -308,6 +309,12 @@ export function updateTourDay(input: {
]);
}
function addDaysToDate(date: string, offset: number): string {
const [year, month, day] = date.split('-').map(Number);
const ts = Date.UTC(year, month - 1, day + offset);
return new Date(ts).toISOString().slice(0, 10);
}
export function cloneTemplateDaysToTour(input: {
operatorTourId: number;
tripId: string;
@@ -316,7 +323,13 @@ export function cloneTemplateDaysToTour(input: {
}): void {
const days = listDaysForOperatorTour(input.operatorTourId);
const allDayPlans = listPlansForOperatorTour(input.operatorTourId);
const tourStartDate =
db.get<{ start_date: string | null }>(
'SELECT start_date FROM package_tours WHERE plan_id = ?',
[input.tourPlanId]
)?.start_date ?? null;
for (const day of days) {
const dayDate = tourStartDate ? addDaysToDate(tourStartDate, day.day_number - 1) : null;
const newDay = createTourDay({
tripId: input.tripId,
userId: input.userId,
@@ -331,7 +344,7 @@ export function cloneTemplateDaysToTour(input: {
tripId: input.tripId,
userId: input.userId,
parentId: newDay.plan_id,
status: 'idea',
status: dp.is_optional ? 'idea' : 'confirmed',
title: dp.title,
notes: dp.notes ?? undefined,
startDate: dp.start_date ?? undefined,
@@ -350,7 +363,7 @@ export function cloneTemplateDaysToTour(input: {
type: dp.type,
name: dp.title,
parentId: newDay.plan_id,
status: 'idea',
status: dp.type === 'activity' ? (dp.is_optional ? 'idea' : 'confirmed') : 'idea',
bookingId: dp.booking_id ?? undefined,
totalCost: dp.total_cost ?? undefined,
description: dp.description ?? dp.notes ?? undefined,
@@ -364,6 +377,27 @@ export function cloneTemplateDaysToTour(input: {
endTime: dp.end_time ?? undefined,
endTimezone: dp.end_timezone ?? undefined
});
} else if (dp.type === 'lodging') {
createLodging({
tripId: input.tripId,
userId: input.userId,
parentId: newDay.plan_id,
status: dp.is_optional ? 'idea' : 'confirmed',
name: dp.title,
chain: dp.chain ?? undefined,
checkInDate: dp.start_date ?? dayDate ?? undefined,
checkInTime: dp.start_time ?? undefined,
checkInTimezone: dp.start_timezone ?? undefined,
checkOutDate: dp.end_date ?? dayDate ?? undefined,
checkOutTime: dp.end_time ?? undefined,
checkOutTimezone: dp.end_timezone ?? undefined,
addressLine1: dp.address_line1 ?? undefined,
addressLine2: dp.address_line2 ?? undefined,
cityName: dp.city_name ?? undefined,
country: dp.country ?? undefined,
countryCode: dp.country_code ?? undefined,
postalCode: dp.postal_code ?? undefined
});
} else if (dp.type === 'packing' || dp.type === 'todo') {
let items: string[] = [];
try {

View File

@@ -101,9 +101,10 @@ export const actions: Actions = {
title: string;
description: string;
dayPlans?: Array<{
type: 'transport' | 'lodging';
type: 'transport' | 'lodging' | 'activity';
title: string;
notes?: string | null;
isOptional?: boolean;
lodgingFields?: {
chain?: string | null;
address_line1?: string | null;
@@ -151,7 +152,10 @@ export const actions: Actions = {
plan.title,
plan.notes ?? undefined,
plan.type === 'lodging' && plan.lodgingFields ? plan.lodgingFields : undefined,
plan.type === 'transport' && plan.transportFields ? plan.transportFields : undefined
plan.type === 'transport' && plan.transportFields ? plan.transportFields : undefined,
undefined,
undefined,
plan.isOptional ?? false
);
}
}

View File

@@ -304,18 +304,18 @@
? { start: tripDates[dayIndex], end: tripDates[dayIndex] }
: null
: scheduleView === 'week'
? weekRanges[weekIndex] ?? null
? (weekRanges[weekIndex] ?? null)
: null
);
const filteredTimeline = $derived(
currentRange
? {
scheduled: timeline.scheduled.filter(
(group) => group.date >= currentRange.start && group.date <= currentRange.end
),
unscheduled: []
}
scheduled: timeline.scheduled.filter(
(group) => group.date >= currentRange.start && group.date <= currentRange.end
),
unscheduled: []
}
: timeline
);
@@ -357,9 +357,7 @@
);
const visibleLodgings = $derived(
filteredPlanIds
? lodgings.filter((lodging) => filteredPlanIds.has(lodging.plan_id))
: lodgings
filteredPlanIds ? lodgings.filter((lodging) => filteredPlanIds.has(lodging.plan_id)) : lodgings
);
const visibleActivities = $derived(
@@ -868,7 +866,7 @@
{#if scheduleView === 'day' && tripDates.length > 1}
<button
type="button"
class="absolute left-3 top-1/2 -translate-y-1/2 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
class="absolute top-1/2 left-3 -translate-y-1/2 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
onclick={() => (dayIndex = Math.max(0, dayIndex - 1))}
disabled={dayIndex === 0}
aria-label="Previous day"
@@ -879,7 +877,7 @@
{#if scheduleView === 'week' && weekRanges.length > 1}
<button
type="button"
class="absolute left-3 top-1/2 -translate-y-1/2 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
class="absolute top-1/2 left-3 -translate-y-1/2 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
onclick={() => (weekIndex = Math.max(0, weekIndex - 1))}
disabled={weekIndex === 0}
aria-label="Previous week"
@@ -905,7 +903,7 @@
{#if scheduleView === 'day' && tripDates.length > 1}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
class="absolute top-1/2 right-3 -translate-y-1/2 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
onclick={() => (dayIndex = Math.min(tripDates.length - 1, dayIndex + 1))}
disabled={dayIndex >= tripDates.length - 1}
aria-label="Next day"
@@ -916,7 +914,7 @@
{#if scheduleView === 'week' && weekRanges.length > 1}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
class="absolute top-1/2 right-3 -translate-y-1/2 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
onclick={() => (weekIndex = Math.min(weekRanges.length - 1, weekIndex + 1))}
disabled={weekIndex >= weekRanges.length - 1}
aria-label="Next week"
@@ -1270,6 +1268,26 @@
onDelete={submitForm}
/>
</form>
{:else}
{@const formId = `remove-plan-timeline-${plan.id}`}
{@const submitForm = () => {
const form = document.getElementById(formId) as HTMLFormElement;
form?.requestSubmit();
}}
<form
id={formId}
method="POST"
action="?/removePlan"
use:enhance={() => {
return ({ update }) => {
update();
};
}}
class="contents"
>
<input type="hidden" name="plan_id" value={plan.id} />
<PlanCard {plan} onDelete={submitForm} />
</form>
{/if}
</div>
</div>
@@ -1288,8 +1306,8 @@
<div class="h-px flex-1 bg-gray-200"></div>
</div>
<div class="mt-4 flex flex-col gap-4">
{#each filteredTimeline.unscheduled as entry (entry.id)}
{#if entry.kind === 'day'}
{#each filteredTimeline.unscheduled as entry (entry.id)}
{#if entry.kind === 'day'}
<div
class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2"
style="border-left: 4px solid {entry.tour.highlightColor ?? '#E5E7EB'}"
@@ -1597,6 +1615,26 @@
onDelete={submitForm}
/>
</form>
{:else}
{@const formId = `remove-plan-timeline-${plan.id}`}
{@const submitForm = () => {
const form = document.getElementById(formId) as HTMLFormElement;
form?.requestSubmit();
}}
<form
id={formId}
method="POST"
action="?/removePlan"
use:enhance={() => {
return ({ update }) => {
update();
};
}}
class="contents"
>
<input type="hidden" name="plan_id" value={plan.id} />
<PlanCard {plan} onDelete={submitForm} />
</form>
{/if}
</div>
</div>
@@ -1674,20 +1712,20 @@
</div>
<!-- Transportation section -->
{#if
visibleFlightBookings.length > 0 ||
visiblePrivateVehicles.length > 0 ||
visibleOtherTransports.length > 0
}
{#if visibleFlightBookings.length > 0 || visiblePrivateVehicles.length > 0 || visibleOtherTransports.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Transportation
</h2>
<div class="flex flex-col gap-3">
{#each visiblePlans.filter((p) => p.type === 'transport') as plan (plan.id)}
{@const flightBooking = visibleFlightBookings.find((b) => b.plan_id === plan.id)}
{@const privateVehicle = visiblePrivateVehicles.find((pv) => pv.plan_id === plan.id)}
{@const otherTransport = visibleOtherTransports.find((ot) => ot.plan_id === plan.id)}
{#each visiblePlans.filter((p) => p.type === 'transport') as plan (plan.id)}
{@const flightBooking = visibleFlightBookings.find((b) => b.plan_id === plan.id)}
{@const privateVehicle = visiblePrivateVehicles.find(
(pv) => pv.plan_id === plan.id
)}
{@const otherTransport = visibleOtherTransports.find(
(ot) => ot.plan_id === plan.id
)}
{@const packageTourContext = byTypeTourContextByPlanId.get(plan.id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
@@ -1795,8 +1833,8 @@
Attractions & Activities
</h2>
<div class="flex flex-col gap-3">
{#each visibleActivities as activity (activity.id)}
{@const plan = visiblePlans.find((p) => p.id === activity.plan_id)}
{#each visibleActivities as activity (activity.id)}
{@const plan = visiblePlans.find((p) => p.id === activity.plan_id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
{@const packageTourContext = byTypeTourContextByPlanId.get(plan.id)}
@@ -1871,8 +1909,8 @@
Restaurants
</h2>
<div class="flex flex-col gap-3">
{#each visibleRestaurants as restaurant (restaurant.id)}
{@const plan = visiblePlans.find((p) => p.id === restaurant.plan_id)}
{#each visibleRestaurants as restaurant (restaurant.id)}
{@const plan = visiblePlans.find((p) => p.id === restaurant.plan_id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
{@const packageTourContext = byTypeTourContextByPlanId.get(plan.id)}
@@ -1947,8 +1985,8 @@
Packing Lists
</h2>
<div class="flex flex-col gap-3">
{#each visiblePackingLists as list (list.id)}
{@const plan = visiblePlans.find((p) => p.id === list.plan_id)}
{#each visiblePackingLists as list (list.id)}
{@const plan = visiblePlans.find((p) => p.id === list.plan_id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
{@const submitForm = () => {
@@ -1988,8 +2026,8 @@
To-dos
</h2>
<div class="flex flex-col gap-3">
{#each visibleTodos as list (list.id)}
{@const plan = visiblePlans.find((p) => p.id === list.plan_id)}
{#each visibleTodos as list (list.id)}
{@const plan = visiblePlans.find((p) => p.id === list.plan_id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
{@const submitForm = () => {
@@ -2029,8 +2067,8 @@
Lodgings
</h2>
<div class="flex flex-col gap-3">
{#each visibleLodgings as lodging (lodging.id)}
{@const plan = visiblePlans.find((p) => p.id === lodging.plan_id)}
{#each visibleLodgings as lodging (lodging.id)}
{@const plan = visiblePlans.find((p) => p.id === lodging.plan_id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
{@const packageTourContext = byTypeTourContextByPlanId.get(plan.id)}
@@ -2105,8 +2143,8 @@
Package Tours
</h2>
<div class="flex flex-col gap-3">
{#each visiblePackageTours as tour (tour.id)}
{@const plan = visiblePlans.find((p) => p.id === tour.plan_id)}
{#each visiblePackageTours as tour (tour.id)}
{@const plan = visiblePlans.find((p) => p.id === tour.plan_id)}
{#if plan}
{@const formId = `remove-plan-${plan.id}`}
{@const submitForm = () => {