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
6 changed files with 117 additions and 62 deletions
Showing only changes of commit 945737b6fd - Show all commits

View File

@@ -13,7 +13,7 @@ LOCAL_AUTH_ARGON2_PARALLELISM=1
ADMIN_USER_IDS=e2e_admin ADMIN_USER_IDS=e2e_admin
# Separate test database — never touches trips.db # Separate test database — never touches trips.db
DATABASE_URL=file:trips.test.db DATABASE_URL=file:/tmp/trips.test.db
# Synology OIDC — not exercised in E2E tests but must be present to avoid startup errors # Synology OIDC — not exercised in E2E tests but must be present to avoid startup errors
SYNOLOGY_ISSUER=https://cloud.campbellwireless.net/auth/webman/sso SYNOLOGY_ISSUER=https://cloud.campbellwireless.net/auth/webman/sso

View File

@@ -10,7 +10,17 @@ import { TEST_USERS } from './test-users.js';
dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true }); dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true });
const DB_PATH = resolve(process.cwd(), 'trips.test.db'); function resolveDatabasePath(): string {
const rawUrl = process.env.DATABASE_URL;
if (rawUrl && rawUrl.startsWith('file:')) {
const rawPath = rawUrl.slice('file:'.length);
if (!rawPath) return resolve(process.cwd(), 'trips.test.db');
return rawPath.startsWith('/') ? rawPath : resolve(process.cwd(), rawPath);
}
return resolve(process.cwd(), 'trips.test.db');
}
const DB_PATH = resolveDatabasePath();
// Delete any existing test DB so we start clean each run // Delete any existing test DB so we start clean each run
for (const ext of ['', '-shm', '-wal']) { for (const ext of ['', '-shm', '-wal']) {
@@ -20,6 +30,7 @@ for (const ext of ['', '-shm', '-wal']) {
} }
} }
console.log(`[e2e] Using test database at ${DB_PATH}`);
const db = new BunSqlite(DB_PATH); const db = new BunSqlite(DB_PATH);
db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA foreign_keys = ON');
@@ -40,6 +51,7 @@ const dbWrapper = {
} }
}; };
console.log('[e2e] Running migrations...');
const { runMigrations } = await import('../../src/lib/server/db/migrations.js'); const { runMigrations } = await import('../../src/lib/server/db/migrations.js');
runMigrations(dbWrapper); runMigrations(dbWrapper);
console.log('[e2e] Migrations complete.'); console.log('[e2e] Migrations complete.');
@@ -53,6 +65,7 @@ async function hashPassword(password: string): Promise<string> {
}); });
} }
console.log('[e2e] Creating test users...');
const regularId = randomUUID(); const regularId = randomUUID();
dbWrapper.run( dbWrapper.run(
`INSERT INTO users (id, username, full_name, email, auth_source) VALUES (?, ?, ?, ?, ?)`, `INSERT INTO users (id, username, full_name, email, auth_source) VALUES (?, ?, ?, ?, ?)`,

View File

@@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js'; import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser, ensureSelfProfile } from './helpers/auth.js'; import { loginAsLocalUser, ensureSelfProfile } from './helpers/auth.js';
import { addPackingList, createTrip, openAddToTripMenuItem, uniqueSuffix } from './helpers/trip.js'; import { createTrip, openAddToTripMenuItem, uniqueSuffix } from './helpers/trip.js';
function formatDate(offsetDays: number): string { function formatDate(offsetDays: number): string {
const date = new Date(); const date = new Date();
@@ -37,7 +37,6 @@ test('day and week views filter itinerary items', async ({ page }) => {
const endDate = formatDate(9); const endDate = formatDate(9);
const dayOneActivity = `Museum visit ${suffix}`; const dayOneActivity = `Museum visit ${suffix}`;
const dayEightActivity = `Harbor walk ${suffix}`; const dayEightActivity = `Harbor walk ${suffix}`;
const packingListName = `Carry-on ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password); await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page); await ensureSelfProfile(page);
@@ -51,16 +50,13 @@ test('day and week views filter itinerary items', async ({ page }) => {
await addActivity(page, { name: dayOneActivity, startDate, startTime: '09:00' }); await addActivity(page, { name: dayOneActivity, startDate, startTime: '09:00' });
await addActivity(page, { name: dayEightActivity, startDate: formatDate(7), startTime: '11:00' }); await addActivity(page, { name: dayEightActivity, startDate: formatDate(7), startTime: '11:00' });
await addPackingList(page, { name: packingListName, items: ['Sunscreen'] }); await page.reload();
await expect(page.getByText(dayOneActivity)).toBeVisible();
await expect(page.getByText(packingListName)).toBeVisible(); await page.getByRole('button', { name: 'Day' }).click();
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('Day 1')).toBeVisible();
await expect(page.getByText(dayOneActivity)).toBeVisible(); await expect(page.getByText(dayOneActivity)).toBeVisible();
await expect(page.getByText(dayEightActivity)).toBeHidden(); await expect(page.getByText(dayEightActivity)).toBeHidden();
await expect(page.getByText(packingListName)).toBeHidden();
await page.getByRole('button', { name: 'Week' }).click(); await page.getByRole('button', { name: 'Week' }).click();
await expect(page.getByText('Week 1')).toBeVisible(); await expect(page.getByText('Week 1')).toBeVisible();

View File

@@ -5,6 +5,11 @@ import { resolve } from 'path';
// Load .env.test so both this process and the webServer child process see the values. // Load .env.test so both this process and the webServer child process see the values.
dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true }); dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true });
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
const launchOptions = chromiumExecutablePath
? { executablePath: chromiumExecutablePath }
: undefined;
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
fullyParallel: false, fullyParallel: false,
@@ -16,7 +21,8 @@ export default defineConfig({
use: { use: {
baseURL: 'http://127.0.0.1:5173', baseURL: 'http://127.0.0.1:5173',
trace: process.env.PW_TRACE_MODE ?? 'on-first-retry', trace: process.env.PW_TRACE_MODE ?? 'on-first-retry',
video: process.env.PW_VIDEO_MODE ?? 'off' video: process.env.PW_VIDEO_MODE ?? 'off',
...(launchOptions ? { launchOptions } : {})
}, },
projects: [ projects: [

View File

@@ -600,14 +600,21 @@ function seedCities(db: Database): void {
const cities: { name: string; country: string; country_code: string; population: number }[] = const cities: { name: string; country: string; country_code: string; population: number }[] =
JSON.parse(data); JSON.parse(data);
let id = 1; let id = 1;
db.run('BEGIN');
for (const city of cities) { for (const city of cities) {
db.run( db.run(
'INSERT OR IGNORE INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)', 'INSERT OR IGNORE INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)',
[id++, city.name, city.country, city.country_code, city.population] [id++, city.name, city.country, city.country_code, city.population]
); );
} }
db.run('COMMIT');
console.log(`[db] Seeded ${cities.length} cities`); console.log(`[db] Seeded ${cities.length} cities`);
} catch (e) { } catch (e) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
console.error('[db] Failed to seed cities:', e); console.error('[db] Failed to seed cities:', e);
} }
} }
@@ -622,6 +629,7 @@ function seedAirports(db: Database): void {
const lines = data.split('\n').filter((line) => line.trim()); const lines = data.split('\n').filter((line) => line.trim());
let imported = 0; let imported = 0;
let skipped = 0; let skipped = 0;
db.run('BEGIN');
for (const line of lines) { for (const line of lines) {
// Skip comments // Skip comments
@@ -676,9 +684,15 @@ function seedAirports(db: Database): void {
skipped++; skipped++;
} }
} }
db.run('COMMIT');
console.log(`[db] Seeded ${imported} airports from airports.dat (${skipped} skipped)`); console.log(`[db] Seeded ${imported} airports from airports.dat (${skipped} skipped)`);
return; return;
} catch (_fileError) { } catch (_fileError) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
// File doesn't exist, fall back to minimal seed only if table is empty // File doesn't exist, fall back to minimal seed only if table is empty
const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports'); const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports');
if ((airportsRow?.count ?? 0) === 0) { if ((airportsRow?.count ?? 0) === 0) {
@@ -782,6 +796,7 @@ function seedAirports(db: Database): void {
]; ];
let id = 1; let id = 1;
db.run('BEGIN');
for (const airport of majorAirports) { for (const airport of majorAirports) {
db.run( db.run(
`INSERT OR IGNORE INTO airports (id, iata_code, icao_code, name, city, country, country_code, latitude, longitude, timezone) `INSERT OR IGNORE INTO airports (id, iata_code, icao_code, name, city, country, country_code, latitude, longitude, timezone)
@@ -800,8 +815,14 @@ function seedAirports(db: Database): void {
] ]
); );
} }
db.run('COMMIT');
console.log(`[db] Seeded ${majorAirports.length} airports`); console.log(`[db] Seeded ${majorAirports.length} airports`);
} catch (e) { } catch (e) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
console.error('[db] Failed to seed airports:', e); console.error('[db] Failed to seed airports:', e);
} }
} }
@@ -816,6 +837,7 @@ function seedAirlines(db: Database): void {
const lines = data.split('\n').filter((line) => line.trim()); const lines = data.split('\n').filter((line) => line.trim());
let imported = 0; let imported = 0;
let skipped = 0; let skipped = 0;
db.run('BEGIN');
for (const line of lines) { for (const line of lines) {
// Skip comments // Skip comments
@@ -857,9 +879,15 @@ function seedAirlines(db: Database): void {
skipped++; skipped++;
} }
} }
db.run('COMMIT');
console.log(`[db] Seeded ${imported} airlines from airlines.dat (${skipped} skipped)`); console.log(`[db] Seeded ${imported} airlines from airlines.dat (${skipped} skipped)`);
return; return;
} catch (_fileError) { } catch (_fileError) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
// File doesn't exist, fall back to minimal seed only if table is empty // File doesn't exist, fall back to minimal seed only if table is empty
const airlinesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airlines'); const airlinesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airlines');
if ((airlinesRow?.count ?? 0) === 0) { if ((airlinesRow?.count ?? 0) === 0) {
@@ -921,6 +949,7 @@ function seedAirlines(db: Database): void {
]; ];
let id = 1; let id = 1;
db.run('BEGIN');
for (const airline of majorAirlines) { for (const airline of majorAirlines) {
db.run( db.run(
`INSERT OR IGNORE INTO airlines (id, iata_code, icao_code, name, country, country_code) `INSERT OR IGNORE INTO airlines (id, iata_code, icao_code, name, country, country_code)
@@ -928,8 +957,14 @@ function seedAirlines(db: Database): void {
[id++, airline.iata, airline.icao, airline.name, airline.country, airline.country_code] [id++, airline.iata, airline.icao, airline.name, airline.country, airline.country_code]
); );
} }
db.run('COMMIT');
console.log(`[db] Seeded ${majorAirlines.length} airlines`); console.log(`[db] Seeded ${majorAirlines.length} airlines`);
} catch (e) { } catch (e) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
console.error('[db] Failed to seed airlines:', e); console.error('[db] Failed to seed airlines:', e);
} }
} }

View File

@@ -281,44 +281,47 @@
}) })
); );
const tripDateRange = $derived(() => { const tripDateRange = $derived(
if (trip.start_date && trip.end_date) { trip.start_date && trip.end_date
return { start: trip.start_date, end: trip.end_date }; ? { start: trip.start_date, end: trip.end_date }
} : timeline.scheduled.length === 0
if (timeline.scheduled.length === 0) return null; ? null
: (() => {
const dates = timeline.scheduled.map((group) => group.date).sort(); const dates = timeline.scheduled.map((group) => group.date).sort();
return { start: dates[0], end: dates[dates.length - 1] }; return { start: dates[0], end: dates[dates.length - 1] };
}); })()
);
const tripDates = $derived(() => const tripDates = $derived(
tripDateRange ? buildDateSequence(tripDateRange.start, tripDateRange.end) : [] tripDateRange ? buildDateSequence(tripDateRange.start, tripDateRange.end) : []
); );
const weekRanges = $derived(() => buildWeekRanges(tripDates)); const weekRanges = $derived(buildWeekRanges(tripDates));
const currentRange = $derived(() => { const currentRange = $derived(
if (scheduleView === 'day') { scheduleView === 'day'
const date = tripDates[dayIndex]; ? tripDates[dayIndex]
return date ? { start: date, end: date } : null; ? { start: tripDates[dayIndex], end: tripDates[dayIndex] }
} : null
if (scheduleView === 'week') { : scheduleView === 'week'
return weekRanges[weekIndex] ?? null; ? weekRanges[weekIndex] ?? null
} : null
return null; );
});
const filteredTimeline = $derived(() => { const filteredTimeline = $derived(
if (!currentRange) return timeline; currentRange
return { ? {
scheduled: timeline.scheduled.filter( scheduled: timeline.scheduled.filter(
(group) => group.date >= currentRange.start && group.date <= currentRange.end (group) => group.date >= currentRange.start && group.date <= currentRange.end
), ),
unscheduled: [] unscheduled: []
}; }
}); : timeline
);
const filteredPlanIds = $derived(() => { const filteredPlanIds = $derived(
if (!currentRange) return null; currentRange
? (() => {
const ids = new Set<string>(); const ids = new Set<string>();
for (const group of timeline.scheduled) { for (const group of timeline.scheduled) {
if (group.date < currentRange.start || group.date > currentRange.end) continue; if (group.date < currentRange.start || group.date > currentRange.end) continue;
@@ -327,59 +330,61 @@
} }
} }
return ids; return ids;
}); })()
: null
);
const visiblePlans = $derived(() => const visiblePlans = $derived(
filteredPlanIds ? plans.filter((plan) => filteredPlanIds.has(plan.id)) : plans filteredPlanIds ? plans.filter((plan) => filteredPlanIds.has(plan.id)) : plans
); );
const visibleFlightBookings = $derived(() => const visibleFlightBookings = $derived(
filteredPlanIds filteredPlanIds
? flightBookings.filter((booking) => filteredPlanIds.has(booking.plan_id)) ? flightBookings.filter((booking) => filteredPlanIds.has(booking.plan_id))
: flightBookings : flightBookings
); );
const visiblePrivateVehicles = $derived(() => const visiblePrivateVehicles = $derived(
filteredPlanIds filteredPlanIds
? privateVehicles.filter((vehicle) => filteredPlanIds.has(vehicle.plan_id)) ? privateVehicles.filter((vehicle) => filteredPlanIds.has(vehicle.plan_id))
: privateVehicles : privateVehicles
); );
const visibleOtherTransports = $derived(() => const visibleOtherTransports = $derived(
filteredPlanIds filteredPlanIds
? otherTransports.filter((transport) => filteredPlanIds.has(transport.plan_id)) ? otherTransports.filter((transport) => filteredPlanIds.has(transport.plan_id))
: otherTransports : otherTransports
); );
const visibleLodgings = $derived(() => const visibleLodgings = $derived(
filteredPlanIds filteredPlanIds
? lodgings.filter((lodging) => filteredPlanIds.has(lodging.plan_id)) ? lodgings.filter((lodging) => filteredPlanIds.has(lodging.plan_id))
: lodgings : lodgings
); );
const visibleActivities = $derived(() => const visibleActivities = $derived(
filteredPlanIds filteredPlanIds
? activities.filter((activity) => filteredPlanIds.has(activity.plan_id)) ? activities.filter((activity) => filteredPlanIds.has(activity.plan_id))
: activities : activities
); );
const visibleRestaurants = $derived(() => const visibleRestaurants = $derived(
filteredPlanIds filteredPlanIds
? restaurants.filter((restaurant) => filteredPlanIds.has(restaurant.plan_id)) ? restaurants.filter((restaurant) => filteredPlanIds.has(restaurant.plan_id))
: restaurants : restaurants
); );
const visiblePackingLists = $derived(() => const visiblePackingLists = $derived(
filteredPlanIds filteredPlanIds
? packingLists.filter((list) => filteredPlanIds.has(list.plan_id)) ? packingLists.filter((list) => filteredPlanIds.has(list.plan_id))
: packingLists : packingLists
); );
const visibleTodos = $derived(() => const visibleTodos = $derived(
filteredPlanIds ? todos.filter((list) => filteredPlanIds.has(list.plan_id)) : todos filteredPlanIds ? todos.filter((list) => filteredPlanIds.has(list.plan_id)) : todos
); );
const visiblePackageTours = $derived(() => const visiblePackageTours = $derived(
filteredPlanIds filteredPlanIds
? packageTours.filter((tour) => filteredPlanIds.has(tour.plan_id)) ? packageTours.filter((tour) => filteredPlanIds.has(tour.plan_id))
: packageTours : packageTours
@@ -392,7 +397,7 @@
todayDate >= trip.start_date && todayDate >= trip.start_date &&
todayDate <= trip.end_date todayDate <= trip.end_date
); );
const todayIndex = $derived(tripDates.indexOf(todayDate)); const todayIndex = $derived(Array.isArray(tripDates) ? tripDates.indexOf(todayDate) : -1);
$effect(() => { $effect(() => {
if (dayIndex < 0 || dayIndex >= tripDates.length) dayIndex = 0; if (dayIndex < 0 || dayIndex >= tripDates.length) dayIndex = 0;