Files
trips/src/lib/server/dashboard.ts
AI Agent b672c4b15c
All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 2m14s
trips) add dashboard overview
2026-02-24 21:18:27 +00:00

193 lines
5.5 KiB
TypeScript

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
}
};
}