Add trip checklists with rule-based packing advice

Each trip gets a checklist whose items group under free-text categories
(Documents, Clothing, Toiletries, Health, Electronics, Extras first, then
any custom ones alphabetically). Items are either shared — every member
sees and can tick them, and checked_by records who — or personal to one
member, which nobody else can see or touch. Items carry an optional
quantity, drag-reorder within their category, and "Uncheck all" resets the
list for the trip home.

The "Suggestions" modal is deterministic, offline advice derived from the
trip itself (src/server/util/packing.js) — no LLM and no external calls, so
it stays unit-testable and works on a self-hosted box. Nights scale
clothing quantities, flights add liquids/power-bank/check-in, rentals add
licence + IDP, ferries add motion-sickness tablets, tropical stops add sun
cream and repellent, and the destination country picks the plug type from a
bundled ~50-country table. Every suggestion carries a short reason, and
already-added ones are keyed by suggestion_key so they can't be duplicated.

Two rules deliberately differ from the naive reading, both regression-tested:
a latitude floor stops a December trip to Bangkok being tagged cold as well
as tropical, and only a flight segment's arrival airport counts, since the
first segment's departure airport is home rather than a destination.

checklist_items is a new table, so the existing CREATE TABLE IF NOT EXISTS
path creates it on upgrade; no MIGRATIONS entry is needed and existing data
is untouched.

