Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
This commit is contained in:
+215
@@ -0,0 +1,215 @@
|
||||
# Trip Plan — API Contract
|
||||
|
||||
All endpoints are JSON over REST, prefixed with `/api`. This document is the **binding contract** between backend and frontend. Do not deviate; extend only by agreement.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Dates: `YYYY-MM-DD` strings. Times: `HH:MM` 24h strings (optional fields).
|
||||
- Auth: cookie session (`cookie-session`). All endpoints except `account` (create) / `login` require auth → otherwise `401 {"error":"unauthorized"}`.
|
||||
- Identity is Mullvad-style: an account is a random **account token** (the only credential — no username/password). Users additionally have a non-unique, editable `display_name` for humans.
|
||||
- Trip access: user must be a member of the trip → otherwise `404 {"error":"not found"}` (don't leak existence).
|
||||
- Errors: `4xx/5xx` with body `{"error": "<human readable message>"}`.
|
||||
- IDs are integers.
|
||||
|
||||
## Server layout
|
||||
|
||||
- `src/server/app.js` — builds and **exports** the Express app (`export function createApp(dbPath)` and `export default` a ready app is fine, but `createApp` must exist for tests).
|
||||
- `src/server/index.js` — reads env (`PORT` default 3000, `DATA_DIR` default `./data`, `SESSION_SECRET` default dev value with console warning), ensures DATA_DIR exists, starts listener, serves `public/` statically.
|
||||
- `src/server/db.js` — `better-sqlite3` connection + schema creation (idempotent `CREATE TABLE IF NOT EXISTS`).
|
||||
- `src/server/routes/` — `auth.js`, `trips.js`, `entries.js`, `geocode.js`.
|
||||
- `src/server/util/distance.js` — `haversineKm(lat1, lng1, lat2, lng2)` returns km (number).
|
||||
|
||||
## Data model (SQLite)
|
||||
|
||||
```sql
|
||||
users (id INTEGER PK, token_hash TEXT UNIQUE NOT NULL, -- sha256 hex of the account token
|
||||
display_name TEXT NOT NULL, -- auto-generated, editable, NOT unique
|
||||
created_at TEXT DEFAULT current_timestamp)
|
||||
trips (id INTEGER PK, name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
currency TEXT NOT NULL DEFAULT 'USD', -- 3-letter code, display only (no FX)
|
||||
join_code TEXT UNIQUE NOT NULL, -- share code for joining the trip
|
||||
created_at TEXT DEFAULT current_timestamp)
|
||||
trip_members (trip_id INTEGER REFERENCES trips(id), user_id INTEGER REFERENCES users(id),
|
||||
role TEXT NOT NULL DEFAULT 'editor', -- 'owner' | 'editor'
|
||||
PRIMARY KEY (trip_id, user_id))
|
||||
entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||
date TEXT NOT NULL, type TEXT NOT NULL,
|
||||
title TEXT NOT NULL, details TEXT DEFAULT '',
|
||||
start_time TEXT, end_time TEXT,
|
||||
location_name TEXT, lat REAL, lng REAL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
price REAL, -- null = no cost tracked
|
||||
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)
|
||||
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 | note`.
|
||||
|
||||
### Rental car details
|
||||
|
||||
`rental`-type entries can carry structured rental data in `entries.rental` (TEXT, JSON — same storage pattern as flight segments). `null`/absent = plain entry.
|
||||
|
||||
```json
|
||||
"rental": {
|
||||
"brand": "Toyota", "model": "Yaris Cross", "car_type": "SUV",
|
||||
"booking_ref": "RC-889231", "included_km": 1500,
|
||||
"pickup": { "date": "2026-08-01", "time": "09:00", "location_name": "CNX Airport",
|
||||
"lat": 18.77, "lng": 98.96 },
|
||||
"dropoff": { "date": "2026-08-07", "time": "18:00", "location_name": "Chiang Mai Old Town",
|
||||
"lat": 18.79, "lng": 98.98 }
|
||||
}
|
||||
```
|
||||
|
||||
Validation (POST/PATCH entries): only allowed when `type === 'rental'` (else 400); all fields optional; `brand`/`model` ≤60, `car_type` ≤40, `booking_ref` ≤60 chars; `included_km` null (= unlimited/unknown) or number ≥ 0; `pickup`/`dropoff` objects with `date` (YYYY-MM-DD, required within the object), optional `time` HH:MM, `location_name` ≤120, lat/lng both-or-neither in range. PATCH `rental: null` clears. Entry JSON always includes `rental` (parsed or null). The entry's own `date` should be the pickup date (frontend sets this); the entry's price/split fields carry the rental cost as usual.
|
||||
|
||||
Summary impact: `summary.rentals` counts rental entries; `summary.includedKm` = sum of non-null `included_km` across rentals, or null when no rental specifies one — the frontend pairs it with `kmDriven` ("≈ 520 km driven / 1,500 km included"). Rental pickup/dropoff locations do NOT feed the route (use the entry's normal location field for a map point if wanted).
|
||||
|
||||
### Flight segments
|
||||
|
||||
Flight entries can carry structured **segments** for multi-leg itineraries (e.g. CNX→BKK→DXB→FRA = 3 segments). Stored as JSON in `entries.segments`, returned parsed. `null`/absent = no segments (plain entry).
|
||||
|
||||
```json
|
||||
"segments": [
|
||||
{ "flight_no": "TG103", "dep_time": "10:30", "arr_time": "11:45",
|
||||
"from": {"code": "CNX", "name": "Chiang Mai Intl", "lat": 18.77, "lng": 98.96},
|
||||
"to": {"code": "BKK", "name": "Suvarnabhumi", "lat": 13.68, "lng": 100.75} },
|
||||
{ "flight_no": "EK385", "dep_time": "13:05", "arr_time": "17:10",
|
||||
"from": {"code": "BKK", "name": "Suvarnabhumi", "lat": 13.68, "lng": 100.75},
|
||||
"to": {"code": "DXB", "name": "Dubai Intl", "lat": 25.25, "lng": 55.36} }
|
||||
]
|
||||
```
|
||||
|
||||
Validation (POST/PATCH entries): only allowed when `type === 'flight'` (else 400); array of 1–8 objects; each needs `from` and `to` with `code` (2–4 uppercase alphanumerics); `name` optional ≤80 chars; `lat`/`lng` both-or-neither, same ranges as entry lat/lng; `flight_no` optional ≤12 chars; `dep_time`/`arr_time` optional `HH:MM`. PATCH with `segments: null` clears them.
|
||||
|
||||
**Route expansion**: an entry whose segments have coordinates contributes airport stops to `/route` instead of its own lat/lng — `from` of the first segment, then `to` of every segment, in order (airports without coords are skipped; consecutive duplicate coords are collapsed). Stops derived this way have `"kind": "airport"` and include `"code"`. So CNX-BKK-DXB-FRA yields 4 stops and 3 measured legs on the map.
|
||||
|
||||
### Airports lookup
|
||||
|
||||
`GET /api/airports?q=<query>` → `200 {results: [{code, name, city, country, lat, lng}]}` (max 8; requires auth).
|
||||
|
||||
Backed by a bundled dataset at `src/server/data/airports.json` generated from the public-domain OurAirports data (filter: has IATA code + scheduled service; keep code/name/municipality/iso_country/lat/lng). Matching: exact/prefix IATA code match first (case-insensitive), then name/city substring. This makes `CNX` → Chiang Mai Intl instant and offline.
|
||||
|
||||
### Cost semantics
|
||||
|
||||
- Only entries with non-null `price` count toward costs.
|
||||
- **Effective participants** of an entry = its `entry_participants` rows, or **all current trip members** if it has none.
|
||||
- `split_mode`:
|
||||
- `equal` — `price` is the TOTAL; split equally among effective participants ("rental car 50/50"). `paid_by` is credited with having paid the total.
|
||||
- `own` — `price` is PER PERSON; each effective participant owes and pays their own ("everyone pays their own flight"). No debt created. Effective total = price × participant count.
|
||||
- `payer` — personal expense: `paid_by` owes and pays the whole price alone (requires `paid_by`; validation error otherwise).
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Auth (Mullvad-style account tokens)
|
||||
|
||||
| Method & path | Body | Response |
|
||||
|---|---|---|
|
||||
| `POST /api/auth/account` | `{}` (empty) | `201 {user: {id, display_name}, token: "XXXX-XXXX-XXXX-XXXX"}` — creates an account, logs it in. The raw token is returned ONLY here, never again. |
|
||||
| `POST /api/auth/login` | `{token}` | `200 {user}`; `401` on unknown token |
|
||||
| `POST /api/auth/logout` | — | `204` |
|
||||
| `GET /api/auth/me` | — | `200 {user: {id, display_name}}` or `401` |
|
||||
| `PATCH /api/auth/me` | `{display_name}` | `200 {user}`; validates 1–40 chars after trim |
|
||||
|
||||
Token rules:
|
||||
- 16 chars from the unambiguous alphabet `ABCDEFGHJKMNPQRSTUVWXYZ23456789` (no I/L/O/0/1), generated with `crypto.randomBytes`, displayed grouped `XXXX-XXXX-XXXX-XXXX`.
|
||||
- Login normalizes input: strip dashes/spaces, uppercase, then compare `sha256(token)` against `users.token_hash`. Only the hash is stored.
|
||||
- `display_name` is auto-generated at account creation as `adjective-animal` (e.g. `brave-otter`) from two small built-in word lists; not unique; user-editable via PATCH.
|
||||
|
||||
User JSON shape everywhere: `{id, display_name}`.
|
||||
|
||||
### Trips
|
||||
|
||||
| Method & path | Body | Response |
|
||||
|---|---|---|
|
||||
| `GET /api/trips` | — | `200 {trips: [{id, name, start_date, end_date, owner_id, currency, role, member_count, entry_count}]}` (trips where user is member, newest first) |
|
||||
| `POST /api/trips` | `{name, start_date, end_date, currency?}` | `201 {trip}`; validates: name non-empty ≤120 chars, valid dates, `end_date >= start_date`, range ≤ 365 days, currency (if given) matches `^[A-Z]{3}$` (default `USD`). Creator becomes member with role `owner`. |
|
||||
| `GET /api/trips/:id` | — | `200 {trip: {id, name, start_date, end_date, owner_id, currency, join_code}, members: [{id, display_name, role}], entries: [entry…]}` entries ordered by `(date, sort_order, id)` |
|
||||
| `PATCH /api/trips/:id` | any of `{name, start_date, end_date, currency}` | `200 {trip}` (same validation; entries outside new range are kept) |
|
||||
| `DELETE /api/trips/:id` | — | `204` — owner only, else `403`. Deletes members + entries too. |
|
||||
| `POST /api/trips/join` | `{code}` | `200 {trip}` — joins the trip with that join_code as `editor` (idempotent: already a member → still `200 {trip}`); `404 {"error":"not found"}` on unknown code. Code normalized like tokens (strip dashes/spaces, uppercase). |
|
||||
| `POST /api/trips/:id/join-code` | — | `200 {trip}` — regenerates join_code, owner only (`403` otherwise) |
|
||||
| `DELETE /api/trips/:id/members/:userId` | — | `204` — owner only (owner cannot remove self) |
|
||||
|
||||
`join_code`: 8 chars, same alphabet as account tokens, generated at trip creation, displayed grouped `XXXX-XXXX`. Members see it in trip JSON (it's how they invite others); it is NOT a credential — it only grants membership of that one trip.
|
||||
|
||||
### Entries
|
||||
|
||||
| Method & path | Body | Response |
|
||||
|---|---|---|
|
||||
| `POST /api/trips/:id/entries` | `{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, 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 |
|
||||
| `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, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments}` where `participants` is an array of user ids (`[]` = all members) and `segments` is the parsed array or `null`.
|
||||
|
||||
### Route & summary (computed)
|
||||
|
||||
`GET /api/trips/:id/route` →
|
||||
|
||||
```json
|
||||
{
|
||||
"stops": [ {"entryId": 1, "date": "2026-08-01", "type": "flight",
|
||||
"title": "BKK → CNX", "location_name": "Chiang Mai", "lat": 18.79, "lng": 98.98} ],
|
||||
"legs": [ {"fromEntryId": 1, "toEntryId": 4, "km": 587.3, "mode": "ground"} ],
|
||||
"totalKm": 587.3,
|
||||
"summary": {
|
||||
"days": 10, "nights": 9,
|
||||
"flights": 2, "flightSegments": 4, "hotels": 3, "travelLegs": 1, "activities": 4,
|
||||
"rentals": 1, "includedKm": 1500,
|
||||
"kmAir": 0, "kmDriven": 587.3,
|
||||
"locations": ["Bangkok", "Chiang Mai"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `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`).
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
### Costs & splitting (computed)
|
||||
|
||||
`GET /api/trips/:id/costs` →
|
||||
|
||||
```json
|
||||
{
|
||||
"currency": "USD",
|
||||
"totalCost": 1450.0,
|
||||
"byType": { "flight": 800.0, "travel": 300.0, "hotel": 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 }
|
||||
],
|
||||
"settlements": [ { "fromUserId": 2, "toUserId": 1, "amount": 225.0 } ],
|
||||
"unassigned": 0.0
|
||||
}
|
||||
```
|
||||
|
||||
Computation (see Cost semantics above):
|
||||
|
||||
- `share` = what the user's part of the trip costs; `paid` = what they actually paid; `net = paid - share` (positive → others owe them).
|
||||
- `equal` with `paid_by` null: the cost still counts into shares and `byType`, but nobody is credited as payer — accumulate that amount into `unassigned` (frontend shows a hint to assign payers).
|
||||
- `own`: adds `price` to each participant's `share` AND `paid` (self-paid, no debt).
|
||||
- `settlements`: minimal-transfer greedy — repeatedly match the largest debtor with the largest creditor until all nets are settled; amounts rounded to 2 decimals, drop transfers < 0.01.
|
||||
- `totalCost` = sum of effective totals of all priced entries; `byType` groups the same by entry type.
|
||||
- All money values rounded to 2 decimals in the response.
|
||||
|
||||
### Geocoding proxy
|
||||
|
||||
`GET /api/geocode?q=<query>` → `200 {results: [{name, lat, lng}]}` (max 5).
|
||||
|
||||
Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept-language=en&q=…` server-side with header `User-Agent: trip-plan-app/0.1 (self-hosted)` (`accept-language=en` so results come back in Latin script, not the local language). Map Nominatim's `display_name`→`name`, parse lat/lon to numbers. On upstream failure return `502 {"error":"geocoding unavailable"}`. Cache identical queries in-memory for 10 minutes.
|
||||
|
||||
## Frontend contract notes
|
||||
|
||||
- SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id`) is the expected design, so no server-side fallback is needed.
|
||||
- Leaflet 1.9.x via unpkg CDN in `index.html`.
|
||||
- Session cookie is httpOnly; frontend detects auth state via `GET /api/auth/me` on load.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Trip Plan — Project Overview
|
||||
|
||||
A self-hosted, multi-user web tool for collaboratively planning trips. Runs as a single Docker container.
|
||||
|
||||
## 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).
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Users & Access (privacy-friendly, Mullvad-style)
|
||||
- **No registration form, no email, no password.** Click "Create account" and you get a random account number (e.g. `7K2M-9QX4-TT8B-3WPF`) — that token IS your login. It's shown once; only its hash is stored server-side.
|
||||
- Every account gets an auto-generated, editable **display name** (e.g. `brave-otter`) so members and cost splits stay human-readable.
|
||||
- Trips are shared via a **join code**: every trip has a short code (e.g. `M4TH-8RK2`); anyone with an account can enter it under "Join a trip" to become an editor. The owner can regenerate the code to cut off further joins.
|
||||
|
||||
### 2. Trips
|
||||
- Create a trip with **name + start date + end date**.
|
||||
- Editing the date range regenerates the calendar (existing day entries outside the new range are kept but flagged).
|
||||
- Trip list dashboard showing all trips you're a member of.
|
||||
|
||||
### 3. Day-by-Day Calendar
|
||||
- Calendar grid generated from the trip's date range (weeks as rows, like a real calendar).
|
||||
- Click any day to open the day editor and add **entries**:
|
||||
|
||||
| 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 |
|
||||
| 📝 Note | free text |
|
||||
|
||||
- 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.
|
||||
|
||||
### 4. Map Visualization
|
||||
- Interactive map (Leaflet + OpenStreetMap tiles — free, no API key required, unlike Google Maps).
|
||||
- All located entries plotted as markers, numbered in chronological order.
|
||||
- Route polyline connecting the stops in order.
|
||||
- **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).
|
||||
- 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)
|
||||
- `payer` — personal expense of the payer
|
||||
- **Cost summary** per trip: total cost, breakdown by type, per-user share vs. paid vs. net balance, and **settle-up suggestions** (who transfers how much to whom).
|
||||
|
||||
### 6. Summary
|
||||
- Total days and nights.
|
||||
- Number of flights, hotel stays, travel legs.
|
||||
- **Total distance in km** across the whole trip.
|
||||
- Countries/locations visited (from entry locations).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Choice | Why |
|
||||
|---|---|---|
|
||||
| Backend | Node.js 20 + Express | Small, ubiquitous, one runtime in the container |
|
||||
| Database | SQLite (better-sqlite3) | Zero-config single file; persisted via Docker volume |
|
||||
| Auth | cookie sessions + bcrypt | Simple and adequate for a self-hosted tool |
|
||||
| Frontend | Vanilla JS SPA (no build step) | Keeps image small and the stack simple |
|
||||
| Map | Leaflet + OpenStreetMap | Free, no API key, offline-friendly tiles options |
|
||||
| Geocoding | Nominatim (proxied server-side) | Free location search, no key |
|
||||
| Packaging | Dockerfile + docker-compose | `docker compose up` and you're running |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
trip_plan/
|
||||
├── src/server/ # Express app
|
||||
│ ├── index.js # bootstrap, static serving
|
||||
│ ├── db.js # SQLite schema + connection
|
||||
│ ├── auth.js # session middleware
|
||||
│ ├── routes/ # auth, trips, entries, geocode
|
||||
│ └── util/distance.js # haversine km calc
|
||||
├── public/ # SPA served statically
|
||||
│ ├── index.html
|
||||
│ ├── css/styles.css
|
||||
│ └── js/ # api client, views (trips, calendar, map, summary)
|
||||
├── tests/ # API tests (node:test + supertest)
|
||||
├── data/ # SQLite file (gitignored, volume-mounted)
|
||||
├── docs/ # this file + API.md
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
users (id, username, password_hash, created_at)
|
||||
trips (id, name, start_date, end_date, owner_id, created_at)
|
||||
trip_members (trip_id, user_id, role) -- owner | editor
|
||||
entries (id, trip_id, date, type, title, details,
|
||||
start_time, end_time, location_name, lat, lng, sort_order)
|
||||
```
|
||||
|
||||
Route + km are **derived**: located entries ordered by (date, sort_order) form the route; each consecutive pair is a leg with haversine distance.
|
||||
|
||||
### API (REST, JSON, under /api)
|
||||
|
||||
- `POST /api/auth/account` (create token account) · `POST /api/auth/login` · `POST /api/auth/logout` · `GET/PATCH /api/auth/me`
|
||||
- `GET/POST /api/trips` · `GET/PATCH/DELETE /api/trips/:id`
|
||||
- `POST /api/trips/join` (by join code) · `POST /api/trips/:id/join-code` (regenerate)
|
||||
- `GET/POST /api/trips/:id/entries` · `PATCH/DELETE /api/entries/:id`
|
||||
- `GET /api/trips/:id/route` (legs + km + summary, computed server-side)
|
||||
- `GET /api/geocode?q=...` (Nominatim proxy)
|
||||
|
||||
## Running It
|
||||
|
||||
```bash
|
||||
# development
|
||||
npm install
|
||||
npm start # http://localhost:3000
|
||||
|
||||
# production
|
||||
docker compose up -d # builds image, mounts ./data for the SQLite file
|
||||
```
|
||||
|
||||
## Non-Goals (for now)
|
||||
|
||||
- Real-time collaborative editing (last-write-wins is fine for this scale)
|
||||
- Driving-route distances via a routing engine (great-circle km first; OSRM could be added later)
|
||||
- Email invites / password reset (admin resets via CLI)
|
||||
|
||||
## Build Plan
|
||||
|
||||
1. **Scaffold** — repo, package.json, this doc, API contract. ✅
|
||||
2. **Swarm build** (parallel agents): backend API + DB · frontend SPA (calendar, map, summary) · Docker/infra + tests.
|
||||
3. **Integrate & verify** — run tests, boot server, smoke-test in browser, build Docker image.
|
||||
4. Initial commit.
|
||||
Reference in New Issue
Block a user