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:
+73
-1
@@ -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 1–99; `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.
|
||||
|
||||
Reference in New Issue
Block a user