Improve G Adventures transport import details and location mapping
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m23s

This commit is contained in:
2026-02-24 21:18:22 -05:00
parent b2556ae4a7
commit 1b4720be17
13 changed files with 254 additions and 32 deletions

View File

@@ -26,6 +26,8 @@
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
let title = $state('');
let notes = $state('');
let startLocation = $state('');
let endLocation = $state('');
let startDate = $state('');
let startTime = $state('');
let startTimezone = $state('');
@@ -37,6 +39,8 @@
status = 'idea';
title = '';
notes = '';
startLocation = '';
endLocation = '';
startDate = '';
startTime = '';
startTimezone = '';
@@ -108,6 +112,35 @@
></textarea>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="other_start_location" class="text-sm font-medium text-gray-700">
Start location
</label>
<input
id="other_start_location"
name="start_location"
type="text"
bind:value={startLocation}
placeholder="e.g. Mariscal Sucre International Airport"
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"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="other_end_location" class="text-sm font-medium text-gray-700">
End location
</label>
<input
id="other_end_location"
name="end_location"
type="text"
bind:value={endLocation}
placeholder="e.g. Quito"
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"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="flex flex-col gap-1.5">
<label for="other_start_date" class="text-sm font-medium text-gray-700">Start date</label>

View File

@@ -40,6 +40,8 @@
let status = $state<'idea' | 'tentative' | 'confirmed'>(planStatus);
let title = $state(planTitle ?? '');
let notes = $state(planNotes ?? '');
let startLocation = $state(otherTransport.start_location ?? '');
let endLocation = $state(otherTransport.end_location ?? '');
let startDate = $state(otherTransport.start_date ?? '');
let startTime = $state(otherTransport.start_time ?? '');
let startTimezone = $state(otherTransport.start_timezone ?? '');
@@ -88,6 +90,33 @@
></textarea>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="edit_other_start_location" class="text-sm font-medium text-gray-700">
Start location
</label>
<input
id="edit_other_start_location"
name="start_location"
type="text"
bind:value={startLocation}
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"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="edit_other_end_location" class="text-sm font-medium text-gray-700">
End location
</label>
<input
id="edit_other_end_location"
name="end_location"
type="text"
bind:value={endLocation}
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"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="flex flex-col gap-1.5">
<label for="edit_other_start_date" class="text-sm font-medium text-gray-700"

View File