docs/API.md documents the full contract. 113/113 tests pass.
This commit is contained in:
2026-08-03 18:18:25 +07:00
parent e2c3089c25
commit e342cd9a91
16 changed files with 1913 additions and 2 deletions
+73 -1
View File
@@ -16,7 +16,7 @@ All endpoints are JSON over REST, prefixed with `/api`. This document is the **b
- `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/routes/``auth.js`, `trips.js`, `entries.js`, `geocode.js`, `checklist.js`.
- `src/server/util/distance.js``haversineKm(lat1, lng1, lat2, lng2)` returns km (number).
## Data model (SQLite)
@@ -54,6 +54,16 @@ entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
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)
checklist_items (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
user_id INTEGER REFERENCES users(id), -- null = shared trip item; else personal to that user
text TEXT NOT NULL, -- what to pack/do, ≤120 chars
category TEXT NOT NULL DEFAULT 'General',
qty INTEGER, -- optional count (null = unspecified)
checked INTEGER NOT NULL DEFAULT 0, -- 0/1
checked_by INTEGER REFERENCES users(id),-- who ticked it (null when unchecked)
sort_order INTEGER NOT NULL DEFAULT 0,
suggestion_key TEXT, -- non-null = added from packing advice (dedupe key)
created_at TEXT DEFAULT current_timestamp)
```
Entry `type``flight | transport | activity | rental | stay | note`.
@@ -187,6 +197,67 @@ User JSON shape everywhere: `{id, display_name}`.
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, auto_ref, waypoints}` where `participants` is an array of user ids (`[]` = all members), `segments` is the parsed array or `null`, `auto_ref` is the parsed `{from,to}` object or `null`, and `waypoints` is the parsed `[{lat,lng,name?}]` array or `null`. `auto_ref` is server-managed (not accepted in POST/PATCH bodies; editing an auto transport keeps its `auto_ref`).
### Checklist (packing list & advice)
Each trip has one flat checklist whose items are grouped by a free-text `category` ("Documents", "Clothing", …). Items are either **shared** (`user_id` null — every member sees and can tick them, e.g. "First-aid kit") or **personal** (`user_id` = the owner — only that user sees them, e.g. their own medication). There is no separate list entity: `category` is the grouping.
`GET /api/trips/:id/checklist``200`
```json
{
"items": [
{ "id": 7, "trip_id": 3, "text": "Passport (valid 6+ months)", "category": "Documents",
"qty": null, "checked": true, "checked_by": 1, "personal": false,
"user_id": null, "sort_order": 0, "suggestion_key": "doc-passport" },
{ "id": 9, "trip_id": 3, "text": "T-shirts", "category": "Clothing",
"qty": 8, "checked": false, "checked_by": null, "personal": true,
"user_id": 1, "sort_order": 1, "suggestion_key": "clothing-tshirts" }
],
"progress": { "total": 2, "checked": 1, "byCategory": [ { "category": "Documents", "total": 1, "checked": 1 } ] }
}
```
- Returns shared items **plus the caller's own personal items** — never another member's personal items.
- Item JSON is always the full row plus the derived boolean `personal` (`user_id !== null`); `checked` is a real boolean (not 0/1) and `qty`/`checked_by`/`suggestion_key` are `null` when unset.
- Order: by `category` first (fixed order `Documents, Clothing, Toiletries, Health, Electronics, Extras`, then any other category alphabetically), then `(sort_order, id)`.
- `progress.byCategory` follows the same category order and covers only categories present in `items`.
| Method & path | Body | Response |
|---|---|---|
| `POST /api/trips/:id/checklist` | `{text, category?, qty?, personal?, checked?, sort_order?}` | `201 {item}` — validates: `text` non-empty ≤120 chars after trim; `category` ≤40 chars after trim (default `General`); `qty` null or integer 199; `personal` boolean (default `false`) → sets `user_id` to the caller; `checked` boolean (default `false`); `sort_order` integer (default: max within the trip's list + 1). Member of the trip required (else `404`). |
| `PATCH /api/checklist/:itemId` | any subset of `{text, category, qty, checked, personal, sort_order}` | `200 {item}` — same validation. Ticking (`checked: true`) sets `checked_by` to the caller; `checked: false` clears it. `personal: true` claims the item for the caller (`user_id` = caller), `personal: false` makes it shared (`user_id` null). |
| `DELETE /api/checklist/:itemId` | — | `204` |
| `POST /api/trips/:id/checklist/reset` | `{}` | `200 {unchecked: <count>}` — unticks every item **visible to the caller** (shared + own personal) and clears their `checked_by`. For re-using a list on the return trip. |
Item routes resolve the trip via the item, then require membership. A non-existent item, an item in a trip the caller is not a member of, **or another user's personal item** all return `404 {"error":"not found"}` (no leaking).
**Packing advice (suggestions)** — deterministic, offline rules derived from the trip itself (duration, months, entry types, destination latitudes/countries). No external service, no LLM.
`GET /api/trips/:id/checklist/suggestions``200`
```json
{
"suggestions": [
{ "key": "doc-passport", "text": "Passport (valid 6+ months)", "category": "Documents",
"qty": null, "reason": "You have 2 flights", "added": true },
{ "key": "clothing-tshirts", "text": "T-shirts", "category": "Clothing",
"qty": 8, "reason": "9 nights", "added": false }
],
"context": { "days": 10, "nights": 9, "months": [8], "countries": ["Thailand"],
"climate": ["tropical"], "flights": 2, "rentals": 1, "transportModes": ["ferry"] }
}
```
- `key` is a stable identifier (kebab-case) — it is what `POST` takes and what gets stored in `checklist_items.suggestion_key`.
- `added` = an item with that `suggestion_key` already exists among the items visible to the caller (shared or own personal), so the UI can grey it out.
- `reason` is a short human string explaining why it was suggested ("9 nights", "Ferry crossing", "Thailand uses type A/B/C sockets"). Suggestions are ordered by category (same fixed order as items).
- Rules live in `src/server/util/packing.js` as a pure `buildSuggestions({trip, entries, days, nights})` so they are unit-testable and stable across calls with the same input. Rough rule set: always-on basics (ID/passport, cards+cash, medication, toothbrush, phone+charger, water bottle, day bag); nights-scaled clothing quantities (`qty = min(nights + 1, 10)` for t-shirts/underwear/socks, laundry kit over 7 nights); flight entries → liquids ≤100 ml, power bank in cabin, check-in done, plus neck pillow/compression socks on long-haul (any air leg > 5000 km); rental entries → driving licence, international driving permit, phone mount; transport modes → ferry ⇒ motion-sickness tablets, train ⇒ snacks + luggage lock; destination latitude/month → tropical (|lat| < 23.5) ⇒ sun cream, insect repellent, rain jacket, rehydration salts; cold (|lat| > 55 year-round, or |lat| ≥ 35 when the trip falls in that hemisphere's winter — the latitude floor stops a December trip to Bangkok being tagged both tropical and cold) ⇒ warm layers, hat + gloves; ≥3 stays ⇒ packing cubes; destination country → power-adapter suggestion naming the socket types (small built-in country table; unknown/mixed ⇒ "Universal travel adapter"). Countries come from the last comma-segment of `location_name` and from flight-segment airport codes. For flights only the **arrival** airport of each segment counts (for both climate and country): the first segment's departure airport is where the traveller starts out, not a destination — counting it would put "warm layers, hat & gloves" and a home-country plug adapter on a December Frankfurt→Bangkok beach trip. Flying back into a cold place later is still covered, since that arrival is a segment's `to`.
`POST /api/trips/:id/checklist/suggestions` `{keys: ["doc-passport", …], personal?: false}``201 {created: [item…], skipped: ["doc-passport"]}`
- Bulk-adds the given suggestions as checklist items (unchecked, `suggestion_key` set, appended in the current suggestion order). `keys` must be a non-empty array of ≤60 strings; an unknown key → `400 {"error":"unknown suggestion key: <key>"}`.
- Keys already present among the caller's visible items go to `skipped` instead of being duplicated.
### Route & summary (computed)
`GET /api/trips/:id/route`
@@ -267,3 +338,4 @@ Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept
- 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.
- Checklist UI lives in `public/js/views/checklist.js`, rendered as a card in the trip detail side column (below Costs). It shows a progress bar, items grouped by category with a checkbox / qty / 🔒-personal marker per row, inline add, drag-reorder (`dragdrop.js` `enableReorder`, desktop-only like the rest), an "Uncheck all" action, and a "💡 Suggestions" modal listing the advice with per-item checkboxes and "Add selected". Ticking a box PATCHes optimistically and re-syncs on failure.