All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 2m14s
193 lines
5.5 KiB
TypeScript
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
|
|
}
|
|
};
|
|
}
|