trip) add timeline view to trip page (#48)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m21s
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>
This commit was merged in pull request #48.
This commit is contained in:
295
src/lib/timeline.test.ts
Normal file
295
src/lib/timeline.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user