trip) add day/week trip views #52

Merged
shaun merged 2 commits from ai-agent/51-add-a-day-view-to-a-trip into main 2026-02-24 20:17:26 +00:00
5 changed files with 442 additions and 46 deletions
Showing only changes of commit f8dcd3b2bf - Show all commits

View File

@@ -12,7 +12,7 @@ export function uniqueSuffix(): string {
export async function createTrip(
page: Page,
values: { name: string; startDate?: string; description?: string }
values: { name: string; startDate?: string; endDate?: string; description?: string }
): Promise<{ tripUrl: string; tripId: string }> {
await page.goto(NEW_TRIP_URL);
await expect(page.getByRole('heading', { name: 'Plan New Trip' })).toBeVisible();
@@ -22,6 +22,9 @@ export async function createTrip(
} else {
await form.getByRole('checkbox', { name: "I don't know yet" }).first().check();
}
if (values.endDate) {
await form.getByLabel('End date').fill(values.endDate);
}
if (values.description) {
await form.getByLabel('Description').fill(values.description);
}

View File

@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser, ensureSelfProfile } from './helpers/auth.js';
import { addPackingList, createTrip, openAddToTripMenuItem, uniqueSuffix } from './helpers/trip.js';
function formatDate(offsetDays: number): string {
const date = new Date();
date.setDate(date.getDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
async function addActivity(
page: import('@playwright/test').Page,
values: { name: string; startDate: string; startTime: string }
) {
await openAddToTripMenuItem(page, 'Attractions & Activities');
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.getByRole('heading', { name: /Attraction & activity/i }) })
.first();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
await dialog.getByLabel('Start date').fill(values.startDate);
await dialog.getByLabel('Start time', { exact: true }).fill(values.startTime);
await dialog.getByRole('button', { name: 'Add' }).click();
await expect(dialog).toBeHidden();
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('day and week views filter itinerary items', async ({ page }) => {
const suffix = uniqueSuffix();
const startDate = formatDate(0);
const endDate = formatDate(9);
const dayOneActivity = `Museum visit ${suffix}`;
const dayEightActivity = `Harbor walk ${suffix}`;
const packingListName = `Carry-on ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page);
await createTrip(page, {
name: `E2E Date Views Trip ${suffix}`,
startDate,
endDate,
description: 'Date view coverage'
});
await addActivity(page, { name: dayOneActivity, startDate, startTime: '09:00' });
await addActivity(page, { name: dayEightActivity, startDate: formatDate(7), startTime: '11:00' });
await addPackingList(page, { name: packingListName, items: ['Sunscreen'] });
await expect(page.getByText(packingListName)).toBeVisible();
await expect(page.getByRole('button', { name: 'Today' })).toBeVisible();
await page.getByRole('button', { name: 'Today' }).click();
await expect(page.getByText('Day 1')).toBeVisible();
await expect(page.getByText(dayOneActivity)).toBeVisible();
await expect(page.getByText(dayEightActivity)).toBeHidden();
await expect(page.getByText(packingListName)).toBeHidden();
await page.getByRole('button', { name: 'Week' }).click();
await expect(page.getByText('Week 1')).toBeVisible();
await expect(page.getByText(dayOneActivity)).toBeVisible();
await expect(page.getByText(dayEightActivity)).toBeHidden();
await page.getByRole('button', { name: 'Next week' }).click();
await expect(page.getByText('Week 2')).toBeVisible();
await expect(page.getByText(dayEightActivity)).toBeVisible();
await expect(page.getByText(dayOneActivity)).toBeHidden();
});

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { addDaysToDate, buildDateSequence, buildWeekRanges } from './date-views.js';
describe('date-views', () => {
it('adds days to a date string', () => {
expect(addDaysToDate('2026-04-01', 1)).toBe('2026-04-02');
expect(addDaysToDate('2026-04-01', 7)).toBe('2026-04-08');
});
it('builds an inclusive date sequence', () => {
const dates = buildDateSequence('2026-04-01', '2026-04-03');
expect(dates).toEqual(['2026-04-01', '2026-04-02', '2026-04-03']);
});
it('returns empty sequence when end is before start', () => {
expect(buildDateSequence('2026-04-03', '2026-04-01')).toEqual([]);
});
it('chunks weeks into seven-day ranges', () => {
const dates = buildDateSequence('2026-04-01', '2026-04-10');
const ranges = buildWeekRanges(dates);
expect(ranges).toHaveLength(2);
expect(ranges[0]).toEqual({
start: '2026-04-01',
end: '2026-04-07',
weekNumber: 1
});
expect(ranges[1]).toEqual({
start: '2026-04-08',
end: '2026-04-10',
weekNumber: 2
});
});
});

33
src/lib/date-views.ts Normal file
View File

@@ -0,0 +1,33 @@
export 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 buildDateSequence(start: string, end: string): string[] {
if (!start || !end) return [];
if (start > end) return [];
const dates: string[] = [];
let current = start;
while (current <= end) {
dates.push(current);
current = addDaysToDate(current, 1);
}
return dates;
}
export function buildWeekRanges(
dates: string[]
): Array<{ start: string; end: string; weekNumber: number }> {
const ranges: Array<{ start: string; end: string; weekNumber: number }> = [];
for (let i = 0; i < dates.length; i += 7) {
const slice = dates.slice(i, i + 7);
if (slice.length === 0) continue;
ranges.push({
start: slice[0],
end: slice[slice.length - 1],
weekNumber: Math.floor(i / 7) + 1
});
}
return ranges;
}

View File

@@ -24,6 +24,7 @@
import PrivateVehicleCard from '$lib/components/PrivateVehicleCard.svelte';
import LodgingCard from '$lib/components/LodgingCard.svelte';
import TravellerChip from '$lib/components/TravellerChip.svelte';
import { buildDateSequence, buildWeekRanges, addDaysToDate } from '$lib/date-views.js';
import { buildTripTimeline } from '$lib/timeline.js';
let { data, form } = $props();
@@ -93,12 +94,21 @@
let togglingChecklistItemChecked = $state<'0' | '1'>('0');
let addingChildToPlanId = $state<string | null>(null);
let planView = $state<'types' | 'timeline'>('types');
let scheduleView = $state<'trip' | 'day' | 'week'>('trip');
let dayIndex = $state(0);
let weekIndex = $state(0);
const planViewOptions = [
{ id: 'types', label: 'By type' },
{ id: 'timeline', label: 'Timeline' }
] as const;
const scheduleViewOptions = [
{ id: 'trip', label: 'Trip' },
{ id: 'day', label: 'Day' },
{ id: 'week', label: 'Week' }
] as const;
let transportationLocationOptions = $derived(
plans
.filter((plan) => plan.type !== 'transport' && plan.type !== 'day')
@@ -155,10 +165,27 @@
return t.slice(0, 5);
}
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);
function toLocalDateString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatLongDate(d: string) {
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
});
}
function formatRangeDate(d: string) {
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
month: 'long',
day: 'numeric'
});
}
function toggleChecklistItem(itemId: string | number, nextChecked: boolean) {
@@ -253,6 +280,127 @@
packageTours
})
);
const tripDateRange = $derived(() => {
if (trip.start_date && trip.end_date) {
return { start: trip.start_date, end: trip.end_date };
}
if (timeline.scheduled.length === 0) return null;
const dates = timeline.scheduled.map((group) => group.date).sort();
return { start: dates[0], end: dates[dates.length - 1] };
});
const tripDates = $derived(() =>
tripDateRange ? buildDateSequence(tripDateRange.start, tripDateRange.end) : []
);
const weekRanges = $derived(() => buildWeekRanges(tripDates));
const currentRange = $derived(() => {
if (scheduleView === 'day') {
const date = tripDates[dayIndex];
return date ? { start: date, end: date } : null;
}
if (scheduleView === 'week') {
return weekRanges[weekIndex] ?? null;
}
return null;
});
const filteredTimeline = $derived(() => {
if (!currentRange) return timeline;
return {
scheduled: timeline.scheduled.filter(
(group) => group.date >= currentRange.start && group.date <= currentRange.end
),
unscheduled: []
};
});
const filteredPlanIds = $derived(() => {
if (!currentRange) return null;
const ids = new Set<string>();
for (const group of timeline.scheduled) {
if (group.date < currentRange.start || group.date > currentRange.end) continue;
for (const entry of group.entries) {
if (entry.kind === 'plan') ids.add(entry.plan.id);
}
}
return ids;
});
const visiblePlans = $derived(() =>
filteredPlanIds ? plans.filter((plan) => filteredPlanIds.has(plan.id)) : plans
);
const visibleFlightBookings = $derived(() =>
filteredPlanIds
? flightBookings.filter((booking) => filteredPlanIds.has(booking.plan_id))
: flightBookings
);
const visiblePrivateVehicles = $derived(() =>
filteredPlanIds
? privateVehicles.filter((vehicle) => filteredPlanIds.has(vehicle.plan_id))
: privateVehicles
);
const visibleOtherTransports = $derived(() =>
filteredPlanIds
? otherTransports.filter((transport) => filteredPlanIds.has(transport.plan_id))
: otherTransports
);
const visibleLodgings = $derived(() =>
filteredPlanIds
? lodgings.filter((lodging) => filteredPlanIds.has(lodging.plan_id))
: lodgings
);
const visibleActivities = $derived(() =>
filteredPlanIds
? activities.filter((activity) => filteredPlanIds.has(activity.plan_id))
: activities
);
const visibleRestaurants = $derived(() =>
filteredPlanIds
? restaurants.filter((restaurant) => filteredPlanIds.has(restaurant.plan_id))
: restaurants
);
const visiblePackingLists = $derived(() =>
filteredPlanIds
? packingLists.filter((list) => filteredPlanIds.has(list.plan_id))
: packingLists
);
const visibleTodos = $derived(() =>
filteredPlanIds ? todos.filter((list) => filteredPlanIds.has(list.plan_id)) : todos
);
const visiblePackageTours = $derived(() =>
filteredPlanIds
? packageTours.filter((tour) => filteredPlanIds.has(tour.plan_id))
: packageTours
);
const todayDate = $derived(toLocalDateString(new Date()));
const showTodayButton = $derived(
!!trip.start_date &&
!!trip.end_date &&
todayDate >= trip.start_date &&
todayDate <= trip.end_date
);
const todayIndex = $derived(tripDates.indexOf(todayDate));
$effect(() => {
if (dayIndex < 0 || dayIndex >= tripDates.length) dayIndex = 0;
});
$effect(() => {
if (weekIndex < 0 || weekIndex >= weekRanges.length) weekIndex = 0;
});
</script>
<svelte:head>
@@ -661,30 +809,130 @@
{:else}
<div class="mt-8 flex flex-wrap items-center justify-between gap-3">
<h2 class="text-sm font-semibold tracking-wider text-gray-400 uppercase">Plans</h2>
<div
class="inline-flex rounded-md border border-gray-200 bg-white p-1 text-xs font-medium text-gray-500"
>
{#each planViewOptions as option}
<div class="flex flex-wrap items-center gap-2">
<div
class="inline-flex rounded-md border border-gray-200 bg-white p-1 text-xs font-medium text-gray-500"
>
{#each planViewOptions as option}
<button
type="button"
onclick={() => (planView = option.id)}
class="rounded-md px-2.5 py-1 transition {planView === option.id
? 'bg-gray-900 text-white'
: 'hover:bg-gray-100'}"
aria-pressed={planView === option.id}
>
{option.label}
</button>
{/each}
</div>
<div
class="inline-flex rounded-md border border-gray-200 bg-white p-1 text-xs font-medium text-gray-500"
>
{#each scheduleViewOptions as option}
<button
type="button"
onclick={() => (scheduleView = option.id)}
class="rounded-md px-2.5 py-1 transition {scheduleView === option.id
? 'bg-gray-900 text-white'
: 'hover:bg-gray-100'}"
aria-pressed={scheduleView === option.id}
>
{option.label}
</button>
{/each}
</div>
{#if showTodayButton}
<button
type="button"
onclick={() => (planView = option.id)}
class="rounded-md px-2.5 py-1 transition {planView === option.id
? 'bg-gray-900 text-white'
: 'hover:bg-gray-100'}"
aria-pressed={planView === option.id}
onclick={() => {
scheduleView = 'day';
if (todayIndex >= 0) dayIndex = todayIndex;
}}
class="rounded-md border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100"
>
{option.label}
Today
</button>
{/each}
{/if}
</div>
</div>
{#if scheduleView !== 'trip' && currentRange}
<div class="mt-4">
<div class="relative rounded-lg border border-gray-200 bg-white px-4 py-3 text-center">
{#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"
onclick={() => (dayIndex = Math.max(0, dayIndex - 1))}
disabled={dayIndex === 0}
aria-label="Previous day"
>
</button>
{/if}
{#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"
onclick={() => (weekIndex = Math.max(0, weekIndex - 1))}
disabled={weekIndex === 0}
aria-label="Previous week"
>
</button>
{/if}
{#if scheduleView === 'day'}
<p class="text-base font-semibold text-gray-800">
{formatLongDate(currentRange.start)}
</p>
<p class="text-xs text-gray-500">Day {dayIndex + 1}</p>
{:else if scheduleView === 'week'}
<p class="text-base font-semibold text-gray-800">
Week {weekRanges[weekIndex]?.weekNumber ?? 1}
</p>
<p class="text-xs text-gray-500">
{formatRangeDate(currentRange.start)} - {formatRangeDate(currentRange.end)}
</p>
{/if}
{#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"
onclick={() => (dayIndex = Math.min(tripDates.length - 1, dayIndex + 1))}
disabled={dayIndex >= tripDates.length - 1}
aria-label="Next day"
>
</button>
{/if}
{#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"
onclick={() => (weekIndex = Math.min(weekRanges.length - 1, weekIndex + 1))}
disabled={weekIndex >= weekRanges.length - 1}
aria-label="Next week"
>
</button>
{/if}
</div>
</div>
{/if}
{#if planView === 'timeline'}
<div class="mt-6">
{#if timeline.scheduled.length === 0}
<p class="text-sm text-gray-500">No scheduled plans yet.</p>
{#if filteredTimeline.scheduled.length === 0}
<p class="text-sm text-gray-500">
{scheduleView === 'trip'
? 'No scheduled plans yet.'
: 'No scheduled plans for this range.'}
</p>
{:else}
{#each timeline.scheduled as group (group.date)}
{#each filteredTimeline.scheduled as group (group.date)}
<div class="mt-6 first:mt-0">
<div class="flex items-center gap-3">
<p class="text-sm font-semibold text-gray-700">
@@ -1028,15 +1276,15 @@
{/each}
{/if}
{#if timeline.unscheduled.length > 0}
{#if filteredTimeline.unscheduled.length > 0}
<div class="mt-8">
<div class="flex items-center gap-3">
<p class="text-sm font-semibold text-gray-700">Unscheduled</p>
<div class="h-px flex-1 bg-gray-200"></div>
</div>
<div class="mt-4 flex flex-col gap-4">
{#each timeline.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'}"
@@ -1361,7 +1609,7 @@
Destinations
</h2>
<div class="flex flex-col gap-3">
{#each plans.filter((p) => p.type === 'destination') as plan (plan.id)}
{#each visiblePlans.filter((p) => p.type === 'destination') as plan (plan.id)}
{@const formId = `remove-plan-${plan.id}`}
{@const packageTourContext = byTypeTourContextByPlanId.get(plan.id)}
{@const submitForm = () => {
@@ -1421,16 +1669,20 @@
</div>
<!-- Transportation section -->
{#if flightBookings.length > 0 || privateVehicles.length > 0 || otherTransports.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 plans.filter((p) => p.type === 'transport') as plan (plan.id)}
{@const flightBooking = flightBookings.find((b) => b.plan_id === plan.id)}
{@const privateVehicle = privateVehicles.find((pv) => pv.plan_id === plan.id)}
{@const otherTransport = otherTransports.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}`}
@@ -1532,14 +1784,14 @@
{/if}
<!-- Activities section -->
{#if activities.length > 0}
{#if visibleActivities.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Attractions & Activities
</h2>
<div class="flex flex-col gap-3">
{#each activities as activity (activity.id)}
{@const plan = plans.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)}
@@ -1608,14 +1860,14 @@
{/if}
<!-- Restaurants section -->
{#if restaurants.length > 0}
{#if visibleRestaurants.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Restaurants
</h2>
<div class="flex flex-col gap-3">
{#each restaurants as restaurant (restaurant.id)}
{@const plan = plans.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)}
@@ -1684,14 +1936,14 @@
{/if}
<!-- Packing list section -->
{#if packingLists.length > 0}
{#if visiblePackingLists.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Packing Lists
</h2>
<div class="flex flex-col gap-3">
{#each packingLists as list (list.id)}
{@const plan = plans.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 = () => {
@@ -1725,14 +1977,14 @@
{/if}
<!-- To-dos section -->
{#if todos.length > 0}
{#if visibleTodos.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
To-dos
</h2>
<div class="flex flex-col gap-3">
{#each todos as list (list.id)}
{@const plan = plans.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 = () => {
@@ -1766,14 +2018,14 @@
{/if}
<!-- Lodgings section -->
{#if lodgings.length > 0}
{#if visibleLodgings.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Lodgings
</h2>
<div class="flex flex-col gap-3">
{#each lodgings as lodging (lodging.id)}
{@const plan = plans.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)}
@@ -1842,14 +2094,14 @@
{/if}
<!-- Package Tours section -->
{#if packageTours.length > 0}
{#if visiblePackageTours.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Package Tours
</h2>
<div class="flex flex-col gap-3">
{#each packageTours as tour (tour.id)}
{@const plan = plans.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 = () => {