All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m21s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net> Reviewed-on: #48 Co-authored-by: AI Agent <ai-agent@campbellwireless.net> Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
296 lines
8.6 KiB
TypeScript
296 lines
8.6 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { buildTripTimeline } from './timeline.js';
|
|
import type { Plan } from './server/plans.js';
|
|
import type { PackageTour, TourDay } from './server/package-tours.js';
|
|
|
|
let planCounter = 0;
|
|
|
|
function makePlan(overrides: Partial<Plan>): Plan {
|
|
planCounter += 1;
|
|
return {
|
|
id: overrides.id ?? `plan-${planCounter}`,
|
|
trip_id: 'trip-1',
|
|
user_id: 'user-1',
|
|
type: overrides.type ?? 'destination',
|
|
status: overrides.status ?? 'idea',
|
|
parent_id: overrides.parent_id ?? null,
|
|
title: overrides.title ?? 'Plan',
|
|
notes: overrides.notes ?? null,
|
|
city_id: overrides.city_id ?? null,
|
|
city_name: overrides.city_name ?? null,
|
|
country: overrides.country ?? null,
|
|
country_code: overrides.country_code ?? null,
|
|
start_date: overrides.start_date ?? null,
|
|
end_date: overrides.end_date ?? null,
|
|
position: overrides.position ?? 0,
|
|
created_at: overrides.created_at ?? '2026-01-01T00:00:00Z',
|
|
updated_at: overrides.updated_at ?? '2026-01-01T00:00:00Z'
|
|
};
|
|
}
|
|
|
|
function makeTour(overrides: Partial<PackageTour>): PackageTour {
|
|
return {
|
|
id: overrides.id ?? 'tour-1',
|
|
plan_id: overrides.plan_id ?? 'tour-plan-1',
|
|
operator_name: overrides.operator_name ?? 'Adventure Co',
|
|
tour_name: overrides.tour_name ?? 'Rainforest',
|
|
confirmation_number: overrides.confirmation_number ?? null,
|
|
start_date: overrides.start_date ?? null,
|
|
start_time: overrides.start_time ?? null,
|
|
start_timezone: overrides.start_timezone ?? null,
|
|
end_date: overrides.end_date ?? null,
|
|
end_time: overrides.end_time ?? null,
|
|
end_timezone: overrides.end_timezone ?? null,
|
|
price: overrides.price ?? null,
|
|
currency: overrides.currency ?? 'USD',
|
|
created_at: overrides.created_at ?? '2026-01-01T00:00:00Z',
|
|
updated_at: overrides.updated_at ?? '2026-01-01T00:00:00Z'
|
|
};
|
|
}
|
|
|
|
function makeTourDay(overrides: Partial<TourDay>): TourDay {
|
|
return {
|
|
plan_id: overrides.plan_id ?? 'day-plan-1',
|
|
day_number: overrides.day_number ?? 1,
|
|
title: overrides.title ?? null,
|
|
notes: overrides.notes ?? null,
|
|
childPlans: overrides.childPlans ?? []
|
|
};
|
|
}
|
|
|
|
describe('buildTripTimeline', () => {
|
|
beforeEach(() => {
|
|
planCounter = 0;
|
|
});
|
|
|
|
it('orders scheduled plans by date and time', () => {
|
|
const early = makePlan({ type: 'activity', start_date: '2026-05-01' });
|
|
const later = makePlan({ type: 'activity', start_date: '2026-05-01' });
|
|
const nextDay = makePlan({ type: 'destination', start_date: '2026-05-02' });
|
|
|
|
const timeline = buildTripTimeline({
|
|
plans: [nextDay, later, early],
|
|
flightBookings: [],
|
|
privateVehicles: [],
|
|
otherTransports: [],
|
|
lodgings: [],
|
|
activities: [
|
|
{
|
|
plan_id: early.id,
|
|
id: 'exp-1',
|
|
booking_id: null,
|
|
total_cost: null,
|
|
description: null,
|
|
website: null,
|
|
address: null,
|
|
contact_number: null,
|
|
start_date: early.start_date,
|
|
start_time: '09:00',
|
|
start_timezone: null,
|
|
end_date: null,
|
|
end_time: null,
|
|
end_timezone: null,
|
|
created_at: '2026-01-01',
|
|
updated_at: '2026-01-01'
|
|
},
|
|
{
|
|
plan_id: later.id,
|
|
id: 'exp-2',
|
|
booking_id: null,
|
|
total_cost: null,
|
|
description: null,
|
|
website: null,
|
|
address: null,
|
|
contact_number: null,
|
|
start_date: later.start_date,
|
|
start_time: '10:30',
|
|
start_timezone: null,
|
|
end_date: null,
|
|
end_time: null,
|
|
end_timezone: null,
|
|
created_at: '2026-01-01',
|
|
updated_at: '2026-01-01'
|
|
}
|
|
],
|
|
restaurants: [],
|
|
packingLists: [],
|
|
todos: [],
|
|
packageTours: []
|
|
});
|
|
|
|
expect(timeline.scheduled).toHaveLength(2);
|
|
expect(timeline.scheduled[0].date).toBe('2026-05-01');
|
|
const ids = timeline.scheduled[0].entries
|
|
.filter((entry) => entry.kind === 'plan')
|
|
.map((entry) => (entry.kind === 'plan' ? entry.plan.id : ''));
|
|
expect(ids).toEqual([early.id, later.id]);
|
|
expect(timeline.scheduled[1].date).toBe('2026-05-02');
|
|
});
|
|
|
|
it('maps tour day items to tour start date offsets', () => {
|
|
const tourPlan = makePlan({ id: 'tour-plan-1', type: 'tour' });
|
|
const day1 = makeTourDay({ plan_id: 'day-1', day_number: 1, title: 'Arrival' });
|
|
const day2 = makeTourDay({ plan_id: 'day-2', day_number: 2, title: 'Hike' });
|
|
const activityPlan = makePlan({ id: 'activity-1', type: 'activity', parent_id: day2.plan_id });
|
|
const tour = makeTour({ plan_id: tourPlan.id, start_date: '2027-01-01' });
|
|
|
|
const timeline = buildTripTimeline({
|
|
plans: [tourPlan, activityPlan],
|
|
flightBookings: [],
|
|
privateVehicles: [],
|
|
otherTransports: [],
|
|
lodgings: [],
|
|
activities: [
|
|
{
|
|
plan_id: activityPlan.id,
|
|
id: 'exp-3',
|
|
booking_id: null,
|
|
total_cost: null,
|
|
description: null,
|
|
website: null,
|
|
address: null,
|
|
contact_number: null,
|
|
start_date: null,
|
|
start_time: null,
|
|
start_timezone: null,
|
|
end_date: null,
|
|
end_time: null,
|
|
end_timezone: null,
|
|
created_at: '2026-01-01',
|
|
updated_at: '2026-01-01'
|
|
}
|
|
],
|
|
restaurants: [],
|
|
packingLists: [],
|
|
todos: [],
|
|
packageTours: [
|
|
{ ...tour, days: [day1, day2], ungroupedChildPlans: [], highlight_color: '#FFAA00' }
|
|
]
|
|
});
|
|
|
|
const day2Group = timeline.scheduled.find((group) => group.date === '2027-01-02');
|
|
expect(day2Group).toBeTruthy();
|
|
const dayEntries = day2Group?.entries.filter((entry) => entry.kind === 'day') ?? [];
|
|
expect(dayEntries).toHaveLength(1);
|
|
const planEntries = day2Group?.entries.filter((entry) => entry.kind === 'plan') ?? [];
|
|
expect(planEntries).toHaveLength(1);
|
|
expect(planEntries[0].kind === 'plan' ? planEntries[0].tourDay?.dayNumber : null).toBe(2);
|
|
});
|
|
|
|
it('puts tour days without a start date into unscheduled', () => {
|
|
const tourPlan = makePlan({ id: 'tour-plan-2', type: 'tour' });
|
|
const day1 = makeTourDay({ plan_id: 'day-3', day_number: 1, title: 'Intro' });
|
|
const activityPlan = makePlan({ id: 'activity-2', type: 'activity', parent_id: day1.plan_id });
|
|
const tour = makeTour({ id: 'tour-2', plan_id: tourPlan.id, start_date: null });
|
|
|
|
const timeline = buildTripTimeline({
|
|
plans: [tourPlan, activityPlan],
|
|
flightBookings: [],
|
|
privateVehicles: [],
|
|
otherTransports: [],
|
|
lodgings: [],
|
|
activities: [
|
|
{
|
|
plan_id: activityPlan.id,
|
|
id: 'exp-4',
|
|
booking_id: null,
|
|
total_cost: null,
|
|
description: null,
|
|
website: null,
|
|
address: null,
|
|
contact_number: null,
|
|
start_date: null,
|
|
start_time: null,
|
|
start_timezone: null,
|
|
end_date: null,
|
|
end_time: null,
|
|
end_timezone: null,
|
|
created_at: '2026-01-01',
|
|
updated_at: '2026-01-01'
|
|
}
|
|
],
|
|
restaurants: [],
|
|
packingLists: [],
|
|
todos: [],
|
|
packageTours: [{ ...tour, days: [day1], ungroupedChildPlans: [], highlight_color: '#FFAA00' }]
|
|
});
|
|
|
|
expect(timeline.scheduled).toHaveLength(0);
|
|
expect(timeline.unscheduled.some((entry) => entry.kind === 'day')).toBe(true);
|
|
expect(
|
|
timeline.unscheduled.some(
|
|
(entry) => entry.kind === 'plan' && entry.plan.id === activityPlan.id
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it('splits multi-segment flights across timeline dates', () => {
|
|
const transportPlan = makePlan({
|
|
id: 'transport-1',
|
|
type: 'transport',
|
|
title: 'Multi-city flight'
|
|
});
|
|
|
|
const timeline = buildTripTimeline({
|
|
plans: [transportPlan],
|
|
flightBookings: [
|
|
{
|
|
id: 'booking-1',
|
|
plan_id: transportPlan.id,
|
|
confirmation_number: null,
|
|
price: null,
|
|
currency: 'USD',
|
|
created_at: '2026-01-01',
|
|
updated_at: '2026-01-01',
|
|
segments: [
|
|
{
|
|
id: 'seg-1',
|
|
booking_id: 'booking-1',
|
|
position: 0,
|
|
departure_date: '2026-07-10',
|
|
airline_id: null,
|
|
airline_iata: 'UA',
|
|
airline_name: 'United',
|
|
flight_number: '100',
|
|
created_at: '2026-01-01',
|
|
updated_at: '2026-01-01',
|
|
route: null
|
|
},
|
|
{
|
|
id: 'seg-2',
|
|
booking_id: 'booking-1',
|
|
position: 1,
|
|
departure_date: '2026-07-12',
|
|
airline_id: null,
|
|
airline_iata: 'UA',
|
|
airline_name: 'United',
|
|
flight_number: '200',
|
|
created_at: '2026-01-01',
|
|
updated_at: '2026-01-01',
|
|
route: null
|
|
}
|
|
]
|
|
}
|
|
],
|
|
privateVehicles: [],
|
|
otherTransports: [],
|
|
lodgings: [],
|
|
activities: [],
|
|
restaurants: [],
|
|
packingLists: [],
|
|
todos: [],
|
|
packageTours: []
|
|
});
|
|
|
|
expect(timeline.scheduled).toHaveLength(2);
|
|
expect(timeline.scheduled[0].date).toBe('2026-07-10');
|
|
expect(timeline.scheduled[1].date).toBe('2026-07-12');
|
|
|
|
const firstEntry = timeline.scheduled[0].entries[0];
|
|
const secondEntry = timeline.scheduled[1].entries[0];
|
|
expect(firstEntry.kind === 'plan' ? firstEntry.flightSegmentIndex : null).toBe(0);
|
|
expect(secondEntry.kind === 'plan' ? secondEntry.flightSegmentIndex : null).toBe(1);
|
|
expect(firstEntry.id).not.toBe(secondEntry.id);
|
|
});
|
|
});
|