feat(dashboard): upcoming reservations endpoint + travel-stats country/distance

Adds GET /api/reservations/upcoming for the dashboard widget, switches travel-stats to the same country source as Atlas (manual + place-derived, ISO codes), and a distance service for flown km.
This commit is contained in:
Maurice
2026-05-26 23:12:07 +02:00
parent 126f2df21b
commit e5000ff7dd
4 changed files with 104 additions and 6 deletions
+6 -1
View File
@@ -44,7 +44,8 @@ import publicConfigRoutes from './routes/publicConfig';
import systemNoticesRoutes from './routes/systemNotices'; import systemNoticesRoutes from './routes/systemNotices';
import { mcpHandler } from './mcp'; import { mcpHandler } from './mcp';
import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider'; import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider';
import { Addon } from './types'; import { Addon, AuthRequest } from './types';
import { getUpcomingReservations } from './services/reservationService';
import { getPhotoProviderConfig } from './services/memories/helpersService'; import { getPhotoProviderConfig } from './services/memories/helpersService';
import { getCollabFeatures } from './services/adminService'; import { getCollabFeatures } from './services/adminService';
import { isAddonEnabled } from './services/adminService'; import { isAddonEnabled } from './services/adminService';
@@ -275,6 +276,10 @@ export function createApp(): express.Application {
app.use('/api/trips/:tripId/budget', budgetRoutes); app.use('/api/trips/:tripId/budget', budgetRoutes);
app.use('/api/trips/:tripId/collab', collabRoutes); app.use('/api/trips/:tripId/collab', collabRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes); app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.get('/api/reservations/upcoming', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ reservations: getUpcomingReservations(authReq.user.id) });
});
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes); app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.get('/api/health', (_req: Request, res: Response) => { app.get('/api/health', (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-store, must-revalidate') res.setHeader('Cache-Control', 'no-store, must-revalidate')
+21 -5
View File
@@ -16,6 +16,7 @@ import { createEphemeralToken } from './ephemeralTokens';
import { revokeUserSessions } from '../mcp'; import { revokeUserSessions } from '../mcp';
import { startTripReminders } from '../scheduler'; import { startTripReminders } from '../scheduler';
import { deleteUserCompletely } from './userCleanupService'; import { deleteUserCompletely } from './userCleanupService';
import { getFlightDistanceKm } from './distanceService';
import { verifyJwtAndLoadUser } from '../middleware/auth'; import { verifyJwtAndLoadUser } from '../middleware/auth';
import { User } from '../types'; import { User } from '../types';
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo'; import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
@@ -892,7 +893,6 @@ export function getTravelStats(userId: number) {
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0 WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
`).get(userId, userId) as { trips: number; days: number } | undefined; `).get(userId, userId) as { trips: number; days: number } | undefined;
const countries = new Set<string>();
const cities = new Set<string>(); const cities = new Set<string>();
const coords: { lat: number; lng: number }[] = []; const coords: { lat: number; lng: number }[] = [];
@@ -900,21 +900,37 @@ export function getTravelStats(userId: number) {
if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng }); if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng });
if (p.address) { if (p.address) {
const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim()); const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim());
for (const part of parts) {
if (KNOWN_COUNTRIES.has(part)) { countries.add(part); break; }
}
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s)); const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s));
if (cityPart) cities.add(cityPart); if (cityPart) cities.add(cityPart);
} }
}); });
// Visited countries \u2014 same source the Atlas page uses: ISO-2 codes from
// auto-resolved place regions plus countries the user marked manually.
const countryCodes = new Set<string>();
const manualCountries = db.prepare(
'SELECT country_code FROM visited_countries WHERE user_id = ?'
).all(userId) as { country_code: string }[];
manualCountries.forEach(m => { if (m.country_code) countryCodes.add(m.country_code.toUpperCase()); });
const placeRegionCodes = db.prepare(`
SELECT DISTINCT pr.country_code
FROM place_regions pr
JOIN places p ON p.id = pr.place_id
JOIN trips t ON p.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND pr.country_code IS NOT NULL
`).all(userId, userId) as { country_code: string }[];
placeRegionCodes.forEach(r => { if (r.country_code) countryCodes.add(r.country_code.toUpperCase()); });
return { return {
countries: [...countries], countries: [...countryCodes],
cities: [...cities], cities: [...cities],
coords, coords,
totalTrips: tripStats?.trips || 0, totalTrips: tripStats?.trips || 0,
totalDays: tripStats?.days || 0, totalDays: tripStats?.days || 0,
totalPlaces: places.length, totalPlaces: places.length,
totalDistanceKm: getFlightDistanceKm(userId),
}; };
} }
+43
View File
@@ -0,0 +1,43 @@
import { db } from '../db/database';
// Great-circle distance between two points in kilometres.
function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Total flight distance a user has covered, summed across every non-cancelled
* flight reservation in their trips. Each flight stores its waypoints in
* reservation_endpoints (from → stops → to, ordered by sequence); we add up the
* legs between consecutive points so multi-stop flights count correctly.
*/
export function getFlightDistanceKm(userId: number): number {
const rows = db.prepare(`
SELECT re.reservation_id, re.lat, re.lng
FROM reservation_endpoints re
JOIN reservations r ON r.id = re.reservation_id
JOIN trips t ON t.id = r.trip_id
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
AND r.type = 'flight'
AND r.status != 'cancelled'
ORDER BY re.reservation_id, re.sequence
`).all(userId, userId) as { reservation_id: number; lat: number; lng: number }[];
let total = 0;
let prev: { id: number; lat: number; lng: number } | null = null;
for (const point of rows) {
if (prev && prev.id === point.reservation_id) {
total += haversineKm(prev.lat, prev.lng, point.lat, point.lng);
}
prev = { id: point.reservation_id, lat: point.lat, lng: point.lng };
}
return Math.round(total);
}
+34
View File
@@ -117,6 +117,40 @@ export function listReservations(tripId: string | number) {
return reservations; return reservations;
} }
/**
* Upcoming reservations across all of a user's active trips, soonest first.
* Used by the dashboard's "Upcoming reservations" widget. A reservation counts
* as upcoming when its own time is in the future, or — for timeless entries —
* when its day falls on or after today. Cancelled bookings are skipped.
*/
export function getUpcomingReservations(userId: number, limit = 6) {
const today = new Date().toISOString().slice(0, 10);
const now = new Date().toISOString();
const reservations = db.prepare(`
SELECT r.id, r.trip_id, r.title, r.type, r.status, r.location,
r.reservation_time, r.confirmation_number,
t.title as trip_title, t.cover_image as trip_cover,
d.date as day_date, p.name as place_name, p.image_url as place_image
FROM reservations r
JOIN trips t ON t.id = r.trip_id
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
AND t.is_archived = 0
AND r.status != 'cancelled'
AND (
(r.reservation_time IS NOT NULL AND r.reservation_time >= ?)
OR (r.reservation_time IS NULL AND d.date IS NOT NULL AND d.date >= ?)
)
ORDER BY COALESCE(r.reservation_time, d.date) ASC
LIMIT ?
`).all(userId, userId, now, today, limit) as any[];
return reservations;
}
export function getReservationWithJoins(id: string | number) { export function getReservationWithJoins(id: string | number) {
const row = db.prepare(` const row = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,