@@ -68,6 +68,12 @@
{#if plan.notes}
<p class="mt-0.5 text-sm text-gray-500">{plan.notes}</p>
{/if}
{#if otherTransport.start_location || otherTransport.end_location}
<p class="mt-1 text-sm text-gray-700">
{otherTransport.start_location || 'Unknown start'} -> {otherTransport.end_location ||
'Unknown end'}
</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
{#if onEdit}

View File

@@ -463,6 +463,8 @@ export interface OperatorTourDayPlan {
end_date?: string | null;
end_time?: string | null;
end_timezone?: string | null;
start_location?: string | null;
end_location?: string | null;
// Activity/restaurant fields
booking_id?: string | null;
total_cost?: number | null;
@@ -506,6 +508,8 @@ export function createOperatorTourDayPlan(
end_date?: string | null;
end_time?: string | null;
end_timezone?: string | null;
start_location?: string | null;
end_location?: string | null;
},
experienceFields?: {
booking_id?: string | null;
@@ -554,7 +558,7 @@ export function createOperatorTourDayPlan(
}
if (type === 'transport') {
cols +=
', transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone';
', transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone, start_location, end_location';
vals = [
...vals,
t?.transport_kind?.trim() || 'other',
@@ -563,7 +567,9 @@ export function createOperatorTourDayPlan(
t?.start_timezone?.trim() || null,
t?.end_date?.trim() || null,
t?.end_time?.trim() || null,
t?.end_timezone?.trim() || null
t?.end_timezone?.trim() || null,
t?.start_location?.trim() || null,
t?.end_location?.trim() || null
];
}
if (type === 'activity' || type === 'restaurant') {
@@ -616,6 +622,8 @@ export function updateOperatorTourDayPlan(
end_date?: string | null;
end_time?: string | null;
end_timezone?: string | null;
start_location?: string | null;
end_location?: string | null;
booking_id?: string | null;
total_cost?: number | null;
description?: string | null;
@@ -649,6 +657,8 @@ export function updateOperatorTourDayPlan(
optional('end_date', updates.end_date ?? undefined);
optional('end_time', updates.end_time ?? undefined);
optional('end_timezone', updates.end_timezone ?? undefined);
optional('start_location', updates.start_location ?? undefined);
optional('end_location', updates.end_location ?? undefined);
optional('booking_id', updates.booking_id ?? undefined);
optional('total_cost', updates.total_cost ?? undefined);
optional('description', updates.description ?? undefined);

View File

@@ -4,6 +4,7 @@ import type {
TourDay,
TourDayPlan,
TourDayPlanLodgingFields,
TourDayPlanTransportFields,
TourProvider,
TourSearchResult
} from './types.js';
@@ -18,6 +19,8 @@ function authHeaders() {
interface GAdventureComponent {
type?: string;
summary?: string;
start_location?: { id?: string; href?: string; name?: string };
end_location?: { id?: string; href?: string; name?: string };
accommodation_dossier?: { id: string; href: string; name: string };
transport_dossier?: { id: string; href: string; name: string };
}
@@ -38,11 +41,22 @@ interface GAdventureAccommodationDossier {
address_line_3?: string;
postal_code?: string;
city?: { name?: string };
country?: { name?: string };
country?: { id?: string; name?: string };
};
}
async function fetchAccommodationDetails(href: string): Promise<GAdventureAccommodationDossier | null> {
interface GAdventureTransportDossier {
name?: string;
details?: Array<{
body?: string;
detail_type?: { code?: string; label?: string };
}>;
dossier_segment?: { code?: string; label?: string };
}
async function fetchAccommodationDetails(
href: string
): Promise<GAdventureAccommodationDossier | null> {
try {
const res = await fetch(href, { headers: authHeaders() });
if (!res.ok) return null;
@@ -52,7 +66,37 @@ async function fetchAccommodationDetails(href: string): Promise<GAdventureAccomm
}
}
function lodgingFieldsFromDossier(dossier: GAdventureAccommodationDossier): TourDayPlanLodgingFields | undefined {
async function fetchTransportDetails(href: string): Promise<GAdventureTransportDossier | null> {
try {
const res = await fetch(href, { headers: authHeaders() });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
function summaryFromTransportDossier(dossier: GAdventureTransportDossier | null): string | null {
if (!dossier?.details?.length) return null;
const summary = dossier.details.find((d) => d.detail_type?.code === 'COMMON__SUMMARY');
const body = summary?.body?.trim();
return body || null;
}
function transportKindFromSegmentCode(code: string | undefined): string {
switch (code) {
case 'TRANSPORT__AIR':
return 'flight';
case 'TRANSPORT__AUTOMOBILE':
return 'private_vehicle';
default:
return 'other';
}
}
function lodgingFieldsFromDossier(
dossier: GAdventureAccommodationDossier
): TourDayPlanLodgingFields | undefined {
const addr = dossier.address;
if (!addr) return undefined;
const address_line1 = addr.address_line_1?.trim() || null;
@@ -61,14 +105,7 @@ function lodgingFieldsFromDossier(dossier: GAdventureAccommodationDossier): Tour
const country = addr.country?.name?.trim() || null;
const country_code = addr.country?.id?.trim() || null;
const postal_code = addr.postal_code?.trim() || null;
if (
!address_line1 &&
!address_line2 &&
!city_name &&
!country &&
!country_code &&
!postal_code
) {
if (!address_line1 && !address_line2 && !city_name && !country && !country_code && !postal_code) {
return undefined;
}
return {
@@ -83,13 +120,34 @@ function lodgingFieldsFromDossier(dossier: GAdventureAccommodationDossier): Tour
async function dayPlansFromComponents(components: GAdventureComponent[]): Promise<TourDayPlan[]> {
const plans: TourDayPlan[] = [];
const transportDossierCache = new Map<string, GAdventureTransportDossier | null>();
for (const c of components ?? []) {
const type = c.type?.toUpperCase();
if (type === 'TRANSPORT' && c.transport_dossier) {
let transportDossier: GAdventureTransportDossier | null = null;
if (c.transport_dossier.href) {
transportDossier = transportDossierCache.get(c.transport_dossier.href) ?? null;
if (!transportDossierCache.has(c.transport_dossier.href)) {
transportDossier = await fetchTransportDetails(c.transport_dossier.href);
transportDossierCache.set(c.transport_dossier.href, transportDossier);
}
}
const title =
transportDossier?.name?.trim() ||
c.transport_dossier.name?.trim() ||
c.summary?.trim() ||
'Transport';
const notes = (summaryFromTransportDossier(transportDossier) ?? c.summary?.trim()) || null;
const transportFields: TourDayPlanTransportFields = {
transport_kind: transportKindFromSegmentCode(transportDossier?.dossier_segment?.code),
start_location: c.start_location?.name?.trim() || null,
end_location: c.end_location?.name?.trim() || null
};
plans.push({
type: 'transport',
title: c.transport_dossier.name?.trim() || c.summary?.trim() || 'Transport',
notes: c.summary?.trim() || null
title,
notes,
transportFields
});
} else if (type === 'ACCOMMODATION' && c.accommodation_dossier) {
const dossier = c.accommodation_dossier;

View File

@@ -16,6 +16,19 @@ export interface TourDayPlanLodgingFields {
postal_code?: string | null;
}
/** Transport-specific fields (from provider transport components) */
export interface TourDayPlanTransportFields {
transport_kind?: string | null;
start_location?: string | null;
end_location?: 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;
}
/** A transport or lodging plan attached to a day (from provider components) */
export interface TourDayPlan {
type: 'transport' | 'lodging';
@@ -23,6 +36,8 @@ export interface TourDayPlan {
notes?: string | null;
/** When type is 'lodging', structured address/location from provider */
lodgingFields?: TourDayPlanLodgingFields;
/** When type is 'transport', structured movement details from provider */
transportFields?: TourDayPlanTransportFields;
}
export interface TourDay {

View File

@@ -277,10 +277,19 @@ export function runMigrations(db: Database): void {
end_date TEXT,
end_time TEXT,
end_timezone TEXT,
start_location TEXT,
end_location TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
for (const col of ['start_location TEXT', 'end_location TEXT']) {
try {
db.run(`ALTER TABLE other_transports ADD COLUMN ${col}`);
} catch {
/* already exists */
}
}
// Activity / restaurant details - links to a plan
db.run(`
@@ -490,6 +499,8 @@ export function runMigrations(db: Database): void {
end_date TEXT,
end_time TEXT,
end_timezone TEXT,
start_location TEXT,
end_location TEXT,
booking_id TEXT,
total_cost REAL,
description TEXT,
@@ -503,12 +514,14 @@ export function runMigrations(db: Database): void {
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
transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone,
start_location, end_location
)
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
transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone,
NULL as start_location, NULL as end_location
FROM operator_tour_day_plans
`);
db.run('DROP TABLE operator_tour_day_plans');
@@ -534,6 +547,8 @@ export function runMigrations(db: Database): void {
'end_date TEXT',
'end_time TEXT',
'end_timezone TEXT',
'start_location TEXT',
'end_location TEXT',
'booking_id TEXT',
'total_cost REAL',
'description TEXT',

View File

@@ -11,6 +11,8 @@ export interface OtherTransport {
end_date: string | null;
end_time: string | null;
end_timezone: string | null;
start_location: string | null;
end_location: string | null;
created_at: string;
updated_at: string;
}
@@ -28,6 +30,8 @@ export interface CreateOtherTransportInput {
endDate?: string;
endTime?: string;
endTimezone?: string;
startLocation?: string;
endLocation?: string;
}
export interface UpdateOtherTransportInput {
@@ -42,6 +46,8 @@ export interface UpdateOtherTransportInput {
endDate?: string;
endTime?: string;
endTimezone?: string;
startLocation?: string;
endLocation?: string;
}
export function createOtherTransport(input: CreateOtherTransportInput): OtherTransport {
@@ -72,8 +78,9 @@ export function createOtherTransport(input: CreateOtherTransportInput): OtherTra
`INSERT INTO other_transports (
id, plan_id,
start_date, start_time, start_timezone,
end_date, end_time, end_timezone
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
end_date, end_time, end_timezone,
start_location, end_location
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
otherTransportId,
planId,
@@ -82,7 +89,9 @@ export function createOtherTransport(input: CreateOtherTransportInput): OtherTra
input.startTimezone ?? null,
input.endDate ?? null,
input.endTime ?? null,
input.endTimezone ?? null
input.endTimezone ?? null,
input.startLocation?.trim() || null,
input.endLocation?.trim() || null
]
);
@@ -100,18 +109,14 @@ export function updateOtherTransport(input: UpdateOtherTransportInput): void {
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
]
[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 = ?,
start_location = ?, end_location = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
@@ -121,6 +126,8 @@ export function updateOtherTransport(input: UpdateOtherTransportInput): void {
input.endDate ?? null,
input.endTime ?? null,
input.endTimezone ?? null,
input.startLocation?.trim() || null,
input.endLocation?.trim() || null,
input.otherTransportId
]
);

View File

@@ -339,7 +339,9 @@ export function cloneTemplateDaysToTour(input: {
startTimezone: dp.start_timezone ?? undefined,
endDate: dp.end_date ?? undefined,
endTime: dp.end_time ?? undefined,
endTimezone: dp.end_timezone ?? undefined
endTimezone: dp.end_timezone ?? undefined,
startLocation: dp.start_location ?? undefined,
endLocation: dp.end_location ?? undefined
});
} else if (dp.type === 'activity' || dp.type === 'restaurant') {
createExperience({

View File

@@ -100,7 +100,31 @@ export const actions: Actions = {
dayNumber: number;
title: string;
description: string;
dayPlans?: { type: 'transport' | 'lodging'; title: string; notes?: string | null }[];
dayPlans?: Array<{
type: 'transport' | 'lodging';
title: string;
notes?: string | null;
lodgingFields?: {
chain?: string | null;
address_line1?: string | null;
address_line2?: string | null;
city_name?: string | null;
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
};
transportFields?: {
transport_kind?: string | null;
start_location?: string | null;
end_location?: 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;
};
}>;
}[] = [];
if (provider && providerId) {
@@ -126,7 +150,8 @@ export const actions: Actions = {
plan.type,
plan.title,
plan.notes ?? undefined,
plan.type === 'lodging' && plan.lodgingFields ? plan.lodgingFields : undefined
plan.type === 'lodging' && plan.lodgingFields ? plan.lodgingFields : undefined,
plan.type === 'transport' && plan.transportFields ? plan.transportFields : undefined
);
}
}

View File

@@ -111,6 +111,8 @@ export const actions: Actions = {
end_date: string | null;
end_time: string | null;
end_timezone: string | null;
start_location: string | null;
end_location: string | null;
}
| undefined;
let experienceFields:
@@ -175,7 +177,9 @@ export const actions: Actions = {
start_timezone: null,
end_date: arrDate,
end_time: arrTime,
end_timezone: null
end_timezone: null,
start_location: null,
end_location: null
};
} else if (transportationType === 'private_vehicle') {
const startAddress = get('start_address');
@@ -188,7 +192,9 @@ export const actions: Actions = {
start_timezone: get('departure_timezone') ?? null,
end_date: get('arrival_date') ?? null,
end_time: get('arrival_time') ?? null,
end_timezone: get('arrival_timezone') ?? null
end_timezone: get('arrival_timezone') ?? null,
start_location: startAddress ?? null,
end_location: endAddress ?? null
};
} else {
transportFields = {
@@ -198,7 +204,9 @@ export const actions: Actions = {
start_timezone: get('start_timezone') ?? null,
end_date: get('end_date') ?? null,
end_time: get('end_time') ?? null,
end_timezone: get('end_timezone') ?? null
end_timezone: get('end_timezone') ?? null,
start_location: get('start_location') ?? null,
end_location: get('end_location') ?? null
};
}
}
@@ -290,6 +298,8 @@ export const actions: Actions = {
end_date: get('end_date') ?? undefined,
end_time: get('end_time') ?? undefined,
end_timezone: get('end_timezone') ?? undefined,
start_location: get('start_location') ?? undefined,
end_location: get('end_location') ?? undefined,
booking_id: get('booking_id') ?? undefined,
total_cost: (() => {
const raw = get('total_cost');

View File

@@ -195,6 +195,8 @@
end_date: editingTransportPlan.end_date ?? null,
end_time: editingTransportPlan.end_time ?? null,
end_timezone: editingTransportPlan.end_timezone ?? null,
start_location: editingTransportPlan.start_location ?? null,
end_location: editingTransportPlan.end_location ?? null,
created_at: '',
updated_at: ''
}
@@ -474,6 +476,8 @@
end_date: plan.end_date ?? null,
end_time: plan.end_time ?? null,
end_timezone: plan.end_timezone ?? null,
start_location: plan.start_location ?? null,
end_location: plan.end_location ?? null,
created_at: '',
updated_at: ''
}}

View File

@@ -279,6 +279,8 @@ export const actions: Actions = {
if (transportationType === 'other') {
const title = (data.get('title') as string)?.trim();
const notes = (data.get('notes') as string)?.trim() || undefined;
const startLocation = (data.get('start_location') as string)?.trim() || undefined;
const endLocation = (data.get('end_location') 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;
@@ -296,6 +298,8 @@ export const actions: Actions = {
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
title,
notes,
startLocation,
endLocation,
startDate,
startTime,
startTimezone,
@@ -484,6 +488,8 @@ export const actions: Actions = {
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 startLocation = (data.get('start_location') as string)?.trim() || undefined;
const endLocation = (data.get('end_location') 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;
@@ -501,6 +507,8 @@ export const actions: Actions = {
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
title,
notes,
startLocation,
endLocation,
startDate,
startTime,
startTimezone,