diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index 81f6308c1..2101bebdf 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -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) { diff --git a/server/src/nest/atlas/public-stats.controller.ts b/server/src/nest/atlas/public-stats.controller.ts index d4317d8ee..ef3e87b49 100644 --- a/server/src/nest/atlas/public-stats.controller.ts +++ b/server/src/nest/atlas/public-stats.controller.ts @@ -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, + }, }; } } diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts index 9993f2a91..475aca282 100644 --- a/server/tests/e2e/atlas.e2e.test.ts +++ b/server/tests/e2e/atlas.e2e.test.ts @@ -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 }); }); }); diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts index 719578a2a..42af08075 100644 --- a/server/tests/unit/nest/atlas.service.test.ts +++ b/server/tests/unit/nest/atlas.service.test.ts @@ -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'); + }); +}); diff --git a/server/tests/unit/nest/public-stats.controller.test.ts b/server/tests/unit/nest/public-stats.controller.test.ts index 50f24b113..e8f64738e 100644 --- a/server/tests/unit/nest/public-stats.controller.test.ts +++ b/server/tests/unit/nest/public-stats.controller.test.ts @@ -46,7 +46,7 @@ const travel = (o: Partial> = {}) => function ctl(atlas: Partial = {}, 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).stats(req(42)); + const nextTrip = vi.fn(() => null); + ctl({ getTravelStats, lastTrip, nextTrip } as unknown as Partial).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).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).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); diff --git a/shared/src/public-api/public-api.schema.ts b/shared/src/public-api/public-api.schema.ts index 8c92e372e..838e68fdc 100644 --- a/shared/src/public-api/public-api.schema.ts +++ b/shared/src/public-api/public-api.schema.ts @@ -308,6 +308,19 @@ export const publicApiLastTripSchema = z.object({ }); export type PublicApiLastTrip = z.infer; +/** + * 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; + /** * 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; diff --git a/wiki/Public-API.md b/wiki/Public-API.md index d92dd1c94..d9def12b5 100644 --- a/wiki/Public-API.md +++ b/wiki/Public-API.md @@ -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