Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Has been cancelled
425 lines
14 KiB
Svelte
425 lines
14 KiB
Svelte
<script lang="ts">
|
||
import { base } from '$app/paths';
|
||
import type { DashboardData, DashboardMapStop } from '$lib/server/dashboard.js';
|
||
|
||
let { data } = $props();
|
||
const dashboard = $derived(data.dashboard) as DashboardData;
|
||
|
||
const mapLongitudeOffset = 18;
|
||
const mapLatitudeOffset = 4;
|
||
const markers = $derived(
|
||
dashboard.mapStops.map((stop: DashboardMapStop, index: number) => {
|
||
const x = ((stop.lon + 180 - mapLongitudeOffset) / 360) * 100;
|
||
const y = ((90 - (stop.lat + mapLatitudeOffset)) / 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>
|
||
|
||
<svelte:head>
|
||
<title>Dashboard — Trips</title>
|
||
</svelte:head>
|
||
|
||
<div class="dashboard-shell space-y-8">
|
||
<header class="rounded-3xl bg-white p-6 shadow-sm">
|
||
<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>
|
||
</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-15"></div>
|
||
<div class="absolute -left-20 top-10 h-48 w-48 rounded-full bg-emerald-400/15 blur-3xl"></div>
|
||
<div class="absolute right-0 bottom-0 h-56 w-56 rounded-full bg-sky-400/15 blur-3xl"></div>
|
||
<img
|
||
class="map-base absolute inset-0 h-full w-full object-cover"
|
||
src="{base}/world-map.svg"
|
||
alt=""
|
||
aria-hidden="true"
|
||
/>
|
||
<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>
|
||
.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-base {
|
||
opacity: 0.45;
|
||
filter: invert(1) grayscale(1);
|
||
}
|
||
|
||
.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>
|