feat(api): add GET /api/v1/stats for dashboard widgets

Homepage's customapi widget and everything shaped like it want a handful
of numbers from one request; /api/v1/trips hands out the rows, but a
widget maps fields, it cannot aggregate a list.

The figures come from AtlasService.getTravelStats, the same call behind
the dashboard's passport card, rather than from a second count. Visited
countries carries years of rules — countries reached only by a flight,
layovers that do not count, countries hidden by hand — and a second
implementation would disagree with the card next to it within a release.

That is also why the controller sits in atlas/ and not in public-api/:
PublicApiModule stays a leaf, so the route comes to the data instead.
TravelStatsController next door already does this for
/api/auth/travel-stats. ApiTokenGuard is provided locally rather than
imported with PublicApiModule — @UseGuards instantiates a guard in the
declaring controller's module, so an export would still leave its
TokenService unresolvable here.

last_trip means the most recently started trip, not the most recently
created one, and is null while every trip is still ahead.

Closes #1367
This commit is contained in:
Maurice
2026-08-28 14:45:28 +02:00
committed by Maurice
parent 7cbd4e5c42
commit 5a7b8d75aa
11 changed files with 561 additions and 34 deletions
+48
View File
@@ -11,6 +11,7 @@ Read trips. That is deliberate and complete:
- list the trips you own or are a member of
- fetch one trip with its days, places in planned order, day notes, reservations, accommodations and fellow travellers
- read the bucket list: places the traveller wants to reach, not tied to any trip
- read a handful of totals for a dashboard widget: trips, countries, cities, places, days, flown distance, and the last trip taken
It cannot write anything. An integration that reads your itinerary needs no ability to delete a place, and a key that can only read is a very different thing to hand to third-party software.
@@ -156,6 +157,53 @@ Places the caller wants to reach, with no trip attached. Its own endpoint becaus
`target_date` is an aspiration, not a booking. Entries are returned whether or not the Atlas addon is switched on: the addon governs whether TREK shows the feature, not whether the rows exist, and an endpoint whose answers change when an unrelated toggle moves is one nobody can build on.
### `GET /api/v1/stats`
Totals for a dashboard. Built for widgets like Homepage's `customapi`, which render a few numbers from one request and cannot aggregate a list themselves.
```json
{
"total_trips": 12,
"total_countries": 23,
"total_cities": 41,
"total_places": 143,
"total_days": 87,
"total_distance_km": 84213,
"last_trip": {
"title": "Hokkaido in winter",
"start_date": "2026-02-03",
"end_date": "2026-02-14",
"country": "JP",
"countries": ["JP"]
}
}
```
These are the same figures TREK's own dashboard shows, computed from the same source — a widget cannot disagree with the passport card next to it. In particular `total_countries` follows TREK's notion of *visited*: countries reached only by a flight or train count, layovers do not, and countries hidden by hand in Atlas stay hidden.
`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.
A Homepage widget then needs no scripting:
```yaml
- TREK:
icon: mdi-map-marker-path
widget:
type: customapi
url: https://trek.example.com/api/v1/stats
headers:
X-API-Key: trek_your_key_here
mappings:
- field: total_trips
label: Trips
- field: total_countries
label: Countries
- field: total_cities
label: Cities
- field: last_trip.country
label: Last
```
## Notes for integrators
**Join on dates, not ids.** Every day carries an ISO `date`, and accommodations carry a resolved date range rather than internal day ids. If your software keeps its own notion of a trip, the date is the one thing both sides can agree on. TREK's internal ids are not in the payload at all, by design — they would tie you to storage details that are free to change.