trips) add dashboard overview
All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 2m14s
All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 2m14s
This commit is contained in:
75
e2e/dashboard.test.ts
Normal file
75
e2e/dashboard.test.ts
Normal file
@@ -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<void> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
192
src/lib/server/dashboard.ts
Normal file
192
src/lib/server/dashboard.ts
Normal file
@@ -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<Trip>(
|
||||||
|
`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<string, { country: string; countryCode: string; count: number }>();
|
||||||
|
const cities = new Set<string>();
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/routes/(protected)/dashboard/+page.server.ts
Normal file
13
src/routes/(protected)/dashboard/+page.server.ts
Normal file
@@ -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)
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,10 +1,449 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import type { DashboardData, DashboardMapStop } from '$lib/server/dashboard.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
const dashboard = $derived(data.dashboard) as DashboardData;
|
||||||
|
|
||||||
|
const markers = $derived(
|
||||||
|
dashboard.mapStops.map((stop: DashboardMapStop, index: number) => {
|
||||||
|
const x = ((stop.lon + 180) / 360) * 100;
|
||||||
|
const y = ((90 - stop.lat) / 180) * 100;
|
||||||
|
return {
|
||||||
|
...stop,
|
||||||
|
id: `${stop.tripId}-${index}`,
|
||||||
|
x: Math.min(98, Math.max(2, x)),
|
||||||
|
y: Math.min(96, Math.max(4, y))
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function parseDate(value: string | null): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = new Date(`${value}T00:00:00`);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return 'TBD';
|
||||||
|
return new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRange(start: string | null, end: string | null): string {
|
||||||
|
if (!start && !end) return 'Dates not set yet';
|
||||||
|
return `${formatDate(start)} — ${formatDate(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayDiffInclusive(start: string | null, end: string | null): number | null {
|
||||||
|
const startDate = parseDate(start);
|
||||||
|
const endDate = parseDate(end);
|
||||||
|
if (!startDate || !endDate) return null;
|
||||||
|
const diffMs = endDate.getTime() - startDate.getTime();
|
||||||
|
if (Number.isNaN(diffMs)) return null;
|
||||||
|
return Math.max(0, Math.round(diffMs / 86400000) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressPercent(start: string | null, end: string | null): number | null {
|
||||||
|
const startDate = parseDate(start);
|
||||||
|
const endDate = parseDate(end);
|
||||||
|
if (!startDate || !endDate) return null;
|
||||||
|
const now = new Date();
|
||||||
|
const totalMs = endDate.getTime() - startDate.getTime();
|
||||||
|
if (totalMs <= 0) return null;
|
||||||
|
const elapsedMs = now.getTime() - startDate.getTime();
|
||||||
|
return Math.min(100, Math.max(0, Math.round((elapsedMs / totalMs) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysUntil(value: string | null): number | null {
|
||||||
|
const target = parseDate(value);
|
||||||
|
if (!target) return null;
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = target.getTime() - now.getTime();
|
||||||
|
return Math.ceil(diffMs / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentTripDay(start: string | null, end: string | null): { current: number; total: number } | null {
|
||||||
|
const startDate = parseDate(start);
|
||||||
|
const endDate = parseDate(end);
|
||||||
|
const total = dayDiffInclusive(start, end);
|
||||||
|
if (!startDate || !endDate || !total) return null;
|
||||||
|
const now = new Date();
|
||||||
|
const elapsed = Math.floor((now.getTime() - startDate.getTime()) / 86400000) + 1;
|
||||||
|
return { current: Math.min(total, Math.max(1, elapsed)), total };
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Dashboard — Trips</title>
|
<title>Dashboard — Trips</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Fraunces:wght@600;700&family=Manrope:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1 class="mb-2 text-2xl font-bold text-gray-900">Dashboard</h1>
|
<div class="dashboard-shell space-y-8">
|
||||||
<p class="text-gray-600">Welcome, {data.session.user?.name ?? data.session.user?.email}.</p>
|
<header class="relative overflow-hidden rounded-3xl border border-slate-200 bg-gradient-to-br from-amber-50 via-white to-sky-50 p-6">
|
||||||
|
<div class="absolute -right-10 top-6 h-32 w-32 rounded-full bg-sky-200/40 blur-3xl"></div>
|
||||||
|
<div class="absolute -left-12 bottom-6 h-40 w-40 rounded-full bg-amber-200/40 blur-3xl"></div>
|
||||||
|
<div class="relative">
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Overview</p>
|
||||||
|
<h1 class="display mt-2 text-3xl text-slate-900 sm:text-4xl">Dashboard</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">
|
||||||
|
Welcome back, {data.session.user?.name ?? data.session.user?.email}. Here’s a quick pulse on
|
||||||
|
your travel story.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid gap-6 lg:grid-cols-[1.6fr,1fr]">
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-3xl border border-slate-200 bg-slate-950/90 p-6 text-white"
|
||||||
|
data-testid="trip-map"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 map-grid opacity-30"></div>
|
||||||
|
<div class="absolute -left-20 top-10 h-48 w-48 rounded-full bg-emerald-400/20 blur-3xl"></div>
|
||||||
|
<div class="absolute right-0 bottom-0 h-56 w-56 rounded-full bg-sky-400/20 blur-3xl"></div>
|
||||||
|
<svg
|
||||||
|
class="absolute inset-0 h-full w-full opacity-70"
|
||||||
|
viewBox="0 0 1000 500"
|
||||||
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect width="1000" height="500" fill="none" />
|
||||||
|
<polygon class="map-land" points="110,140 220,90 330,120 360,200 290,260 170,240" />
|
||||||
|
<polygon class="map-land" points="260,260 330,260 370,320 340,430 260,400 230,320" />
|
||||||
|
<polygon class="map-land" points="420,120 520,110 560,150 520,190 450,170" />
|
||||||
|
<polygon class="map-land" points="450,200 560,210 610,320 550,420 470,340" />
|
||||||
|
<polygon class="map-land" points="580,120 760,110 880,200 780,270 620,220" />
|
||||||
|
<polygon class="map-land" points="740,320 830,330 860,400 800,440 720,380" />
|
||||||
|
</svg>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-emerald-200">World map</p>
|
||||||
|
<h2 class="display mt-2 text-xl text-white">Past & future routes</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-200/80">
|
||||||
|
Pins highlight places from your journey log and what’s still ahead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 text-xs text-slate-200">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="legend-dot legend-past"></span>
|
||||||
|
Past trips
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="legend-dot legend-current"></span>
|
||||||
|
In progress
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="legend-dot legend-future"></span>
|
||||||
|
Upcoming
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative mt-6 h-64">
|
||||||
|
{#if markers.length === 0}
|
||||||
|
<div class="flex h-full items-center justify-center rounded-2xl border border-dashed border-white/30 text-sm text-slate-200">
|
||||||
|
Add destinations to your trips to light up the map.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each markers as marker}
|
||||||
|
<span
|
||||||
|
class="map-marker map-marker--{marker.status}"
|
||||||
|
data-testid="map-stop"
|
||||||
|
data-status={marker.status}
|
||||||
|
style={`left:${marker.x}%; top:${marker.y}%;`}
|
||||||
|
title={`${marker.tripName} · ${marker.label}`}
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Travel diary</p>
|
||||||
|
<h2 class="display mt-2 text-xl text-slate-900">Where you’ve been</h2>
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Past trips</span>
|
||||||
|
<span class="stat-value" data-testid="stat-past-trips">{dashboard.stats.pastTrips}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Days away</span>
|
||||||
|
<span class="stat-value" data-testid="stat-days-away">{dashboard.stats.daysAway}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Countries visited</span>
|
||||||
|
<span class="stat-value" data-testid="stat-countries">{dashboard.stats.countriesVisited}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Cities explored</span>
|
||||||
|
<span class="stat-value" data-testid="stat-cities">{dashboard.stats.citiesVisited}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Top destinations</p>
|
||||||
|
{#if dashboard.stats.topCountries.length === 0}
|
||||||
|
<p class="mt-2 text-sm text-slate-500">No past destinations yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-3 space-y-2">
|
||||||
|
{#each dashboard.stats.topCountries as country}
|
||||||
|
<div class="flex items-center justify-between text-sm text-slate-700">
|
||||||
|
<span>{country.country}</span>
|
||||||
|
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
|
||||||
|
{country.count} stop{country.count === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-gradient-to-br from-slate-900 via-slate-900 to-emerald-900 p-5 text-white shadow-sm">
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-emerald-200">Next up</p>
|
||||||
|
<h2 class="display mt-2 text-xl">Trips in progress</h2>
|
||||||
|
{#if dashboard.inProgressTrips.length === 0}
|
||||||
|
<p class="mt-3 text-sm text-emerald-100/80">No trips are underway right now.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
{#each dashboard.inProgressTrips as trip}
|
||||||
|
<a
|
||||||
|
href="{base}/trips/{trip.id}"
|
||||||
|
class="block rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-white">{trip.name}</h3>
|
||||||
|
<span class="badge badge-current">In progress</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-emerald-100/80">
|
||||||
|
{formatRange(trip.start_date, trip.end_date)}
|
||||||
|
</p>
|
||||||
|
{#if progressPercent(trip.start_date, trip.end_date) !== null}
|
||||||
|
<div class="mt-3 h-1.5 w-full rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
class="h-1.5 rounded-full bg-emerald-300"
|
||||||
|
style={`width:${progressPercent(trip.start_date, trip.end_date)}%`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-2 text-xs text-emerald-100/70">
|
||||||
|
{#if currentTripDay(trip.start_date, trip.end_date)}
|
||||||
|
Day {currentTripDay(trip.start_date, trip.end_date)?.current} of {currentTripDay(
|
||||||
|
trip.start_date,
|
||||||
|
trip.end_date
|
||||||
|
)?.total}
|
||||||
|
·
|
||||||
|
{/if}
|
||||||
|
{trip.planCount} plans logged
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">On the horizon</p>
|
||||||
|
<h2 class="display mt-2 text-xl text-slate-900">Trips being planned</h2>
|
||||||
|
</div>
|
||||||
|
<a href="{base}/trips/upcoming" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 hover:text-slate-800">
|
||||||
|
View all
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if dashboard.plannedTrips.length === 0}
|
||||||
|
<p class="mt-4 text-sm text-slate-500">No future trips yet. Start planning the next adventure.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
{#each dashboard.plannedTrips as trip}
|
||||||
|
<a
|
||||||
|
href="{base}/trips/{trip.id}"
|
||||||
|
class="block rounded-2xl border border-slate-200 bg-slate-50 p-4 transition hover:bg-white"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">{trip.name}</h3>
|
||||||
|
<span class="badge badge-future">Planning</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{formatRange(trip.start_date, trip.end_date)}</p>
|
||||||
|
<p class="mt-2 text-xs text-slate-600">
|
||||||
|
{#if daysUntil(trip.start_date) !== null}
|
||||||
|
Starts in {daysUntil(trip.start_date)} day{daysUntil(trip.start_date) === 1 ? '' : 's'}
|
||||||
|
{:else}
|
||||||
|
Dates to be confirmed
|
||||||
|
{/if}
|
||||||
|
· {trip.planCount} plans sketched
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-gradient-to-br from-white via-white to-sky-50 p-6 shadow-sm">
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Quick actions</p>
|
||||||
|
<h2 class="display mt-2 text-xl text-slate-900">Build your next story</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">
|
||||||
|
Add a destination, log transportation, or save an idea to keep your timeline evolving.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
<a
|
||||||
|
href="{base}/trips/new"
|
||||||
|
class="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white transition hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Plan new trip
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{base}/trips/upcoming"
|
||||||
|
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-600 transition hover:border-slate-400 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
Browse upcoming
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{base}/trips/past"
|
||||||
|
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-600 transition hover:border-slate-400 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
Revisit past trips
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dashboard-shell {
|
||||||
|
font-family: 'Manrope', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display {
|
||||||
|
font-family: 'Fraunces', 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-grid {
|
||||||
|
background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(0deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-land {
|
||||||
|
fill: rgba(148, 163, 184, 0.25);
|
||||||
|
stroke: rgba(148, 163, 184, 0.35);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker {
|
||||||
|
position: absolute;
|
||||||
|
width: 0.8rem;
|
||||||
|
height: 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15), 0 8px 20px rgba(15, 23, 42, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker--past {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border: 2px solid #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker--current {
|
||||||
|
background: #34d399;
|
||||||
|
border: 2px solid #0f766e;
|
||||||
|
animation: pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker--future {
|
||||||
|
background: #fbbf24;
|
||||||
|
border: 2px solid #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
height: 0.6rem;
|
||||||
|
width: 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-past {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-current {
|
||||||
|
background: #34d399;
|
||||||
|
border-color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-future {
|
||||||
|
background: #fbbf24;
|
||||||
|
border-color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-current {
|
||||||
|
background: rgba(52, 211, 153, 0.2);
|
||||||
|
color: #a7f3d0;
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-future {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.3), 0 8px 20px rgba(15, 23, 42, 0.35);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.12);
|
||||||
|
box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.18), 0 8px 20px rgba(15, 23, 42, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user