From e5000ff7dd3549bc3bf98534945defec357dd7eb Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 26 May 2026 23:12:07 +0200 Subject: [PATCH] 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. --- server/src/app.ts | 7 +++- server/src/services/authService.ts | 26 +++++++++++--- server/src/services/distanceService.ts | 43 +++++++++++++++++++++++ server/src/services/reservationService.ts | 34 ++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 server/src/services/distanceService.ts diff --git a/server/src/app.ts b/server/src/app.ts index a3fd89b9..c891b5b3 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -44,7 +44,8 @@ import publicConfigRoutes from './routes/publicConfig'; import systemNoticesRoutes from './routes/systemNotices'; import { mcpHandler } from './mcp'; 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 { getCollabFeatures } 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/collab', collabRoutes); 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.get('/api/health', (_req: Request, res: Response) => { res.setHeader('Cache-Control', 'no-store, must-revalidate') diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 794fe5c1..ac90bd71 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -16,6 +16,7 @@ import { createEphemeralToken } from './ephemeralTokens'; import { revokeUserSessions } from '../mcp'; import { startTripReminders } from '../scheduler'; import { deleteUserCompletely } from './userCleanupService'; +import { getFlightDistanceKm } from './distanceService'; import { verifyJwtAndLoadUser } from '../middleware/auth'; import { User } from '../types'; 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 `).get(userId, userId) as { trips: number; days: number } | undefined; - const countries = new Set(); const cities = new Set(); 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.address) { 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)); 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(); + 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 { - countries: [...countries], + countries: [...countryCodes], cities: [...cities], coords, totalTrips: tripStats?.trips || 0, totalDays: tripStats?.days || 0, totalPlaces: places.length, + totalDistanceKm: getFlightDistanceKm(userId), }; } diff --git a/server/src/services/distanceService.ts b/server/src/services/distanceService.ts new file mode 100644 index 00000000..559b8159 --- /dev/null +++ b/server/src/services/distanceService.ts @@ -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); +} diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index f78200ca..6e218010 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -117,6 +117,40 @@ export function listReservations(tripId: string | number) { 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) { const row = db.prepare(` SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,