Rework entry types: merge hotel into stay, transport with modes, auto-transport

- Type set is now activity/stay/transport/flight/rental/note; hotel, travel
  and immigration are removed with idempotent startup data migrations
  (hotel->stay, travel->transport, immigration->activity with flag prefix)
- Transport entries carry an optional mode (train/bus/ferry/taxi/drive/other)
  that drives the chip/map icon; route stops expose transport_mode
- Creating a stay auto-creates a bridging transport to its neighbouring
  stays unless a transport/flight already covers the gap (one-shot)
- Summary: transports count replaces hotels/travelLegs
This commit is contained in:
2026-07-19 17:47:21 +07:00
parent b066e7b885
commit d393e16ae1
17 changed files with 469 additions and 62 deletions
+17 -7
View File
@@ -45,13 +45,23 @@ entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
paid_by INTEGER REFERENCES users(id), -- who paid (null = unassigned)
split_mode TEXT NOT NULL DEFAULT 'equal', -- 'equal' | 'own' | 'payer'
segments TEXT, -- JSON array, flight entries only (see below)
rental TEXT, -- JSON object, rental entries only (see below)
transport_mode TEXT, -- transport entries only (see below)
created_at TEXT DEFAULT current_timestamp)
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
PRIMARY KEY (entry_id, user_id))
-- no rows for an entry = "all trip members participate" (dynamic default)
```
Entry `type``flight | immigration | travel | hotel | activity | rental | stay | note`.
Entry `type``flight | transport | activity | rental | stay | note`.
**Legacy types** (removed 2026-07): `hotel`, `travel`, `immigration`. Idempotent data migrations run at startup: `hotel` rows become `stay`, `travel` rows become `transport`, `immigration` rows become `activity` with the title prefixed `🛂 `. POST/PATCH with a legacy type is a 400 (`invalid entry type`).
### Transport entries
`transport` covers ground/sea travel (the old `travel` type). Optional `transport_mode``train | bus | ferry | taxi | drive | other` (nullable; drives the icon in the UI). Validation: only allowed when the effective `type === 'transport'` (else 400 `transport_mode is only allowed on transport entries`); PATCH `transport_mode: null` clears it. Entry JSON always includes `transport_mode` (string or null).
**Auto-transport between stays**: when a `stay` entry is POSTed and it has a chronological neighbour stay (the nearest stay before and/or after it, ordered by `date`), the server auto-creates one `transport` entry per neighbour pair — titled `"<from short name> → <to short name>"` (short name = first comma-segment of the stay's `location_name`, fallback `title`), dated on the **later** stay's `date`, `transport_mode` null, no price/location, `sort_order` 0. Skipped when any `transport` or `flight` entry already exists with `date` between the earlier stay's end (`end_date` or `date`) and the later stay's `date` (inclusive). One-shot: fires only on stay **creation** (never PATCH), so deleting an auto-created transport does not resurrect it. The POST response is unchanged (`201 {entry}` = the stay); clients should refetch the entry list.
### Multi-day entries & stays
@@ -155,11 +165,11 @@ User JSON shape everywhere: `{id, display_name}`.
| Method & path | Body | Response |
|---|---|---|
| `POST /api/trips/:id/entries` | `{date, end_date?, type, title, details?, start_time?, end_time?, location_name?, lat?, lng?, sort_order?, price?, paid_by?, split_mode?, participants?, segments?}` | `201 {entry}`; validates type enum, date format, end_date null or valid date ≥ date, title non-empty ≤200 chars; lat/lng must both be present or both absent, lat ∈ [-90,90], lng ∈ [-180,180]; price null or number ≥ 0; paid_by null or a trip member's user id; split_mode ∈ `equal\|own\|payer` (`payer` requires paid_by); participants null/[] (= all members) or array of trip-member user ids; segments per "Flight segments" above |
| `POST /api/trips/:id/entries` | `{date, end_date?, type, title, details?, start_time?, end_time?, location_name?, lat?, lng?, sort_order?, price?, paid_by?, split_mode?, participants?, segments?}` | `201 {entry}`; validates type enum, date format, end_date null or valid date ≥ date, title non-empty ≤200 chars; lat/lng must both be present or both absent, lat ∈ [-90,90], lng ∈ [-180,180]; price null or number ≥ 0; paid_by null or a trip member's user id; split_mode ∈ `equal\|own\|payer` (`payer` requires paid_by); participants null/[] (= all members) or array of trip-member user ids; segments per "Flight segments" above; transport_mode per "Transport entries" above (POSTing a stay may auto-create a transport entry, see same section) |
| `PATCH /api/entries/:id` | any subset of the above | `200 {entry}` (member of the entry's trip required; `participants` replaces the whole set) |
| `DELETE /api/entries/:id` | — | `204` (also deletes its entry_participants rows) |
Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments, rental}` where `participants` is an array of user ids (`[]` = all members) and `segments` is the parsed array or `null`.
Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments, rental, transport_mode}` where `participants` is an array of user ids (`[]` = all members) and `segments` is the parsed array or `null`.
### Route & summary (computed)
@@ -173,7 +183,7 @@ Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title,
"totalKm": 587.3,
"summary": {
"days": 10, "nights": 9,
"flights": 2, "flightSegments": 4, "hotels": 3, "travelLegs": 1, "activities": 4,
"flights": 2, "flightSegments": 4, "transports": 1, "activities": 4,
"rentals": 1, "includedKm": 1500,
"stays": 2, "areas": [{"name": "Venice", "days": 3}, {"name": "Berlin", "days": 7}],
"kmAir": 0, "kmDriven": 587.3,
@@ -182,9 +192,9 @@ Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title,
}
```
- `stops` = entries having lat/lng, ordered by `(date, sort_order, id)`; flight entries with coordinate-bearing segments are expanded into airport stops instead (see "Flight segments" — `kind: "airport"`, includes `code`).
- `stops` = entries having lat/lng, ordered by `(date, sort_order, id)`; flight entries with coordinate-bearing segments are expanded into airport stops instead (see "Flight segments" — `kind: "airport"`, includes `code`). Stops also carry `transport_mode` (the entry's value, or null) so the map can show mode-specific icons.
- `legs` = consecutive stop pairs; skip zero-distance pairs (< 0.05 km) — still include the stop, just no leg.
- Every leg has `mode`: `"air"` when BOTH endpoints are `kind:"airport"` stops expanded from the SAME flight entry (i.e. actual flight segments); `"ground"` for everything else (hotel→airport transfers, city-to-city drives). `summary.kmAir` / `summary.kmDriven` are the per-mode sums (1 decimal). `kmDriven` is the rough rental-car figure: great-circle, so real road km will be somewhat higher.
- Every leg has `mode`: `"air"` when BOTH endpoints are `kind:"airport"` stops expanded from the SAME flight entry (i.e. actual flight segments); `"ground"` for everything else (stay→airport transfers, city-to-city drives). `summary.kmAir` / `summary.kmDriven` are the per-mode sums (1 decimal). `kmDriven` is the rough rental-car figure: great-circle, so real road km will be somewhat higher.
- `days` = inclusive count from start_date to end_date; `nights = days - 1` (0 for single-day trips).
- `locations` = unique `location_name` values in stop order.
- km rounded to 1 decimal.
@@ -197,7 +207,7 @@ Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title,
{
"currency": "USD",
"totalCost": 1450.0,
"byType": { "flight": 800.0, "travel": 300.0, "hotel": 350.0 },
"byType": { "flight": 800.0, "transport": 300.0, "stay": 350.0 },
"perUser": [
{ "userId": 1, "displayName": "brave-otter", "share": 725.0, "paid": 950.0, "net": 225.0 },
{ "userId": 2, "displayName": "calm-heron", "share": 725.0, "paid": 500.0, "net": -225.0 }
+9 -7
View File
@@ -4,7 +4,7 @@ A self-hosted, multi-user web tool for collaboratively planning trips. Runs as a
## Core Idea
Multiple people log in to the same instance. Anyone can create a trip by picking a name and a date range. The tool generates a day-by-day calendar for that range, and every member of the trip can fill in what happens on each day — flights, hotel stays, border crossings, drives, activities. The trip is visualized on an interactive map with the route drawn between stops, distances per leg, and an overall summary (days, nights, flights, total km).
Multiple people log in to the same instance. Anyone can create a trip by picking a name and a date range. The tool generates a day-by-day calendar for that range, and every member of the trip can fill in what happens on each day — flights, stays, transports, activities. The trip is visualized on an interactive map with the route drawn between stops, distances per leg, and an overall summary (days, nights, flights, total km).
## Features
@@ -24,13 +24,15 @@ Multiple people log in to the same instance. Anyone can create a trip by picking
| Entry type | Typical fields |
|---|---|
| ✈️ Flight | flight no., from/to airports, departure/arrival time |
| 🛂 Immigration / border | location, notes (visa, documents) |
| 🚗 Travel / transfer | mode (car/train/bus/boat), from → to |
| 🏨 Hotel stay | hotel name, check-in/check-out, booking ref |
| 📍 Activity / sightseeing | place, time, notes |
| 🏙️ Stay (area block / accommodation) | location, from → until dates, hotel name in title, price |
| 🚆 Transport | mode (train/bus/ferry/taxi/drive), from → to; auto-created between consecutive stays |
| ✈️ Flight | flight no., from/to airports, departure/arrival time, multi-leg segments |
| 🚙 Rental car | pickup/dropoff, included km |
| 📝 Note | free text |
(Legacy `hotel`/`travel`/`immigration` entries are migrated automatically at startup: hotel → stay, travel → transport, immigration → activity with a 🛂 title prefix.)
- Every entry can have a **location** (searched via OpenStreetMap geocoding — type "Chiang Mai" and pick from suggestions; lat/lng stored automatically).
- Entries show as compact chips inside the day cell; multiple entries per day, ordered.
@@ -41,7 +43,7 @@ Multiple people log in to the same instance. Anyone can create a trip by picking
- **Distance per leg** (km, great-circle) shown on the route and in a leg-by-leg list.
### 5. Costs & Splitting
- Every entry can carry a **price** (flights, hotels, car rental, train tickets, activities…), in the trip's currency (one currency per trip, no FX conversion).
- Every entry can carry a **price** (flights, stays, car rental, train tickets, activities…), in the trip's currency (one currency per trip, no FX conversion).
- Each priced entry records **who paid** and how it's **split**:
- `equal` — total split equally among selected participants (e.g. rental car 50/50)
- `own` — price is per person, everyone pays their own (e.g. flights)
@@ -50,7 +52,7 @@ Multiple people log in to the same instance. Anyone can create a trip by picking
### 6. Summary
- Total days and nights.
- Number of flights, hotel stays, travel legs.
- Number of flights, stays, transports.
- **Total distance in km** across the whole trip.
- Countries/locations visited (from entry locations).