Files
trips/src/routes/(protected)/dashboard/+page.svelte
AI Agent 7a6bf84eb5
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Has been cancelled
trips) tune dashboard map offsets
2026-02-24 22:40:17 +00:00

425 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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}. Heres 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 &amp; future routes</h2>
<p class="mt-2 text-sm text-slate-200/80">
Pins highlight places from your journey log and whats 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 youve 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>