Files
trips/src/lib/timeline.test.ts
AI Agent ae39f7961a
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m21s
trip) add timeline view to trip page (#48)
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>
2026-02-23 03:17:44 +00:00

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);
});
});