mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
+6
-1
@@ -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')
|
||||
|
||||
@@ -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<string>();
|
||||
const cities = new Set<string>();
|
||||
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<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 {
|
||||
countries: [...countries],
|
||||
countries: [...countryCodes],
|
||||
cities: [...cities],
|
||||
coords,
|
||||
totalTrips: tripStats?.trips || 0,
|
||||
totalDays: tripStats?.days || 0,
|
||||
totalPlaces: places.length,
|
||||
totalDistanceKm: getFlightDistanceKm(userId),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user