mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(public-api): next_trip in /api/v1/stats counts down to the next trip
The counterpart of last_trip for a homepage widget: the nearest trip that has not started yet, with its countries and the whole days until it starts, or null when nothing is planned. A trip under way stays last_trip, so the two never name the same trip. (#2542)
This commit is contained in:
@@ -1118,6 +1118,49 @@ export class AtlasService {
|
||||
`, userId, userId);
|
||||
if (!trip) return null;
|
||||
|
||||
return {
|
||||
title: trip.title,
|
||||
start_date: trip.start_date,
|
||||
end_date: trip.end_date,
|
||||
countries: this.tripCountries(trip.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip the user goes on next, the counterpart of lastTrip for a widget that
|
||||
* counts down (#2542).
|
||||
*
|
||||
* Next means not started yet: a trip that is under way is still the last trip,
|
||||
* so the two never name the same one. Only a trip with a start date qualifies,
|
||||
* because a trip with only an end date has nothing to count down to. The days
|
||||
* are counted in SQLite's own calendar, the same `date('now')` lastTrip cuts
|
||||
* on, so the switch from next to last happens at one moment for both fields.
|
||||
*/
|
||||
nextTrip(userId: number): { title: string; start_date: string; end_date: string | null; days_until: number; countries: string[] } | null {
|
||||
const trip = this.db.get<{ id: number; title: string; start_date: string; end_date: string | null; days_until: number }>(`
|
||||
SELECT t.id, t.title, t.start_date, t.end_date,
|
||||
CAST(julianday(t.start_date) - julianday(date('now')) AS INTEGER) AS days_until
|
||||
FROM trips t
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?)
|
||||
AND t.start_date IS NOT NULL
|
||||
AND t.start_date > date('now')
|
||||
ORDER BY t.start_date ASC, t.id ASC
|
||||
LIMIT 1
|
||||
`, userId, userId);
|
||||
if (!trip) return null;
|
||||
|
||||
return {
|
||||
title: trip.title,
|
||||
start_date: trip.start_date,
|
||||
end_date: trip.end_date,
|
||||
days_until: trip.days_until,
|
||||
countries: this.tripCountries(trip.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** The countries a trip's places resolved to in `place_regions`, most-visited first. */
|
||||
private tripCountries(tripId: number): string[] {
|
||||
const rows = this.db.all<{ country_code: string; places: number }>(`
|
||||
SELECT pr.country_code, COUNT(DISTINCT p.id) AS places
|
||||
FROM place_regions pr
|
||||
@@ -1125,14 +1168,8 @@ export class AtlasService {
|
||||
WHERE p.trip_id = ? AND pr.country_code IS NOT NULL
|
||||
GROUP BY pr.country_code
|
||||
ORDER BY places DESC, pr.country_code ASC
|
||||
`, trip.id);
|
||||
|
||||
return {
|
||||
title: trip.title,
|
||||
start_date: trip.start_date,
|
||||
end_date: trip.end_date,
|
||||
countries: rows.map(r => r.country_code.toUpperCase()),
|
||||
};
|
||||
`, tripId);
|
||||
return rows.map(r => r.country_code.toUpperCase());
|
||||
}
|
||||
|
||||
getTravelStats(userId: number) {
|
||||
|
||||
@@ -53,6 +53,7 @@ export class PublicStatsController {
|
||||
|
||||
const travel = this.atlas.getTravelStats(userId);
|
||||
const last = this.atlas.lastTrip(userId);
|
||||
const next = this.atlas.nextTrip(userId);
|
||||
|
||||
// Counts, not the arrays behind them. A consumer that wants the members asks
|
||||
// /api/v1/trips; this endpoint exists for the one that wants a number.
|
||||
@@ -71,6 +72,14 @@ export class PublicStatsController {
|
||||
country: last.countries[0] ?? null,
|
||||
countries: last.countries,
|
||||
},
|
||||
next_trip: next && {
|
||||
title: next.title,
|
||||
start_date: next.start_date,
|
||||
end_date: next.end_date,
|
||||
days_until: next.days_until,
|
||||
country: next.countries[0] ?? null,
|
||||
countries: next.countries,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +316,7 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
|
||||
total_countries: 1,
|
||||
total_places: 1,
|
||||
last_trip: { title: 'Recent', country: 'IT', countries: ['IT'] },
|
||||
next_trip: { title: 'Booked', start_date: iso(30), end_date: iso(40), days_until: 30, country: null, countries: [] },
|
||||
});
|
||||
// Scalars all the way — a widget maps fields, it cannot aggregate a list.
|
||||
for (const k of ['total_trips', 'total_countries', 'total_cities', 'total_places', 'total_days', 'total_distance_km']) {
|
||||
@@ -331,7 +332,7 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
|
||||
const key = mintApiKey(stranger.id);
|
||||
const res = await request(server).get('/api/v1/stats').set('X-API-Key', key);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ total_trips: 0, total_countries: 0, last_trip: null });
|
||||
expect(res.body).toMatchObject({ total_trips: 0, total_countries: 0, last_trip: null, next_trip: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1956,3 +1956,59 @@ describe('lastTrip', () => {
|
||||
expect(atlas.lastTrip(user.id)?.title).toBe('Second');
|
||||
});
|
||||
});
|
||||
|
||||
// ── nextTrip (#2542, feeds GET /api/v1/stats) ───────────────────────────────
|
||||
|
||||
describe('nextTrip', () => {
|
||||
it('ATLAS-NEXT-001: returns null when nothing is ahead', () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(atlas.nextTrip(user.id)).toBeNull();
|
||||
createTrip(testDb, user.id, { title: 'Been there', start_date: PAST_START, end_date: PAST_END });
|
||||
expect(atlas.nextTrip(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('ATLAS-NEXT-002: picks the nearest trip that has not started, with the days until it', () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'Later', start_date: isoOffsetDays(90), end_date: isoOffsetDays(95) });
|
||||
const soon = createTrip(testDb, user.id, { title: 'Soon', start_date: FUTURE_START, end_date: FUTURE_END });
|
||||
const stamp = testDb.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
|
||||
stamp.run(insertPlaceWithCoords(testDb, soon.id, 'Lisboa', 38.72, -9.14).id, 'pt', 'PT-11', 'Lisboa');
|
||||
|
||||
expect(atlas.nextTrip(user.id)).toEqual({
|
||||
title: 'Soon',
|
||||
start_date: FUTURE_START,
|
||||
end_date: FUTURE_END,
|
||||
days_until: 30,
|
||||
countries: ['PT'],
|
||||
});
|
||||
});
|
||||
|
||||
it('ATLAS-NEXT-003: a trip under way is the last trip, not the next one', () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'Now', start_date: isoOffsetDays(-2), end_date: isoOffsetDays(3) });
|
||||
expect(atlas.nextTrip(user.id)).toBeNull();
|
||||
expect(atlas.lastTrip(user.id)?.title).toBe('Now');
|
||||
});
|
||||
|
||||
it('ATLAS-NEXT-004: a trip starting tomorrow is one day away', () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'Tomorrow', start_date: isoOffsetDays(1) });
|
||||
expect(atlas.nextTrip(user.id)).toMatchObject({ title: 'Tomorrow', days_until: 1, end_date: null });
|
||||
});
|
||||
|
||||
it('ATLAS-NEXT-005: a trip without a start date has nothing to count down to', () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'Someday', end_date: FUTURE_END });
|
||||
expect(atlas.nextTrip(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('ATLAS-NEXT-006: a shared trip counts for the member, and equal starts resolve by id', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const first = createTrip(testDb, owner.id, { title: 'First', start_date: FUTURE_START, end_date: FUTURE_END });
|
||||
const second = createTrip(testDb, owner.id, { title: 'Second', start_date: FUTURE_START, end_date: FUTURE_END });
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(first.id, member.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(second.id, member.id);
|
||||
expect(atlas.nextTrip(member.id)?.title).toBe('First');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ const travel = (o: Partial<ReturnType<AtlasService['getTravelStats']>> = {}) =>
|
||||
|
||||
function ctl(atlas: Partial<AtlasService> = {}, rl = new RateLimitService()) {
|
||||
return new PublicStatsController(
|
||||
{ getTravelStats: vi.fn(() => travel()), lastTrip: vi.fn(() => null), ...atlas } as unknown as AtlasService,
|
||||
{ getTravelStats: vi.fn(() => travel()), lastTrip: vi.fn(() => null), nextTrip: vi.fn(() => null), ...atlas } as unknown as AtlasService,
|
||||
rl,
|
||||
);
|
||||
}
|
||||
@@ -70,15 +70,18 @@ describe('PublicStatsController', () => {
|
||||
total_days: 31,
|
||||
total_distance_km: 18402,
|
||||
last_trip: null,
|
||||
next_trip: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('PUBSTATS-002: passes the authenticated user through to the service', () => {
|
||||
const getTravelStats = vi.fn(() => travel());
|
||||
const lastTrip = vi.fn(() => null);
|
||||
ctl({ getTravelStats, lastTrip } as unknown as Partial<AtlasService>).stats(req(42));
|
||||
const nextTrip = vi.fn(() => null);
|
||||
ctl({ getTravelStats, lastTrip, nextTrip } as unknown as Partial<AtlasService>).stats(req(42));
|
||||
expect(getTravelStats).toHaveBeenCalledWith(42);
|
||||
expect(lastTrip).toHaveBeenCalledWith(42);
|
||||
expect(nextTrip).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('PUBSTATS-003: last_trip carries the dominant country as the head of the list', () => {
|
||||
@@ -101,6 +104,27 @@ describe('PublicStatsController', () => {
|
||||
expect(out.last_trip).toMatchObject({ country: null, countries: [] });
|
||||
});
|
||||
|
||||
it('PUBSTATS-008: next_trip counts down and names its countries like last_trip (#2542)', () => {
|
||||
const nextTrip = vi.fn(() => ({
|
||||
title: 'Lisbon', start_date: '2026-11-12', end_date: '2026-11-16', days_until: 42, countries: ['PT', 'ES'],
|
||||
}));
|
||||
const out = ctl({ nextTrip } as unknown as Partial<AtlasService>).stats(req());
|
||||
expect(out.next_trip).toEqual({
|
||||
title: 'Lisbon',
|
||||
start_date: '2026-11-12',
|
||||
end_date: '2026-11-16',
|
||||
days_until: 42,
|
||||
country: 'PT',
|
||||
countries: ['PT', 'ES'],
|
||||
});
|
||||
});
|
||||
|
||||
it('PUBSTATS-009: an ungeocoded next trip reports country null', () => {
|
||||
const nextTrip = vi.fn(() => ({ title: 'Somewhere', start_date: '2026-12-01', end_date: null, days_until: 61, countries: [] }));
|
||||
const out = ctl({ nextTrip } as unknown as Partial<AtlasService>).stats(req());
|
||||
expect(out.next_trip).toMatchObject({ country: null, countries: [], end_date: null });
|
||||
});
|
||||
|
||||
it('PUBSTATS-005: shares one rate-limit budget with the rest of /api/v1', () => {
|
||||
const rl = new RateLimitService();
|
||||
const c = ctl({}, rl);
|
||||
|
||||
@@ -308,6 +308,19 @@ export const publicApiLastTripSchema = z.object({
|
||||
});
|
||||
export type PublicApiLastTrip = z.infer<typeof publicApiLastTripSchema>;
|
||||
|
||||
/**
|
||||
* The trip the traveller goes on next, for a widget that counts down to it (#2542).
|
||||
*
|
||||
* "Next" means not started yet, so a trip under way is still `last_trip` and the
|
||||
* two never name the same trip. A user with nothing ahead gets `null`.
|
||||
*/
|
||||
export const publicApiNextTripSchema = publicApiLastTripSchema.extend({
|
||||
start_date: z.string(),
|
||||
/** Whole days from today until the start date; 1 means it starts tomorrow. */
|
||||
days_until: z.number().int().positive(),
|
||||
});
|
||||
export type PublicApiNextTrip = z.infer<typeof publicApiNextTripSchema>;
|
||||
|
||||
/**
|
||||
* Aggregate counts for a dashboard widget (#1367) — Homepage's `customapi` and
|
||||
* anything else that renders a handful of numbers and cannot aggregate a list
|
||||
@@ -337,5 +350,6 @@ export const publicApiStatsSchema = z.object({
|
||||
/** Flown distance, summed over non-cancelled flight bookings. Kilometres. */
|
||||
total_distance_km: z.number(),
|
||||
last_trip: publicApiLastTripSchema.nullable(),
|
||||
next_trip: publicApiNextTripSchema.nullable(),
|
||||
});
|
||||
export type PublicApiStats = z.infer<typeof publicApiStatsSchema>;
|
||||
|
||||
@@ -193,6 +193,14 @@ Totals for a dashboard. Built for widgets like Homepage's `customapi`, which ren
|
||||
"end_date": "2026-02-14",
|
||||
"country": "JP",
|
||||
"countries": ["JP"]
|
||||
},
|
||||
"next_trip": {
|
||||
"title": "Lisbon long weekend",
|
||||
"start_date": "2026-11-12",
|
||||
"end_date": "2026-11-16",
|
||||
"days_until": 42,
|
||||
"country": "PT",
|
||||
"countries": ["PT"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -201,6 +209,8 @@ These are the same figures TREK's own dashboard shows, computed from the same so
|
||||
|
||||
`last_trip` is the most recent trip that has **started** — a trip booked for next year is not one you have been on — and is `null` when every trip is still ahead. `country` is the country most of its places sit in, and is the head of `countries`, which lists them all for a trip that crossed a border. Both are empty or `null` for a trip whose places were never geocoded.
|
||||
|
||||
`next_trip` is its counterpart: the trip with the nearest start date that has **not started yet**, with the same `country` and `countries`, and `null` when nothing is planned. A trip that is under way is still `last_trip`, so the two never name the same trip. `days_until` counts whole days from today to the start date, so `1` means tomorrow.
|
||||
|
||||
A Homepage widget then needs no scripting:
|
||||
|
||||
```yaml
|
||||
@@ -220,6 +230,8 @@ A Homepage widget then needs no scripting:
|
||||
label: Cities
|
||||
- field: last_trip.country
|
||||
label: Last
|
||||
- field: next_trip.days_until
|
||||
label: Days to go
|
||||
```
|
||||
|
||||
## Notes for integrators
|
||||
|
||||
Reference in New Issue
Block a user