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:
Maurice
2026-10-01 09:22:37 +02:00
parent e24da67218
commit ba3dc65926
7 changed files with 164 additions and 11 deletions
+45 -8
View File
@@ -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,
},
};
}
}
+2 -1
View File
@@ -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>;
+12
View File
@@ -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