From ebc8f80bb578c5b62c03eb00e9d619a06b7e5263 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Tue, 24 Feb 2026 21:23:54 +0000 Subject: [PATCH] trips) add dashboard overview (#54) Reviewed-on: https://cloud.campbellwireless.net/git/campbellwireless/trips/pulls/54 Co-authored-by: AI Agent Co-committed-by: AI Agent --- e2e/dashboard.test.ts | 75 +++ src/lib/server/dashboard.ts | 192 ++++++++ .../(protected)/dashboard/+page.server.ts | 13 + src/routes/(protected)/dashboard/+page.svelte | 443 +++++++++++++++++- 4 files changed, 721 insertions(+), 2 deletions(-) create mode 100644 e2e/dashboard.test.ts create mode 100644 src/lib/server/dashboard.ts create mode 100644 src/routes/(protected)/dashboard/+page.server.ts diff --git a/e2e/dashboard.test.ts b/e2e/dashboard.test.ts new file mode 100644 index 0000000..66ea581 --- /dev/null +++ b/e2e/dashboard.test.ts @@ -0,0 +1,75 @@ +import { test, expect, type Page } from '@playwright/test'; +import { TEST_USERS } from './setup/test-users.js'; +import { loginAsLocalUser } from './helpers/auth.js'; +import { 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 addDestination(page: Page, cityQuery: string, arrivalDate?: string): Promise { + await openAddToTripMenuItem(page, 'Destinations'); + const dialog = page.getByRole('dialog', { name: 'Add destination' }); + await expect(dialog).toBeVisible(); + + await dialog.getByLabel(/City/).fill(cityQuery); + const cityOption = dialog.locator('ul button').filter({ hasText: cityQuery }).first(); + await expect(cityOption).toBeVisible(); + await cityOption.click(); + + if (arrivalDate) { + await dialog.getByLabel('Arrival').fill(arrivalDate); + } + + await dialog.getByRole('button', { name: 'Add to trip' }).click(); + await expect(dialog).toBeHidden(); +} + +test.beforeEach(async ({ context }) => { + await context.clearCookies(); +}); + +test('dashboard surfaces map stops, stats, and trip status', async ({ page }) => { + const suffix = uniqueSuffix(); + const pastStart = formatDate(-18); + const pastEnd = formatDate(-15); + const currentStart = formatDate(-2); + const currentEnd = formatDate(4); + const futureStart = formatDate(28); + const futureEnd = formatDate(34); + + const pastTripName = `E2E Past Trip ${suffix}`; + const currentTripName = `E2E Current Trip ${suffix}`; + const futureTripName = `E2E Future Trip ${suffix}`; + + await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password); + + await createTrip(page, { name: pastTripName, startDate: pastStart, endDate: pastEnd }); + await addDestination(page, 'London', pastStart); + + await createTrip(page, { name: currentTripName, startDate: currentStart, endDate: currentEnd }); + await addDestination(page, 'New York', currentStart); + + await createTrip(page, { name: futureTripName, startDate: futureStart, endDate: futureEnd }); + await addDestination(page, 'Tokyo', futureStart); + + await page.goto('/trips/dashboard'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + + const map = page.getByTestId('trip-map'); + await expect(map).toBeVisible(); + await expect(map.locator('[data-testid="map-stop"][data-status="past"]')).toHaveCount(1); + await expect(map.locator('[data-testid="map-stop"][data-status="current"]')).toHaveCount(1); + await expect(map.locator('[data-testid="map-stop"][data-status="future"]')).toHaveCount(1); + + await expect(page.getByRole('heading', { name: 'Trips in progress' })).toBeVisible(); + await expect(page.getByText(currentTripName)).toBeVisible(); + + await expect(page.getByRole('heading', { name: 'Trips being planned' })).toBeVisible(); + await expect(page.getByText(futureTripName)).toBeVisible(); + + await expect(page.getByTestId('stat-past-trips')).toHaveText('1'); + await expect(page.getByTestId('stat-countries')).toHaveText('1'); +}); diff --git a/src/lib/server/dashboard.ts b/src/lib/server/dashboard.ts new file mode 100644 index 0000000..d460572 --- /dev/null +++ b/src/lib/server/dashboard.ts @@ -0,0 +1,192 @@ +import { db } from './db/index.js'; +import type { Trip } from './trips.js'; + +export type TripStatus = 'past' | 'in_progress' | 'planned'; +export type MapStopStatus = 'past' | 'current' | 'future'; + +export interface DashboardTripSummary extends Trip { + status: TripStatus; + planCount: number; +} + +export interface DashboardMapStop { + tripId: string; + tripName: string; + label: string; + country: string | null; + countryCode: string; + lat: number; + lon: number; + status: MapStopStatus; +} + +export interface DashboardStats { + pastTrips: number; + daysAway: number; + countriesVisited: number; + citiesVisited: number; + topCountries: Array<{ country: string; countryCode: string; count: number }>; +} + +export interface DashboardData { + trips: DashboardTripSummary[]; + inProgressTrips: DashboardTripSummary[]; + plannedTrips: DashboardTripSummary[]; + mapStops: DashboardMapStop[]; + stats: DashboardStats; +} + +function isValidDate(value: string | null): value is string { + return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value)); +} + +function toDate(value: string | null): Date | null { + if (!isValidDate(value)) return null; + const parsed = new Date(`${value}T00:00:00Z`); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function dayDiffInclusive(start: string | null, end: string | null): number { + const startDate = toDate(start); + const endDate = toDate(end); + if (!startDate || !endDate) return 0; + const diffMs = endDate.getTime() - startDate.getTime(); + if (Number.isNaN(diffMs)) return 0; + return Math.max(0, Math.round(diffMs / 86400000) + 1); +} + +function getTripStatus(trip: Trip, today: string): TripStatus { + if (trip.end_date && trip.end_date < today) return 'past'; + if (trip.start_date && trip.start_date <= today) return 'in_progress'; + return 'planned'; +} + +export function getDashboardData(userId: string): DashboardData { + const today = new Date().toISOString().slice(0, 10); + const trips = db.all( + `SELECT * FROM trips WHERE user_id = ? ORDER BY COALESCE(start_date, created_at) ASC`, + [userId] + ); + + const planCounts = db.all<{ trip_id: string; count: number }>( + `SELECT trip_id, COUNT(*) as count FROM plans WHERE user_id = ? GROUP BY trip_id`, + [userId] + ); + const planCountByTripId = new Map(planCounts.map((row) => [row.trip_id, row.count])); + + const summaryTrips: DashboardTripSummary[] = trips.map((trip) => ({ + ...trip, + status: getTripStatus(trip, today), + planCount: planCountByTripId.get(trip.id) ?? 0 + })); + + const inProgressTrips = summaryTrips.filter((trip) => trip.status === 'in_progress'); + const plannedTrips = summaryTrips.filter((trip) => trip.status === 'planned'); + const pastTrips = summaryTrips.filter((trip) => trip.status === 'past'); + + const mapStopRows = db.all<{ + trip_id: string; + trip_name: string; + label: string | null; + country: string | null; + country_code: string | null; + lat: number | null; + lon: number | null; + }>( + `SELECT + p.trip_id as trip_id, + MAX(t.name) as trip_name, + COALESCE(MAX(p.city_name), MAX(p.country)) as label, + MAX(p.country) as country, + p.country_code as country_code, + c.lat as lat, + c.lon as lon + FROM plans p + JOIN trips t ON t.id = p.trip_id + JOIN ( + SELECT country_code, AVG(latitude) as lat, AVG(longitude) as lon + FROM airports + WHERE latitude IS NOT NULL AND longitude IS NOT NULL + GROUP BY country_code + ) c ON c.country_code = p.country_code + WHERE p.user_id = ? + AND p.type = 'destination' + AND p.country_code IS NOT NULL + AND TRIM(p.country_code) != '' + GROUP BY p.trip_id, p.country_code + ORDER BY MIN(p.created_at) ASC`, + [userId] + ); + + const statusByTripId = new Map(summaryTrips.map((trip) => [trip.id, trip.status])); + const mapStops: DashboardMapStop[] = mapStopRows + .filter((row) => row.country_code && row.lat !== null && row.lon !== null) + .map((row) => { + const status = statusByTripId.get(row.trip_id) ?? 'planned'; + const mapStatus: MapStopStatus = + status === 'past' ? 'past' : status === 'in_progress' ? 'current' : 'future'; + return { + tripId: row.trip_id, + tripName: row.trip_name ?? 'Trip', + label: row.label ?? row.country ?? 'Destination', + country: row.country, + countryCode: row.country_code ?? 'XX', + lat: row.lat ?? 0, + lon: row.lon ?? 0, + status: mapStatus + }; + }); + + const destinationRows = db.all<{ + country: string | null; + country_code: string | null; + city_name: string | null; + }>( + `SELECT p.country, p.country_code, p.city_name + FROM plans p + JOIN trips t ON t.id = p.trip_id + WHERE p.user_id = ? + AND t.user_id = ? + AND t.end_date IS NOT NULL + AND t.end_date < ? + AND p.type = 'destination'`, + [userId, userId, today] + ); + + const countryCounts = new Map(); + const cities = new Set(); + for (const row of destinationRows) { + if (row.city_name) cities.add(row.city_name); + if (!row.country_code) continue; + const existing = countryCounts.get(row.country_code) ?? { + country: row.country ?? row.country_code, + countryCode: row.country_code, + count: 0 + }; + existing.count += 1; + countryCounts.set(row.country_code, existing); + } + + const topCountries = Array.from(countryCounts.values()) + .sort((a, b) => b.count - a.count || a.country.localeCompare(b.country)) + .slice(0, 5); + + const daysAway = pastTrips.reduce( + (total, trip) => total + dayDiffInclusive(trip.start_date, trip.end_date), + 0 + ); + + return { + trips: summaryTrips, + inProgressTrips, + plannedTrips, + mapStops, + stats: { + pastTrips: pastTrips.length, + daysAway, + countriesVisited: countryCounts.size, + citiesVisited: cities.size, + topCountries + } + }; +} diff --git a/src/routes/(protected)/dashboard/+page.server.ts b/src/routes/(protected)/dashboard/+page.server.ts new file mode 100644 index 0000000..83becee --- /dev/null +++ b/src/routes/(protected)/dashboard/+page.server.ts @@ -0,0 +1,13 @@ +import { error } from '@sveltejs/kit'; +import { getDashboardData } from '$lib/server/dashboard.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async (event) => { + const session = await event.locals.auth(); + const userId = session?.user?.id; + if (!userId) error(401, 'Not authenticated'); + + return { + dashboard: getDashboardData(userId) + }; +}; diff --git a/src/routes/(protected)/dashboard/+page.svelte b/src/routes/(protected)/dashboard/+page.svelte index a97571b..97c587c 100644 --- a/src/routes/(protected)/dashboard/+page.svelte +++ b/src/routes/(protected)/dashboard/+page.svelte @@ -1,10 +1,449 @@ Dashboard — Trips + + + -

Dashboard

-

Welcome, {data.session.user?.name ?? data.session.user?.email}.

+
+
+
+
+
+

Overview

+

Dashboard

+

+ Welcome back, {data.session.user?.name ?? data.session.user?.email}. Here’s a quick pulse on + your travel story. +

+
+
+ +
+
+
+
+
+ +
+
+
+

World map

+

Past & future routes

+

+ Pins highlight places from your journey log and what’s still ahead. +

+
+
+
+ + Past trips +
+
+ + In progress +
+
+ + Upcoming +
+
+
+ +
+ {#if markers.length === 0} +
+ Add destinations to your trips to light up the map. +
+ {:else} + {#each markers as marker} + + {/each} + {/if} +
+
+
+ +
+
+

Travel diary

+

Where you’ve been

+
+
+ Past trips + {dashboard.stats.pastTrips} +
+
+ Days away + {dashboard.stats.daysAway} +
+
+ Countries visited + {dashboard.stats.countriesVisited} +
+
+ Cities explored + {dashboard.stats.citiesVisited} +
+
+
+

Top destinations

+ {#if dashboard.stats.topCountries.length === 0} +

No past destinations yet.

+ {:else} +
+ {#each dashboard.stats.topCountries as country} +
+ {country.country} + + {country.count} stop{country.count === 1 ? '' : 's'} + +
+ {/each} +
+ {/if} +
+
+ + +
+
+ +
+
+
+
+

On the horizon

+

Trips being planned

+
+ + View all + +
+ + {#if dashboard.plannedTrips.length === 0} +

No future trips yet. Start planning the next adventure.

+ {:else} + + {/if} +
+ +
+

Quick actions

+

Build your next story

+

+ Add a destination, log transportation, or save an idea to keep your timeline evolving. +

+ +
+
+
+ +