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/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/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/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).
|
- `src/server/util/distance.js` — `haversineKm(lat1, lng1, lat2, lng2)` returns km (number).
|
||||||
|
|
||||||
## Data model (SQLite)
|
## 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),
|
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
|
||||||
PRIMARY KEY (entry_id, user_id))
|
PRIMARY KEY (entry_id, user_id))
|
||||||
-- no rows for an entry = "all trip members participate" (dynamic default)
|
-- 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`.
|
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`).
|
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)
|
### Route & summary (computed)
|
||||||
|
|
||||||
`GET /api/trips/:id/route` →
|
`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.
|
- 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`.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/* Checklist (packing list) card + suggestions modal. Split out of styles.css
|
||||||
|
— which was already at its 500-line cap — following the same per-feature
|
||||||
|
stylesheet convention as flipclock.css. Reuses the shared palette
|
||||||
|
(var(--brand) etc.) and existing vocabulary (.card, .btn, .icon-btn,
|
||||||
|
.field, .form-actions, .slideover-head, .overlay, .entry-drag-handle,
|
||||||
|
.empty-state) rather than re-declaring it. */
|
||||||
|
|
||||||
|
.checklist-section { display: flex; flex-direction: column; }
|
||||||
|
.checklist-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.checklist-progress { margin-bottom: 1rem; }
|
||||||
|
.checklist-progress-label { font-size: 0.85rem; font-weight: 600; color: var(--text-muted); margin-bottom: 0.35rem; }
|
||||||
|
.checklist-progress-bar { height: 0.5rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
|
||||||
|
.checklist-progress-fill { height: 100%; background: linear-gradient(90deg, var(--brand), var(--success)); border-radius: 999px; transition: width 0.2s ease; }
|
||||||
|
|
||||||
|
.checklist-category { margin-top: 1.1rem; }
|
||||||
|
.checklist-category:first-of-type { margin-top: 0; }
|
||||||
|
.checklist-category-head { display: flex; align-items: baseline; gap: 0.45rem; margin-bottom: 0.5rem; }
|
||||||
|
.checklist-category-count { font-size: 0.74rem; font-weight: 600; color: var(--text-faint); }
|
||||||
|
.checklist-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
|
||||||
|
.checklist-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||||
|
.checklist-row.checked .checklist-row-text { text-decoration: line-through; color: var(--text-faint); }
|
||||||
|
.checklist-row.dragging { opacity: 0.4; }
|
||||||
|
.checklist-row.drop-before { box-shadow: inset 0 3px 0 var(--brand); }
|
||||||
|
.checklist-row.drop-after { box-shadow: inset 0 -3px 0 var(--brand); }
|
||||||
|
.checklist-check { width: 1.05rem; height: 1.05rem; flex-shrink: 0; accent-color: var(--brand); cursor: pointer; }
|
||||||
|
.checklist-row-text { flex: 1; min-width: 0; font-size: 0.9rem; overflow-wrap: break-word; }
|
||||||
|
.checklist-qty { flex-shrink: 0; font-size: 0.72rem; font-weight: 700; color: var(--text-muted); background: var(--surface); border: 1px solid var(--border); padding: 0.05rem 0.4rem; border-radius: 999px; }
|
||||||
|
.checklist-lock { flex-shrink: 0; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.checklist-add { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: flex-end; margin-top: 1.1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||||
|
.checklist-add .field { flex: 1 1 130px; }
|
||||||
|
.checklist-add .form-error { flex-basis: 100%; margin: 0; }
|
||||||
|
.checklist-add-category { min-width: 100px; }
|
||||||
|
.checklist-add-qty { min-width: 70px; }
|
||||||
|
.checklist-personal-toggle { display: flex; align-items: center; gap: 0.3rem; font-size: 0.85rem; color: var(--text-muted); cursor: pointer; white-space: nowrap; padding-bottom: 0.55rem; }
|
||||||
|
|
||||||
|
/* ---------- Suggestions modal ---------- */
|
||||||
|
.overlay-center { justify-content: center; align-items: center; }
|
||||||
|
/* Only the suggestion list scrolls — the heading and the scope/Add footer stay
|
||||||
|
put. With ~28 suggestions the content runs to roughly three screens, and a
|
||||||
|
footer inside the scroll area would mean scrolling past every suggestion to
|
||||||
|
reach the "Add selected" button. */
|
||||||
|
.modal { width: min(560px, 92vw); max-height: 85vh; overflow: hidden; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 1.3rem; display: flex; flex-direction: column; gap: 1rem; animation: fade 0.15s ease; }
|
||||||
|
.modal > .slideover-head,
|
||||||
|
.modal > .suggest-scope,
|
||||||
|
.modal > .form-error,
|
||||||
|
.modal > .form-actions { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.suggest-list { display: flex; flex-direction: column; gap: 1.1rem; flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
||||||
|
.suggest-category h3 { margin-bottom: 0.5rem; }
|
||||||
|
.suggest-rows { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.suggest-row { display: flex; align-items: flex-start; gap: 0.55rem; padding: 0.5rem 0.6rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); cursor: pointer; }
|
||||||
|
.suggest-row.added { opacity: 0.55; cursor: default; }
|
||||||
|
.suggest-check { width: 1.05rem; height: 1.05rem; margin-top: 0.15rem; flex-shrink: 0; accent-color: var(--brand); }
|
||||||
|
.suggest-body { flex: 1; min-width: 0; }
|
||||||
|
.suggest-text { font-size: 0.9rem; font-weight: 600; }
|
||||||
|
.suggest-qty { margin-left: 0.35rem; font-size: 0.8rem; font-weight: 700; color: var(--text-muted); }
|
||||||
|
.suggest-reason { font-size: 0.78rem; margin-top: 0.1rem; }
|
||||||
|
.suggest-scope { display: flex; gap: 1.2rem; align-items: center; font-size: 0.85rem; padding-top: 0.7rem; border-top: 1px solid var(--border); }
|
||||||
|
.suggest-scope label { display: flex; align-items: center; gap: 0.3rem; cursor: pointer; }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.checklist-add .field { flex-basis: 100%; }
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
<link rel="stylesheet" href="./css/styles.css" />
|
<link rel="stylesheet" href="./css/styles.css" />
|
||||||
<link rel="stylesheet" href="./css/flipclock.css" />
|
<link rel="stylesheet" href="./css/flipclock.css" />
|
||||||
|
<link rel="stylesheet" href="./css/checklist.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ export const api = {
|
|||||||
},
|
},
|
||||||
geocode: (q) => get(`/api/geocode?q=${encodeURIComponent(q)}`),
|
geocode: (q) => get(`/api/geocode?q=${encodeURIComponent(q)}`),
|
||||||
airports: (q) => get(`/api/airports?q=${encodeURIComponent(q)}`),
|
airports: (q) => get(`/api/airports?q=${encodeURIComponent(q)}`),
|
||||||
|
checklist: {
|
||||||
|
list: (tripId) => get(`/api/trips/${tripId}/checklist`),
|
||||||
|
create: (tripId, payload) => post(`/api/trips/${tripId}/checklist`, payload),
|
||||||
|
update: (itemId, patchBody) => patch(`/api/checklist/${itemId}`, patchBody),
|
||||||
|
remove: (itemId) => del(`/api/checklist/${itemId}`),
|
||||||
|
reset: (tripId) => post(`/api/trips/${tripId}/checklist/reset`, {}),
|
||||||
|
suggestions: (tripId) => get(`/api/trips/${tripId}/checklist/suggestions`),
|
||||||
|
addSuggestions: (tripId, keys, personal) =>
|
||||||
|
post(`/api/trips/${tripId}/checklist/suggestions`, personal === undefined ? { keys } : { keys, personal }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
// Packing checklist card for the trip detail page (below Costs). Self-fetches
|
||||||
|
// via GET /api/trips/:id/checklist and re-renders itself in place after every
|
||||||
|
// mutation — it never triggers tctx.refreshTrip(), so ticking/adding/removing
|
||||||
|
// items never re-fetches the whole trip. The suggestions modal lives in its
|
||||||
|
// own module (checklistSuggestions.js) so this file stays small, mirroring how
|
||||||
|
// dayEditor.js splits out costForm/waypoints/rental.
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { el, mount, loading, errorBox, emptyState, toast } from '../dom.js';
|
||||||
|
import { enableChecklistReorder } from './dragdrop.js';
|
||||||
|
import { openSuggestionsModal } from './checklistSuggestions.js';
|
||||||
|
|
||||||
|
// The server's fixed category order (see docs/API.md) — offered first in the
|
||||||
|
// add-row category datalist, ahead of any custom categories already in use.
|
||||||
|
const FIXED_CATEGORIES = ['Documents', 'Clothing', 'Toiletries', 'Health', 'Electronics', 'Extras'];
|
||||||
|
|
||||||
|
export function renderChecklist(tctx) {
|
||||||
|
const state = { items: [] };
|
||||||
|
const section = el('section', { class: 'card checklist-section' });
|
||||||
|
|
||||||
|
// Refs into the current draw() so a checkbox toggle can patch just the
|
||||||
|
// progress bar + category count instead of rebuilding the whole card.
|
||||||
|
let progressLabelEl = null;
|
||||||
|
let progressFillEl = null;
|
||||||
|
const categoryCountEls = new Map();
|
||||||
|
// The add-row's text input is rebuilt by every draw(); track the current
|
||||||
|
// one so a successful submit can refocus it (the pre-reload element it
|
||||||
|
// closed over would already be detached by then).
|
||||||
|
let addTextInputEl = null;
|
||||||
|
|
||||||
|
mount(section, loading('Loading checklist…'));
|
||||||
|
reload();
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
const data = await api.checklist.list(tctx.tripId);
|
||||||
|
state.items = data.items || [];
|
||||||
|
draw();
|
||||||
|
} catch (err) {
|
||||||
|
mount(section, errorBox(err.message, reload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByCategory(items) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const item of items) {
|
||||||
|
if (!map.has(item.category)) map.set(item.category, []);
|
||||||
|
map.get(item.category).push(item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgressUI() {
|
||||||
|
const total = state.items.length;
|
||||||
|
const checkedCount = state.items.filter((i) => i.checked).length;
|
||||||
|
if (progressLabelEl) progressLabelEl.textContent = `${checkedCount} / ${total} packed`;
|
||||||
|
if (progressFillEl) progressFillEl.style.width = `${total ? Math.round((checkedCount / total) * 100) : 0}%`;
|
||||||
|
for (const [category, countEl] of categoryCountEls) {
|
||||||
|
const catItems = state.items.filter((i) => i.category === category);
|
||||||
|
countEl.textContent = `${catItems.filter((i) => i.checked).length}/${catItems.length}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
progressLabelEl = null;
|
||||||
|
progressFillEl = null;
|
||||||
|
categoryCountEls.clear();
|
||||||
|
|
||||||
|
const items = state.items;
|
||||||
|
const total = items.length;
|
||||||
|
const checkedCount = items.filter((i) => i.checked).length;
|
||||||
|
|
||||||
|
const head = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'section-head cal-section-head' },
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{},
|
||||||
|
el('h2', {}, 'Checklist'),
|
||||||
|
el('p', { class: 'muted' }, 'Shared items everyone can tick; 🔒 personal ones are just for you.'),
|
||||||
|
),
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'checklist-actions' },
|
||||||
|
el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onUncheckAll }, 'Uncheck all'),
|
||||||
|
el('button', { class: 'btn btn-sm', type: 'button', onClick: onOpenSuggestions }, '💡 Suggestions'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = [];
|
||||||
|
if (!total) {
|
||||||
|
body.push(emptyState('Nothing packed yet', 'Add an item below or grab some suggestions.'));
|
||||||
|
} else {
|
||||||
|
progressLabelEl = el('div', { class: 'checklist-progress-label' }, `${checkedCount} / ${total} packed`);
|
||||||
|
progressFillEl = el('div', {
|
||||||
|
class: 'checklist-progress-fill',
|
||||||
|
style: { width: `${Math.round((checkedCount / total) * 100)}%` },
|
||||||
|
});
|
||||||
|
body.push(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'checklist-progress' },
|
||||||
|
progressLabelEl,
|
||||||
|
el('div', { class: 'checklist-progress-bar' }, progressFillEl),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [category, catItems] of groupByCategory(items)) {
|
||||||
|
const countEl = el('span', { class: 'checklist-category-count' },
|
||||||
|
`${catItems.filter((i) => i.checked).length}/${catItems.length}`);
|
||||||
|
categoryCountEls.set(category, countEl);
|
||||||
|
|
||||||
|
const list = el('div', { class: 'checklist-list' });
|
||||||
|
const rows = [];
|
||||||
|
for (const item of catItems) {
|
||||||
|
const { node, handle } = itemRow(item);
|
||||||
|
rows.push({ node, handle, item });
|
||||||
|
list.appendChild(node);
|
||||||
|
}
|
||||||
|
if (catItems.length > 1) enableChecklistReorder(rows, reload);
|
||||||
|
|
||||||
|
body.push(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'checklist-category' },
|
||||||
|
el('div', { class: 'checklist-category-head' }, el('h3', {}, category), countEl),
|
||||||
|
list,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(section, head, ...body, addRow());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { node, handle } for one item row: drag handle, checkbox, text,
|
||||||
|
// optional qty badge, 🔒 marker for personal items, delete button.
|
||||||
|
function itemRow(item) {
|
||||||
|
const checkbox = el('input', {
|
||||||
|
type: 'checkbox',
|
||||||
|
class: 'checklist-check',
|
||||||
|
checked: item.checked,
|
||||||
|
onChange: () => onToggle(item, checkbox, node),
|
||||||
|
});
|
||||||
|
const handle = el('span', {
|
||||||
|
class: 'entry-drag-handle', title: 'Drag to reorder', 'aria-hidden': 'true',
|
||||||
|
}, '⋮⋮');
|
||||||
|
const node = el(
|
||||||
|
'div',
|
||||||
|
{ class: `checklist-row${item.checked ? ' checked' : ''}` },
|
||||||
|
handle,
|
||||||
|
checkbox,
|
||||||
|
el('span', { class: 'checklist-row-text' }, item.text),
|
||||||
|
item.qty != null ? el('span', { class: 'checklist-qty' }, `×${item.qty}`) : null,
|
||||||
|
item.personal ? el('span', { class: 'checklist-lock', title: 'Personal item' }, '🔒') : null,
|
||||||
|
el('button', {
|
||||||
|
class: 'icon-btn danger', type: 'button', title: 'Delete', onClick: () => onDeleteItem(item),
|
||||||
|
}, '🗑'),
|
||||||
|
);
|
||||||
|
return { node, handle };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticking a box should feel instant: patch the row + progress bar in place,
|
||||||
|
// PATCH the server, and revert + toast on failure — no full re-render.
|
||||||
|
function onToggle(item, checkbox, rowNode) {
|
||||||
|
const next = checkbox.checked;
|
||||||
|
const prev = item.checked;
|
||||||
|
item.checked = next;
|
||||||
|
rowNode.classList.toggle('checked', next);
|
||||||
|
updateProgressUI();
|
||||||
|
api.checklist.update(item.id, { checked: next }).catch((err) => {
|
||||||
|
item.checked = prev;
|
||||||
|
checkbox.checked = prev;
|
||||||
|
rowNode.classList.toggle('checked', prev);
|
||||||
|
updateProgressUI();
|
||||||
|
toast(err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeleteItem(item) {
|
||||||
|
try {
|
||||||
|
await api.checklist.remove(item.id);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUncheckAll() {
|
||||||
|
try {
|
||||||
|
const res = await api.checklist.reset(tctx.tripId);
|
||||||
|
toast(`Unchecked ${res.unchecked} item${res.unchecked === 1 ? '' : 's'}`, 'success');
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenSuggestions() {
|
||||||
|
openSuggestionsModal(tctx, reload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryOptions() {
|
||||||
|
const used = new Set(state.items.map((i) => i.category).filter(Boolean));
|
||||||
|
const extras = [...used].filter((c) => !FIXED_CATEGORIES.includes(c)).sort((a, b) => a.localeCompare(b));
|
||||||
|
return [...FIXED_CATEGORIES, ...extras];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
const textInput = el('input', {
|
||||||
|
class: 'input input-sm', type: 'text', maxlength: '120', placeholder: 'Add an item…',
|
||||||
|
});
|
||||||
|
addTextInputEl = textInput;
|
||||||
|
const categoryInput = el('input', {
|
||||||
|
class: 'input input-sm checklist-add-category', type: 'text', maxlength: '40',
|
||||||
|
placeholder: 'Category', list: 'checklist-categories', value: FIXED_CATEGORIES[0],
|
||||||
|
});
|
||||||
|
const categoryList = el(
|
||||||
|
'datalist',
|
||||||
|
{ id: 'checklist-categories' },
|
||||||
|
...categoryOptions().map((c) => el('option', { value: c })),
|
||||||
|
);
|
||||||
|
const qtyInput = el('input', {
|
||||||
|
class: 'input input-sm checklist-add-qty', type: 'number', min: '1', max: '99', placeholder: 'Qty',
|
||||||
|
});
|
||||||
|
const personalToggle = el('input', { type: 'checkbox' });
|
||||||
|
const errorEl = el('p', { class: 'form-error' });
|
||||||
|
const addBtn = el('button', { class: 'btn btn-primary btn-sm', type: 'submit' }, '+ Add');
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
errorEl.textContent = '';
|
||||||
|
const text = textInput.value.trim();
|
||||||
|
if (!text) return (errorEl.textContent = 'Item text is required.');
|
||||||
|
if (text.length > 120) return (errorEl.textContent = 'Keep it under 120 characters.');
|
||||||
|
const category = categoryInput.value.trim();
|
||||||
|
if (category.length > 40) return (errorEl.textContent = 'Category must be 40 characters or fewer.');
|
||||||
|
const qtyRaw = qtyInput.value.trim();
|
||||||
|
let qty = null;
|
||||||
|
if (qtyRaw !== '') {
|
||||||
|
qty = Number(qtyRaw);
|
||||||
|
if (!Number.isInteger(qty) || qty < 1 || qty > 99) {
|
||||||
|
return (errorEl.textContent = 'Qty must be a whole number from 1 to 99.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payload = { text, personal: personalToggle.checked };
|
||||||
|
if (category) payload.category = category;
|
||||||
|
if (qty != null) payload.qty = qty;
|
||||||
|
|
||||||
|
addBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await api.checklist.create(tctx.tripId, payload);
|
||||||
|
textInput.value = '';
|
||||||
|
qtyInput.value = '';
|
||||||
|
toast('Item added', 'success');
|
||||||
|
await reload();
|
||||||
|
if (addTextInputEl) addTextInputEl.focus();
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
} finally {
|
||||||
|
addBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return el(
|
||||||
|
'form',
|
||||||
|
{ class: 'checklist-add', onSubmit },
|
||||||
|
categoryList,
|
||||||
|
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Item'), textInput),
|
||||||
|
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Category'), categoryInput),
|
||||||
|
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Qty'), qtyInput),
|
||||||
|
el('label', { class: 'checklist-personal-toggle' }, personalToggle, '🔒 Personal'),
|
||||||
|
addBtn,
|
||||||
|
errorEl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// "💡 Suggestions" modal for the checklist card — fetches the deterministic
|
||||||
|
// packing advice (GET .../checklist/suggestions), lets the user tick which
|
||||||
|
// ones to add and whether they go in as shared or personal items, then bulk
|
||||||
|
// adds them (POST .../checklist/suggestions). Kept as its own module so
|
||||||
|
// checklist.js stays small (mirrors dayEditor.js's costForm/waypoints split).
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { el, mount, loading, errorBox, toast } from '../dom.js';
|
||||||
|
|
||||||
|
export function openSuggestionsModal(tctx, onAdded) {
|
||||||
|
const overlay = el('div', { class: 'overlay overlay-center' });
|
||||||
|
const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true' });
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
}
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
mount(modal, modalHead(), loading('Loading suggestions…'));
|
||||||
|
try {
|
||||||
|
const data = await api.checklist.suggestions(tctx.tripId);
|
||||||
|
draw(data.suggestions || []);
|
||||||
|
} catch (err) {
|
||||||
|
mount(modal, modalHead(), errorBox(err.message, load));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modalHead() {
|
||||||
|
return el(
|
||||||
|
'div',
|
||||||
|
{ class: 'slideover-head' },
|
||||||
|
el('h2', {}, '💡 Packing suggestions'),
|
||||||
|
el('button', { class: 'icon-btn', type: 'button', title: 'Close', onClick: close }, '×'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(suggestions) {
|
||||||
|
let scope = 'shared'; // 'shared' | 'personal' — applies to the whole batch
|
||||||
|
const checks = new Map(); // suggestion key -> its (enabled) checkbox
|
||||||
|
|
||||||
|
const groups = new Map();
|
||||||
|
for (const s of suggestions) {
|
||||||
|
if (!groups.has(s.category)) groups.set(s.category, []);
|
||||||
|
groups.get(s.category).push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEl = el('div', { class: 'suggest-list' });
|
||||||
|
if (!suggestions.length) {
|
||||||
|
listEl.appendChild(el('p', { class: 'muted' }, 'No suggestions yet — add some entries to the trip first.'));
|
||||||
|
}
|
||||||
|
for (const [category, items] of groups) {
|
||||||
|
const rows = el('div', { class: 'suggest-rows' });
|
||||||
|
for (const s of items) {
|
||||||
|
rows.appendChild(suggestionRow(s, checks));
|
||||||
|
}
|
||||||
|
listEl.appendChild(el('div', { class: 'suggest-category' }, el('h3', {}, category), rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedRadio = el('input', { type: 'radio', name: 'suggest-scope', checked: true });
|
||||||
|
const personalRadio = el('input', { type: 'radio', name: 'suggest-scope' });
|
||||||
|
sharedRadio.addEventListener('change', () => { if (sharedRadio.checked) scope = 'shared'; });
|
||||||
|
personalRadio.addEventListener('change', () => { if (personalRadio.checked) scope = 'personal'; });
|
||||||
|
const scopeRow = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'suggest-scope' },
|
||||||
|
el('label', {}, sharedRadio, 'Shared'),
|
||||||
|
el('label', {}, personalRadio, '🔒 Personal to me'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorEl = el('p', { class: 'form-error' });
|
||||||
|
const cancelBtn = el('button', { class: 'btn btn-ghost', type: 'button', onClick: close }, 'Cancel');
|
||||||
|
const addBtn = el('button', { class: 'btn btn-primary', type: 'button', onClick: onAddSelected }, 'Add selected');
|
||||||
|
|
||||||
|
async function onAddSelected() {
|
||||||
|
errorEl.textContent = '';
|
||||||
|
const keys = [...checks.entries()].filter(([, cb]) => cb.checked).map(([key]) => key);
|
||||||
|
if (!keys.length) return (errorEl.textContent = 'Pick at least one suggestion.');
|
||||||
|
addBtn.disabled = true;
|
||||||
|
addBtn.textContent = 'Adding…';
|
||||||
|
try {
|
||||||
|
await api.checklist.addSuggestions(tctx.tripId, keys, scope === 'personal');
|
||||||
|
toast(`Added ${keys.length} item${keys.length === 1 ? '' : 's'}`, 'success');
|
||||||
|
close();
|
||||||
|
await onAdded();
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
addBtn.disabled = false;
|
||||||
|
addBtn.textContent = 'Add selected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(modal, modalHead(), listEl, suggestions.length ? scopeRow : null, errorEl, el('div', { class: 'form-actions' }, cancelBtn, addBtn));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One suggestion row: checkbox (ticked + disabled if already added), text +
|
||||||
|
// qty, and the reason as muted subtext. Registers its checkbox in `checks`
|
||||||
|
// only when selectable (not already added), for onAddSelected to read.
|
||||||
|
function suggestionRow(s, checks) {
|
||||||
|
const disabled = !!s.added;
|
||||||
|
const check = el('input', {
|
||||||
|
type: 'checkbox', class: 'suggest-check', checked: disabled, disabled,
|
||||||
|
});
|
||||||
|
if (!disabled) checks.set(s.key, check);
|
||||||
|
return el(
|
||||||
|
'label',
|
||||||
|
{ class: `suggest-row${disabled ? ' added' : ''}` },
|
||||||
|
check,
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'suggest-body' },
|
||||||
|
el('span', { class: 'suggest-text' }, s.text, s.qty != null ? el('span', { class: 'suggest-qty' }, `×${s.qty}`) : null),
|
||||||
|
el('div', { class: 'suggest-reason muted' }, s.reason),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+16
@@ -206,3 +206,19 @@ export function enableTripReorder(rows, refresh) {
|
|||||||
refresh,
|
refresh,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Checklist: reorder items within a category ----------
|
||||||
|
|
||||||
|
// `rows`: [{ node, handle, item }] — same mechanics as enableRowReorder,
|
||||||
|
// scoped to one category's rows (checklist.js calls this once per category,
|
||||||
|
// so the resulting sort_order values only need to rank correctly within that
|
||||||
|
// category — category is always the primary sort key server-side).
|
||||||
|
export function enableChecklistReorder(rows, refresh) {
|
||||||
|
enableReorder(
|
||||||
|
rows,
|
||||||
|
(item) => item.id,
|
||||||
|
(item) => item.sort_order,
|
||||||
|
(id, sort_order) => api.checklist.update(id, { sort_order }),
|
||||||
|
refresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { renderCalendar } from './calendar.js';
|
|||||||
import { renderMap } from './map.js';
|
import { renderMap } from './map.js';
|
||||||
import { renderSummary } from './summary.js';
|
import { renderSummary } from './summary.js';
|
||||||
import { renderCosts } from './costs.js';
|
import { renderCosts } from './costs.js';
|
||||||
|
import { renderChecklist } from './checklist.js';
|
||||||
import { renderCountdown } from './flipclock.js';
|
import { renderCountdown } from './flipclock.js';
|
||||||
import { openDayEditor } from './dayEditor.js';
|
import { openDayEditor } from './dayEditor.js';
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ export function renderTripDetail(container, ctx, id) {
|
|||||||
'div',
|
'div',
|
||||||
{ class: 'detail-grid' },
|
{ class: 'detail-grid' },
|
||||||
renderMap(tctx),
|
renderMap(tctx),
|
||||||
el('div', { class: 'detail-side' }, renderSummary(tctx), renderCosts(tctx)),
|
el('div', { class: 'detail-side' }, renderSummary(tctx), renderCosts(tctx), renderChecklist(tctx)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
mount(container, page);
|
mount(container, page);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { requireAuth } from './auth.js';
|
|||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
import tripsRoutes from './routes/trips.js';
|
import tripsRoutes from './routes/trips.js';
|
||||||
import entriesRoutes from './routes/entries.js';
|
import entriesRoutes from './routes/entries.js';
|
||||||
|
import checklistRoutes from './routes/checklist.js';
|
||||||
import geocodeRoutes from './routes/geocode.js';
|
import geocodeRoutes from './routes/geocode.js';
|
||||||
import airportsRoutes from './routes/airports.js';
|
import airportsRoutes from './routes/airports.js';
|
||||||
import directionsRoutes from './routes/directions.js';
|
import directionsRoutes from './routes/directions.js';
|
||||||
@@ -50,6 +51,7 @@ export function createApp(options = {}) {
|
|||||||
app.use('/api/auth', authRoutes(db));
|
app.use('/api/auth', authRoutes(db));
|
||||||
app.use('/api/trips', requireAuth, tripsRoutes(db));
|
app.use('/api/trips', requireAuth, tripsRoutes(db));
|
||||||
app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id
|
app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id
|
||||||
|
app.use('/api', requireAuth, checklistRoutes(db)); // /trips/:id/checklist* + /checklist/:itemId
|
||||||
app.use('/api/geocode', requireAuth, geocodeRoutes(db));
|
app.use('/api/geocode', requireAuth, geocodeRoutes(db));
|
||||||
app.use('/api/airports', requireAuth, airportsRoutes());
|
app.use('/api/airports', requireAuth, airportsRoutes());
|
||||||
app.use('/api/directions', requireAuth, directionsRoutes());
|
app.use('/api/directions', requireAuth, directionsRoutes());
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"US": { "name": "United States", "plugs": ["A", "B"] },
|
||||||
|
"CA": { "name": "Canada", "plugs": ["A", "B"] },
|
||||||
|
"MX": { "name": "Mexico", "plugs": ["A", "B"] },
|
||||||
|
"GB": { "name": "United Kingdom", "plugs": ["G"] },
|
||||||
|
"IE": { "name": "Ireland", "plugs": ["G"] },
|
||||||
|
"FR": { "name": "France", "plugs": ["C", "E"] },
|
||||||
|
"DE": { "name": "Germany", "plugs": ["C", "F"] },
|
||||||
|
"IT": { "name": "Italy", "plugs": ["C", "F", "L"] },
|
||||||
|
"ES": { "name": "Spain", "plugs": ["C", "F"] },
|
||||||
|
"PT": { "name": "Portugal", "plugs": ["C", "F"] },
|
||||||
|
"NL": { "name": "Netherlands", "plugs": ["C", "F"] },
|
||||||
|
"BE": { "name": "Belgium", "plugs": ["C", "E"] },
|
||||||
|
"CH": { "name": "Switzerland", "plugs": ["C", "J"] },
|
||||||
|
"AT": { "name": "Austria", "plugs": ["C", "F"] },
|
||||||
|
"GR": { "name": "Greece", "plugs": ["C", "F"] },
|
||||||
|
"SE": { "name": "Sweden", "plugs": ["C", "F"] },
|
||||||
|
"NO": { "name": "Norway", "plugs": ["C", "F"] },
|
||||||
|
"DK": { "name": "Denmark", "plugs": ["C", "K"] },
|
||||||
|
"FI": { "name": "Finland", "plugs": ["C", "F"] },
|
||||||
|
"PL": { "name": "Poland", "plugs": ["C", "E"] },
|
||||||
|
"CZ": { "name": "Czechia", "plugs": ["C", "E"] },
|
||||||
|
"HU": { "name": "Hungary", "plugs": ["C", "F"] },
|
||||||
|
"HR": { "name": "Croatia", "plugs": ["C", "F"] },
|
||||||
|
"IS": { "name": "Iceland", "plugs": ["C", "F"] },
|
||||||
|
"RU": { "name": "Russia", "plugs": ["C", "F"] },
|
||||||
|
"TR": { "name": "Turkey", "plugs": ["C", "F"] },
|
||||||
|
"TH": { "name": "Thailand", "plugs": ["A", "B", "C"] },
|
||||||
|
"VN": { "name": "Vietnam", "plugs": ["A", "C"] },
|
||||||
|
"KH": { "name": "Cambodia", "plugs": ["A", "C", "G"] },
|
||||||
|
"LA": { "name": "Laos", "plugs": ["A", "B", "C"] },
|
||||||
|
"JP": { "name": "Japan", "plugs": ["A", "B"] },
|
||||||
|
"CN": { "name": "China", "plugs": ["A", "C", "I"] },
|
||||||
|
"KR": { "name": "South Korea", "plugs": ["C", "F"] },
|
||||||
|
"IN": { "name": "India", "plugs": ["C", "D", "M"] },
|
||||||
|
"ID": { "name": "Indonesia", "plugs": ["C", "F"] },
|
||||||
|
"MY": { "name": "Malaysia", "plugs": ["G"] },
|
||||||
|
"SG": { "name": "Singapore", "plugs": ["G"] },
|
||||||
|
"PH": { "name": "Philippines", "plugs": ["A", "B", "C"] },
|
||||||
|
"AU": { "name": "Australia", "plugs": ["I"] },
|
||||||
|
"NZ": { "name": "New Zealand", "plugs": ["I"] },
|
||||||
|
"ZA": { "name": "South Africa", "plugs": ["M", "N"] },
|
||||||
|
"EG": { "name": "Egypt", "plugs": ["C", "F"] },
|
||||||
|
"AE": { "name": "United Arab Emirates", "plugs": ["G", "C"] },
|
||||||
|
"IL": { "name": "Israel", "plugs": ["C", "H"] },
|
||||||
|
"BR": { "name": "Brazil", "plugs": ["N", "C"] },
|
||||||
|
"AR": { "name": "Argentina", "plugs": ["C", "I"] },
|
||||||
|
"CL": { "name": "Chile", "plugs": ["C", "L"] },
|
||||||
|
"PE": { "name": "Peru", "plugs": ["A", "C"] },
|
||||||
|
"CO": { "name": "Colombia", "plugs": ["A", "B"] },
|
||||||
|
"MA": { "name": "Morocco", "plugs": ["C", "E"] },
|
||||||
|
"KE": { "name": "Kenya", "plugs": ["G"] }
|
||||||
|
}
|
||||||
@@ -58,9 +58,24 @@ CREATE TABLE IF NOT EXISTS entry_participants (
|
|||||||
PRIMARY KEY (entry_id, user_id)
|
PRIMARY KEY (entry_id, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS checklist_items (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'General',
|
||||||
|
qty INTEGER,
|
||||||
|
checked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
checked_by INTEGER REFERENCES users(id),
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
suggestion_key TEXT,
|
||||||
|
created_at TEXT DEFAULT current_timestamp
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_trip ON entries(trip_id, date, sort_order, id);
|
CREATE INDEX IF NOT EXISTS idx_entries_trip ON entries(trip_id, date, sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_members_user ON trip_members(user_id);
|
CREATE INDEX IF NOT EXISTS idx_members_user ON trip_members(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_participants_entry ON entry_participants(entry_id);
|
CREATE INDEX IF NOT EXISTS idx_participants_entry ON entry_participants(entry_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_checklist_trip ON checklist_items(trip_id, category, sort_order, id);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Columns added after the initial release: CREATE TABLE IF NOT EXISTS never
|
// Columns added after the initial release: CREATE TABLE IF NOT EXISTS never
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { daysInclusive } from '../util/dates.js';
|
||||||
|
import { membership } from '../util/access.js';
|
||||||
|
import { parseSegments } from '../util/entrySerialize.js';
|
||||||
|
import { buildSuggestions, CATEGORY_ORDER } from '../util/packing.js';
|
||||||
|
|
||||||
|
const MAX_TEXT_LEN = 120;
|
||||||
|
const MAX_CATEGORY_LEN = 40;
|
||||||
|
const MAX_KEYS = 60;
|
||||||
|
|
||||||
|
function categoryRank(category) {
|
||||||
|
const idx = CATEGORY_ORDER.indexOf(category);
|
||||||
|
return idx === -1 ? CATEGORY_ORDER.length : idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order: fixed category order (then any other category alphabetically),
|
||||||
|
// then (sort_order, id) within a category.
|
||||||
|
function compareItems(a, b) {
|
||||||
|
const ra = categoryRank(a.category);
|
||||||
|
const rb = categoryRank(b.category);
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
if (ra === CATEGORY_ORDER.length && a.category !== b.category) {
|
||||||
|
return a.category < b.category ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order;
|
||||||
|
return a.id - b.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemJson(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
trip_id: row.trip_id,
|
||||||
|
text: row.text,
|
||||||
|
category: row.category,
|
||||||
|
qty: row.qty ?? null,
|
||||||
|
checked: !!row.checked,
|
||||||
|
checked_by: row.checked_by ?? null,
|
||||||
|
personal: row.user_id !== null,
|
||||||
|
user_id: row.user_id ?? null,
|
||||||
|
sort_order: row.sort_order,
|
||||||
|
suggestion_key: row.suggestion_key ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressFor(items) {
|
||||||
|
const total = items.length;
|
||||||
|
const checked = items.filter((i) => i.checked).length;
|
||||||
|
const byCategory = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.category)) continue;
|
||||||
|
seen.add(item.category);
|
||||||
|
const inCategory = items.filter((i) => i.category === item.category);
|
||||||
|
byCategory.push({
|
||||||
|
category: item.category,
|
||||||
|
total: inCategory.length,
|
||||||
|
checked: inCategory.filter((i) => i.checked).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { total, checked, byCategory };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a checklist item body. `partial` = true for PATCH (only provided
|
||||||
|
// keys checked). Returns { error } or { fields } (fields is a plain object of
|
||||||
|
// column -> value to write, using the checklist_items column names).
|
||||||
|
function validateItem(body, { partial, callerId }) {
|
||||||
|
const fields = {};
|
||||||
|
const has = (k) => k in body;
|
||||||
|
|
||||||
|
if (!partial || has('text')) {
|
||||||
|
if (typeof body.text !== 'string' || body.text.trim() === '') {
|
||||||
|
return { error: 'text is required' };
|
||||||
|
}
|
||||||
|
const trimmed = body.text.trim();
|
||||||
|
if (trimmed.length > MAX_TEXT_LEN) {
|
||||||
|
return { error: `text must be at most ${MAX_TEXT_LEN} characters` };
|
||||||
|
}
|
||||||
|
fields.text = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has('category')) {
|
||||||
|
if (typeof body.category !== 'string') {
|
||||||
|
return { error: 'category must be a string' };
|
||||||
|
}
|
||||||
|
const trimmed = body.category.trim();
|
||||||
|
if (trimmed.length > MAX_CATEGORY_LEN) {
|
||||||
|
return { error: `category must be at most ${MAX_CATEGORY_LEN} characters` };
|
||||||
|
}
|
||||||
|
fields.category = trimmed || 'General';
|
||||||
|
} else if (!partial) {
|
||||||
|
fields.category = 'General';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has('qty')) {
|
||||||
|
const v = body.qty;
|
||||||
|
if (v !== null && !(Number.isInteger(v) && v >= 1 && v <= 99)) {
|
||||||
|
return { error: 'qty must be null or an integer from 1 to 99' };
|
||||||
|
}
|
||||||
|
fields.qty = v;
|
||||||
|
} else if (!partial) {
|
||||||
|
fields.qty = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has('checked')) {
|
||||||
|
if (typeof body.checked !== 'boolean') {
|
||||||
|
return { error: 'checked must be a boolean' };
|
||||||
|
}
|
||||||
|
fields.checked = body.checked ? 1 : 0;
|
||||||
|
fields.checked_by = body.checked ? callerId : null;
|
||||||
|
} else if (!partial) {
|
||||||
|
fields.checked = 0;
|
||||||
|
fields.checked_by = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has('personal')) {
|
||||||
|
if (typeof body.personal !== 'boolean') {
|
||||||
|
return { error: 'personal must be a boolean' };
|
||||||
|
}
|
||||||
|
fields.user_id = body.personal ? callerId : null;
|
||||||
|
} else if (!partial) {
|
||||||
|
fields.user_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has('sort_order')) {
|
||||||
|
if (!Number.isInteger(body.sort_order)) {
|
||||||
|
return { error: 'sort_order must be an integer' };
|
||||||
|
}
|
||||||
|
fields.sort_order = body.sort_order;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fields };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function checklistRoutes(db) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const getTrip = db.prepare('SELECT id, start_date, end_date FROM trips WHERE id = ?');
|
||||||
|
const getTripEntries = db.prepare(
|
||||||
|
'SELECT id, type, location_name, lat, lng, transport_mode, segments FROM entries WHERE trip_id = ?'
|
||||||
|
);
|
||||||
|
const getVisibleItems = db.prepare(
|
||||||
|
'SELECT * FROM checklist_items WHERE trip_id = ? AND (user_id IS NULL OR user_id = ?)'
|
||||||
|
);
|
||||||
|
const getItemById = db.prepare('SELECT * FROM checklist_items WHERE id = ?');
|
||||||
|
const getMaxSortOrder = db.prepare(
|
||||||
|
'SELECT MAX(sort_order) AS max FROM checklist_items WHERE trip_id = ?'
|
||||||
|
);
|
||||||
|
const insertItem = db.prepare(`
|
||||||
|
INSERT INTO checklist_items
|
||||||
|
(trip_id, user_id, text, category, qty, checked, checked_by, sort_order, suggestion_key)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
function visibleItems(tripId, callerId) {
|
||||||
|
return getVisibleItems.all(tripId, callerId).sort(compareItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSortOrder(tripId) {
|
||||||
|
const row = getMaxSortOrder.get(tripId);
|
||||||
|
return (row && row.max !== null ? row.max : -1) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tripContext(tripId) {
|
||||||
|
const trip = getTrip.get(tripId);
|
||||||
|
const entries = getTripEntries.all(tripId).map((r) => ({
|
||||||
|
...r,
|
||||||
|
segments: parseSegments(r.segments),
|
||||||
|
}));
|
||||||
|
const days = daysInclusive(trip.start_date, trip.end_date);
|
||||||
|
const nights = Math.max(0, days - 1);
|
||||||
|
return { trip, entries, days, nights };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve an item by id, confirm the caller is a trip member, and that the
|
||||||
|
// item is visible to them (shared, or their own personal item). Sends 404
|
||||||
|
// and returns null otherwise (no leaking of other members' personal items).
|
||||||
|
function requireVisibleItem(req, res) {
|
||||||
|
const itemId = Number(req.params.itemId);
|
||||||
|
const row = Number.isInteger(itemId) ? getItemById.get(itemId) : null;
|
||||||
|
if (!row || !membership(db, row.trip_id, req.session.userId)) {
|
||||||
|
res.status(404).json({ error: 'not found' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (row.user_id !== null && row.user_id !== req.session.userId) {
|
||||||
|
res.status(404).json({ error: 'not found' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /trips/:id/checklist
|
||||||
|
router.get('/trips/:id/checklist', (req, res) => {
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
const items = visibleItems(tripId, req.session.userId).map(itemJson);
|
||||||
|
res.status(200).json({ items, progress: progressFor(items) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /trips/:id/checklist/suggestions
|
||||||
|
router.get('/trips/:id/checklist/suggestions', (req, res) => {
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
const { trip, entries, days, nights } = tripContext(tripId);
|
||||||
|
const { suggestions, context } = buildSuggestions({ trip, entries, days, nights });
|
||||||
|
const visibleKeys = new Set(
|
||||||
|
visibleItems(tripId, req.session.userId)
|
||||||
|
.map((i) => i.suggestion_key)
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
res.status(200).json({
|
||||||
|
suggestions: suggestions.map((s) => ({ ...s, added: visibleKeys.has(s.key) })),
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /trips/:id/checklist/suggestions { keys, personal? }
|
||||||
|
router.post('/trips/:id/checklist/suggestions', (req, res) => {
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
const body = req.body || {};
|
||||||
|
const keys = body.keys;
|
||||||
|
if (!Array.isArray(keys) || keys.length === 0 || keys.length > MAX_KEYS) {
|
||||||
|
return res.status(400).json({ error: `keys must be a non-empty array of at most ${MAX_KEYS} strings` });
|
||||||
|
}
|
||||||
|
if (body.personal !== undefined && typeof body.personal !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'personal must be a boolean' });
|
||||||
|
}
|
||||||
|
const personal = body.personal === true;
|
||||||
|
const callerId = req.session.userId;
|
||||||
|
|
||||||
|
const { trip, entries, days, nights } = tripContext(tripId);
|
||||||
|
const { suggestions } = buildSuggestions({ trip, entries, days, nights });
|
||||||
|
const byKey = new Map(suggestions.map((s) => [s.key, s]));
|
||||||
|
for (const key of keys) {
|
||||||
|
if (typeof key !== 'string' || !byKey.has(key)) {
|
||||||
|
return res.status(400).json({ error: `unknown suggestion key: ${key}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = new Set(keys);
|
||||||
|
const already = new Set(
|
||||||
|
visibleItems(tripId, callerId).map((i) => i.suggestion_key).filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = db.transaction(() => {
|
||||||
|
const created = [];
|
||||||
|
const skipped = [];
|
||||||
|
let sortOrder = nextSortOrder(tripId);
|
||||||
|
for (const s of suggestions) {
|
||||||
|
if (!requested.has(s.key)) continue;
|
||||||
|
if (already.has(s.key)) {
|
||||||
|
skipped.push(s.key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const info = insertItem.run(
|
||||||
|
tripId,
|
||||||
|
personal ? callerId : null,
|
||||||
|
s.text,
|
||||||
|
s.category,
|
||||||
|
s.qty ?? null,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
sortOrder,
|
||||||
|
s.key
|
||||||
|
);
|
||||||
|
sortOrder += 1;
|
||||||
|
created.push(itemJson(getItemById.get(Number(info.lastInsertRowid))));
|
||||||
|
}
|
||||||
|
return { created, skipped };
|
||||||
|
})();
|
||||||
|
|
||||||
|
res.status(201).json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /trips/:id/checklist/reset {} — unticks caller-visible items.
|
||||||
|
router.post('/trips/:id/checklist/reset', (req, res) => {
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
const callerId = req.session.userId;
|
||||||
|
const info = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE checklist_items SET checked = 0, checked_by = NULL
|
||||||
|
WHERE trip_id = ? AND (user_id IS NULL OR user_id = ?) AND checked = 1`
|
||||||
|
)
|
||||||
|
.run(tripId, callerId);
|
||||||
|
res.status(200).json({ unchecked: info.changes });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /trips/:id/checklist { text, category?, qty?, personal?, checked?, sort_order? }
|
||||||
|
router.post('/trips/:id/checklist', (req, res) => {
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
const callerId = req.session.userId;
|
||||||
|
const check = validateItem(req.body || {}, { partial: false, callerId });
|
||||||
|
if (check.error) return res.status(400).json({ error: check.error });
|
||||||
|
const f = check.fields;
|
||||||
|
const sortOrder = 'sort_order' in f ? f.sort_order : nextSortOrder(tripId);
|
||||||
|
|
||||||
|
const item = db.transaction(() => {
|
||||||
|
const info = insertItem.run(
|
||||||
|
tripId,
|
||||||
|
f.user_id,
|
||||||
|
f.text,
|
||||||
|
f.category,
|
||||||
|
f.qty,
|
||||||
|
f.checked,
|
||||||
|
f.checked_by,
|
||||||
|
sortOrder,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
return getItemById.get(Number(info.lastInsertRowid));
|
||||||
|
})();
|
||||||
|
|
||||||
|
res.status(201).json({ item: itemJson(item) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /checklist/:itemId
|
||||||
|
router.patch('/checklist/:itemId', (req, res) => {
|
||||||
|
const row = requireVisibleItem(req, res);
|
||||||
|
if (!row) return;
|
||||||
|
const check = validateItem(req.body || {}, { partial: true, callerId: req.session.userId });
|
||||||
|
if (check.error) return res.status(400).json({ error: check.error });
|
||||||
|
|
||||||
|
const keys = Object.keys(check.fields);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
const setClause = keys.map((k) => `${k} = ?`).join(', ');
|
||||||
|
const values = keys.map((k) => check.fields[k]);
|
||||||
|
db.prepare(`UPDATE checklist_items SET ${setClause} WHERE id = ?`).run(...values, row.id);
|
||||||
|
}
|
||||||
|
res.status(200).json({ item: itemJson(getItemById.get(row.id)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /checklist/:itemId
|
||||||
|
router.delete('/checklist/:itemId', (req, res) => {
|
||||||
|
const row = requireVisibleItem(req, res);
|
||||||
|
if (!row) return;
|
||||||
|
db.prepare('DELETE FROM checklist_items WHERE id = ?').run(row.id);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
// Deterministic, offline packing-advice rule engine — see "Packing advice
|
||||||
|
// (suggestions)" in docs/API.md. Pure: same input always yields the same
|
||||||
|
// output (no randomness, no network calls, no dependence on today's date).
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { haversineKm } from './distance.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const AIRPORTS = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(__dirname, '..', 'data', 'airports.json'), 'utf8')
|
||||||
|
);
|
||||||
|
const COUNTRY_PLUGS = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(__dirname, '..', 'data', 'countryPlugs.json'), 'utf8')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fixed category order shared with checklist items (see docs/API.md).
|
||||||
|
export const CATEGORY_ORDER = [
|
||||||
|
'Documents', 'Clothing', 'Toiletries', 'Health', 'Electronics', 'Extras',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LONG_HAUL_KM = 5000;
|
||||||
|
const TROPICAL_LAT = 23.5;
|
||||||
|
const COLD_LAT = 55;
|
||||||
|
// Poleward of this, a local winter month means genuinely cold (Tokyo 35.7,
|
||||||
|
// Athens 38, New York 40); equatorward of it, winter is still mild (Delhi 28,
|
||||||
|
// Bangkok 13.7), so warm layers would be bad advice.
|
||||||
|
const WINTER_COLD_LAT = 35;
|
||||||
|
const NORTHERN_WINTER = new Set([12, 1, 2]);
|
||||||
|
const SOUTHERN_WINTER = new Set([6, 7, 8]);
|
||||||
|
|
||||||
|
// Airport code -> ISO country (airports.json field is `country`, not
|
||||||
|
// `iso_country`). Built once from the bundled OurAirports dataset.
|
||||||
|
const AIRPORT_COUNTRY = new Map(AIRPORTS.map((a) => [a.code, a.country]));
|
||||||
|
// Country name (lowercased) -> ISO code, for matching free-text location names.
|
||||||
|
const NAME_TO_ISO = new Map(
|
||||||
|
Object.entries(COUNTRY_PLUGS).map(([iso, c]) => [c.name.toLowerCase(), iso])
|
||||||
|
);
|
||||||
|
|
||||||
|
function lastCommaSegment(locationName) {
|
||||||
|
if (typeof locationName !== 'string' || !locationName.trim()) return null;
|
||||||
|
const parts = locationName.split(',');
|
||||||
|
const seg = parts[parts.length - 1].trim();
|
||||||
|
return seg || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Month numbers (1-12) spanned by the trip's date range, in chronological
|
||||||
|
// order, deduped (a >365-day range can't happen, so at most ~13 entries).
|
||||||
|
function monthsSpanned(startDate, endDate) {
|
||||||
|
const [sy, sm] = startDate.split('-').map(Number);
|
||||||
|
const [ey, em] = endDate.split('-').map(Number);
|
||||||
|
const months = [];
|
||||||
|
let y = sy;
|
||||||
|
let m = sm;
|
||||||
|
while (y < ey || (y === ey && m <= em)) {
|
||||||
|
if (!months.includes(m)) months.push(m);
|
||||||
|
m += 1;
|
||||||
|
if (m > 12) {
|
||||||
|
m = 1;
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return months;
|
||||||
|
}
|
||||||
|
|
||||||
|
// { lat, lng } for every located stop: entry lat/lng, or flight-segment
|
||||||
|
// airport coordinates for flight entries carrying segments.
|
||||||
|
// Places the traveller will actually BE, for climate purposes. For flights we
|
||||||
|
// take only each segment's arrival airport: the first segment's `from` is
|
||||||
|
// where they depart from, not a destination — counting it would put "warm
|
||||||
|
// layers, hat & gloves" on a December Frankfurt→Bangkok beach trip. Landing
|
||||||
|
// back in a cold place later is still covered, because that shows up as a
|
||||||
|
// segment's `to`.
|
||||||
|
function locatedPoints(entries) {
|
||||||
|
const points = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.type === 'flight' && Array.isArray(e.segments) && e.segments.length) {
|
||||||
|
const airports = e.segments.map((s) => s.to);
|
||||||
|
for (const a of airports) {
|
||||||
|
if (a && typeof a.lat === 'number' && typeof a.lng === 'number') {
|
||||||
|
points.push({ lat: a.lat, lng: a.lng });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof e.lat === 'number' && typeof e.lng === 'number') {
|
||||||
|
points.push({ lat: e.lat, lng: e.lng });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique country names (last comma-segment of location_name, plus flight
|
||||||
|
// segment airport codes resolved via the bundled dataset), in order of
|
||||||
|
// first appearance.
|
||||||
|
function collectCountries(entries) {
|
||||||
|
const names = [];
|
||||||
|
const push = (name) => {
|
||||||
|
if (name && !names.includes(name)) names.push(name);
|
||||||
|
};
|
||||||
|
for (const e of entries) {
|
||||||
|
push(lastCommaSegment(e.location_name));
|
||||||
|
if (e.type === 'flight' && Array.isArray(e.segments)) {
|
||||||
|
// Arrival airports only — same reasoning as locatedPoints(): the origin
|
||||||
|
// is home, and you don't pack a travel adapter for your own sockets.
|
||||||
|
for (const seg of e.segments) {
|
||||||
|
const iso = seg.to && seg.to.code ? AIRPORT_COUNTRY.get(seg.to.code) : null;
|
||||||
|
const plug = iso ? COUNTRY_PLUGS[iso] : null;
|
||||||
|
if (plug) push(plug.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve destination country names to a plug-adapter suggestion.
|
||||||
|
// Unknown or mixed-and-incompatible plug types fall back to "Universal".
|
||||||
|
function adapterSuggestion(countries) {
|
||||||
|
const resolved = countries
|
||||||
|
.map((name) => NAME_TO_ISO.get(name.toLowerCase()))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (resolved.length === 0) {
|
||||||
|
return { text: 'Universal travel adapter', reason: 'Unknown socket type at destination' };
|
||||||
|
}
|
||||||
|
const plugSets = resolved.map((iso) => COUNTRY_PLUGS[iso].plugs);
|
||||||
|
const shared = plugSets[0].filter((p) => plugSets.every((set) => set.includes(p)));
|
||||||
|
if (shared.length === 0) {
|
||||||
|
return { text: 'Universal travel adapter', reason: 'Mixed socket types across destinations' };
|
||||||
|
}
|
||||||
|
const names = [...new Set(resolved.map((iso) => COUNTRY_PLUGS[iso].name))];
|
||||||
|
return {
|
||||||
|
text: `Plug adapter (type ${shared.join('/')})`,
|
||||||
|
reason: `${names.join(' & ')} uses type ${shared.join('/')} sockets`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// tropical: any stop within the tropics. cold: any stop far enough poleward to
|
||||||
|
// be cold year-round, or a temperate stop visited during its own hemisphere's
|
||||||
|
// winter. The WINTER_COLD_LAT floor matters: without it a December trip to
|
||||||
|
// Bangkok (lat 13.7, northern winter) would be tagged both tropical and cold
|
||||||
|
// and suggest sun cream alongside hat & gloves.
|
||||||
|
function climates(entries, months) {
|
||||||
|
const set = new Set();
|
||||||
|
for (const { lat } of locatedPoints(entries)) {
|
||||||
|
if (Math.abs(lat) < TROPICAL_LAT) set.add('tropical');
|
||||||
|
const winterMonths = lat >= 0 ? NORTHERN_WINTER : SOUTHERN_WINTER;
|
||||||
|
const localWinter = months.some((m) => winterMonths.has(m));
|
||||||
|
if (Math.abs(lat) > COLD_LAT || (localWinter && Math.abs(lat) >= WINTER_COLD_LAT)) {
|
||||||
|
set.add('cold');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...set];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any flight segment whose great-circle distance exceeds the long-haul threshold.
|
||||||
|
function isLongHaul(entries) {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.type !== 'flight' || !Array.isArray(e.segments)) continue;
|
||||||
|
for (const { from, to } of e.segments) {
|
||||||
|
if (
|
||||||
|
from && to &&
|
||||||
|
typeof from.lat === 'number' && typeof from.lng === 'number' &&
|
||||||
|
typeof to.lat === 'number' && typeof to.lng === 'number' &&
|
||||||
|
haversineKm(from.lat, from.lng, to.lat, to.lng) > LONG_HAUL_KM
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(n, word) {
|
||||||
|
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the trip-derived context plus the ordered suggestion list. `added`
|
||||||
|
// is not included here — the route layer stamps that in from the caller's
|
||||||
|
// visible checklist items.
|
||||||
|
export function buildSuggestions({ trip, entries, days, nights }) {
|
||||||
|
const months = monthsSpanned(trip.start_date, trip.end_date);
|
||||||
|
const flights = entries.filter((e) => e.type === 'flight').length;
|
||||||
|
const rentals = entries.filter((e) => e.type === 'rental').length;
|
||||||
|
const stays = entries.filter((e) => e.type === 'stay').length;
|
||||||
|
const transportModes = [
|
||||||
|
...new Set(
|
||||||
|
entries
|
||||||
|
.filter((e) => e.type === 'transport' && e.transport_mode)
|
||||||
|
.map((e) => e.transport_mode)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const countries = collectCountries(entries);
|
||||||
|
const climate = climates(entries, months);
|
||||||
|
const longHaul = isLongHaul(entries);
|
||||||
|
const adapter = adapterSuggestion(countries);
|
||||||
|
|
||||||
|
const context = { days, nights, months, countries, climate, flights, rentals, transportModes };
|
||||||
|
|
||||||
|
const clothingQty = Math.min(nights + 1, 10);
|
||||||
|
const hasFerry = transportModes.includes('ferry');
|
||||||
|
const hasTrain = transportModes.includes('train');
|
||||||
|
const tripLen = plural(days, 'day') + ' trip';
|
||||||
|
const flightCount = `You have ${plural(flights, 'flight')}`;
|
||||||
|
|
||||||
|
// Ordered rule table: `when` gates inclusion, `qty`/`reason` may be static
|
||||||
|
// or computed from context above. Grouped here by category for readability;
|
||||||
|
// final ordering is enforced via CATEGORY_ORDER below regardless.
|
||||||
|
const rules = [
|
||||||
|
// Documents
|
||||||
|
{ key: 'doc-passport', text: 'Passport (valid 6+ months)', category: 'Documents',
|
||||||
|
when: true, reason: flights > 0 ? flightCount : tripLen },
|
||||||
|
{ key: 'doc-cards-cash', text: 'Cards & local cash', category: 'Documents',
|
||||||
|
when: true, reason: tripLen },
|
||||||
|
{ key: 'doc-checkin', text: 'Complete online check-in', category: 'Documents',
|
||||||
|
when: flights > 0, reason: flightCount },
|
||||||
|
{ key: 'doc-driving-licence', text: 'Driving licence', category: 'Documents',
|
||||||
|
when: rentals > 0, reason: 'Rental car booked' },
|
||||||
|
{ key: 'doc-idp', text: 'International Driving Permit', category: 'Documents',
|
||||||
|
when: rentals > 0, reason: 'Rental car booked' },
|
||||||
|
|
||||||
|
// Clothing
|
||||||
|
{ key: 'clothing-tshirts', text: 'T-shirts', category: 'Clothing',
|
||||||
|
when: true, qty: clothingQty, reason: plural(nights, 'night') },
|
||||||
|
{ key: 'clothing-underwear', text: 'Underwear', category: 'Clothing',
|
||||||
|
when: true, qty: clothingQty, reason: plural(nights, 'night') },
|
||||||
|
{ key: 'clothing-socks', text: 'Socks', category: 'Clothing',
|
||||||
|
when: true, qty: clothingQty, reason: plural(nights, 'night') },
|
||||||
|
{ key: 'clothing-laundry-kit', text: 'Travel laundry kit (detergent sheets)', category: 'Clothing',
|
||||||
|
when: nights > 7, reason: 'Over 7 nights' },
|
||||||
|
{ key: 'clothing-rain-jacket', text: 'Light rain jacket', category: 'Clothing',
|
||||||
|
when: climate.includes('tropical'), reason: 'Tropical climate' },
|
||||||
|
{ key: 'clothing-warm-layers', text: 'Warm layers (fleece/base layer)', category: 'Clothing',
|
||||||
|
when: climate.includes('cold'), reason: 'Cold climate' },
|
||||||
|
{ key: 'clothing-hat-gloves', text: 'Hat & gloves', category: 'Clothing',
|
||||||
|
when: climate.includes('cold'), reason: 'Cold climate' },
|
||||||
|
|
||||||
|
// Toiletries
|
||||||
|
{ key: 'toiletries-toothbrush', text: 'Toothbrush & toothpaste', category: 'Toiletries',
|
||||||
|
when: true, reason: tripLen },
|
||||||
|
{ key: 'toiletries-liquids-100ml', text: 'Liquids in containers ≤100 ml (TSA bag)', category: 'Toiletries',
|
||||||
|
when: flights > 0, reason: flightCount },
|
||||||
|
{ key: 'toiletries-sun-cream', text: 'Sun cream (SPF 30+)', category: 'Toiletries',
|
||||||
|
when: climate.includes('tropical'), reason: 'Tropical climate' },
|
||||||
|
|
||||||
|
// Health
|
||||||
|
{ key: 'health-medication', text: 'Personal medication', category: 'Health',
|
||||||
|
when: true, reason: tripLen },
|
||||||
|
{ key: 'health-compression-socks', text: 'Compression socks', category: 'Health',
|
||||||
|
when: longHaul, reason: 'Long-haul flight (>5000 km)' },
|
||||||
|
{ key: 'health-motion-sickness', text: 'Motion-sickness tablets', category: 'Health',
|
||||||
|
when: hasFerry, reason: 'Ferry crossing' },
|
||||||
|
{ key: 'health-insect-repellent', text: 'Insect repellent', category: 'Health',
|
||||||
|
when: climate.includes('tropical'), reason: 'Tropical climate' },
|
||||||
|
{ key: 'health-rehydration-salts', text: 'Rehydration salts', category: 'Health',
|
||||||
|
when: climate.includes('tropical'), reason: 'Tropical climate' },
|
||||||
|
|
||||||
|
// Electronics
|
||||||
|
{ key: 'electronics-phone-charger', text: 'Phone & charger', category: 'Electronics',
|
||||||
|
when: true, reason: tripLen },
|
||||||
|
{ key: 'electronics-power-bank', text: 'Power bank (pack in carry-on)', category: 'Electronics',
|
||||||
|
when: flights > 0, reason: flightCount },
|
||||||
|
{ key: 'electronics-phone-mount', text: 'Phone mount', category: 'Electronics',
|
||||||
|
when: rentals > 0, reason: 'Rental car booked' },
|
||||||
|
{ key: 'electronics-adapter', text: adapter.text, category: 'Electronics',
|
||||||
|
when: countries.length > 0, reason: adapter.reason },
|
||||||
|
|
||||||
|
// Extras
|
||||||
|
{ key: 'extras-water-bottle', text: 'Reusable water bottle', category: 'Extras',
|
||||||
|
when: true, reason: tripLen },
|
||||||
|
{ key: 'extras-day-bag', text: 'Day bag / daypack', category: 'Extras',
|
||||||
|
when: true, reason: tripLen },
|
||||||
|
{ key: 'extras-neck-pillow', text: 'Neck pillow', category: 'Extras',
|
||||||
|
when: longHaul, reason: 'Long-haul flight (>5000 km)' },
|
||||||
|
{ key: 'extras-snacks', text: 'Snacks for the journey', category: 'Extras',
|
||||||
|
when: hasTrain, reason: 'Train travel' },
|
||||||
|
{ key: 'extras-luggage-lock', text: 'Luggage lock', category: 'Extras',
|
||||||
|
when: hasTrain, reason: 'Train travel' },
|
||||||
|
{ key: 'extras-packing-cubes', text: 'Packing cubes', category: 'Extras',
|
||||||
|
when: stays >= 3, reason: `${stays} stays` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const byCategory = new Map(CATEGORY_ORDER.map((c) => [c, []]));
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (!rule.when) continue;
|
||||||
|
byCategory.get(rule.category).push({
|
||||||
|
key: rule.key,
|
||||||
|
text: rule.text,
|
||||||
|
category: rule.category,
|
||||||
|
qty: rule.qty ?? null,
|
||||||
|
reason: rule.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = CATEGORY_ORDER.flatMap((c) => byCategory.get(c));
|
||||||
|
return { suggestions, context };
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { test, beforeEach, afterEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from '../src/server/app.js';
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-checklist-sugg-'));
|
||||||
|
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
if (app.locals.db) app.locals.db.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createAccount() {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
const res = await agent.post('/api/auth/account').send({});
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
return { agent, user: res.body.user };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTrip(agent, overrides = {}) {
|
||||||
|
return (await agent.post('/api/trips').send({
|
||||||
|
name: 'S', start_date: '2026-08-01', end_date: '2026-08-10', ...overrides,
|
||||||
|
})).body.trip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSuggestion(suggestions, key) {
|
||||||
|
return suggestions.find((s) => s.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Determinism & `added`
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('suggestions: two identical GETs return identical arrays', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'flight', title: 'F' });
|
||||||
|
|
||||||
|
const first = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`);
|
||||||
|
const second = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`);
|
||||||
|
assert.equal(first.status, 200);
|
||||||
|
assert.deepEqual(first.body.suggestions, second.body.suggestions);
|
||||||
|
assert.deepEqual(first.body.context, second.body.context);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('suggestions: `added` is false before the item exists, true after', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
|
||||||
|
const before = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`);
|
||||||
|
assert.equal(findSuggestion(before.body.suggestions, 'doc-passport').added, false);
|
||||||
|
|
||||||
|
const add = await agent.post(`/api/trips/${trip.id}/checklist/suggestions`).send({ keys: ['doc-passport'] });
|
||||||
|
assert.equal(add.status, 201);
|
||||||
|
|
||||||
|
const after = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`);
|
||||||
|
assert.equal(findSuggestion(after.body.suggestions, 'doc-passport').added, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bulk-add
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('bulk-add: creates items with suggestion_key set, in current suggestion order', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
|
||||||
|
const res = await agent.post(`/api/trips/${trip.id}/checklist/suggestions`).send({
|
||||||
|
keys: ['doc-cards-cash', 'doc-passport'],
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
assert.equal(res.body.created.length, 2);
|
||||||
|
assert.equal(res.body.skipped.length, 0);
|
||||||
|
// doc-passport precedes doc-cards-cash in the rule table -> current suggestion order.
|
||||||
|
assert.deepEqual(res.body.created.map((i) => i.suggestion_key), ['doc-passport', 'doc-cards-cash']);
|
||||||
|
assert.ok(res.body.created.every((i) => i.category === 'Documents'));
|
||||||
|
assert.ok(res.body.created.every((i) => i.user_id === null), 'shared by default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk-add: already-present keys go to skipped rather than duplicating', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
|
||||||
|
const first = await agent.post(`/api/trips/${trip.id}/checklist/suggestions`).send({ keys: ['doc-passport'] });
|
||||||
|
assert.equal(first.status, 201);
|
||||||
|
|
||||||
|
const second = await agent.post(`/api/trips/${trip.id}/checklist/suggestions`).send({
|
||||||
|
keys: ['doc-passport', 'toiletries-toothbrush'],
|
||||||
|
});
|
||||||
|
assert.equal(second.status, 201);
|
||||||
|
assert.deepEqual(second.body.skipped, ['doc-passport']);
|
||||||
|
assert.equal(second.body.created.length, 1);
|
||||||
|
assert.equal(second.body.created[0].suggestion_key, 'toiletries-toothbrush');
|
||||||
|
|
||||||
|
const list = (await agent.get(`/api/trips/${trip.id}/checklist`)).body.items;
|
||||||
|
assert.equal(list.filter((i) => i.suggestion_key === 'doc-passport').length, 1, 'not duplicated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk-add: unknown key -> 400', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const res = await agent.post(`/api/trips/${trip.id}/checklist/suggestions`).send({ keys: ['not-a-real-key'] });
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.deepEqual(res.body, { error: 'unknown suggestion key: not-a-real-key' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk-add: bad `keys` payloads -> 400', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/checklist/suggestions`;
|
||||||
|
|
||||||
|
assert.equal((await agent.post(base).send({ keys: [] })).status, 400);
|
||||||
|
assert.equal((await agent.post(base).send({ keys: 'doc-passport' })).status, 400);
|
||||||
|
assert.equal((await agent.post(base).send({})).status, 400);
|
||||||
|
assert.equal((await agent.post(base).send({ keys: [123] })).status, 400);
|
||||||
|
const tooMany = Array.from({ length: 61 }, () => 'doc-passport');
|
||||||
|
assert.equal((await agent.post(base).send({ keys: tooMany })).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rule coverage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('rules: clothing qty scales with nights, capped at 10 for a 20-night trip', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
// 21 days inclusive Aug 1 -> Aug 21 = 20 nights.
|
||||||
|
const trip = await makeTrip(agent, { start_date: '2026-08-01', end_date: '2026-08-21' });
|
||||||
|
|
||||||
|
const res = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`);
|
||||||
|
assert.equal(res.body.context.nights, 20);
|
||||||
|
assert.equal(findSuggestion(res.body.suggestions, 'clothing-tshirts').qty, 10);
|
||||||
|
assert.equal(findSuggestion(res.body.suggestions, 'clothing-underwear').qty, 10);
|
||||||
|
assert.equal(findSuggestion(res.body.suggestions, 'clothing-socks').qty, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rules: a flight entry triggers liquids and power-bank suggestions', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
|
||||||
|
const before = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions;
|
||||||
|
assert.equal(findSuggestion(before, 'toiletries-liquids-100ml'), undefined);
|
||||||
|
assert.equal(findSuggestion(before, 'electronics-power-bank'), undefined);
|
||||||
|
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'flight', title: 'F' });
|
||||||
|
|
||||||
|
const after = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions;
|
||||||
|
assert.ok(findSuggestion(after, 'toiletries-liquids-100ml'));
|
||||||
|
assert.ok(findSuggestion(after, 'electronics-power-bank'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rules: a rental entry triggers driving-licence and IDP suggestions', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
|
||||||
|
const before = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions;
|
||||||
|
assert.equal(findSuggestion(before, 'doc-driving-licence'), undefined);
|
||||||
|
assert.equal(findSuggestion(before, 'doc-idp'), undefined);
|
||||||
|
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'rental', title: 'Car' });
|
||||||
|
|
||||||
|
const after = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions;
|
||||||
|
assert.ok(findSuggestion(after, 'doc-driving-licence'));
|
||||||
|
assert.ok(findSuggestion(after, 'doc-idp'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rules: a ferry transport entry triggers motion-sickness suggestion', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
|
||||||
|
const before = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions;
|
||||||
|
assert.equal(findSuggestion(before, 'health-motion-sickness'), undefined);
|
||||||
|
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||||
|
date: '2026-08-01', type: 'transport', title: 'Ferry', transport_mode: 'ferry',
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions;
|
||||||
|
assert.ok(findSuggestion(after, 'health-motion-sickness'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rules: 3+ stays trigger packing cubes', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/entries`;
|
||||||
|
|
||||||
|
await agent.post(base).send({ date: '2026-08-01', type: 'stay', title: 'A' });
|
||||||
|
await agent.post(base).send({ date: '2026-08-03', type: 'stay', title: 'B' });
|
||||||
|
|
||||||
|
const twoStays = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions;
|
||||||
|
assert.equal(findSuggestion(twoStays, 'extras-packing-cubes'), undefined);
|
||||||
|
|
||||||
|
await agent.post(base).send({ date: '2026-08-05', type: 'stay', title: 'C' });
|
||||||
|
|
||||||
|
const threeStays = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions;
|
||||||
|
assert.ok(findSuggestion(threeStays, 'extras-packing-cubes'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Climate regression: tropical winter destination must not also be tagged cold
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('climate: a tropical stop during northern winter is tropical but NOT cold (no warm-layers/hat-gloves)', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
// December trip -> northern winter months.
|
||||||
|
const trip = await makeTrip(agent, { start_date: '2026-12-05', end_date: '2026-12-15' });
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||||
|
date: '2026-12-06', type: 'activity', title: 'Grand Palace',
|
||||||
|
location_name: 'Bangkok, Thailand', lat: 13.7, lng: 100.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`);
|
||||||
|
assert.deepEqual(res.body.context.climate, ['tropical']);
|
||||||
|
assert.ok(findSuggestion(res.body.suggestions, 'clothing-rain-jacket'));
|
||||||
|
assert.ok(findSuggestion(res.body.suggestions, 'toiletries-sun-cream'));
|
||||||
|
assert.equal(findSuggestion(res.body.suggestions, 'clothing-warm-layers'), undefined);
|
||||||
|
assert.equal(findSuggestion(res.body.suggestions, 'clothing-hat-gloves'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real bundled airports.json coordinates, so this exercises the actual
|
||||||
|
// code -> country lookup rather than made-up lat/lng.
|
||||||
|
const FRA = { code: 'FRA', lat: 50.026706, lng: 8.55835 }; // Frankfurt, Germany
|
||||||
|
const BKK = { code: 'BKK', lat: 13.6811, lng: 100.747002 }; // Bangkok, Thailand
|
||||||
|
const MUC = { code: 'MUC', lat: 48.353802, lng: 11.7861 }; // Munich, Germany
|
||||||
|
|
||||||
|
test('climate/countries: a flight\'s departure airport does not count, only each segment\'s arrival', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
// December trip, flying FROM cold Frankfurt TO tropical Bangkok for a beach
|
||||||
|
// holiday. Only the arrival (Bangkok) should count for climate/countries;
|
||||||
|
// Frankfurt as the mere departure point must not leak in as "cold"/"Germany".
|
||||||
|
const trip = await makeTrip(agent, { start_date: '2026-12-05', end_date: '2026-12-15' });
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||||
|
date: '2026-12-06', type: 'stay', title: 'Bangkok stay', location_name: 'Bangkok, Thailand',
|
||||||
|
});
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||||
|
date: '2026-12-05', type: 'flight', title: 'FRA-BKK',
|
||||||
|
segments: [{ from: FRA, to: BKK }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`);
|
||||||
|
assert.deepEqual(res.body.context.climate, ['tropical']);
|
||||||
|
assert.deepEqual(res.body.context.countries, ['Thailand']);
|
||||||
|
assert.equal(findSuggestion(res.body.suggestions, 'clothing-warm-layers'), undefined);
|
||||||
|
assert.equal(findSuggestion(res.body.suggestions, 'clothing-hat-gloves'), undefined);
|
||||||
|
|
||||||
|
// Adapter suggestion names Thailand's sockets rather than falling back to
|
||||||
|
// the mixed/universal wording (which would happen if Germany leaked in).
|
||||||
|
const adapter = findSuggestion(res.body.suggestions, 'electronics-adapter');
|
||||||
|
assert.equal(adapter.text, 'Plug adapter (type A/B/C)');
|
||||||
|
assert.equal(adapter.reason, 'Thailand uses type A/B/C sockets');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('climate/countries: landing back in a cold place is still counted (arrival of a later segment)', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent, { start_date: '2026-12-05', end_date: '2026-12-15' });
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||||
|
date: '2026-12-06', type: 'stay', title: 'Bangkok stay', location_name: 'Bangkok, Thailand',
|
||||||
|
});
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||||
|
date: '2026-12-05', type: 'flight', title: 'FRA-BKK',
|
||||||
|
segments: [{ from: FRA, to: BKK }],
|
||||||
|
});
|
||||||
|
// Return leg: Bangkok -> Munich. Munich is only ever an arrival, never a
|
||||||
|
// mere departure, so it must still register as cold/Germany.
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||||
|
date: '2026-12-14', type: 'flight', title: 'BKK-MUC',
|
||||||
|
segments: [{ from: BKK, to: MUC }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`);
|
||||||
|
assert.deepEqual(res.body.context.climate.sort(), ['cold', 'tropical']);
|
||||||
|
assert.deepEqual(res.body.context.countries, ['Thailand', 'Germany']);
|
||||||
|
assert.ok(findSuggestion(res.body.suggestions, 'clothing-warm-layers'));
|
||||||
|
assert.ok(findSuggestion(res.body.suggestions, 'clothing-hat-gloves'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('climate: a stop at lat 48 during northern winter IS cold', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent, { start_date: '2026-12-05', end_date: '2026-12-15' });
|
||||||
|
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||||
|
date: '2026-12-06', type: 'activity', title: 'Christmas market',
|
||||||
|
location_name: 'Munich, Germany', lat: 48.1, lng: 11.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`);
|
||||||
|
assert.deepEqual(res.body.context.climate, ['cold']);
|
||||||
|
assert.ok(findSuggestion(res.body.suggestions, 'clothing-warm-layers'));
|
||||||
|
assert.ok(findSuggestion(res.body.suggestions, 'clothing-hat-gloves'));
|
||||||
|
assert.equal(findSuggestion(res.body.suggestions, 'clothing-rain-jacket'), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { test, beforeEach, afterEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from '../src/server/app.js';
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-checklist-'));
|
||||||
|
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
if (app.locals.db) app.locals.db.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createAccount() {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
const res = await agent.post('/api/auth/account').send({});
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
return { agent, user: res.body.user };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTrip(agent) {
|
||||||
|
return (await agent.post('/api/trips').send({
|
||||||
|
name: 'C', start_date: '2026-08-01', end_date: '2026-08-10',
|
||||||
|
})).body.trip;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinTrip(agent, trip) {
|
||||||
|
const res = await agent.post('/api/trips/join').send({ code: trip.join_code });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('POST checklist item: defaults applied', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const res = await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' });
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
const item = res.body.item;
|
||||||
|
assert.equal(item.category, 'General');
|
||||||
|
assert.equal(item.checked, false);
|
||||||
|
assert.equal(item.qty, null);
|
||||||
|
assert.equal(item.checked_by, null);
|
||||||
|
assert.equal(item.personal, false);
|
||||||
|
assert.equal(item.suggestion_key, null);
|
||||||
|
assert.equal(item.sort_order, 0);
|
||||||
|
|
||||||
|
const second = await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Visa' });
|
||||||
|
assert.equal(second.body.item.sort_order, 1, 'sort_order appended after the previous max');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET checklist: list shape (items + progress), checked is real boolean', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport', checked: true });
|
||||||
|
await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Socks', category: 'Clothing', qty: 5 });
|
||||||
|
|
||||||
|
const res = await agent.get(`/api/trips/${trip.id}/checklist`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.items.length, 2);
|
||||||
|
assert.equal(typeof res.body.items[0].checked, 'boolean');
|
||||||
|
assert.equal(res.body.progress.total, 2);
|
||||||
|
assert.equal(res.body.progress.checked, 1);
|
||||||
|
assert.ok(Array.isArray(res.body.progress.byCategory));
|
||||||
|
|
||||||
|
const socks = res.body.items.find((i) => i.text === 'Socks');
|
||||||
|
assert.equal(socks.qty, 5);
|
||||||
|
assert.equal(socks.checked_by, null);
|
||||||
|
assert.equal(socks.suggestion_key, null);
|
||||||
|
assert.equal(socks.personal, false);
|
||||||
|
assert.equal(socks.user_id, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PATCH checklist item: each field individually', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const item = (await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' })).body.item;
|
||||||
|
|
||||||
|
const text = await agent.patch(`/api/checklist/${item.id}`).send({ text: 'Passport (renewed)' });
|
||||||
|
assert.equal(text.status, 200);
|
||||||
|
assert.equal(text.body.item.text, 'Passport (renewed)');
|
||||||
|
|
||||||
|
const category = await agent.patch(`/api/checklist/${item.id}`).send({ category: 'Documents' });
|
||||||
|
assert.equal(category.body.item.category, 'Documents');
|
||||||
|
|
||||||
|
const qty = await agent.patch(`/api/checklist/${item.id}`).send({ qty: 3 });
|
||||||
|
assert.equal(qty.body.item.qty, 3);
|
||||||
|
const qtyCleared = await agent.patch(`/api/checklist/${item.id}`).send({ qty: null });
|
||||||
|
assert.equal(qtyCleared.body.item.qty, null);
|
||||||
|
|
||||||
|
const sortOrder = await agent.patch(`/api/checklist/${item.id}`).send({ sort_order: 7 });
|
||||||
|
assert.equal(sortOrder.body.item.sort_order, 7);
|
||||||
|
|
||||||
|
const personal = await agent.patch(`/api/checklist/${item.id}`).send({ personal: true });
|
||||||
|
assert.equal(personal.body.item.personal, true);
|
||||||
|
const shared = await agent.patch(`/api/checklist/${item.id}`).send({ personal: false });
|
||||||
|
assert.equal(shared.body.item.personal, false);
|
||||||
|
|
||||||
|
const checked = await agent.patch(`/api/checklist/${item.id}`).send({ checked: true });
|
||||||
|
assert.equal(checked.body.item.checked, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE checklist item -> 204, then absent from list', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const item = (await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' })).body.item;
|
||||||
|
|
||||||
|
const del = await agent.delete(`/api/checklist/${item.id}`);
|
||||||
|
assert.equal(del.status, 204);
|
||||||
|
|
||||||
|
const list = (await agent.get(`/api/trips/${trip.id}/checklist`)).body.items;
|
||||||
|
assert.ok(!list.some((i) => i.id === item.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ordering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('ordering: fixed category order, then other categories alphabetically, then (sort_order, id)', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/checklist`;
|
||||||
|
|
||||||
|
// Created deliberately out of order.
|
||||||
|
await agent.post(base).send({ text: 'e1', category: 'Extras' });
|
||||||
|
await agent.post(base).send({ text: 'z1', category: 'Zebra' });
|
||||||
|
await agent.post(base).send({ text: 'd-late', category: 'Documents', sort_order: 5 });
|
||||||
|
await agent.post(base).send({ text: 'c1', category: 'Clothing' });
|
||||||
|
await agent.post(base).send({ text: 'a1', category: 'Apple' });
|
||||||
|
await agent.post(base).send({ text: 'd-early', category: 'Documents', sort_order: 1 });
|
||||||
|
|
||||||
|
const items = (await agent.get(`/api/trips/${trip.id}/checklist`)).body.items;
|
||||||
|
assert.deepEqual(
|
||||||
|
items.map((i) => i.text),
|
||||||
|
['d-early', 'd-late', 'c1', 'e1', 'a1', 'z1']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordering: (sort_order, id) breaks ties within a category', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/checklist`;
|
||||||
|
|
||||||
|
const a = (await agent.post(base).send({ text: 'a', category: 'General', sort_order: 3 })).body.item;
|
||||||
|
const b = (await agent.post(base).send({ text: 'b', category: 'General', sort_order: 3 })).body.item;
|
||||||
|
|
||||||
|
const items = (await agent.get(`/api/trips/${trip.id}/checklist`)).body.items;
|
||||||
|
assert.deepEqual(items.map((i) => i.id), [a.id, b.id], 'equal sort_order falls back to id order');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Personal vs shared isolation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('personal items are invisible to other members; shared items are visible to all and tickable by either', async () => {
|
||||||
|
const { agent: a, user: userA } = await createAccount();
|
||||||
|
const trip = await makeTrip(a);
|
||||||
|
const { agent: b, user: userB } = await createAccount();
|
||||||
|
await joinTrip(b, trip);
|
||||||
|
|
||||||
|
const personalA = (await a.post(`/api/trips/${trip.id}/checklist`).send({
|
||||||
|
text: 'My medication', personal: true,
|
||||||
|
})).body.item;
|
||||||
|
assert.equal(personalA.user_id, userA.id);
|
||||||
|
|
||||||
|
const shared = (await a.post(`/api/trips/${trip.id}/checklist`).send({
|
||||||
|
text: 'First-aid kit',
|
||||||
|
})).body.item;
|
||||||
|
|
||||||
|
const listForB = (await b.get(`/api/trips/${trip.id}/checklist`)).body.items;
|
||||||
|
assert.ok(!listForB.some((i) => i.id === personalA.id), 'B must not see A\'s personal item');
|
||||||
|
assert.ok(listForB.some((i) => i.id === shared.id), 'B sees the shared item');
|
||||||
|
|
||||||
|
const listForA = (await a.get(`/api/trips/${trip.id}/checklist`)).body.items;
|
||||||
|
assert.ok(listForA.some((i) => i.id === personalA.id));
|
||||||
|
assert.ok(listForA.some((i) => i.id === shared.id));
|
||||||
|
|
||||||
|
// B cannot PATCH or DELETE A's personal item.
|
||||||
|
const patchAttempt = await b.patch(`/api/checklist/${personalA.id}`).send({ checked: true });
|
||||||
|
assert.equal(patchAttempt.status, 404);
|
||||||
|
const deleteAttempt = await b.delete(`/api/checklist/${personalA.id}`);
|
||||||
|
assert.equal(deleteAttempt.status, 404);
|
||||||
|
|
||||||
|
// Either member can tick the shared item; checked_by records who.
|
||||||
|
const bTicks = await b.patch(`/api/checklist/${shared.id}`).send({ checked: true });
|
||||||
|
assert.equal(bTicks.status, 200);
|
||||||
|
assert.equal(bTicks.body.item.checked_by, userB.id);
|
||||||
|
|
||||||
|
const aTicks = await a.patch(`/api/checklist/${shared.id}`).send({ checked: true });
|
||||||
|
assert.equal(aTicks.status, 200);
|
||||||
|
assert.equal(aTicks.body.item.checked_by, userA.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Access control
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('access control: non-member gets 404 on every trip-scoped and item-scoped route', async () => {
|
||||||
|
const { agent: owner } = await createAccount();
|
||||||
|
const trip = await makeTrip(owner);
|
||||||
|
const item = (await owner.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' })).body.item;
|
||||||
|
|
||||||
|
const { agent: outsider } = await createAccount();
|
||||||
|
|
||||||
|
assert.equal((await outsider.get(`/api/trips/${trip.id}/checklist`)).status, 404);
|
||||||
|
assert.equal((await outsider.get(`/api/trips/${trip.id}/checklist/suggestions`)).status, 404);
|
||||||
|
assert.equal(
|
||||||
|
(await outsider.post(`/api/trips/${trip.id}/checklist/suggestions`).send({ keys: ['doc-passport'] })).status,
|
||||||
|
404
|
||||||
|
);
|
||||||
|
assert.equal((await outsider.post(`/api/trips/${trip.id}/checklist/reset`).send({})).status, 404);
|
||||||
|
assert.equal(
|
||||||
|
(await outsider.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Sneaky' })).status,
|
||||||
|
404
|
||||||
|
);
|
||||||
|
assert.equal((await outsider.patch(`/api/checklist/${item.id}`).send({ checked: true })).status, 404);
|
||||||
|
assert.equal((await outsider.delete(`/api/checklist/${item.id}`)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('access control: unknown item id -> 404', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
await makeTrip(agent);
|
||||||
|
assert.equal((await agent.patch('/api/checklist/999999').send({ checked: true })).status, 404);
|
||||||
|
assert.equal((await agent.delete('/api/checklist/999999')).status, 404);
|
||||||
|
assert.equal((await agent.patch('/api/checklist/not-a-number').send({ checked: true })).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('validation: empty/whitespace text, text/category too long, bad qty -> 400', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/checklist`;
|
||||||
|
|
||||||
|
assert.equal((await agent.post(base).send({ text: '' })).status, 400);
|
||||||
|
assert.equal((await agent.post(base).send({ text: ' ' })).status, 400);
|
||||||
|
assert.equal((await agent.post(base).send({ text: 'x'.repeat(121) })).status, 400);
|
||||||
|
assert.equal((await agent.post(base).send({ text: 'ok', category: 'x'.repeat(41) })).status, 400);
|
||||||
|
assert.equal((await agent.post(base).send({ text: 'ok', qty: 0 })).status, 400);
|
||||||
|
assert.equal((await agent.post(base).send({ text: 'ok', qty: 100 })).status, 400);
|
||||||
|
assert.equal((await agent.post(base).send({ text: 'ok', qty: 2.5 })).status, 400);
|
||||||
|
|
||||||
|
// Same checks apply to PATCH.
|
||||||
|
const item = (await agent.post(base).send({ text: 'Passport' })).body.item;
|
||||||
|
assert.equal((await agent.patch(`/api/checklist/${item.id}`).send({ text: ' ' })).status, 400);
|
||||||
|
assert.equal((await agent.patch(`/api/checklist/${item.id}`).send({ qty: 0 })).status, 400);
|
||||||
|
assert.equal((await agent.patch(`/api/checklist/${item.id}`).send({ category: 'x'.repeat(41) })).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// checked_by semantics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('checked_by: set to caller on checked:true, cleared on checked:false', async () => {
|
||||||
|
const { agent, user } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const item = (await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' })).body.item;
|
||||||
|
|
||||||
|
const checked = await agent.patch(`/api/checklist/${item.id}`).send({ checked: true });
|
||||||
|
assert.equal(checked.body.item.checked, true);
|
||||||
|
assert.equal(checked.body.item.checked_by, user.id);
|
||||||
|
|
||||||
|
const unchecked = await agent.patch(`/api/checklist/${item.id}`).send({ checked: false });
|
||||||
|
assert.equal(unchecked.body.item.checked, false);
|
||||||
|
assert.equal(unchecked.body.item.checked_by, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reset
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('reset: unticks only the caller\'s visible items and returns the count; another member\'s personal item is untouched', async () => {
|
||||||
|
const { agent: a, user: userA } = await createAccount();
|
||||||
|
const trip = await makeTrip(a);
|
||||||
|
const { agent: b, user: userB } = await createAccount();
|
||||||
|
await joinTrip(b, trip);
|
||||||
|
|
||||||
|
const shared = (await a.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Shared', checked: true })).body.item;
|
||||||
|
const personalA = (await a.post(`/api/trips/${trip.id}/checklist`).send({
|
||||||
|
text: 'A personal', personal: true, checked: true,
|
||||||
|
})).body.item;
|
||||||
|
const personalB = (await b.post(`/api/trips/${trip.id}/checklist`).send({
|
||||||
|
text: 'B personal', personal: true, checked: true,
|
||||||
|
})).body.item;
|
||||||
|
|
||||||
|
const res = await a.post(`/api/trips/${trip.id}/checklist/reset`).send({});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.unchecked, 2, 'shared + A\'s own personal item');
|
||||||
|
|
||||||
|
const listForA = (await a.get(`/api/trips/${trip.id}/checklist`)).body.items;
|
||||||
|
assert.equal(listForA.find((i) => i.id === shared.id).checked, false);
|
||||||
|
assert.equal(listForA.find((i) => i.id === shared.id).checked_by, null);
|
||||||
|
assert.equal(listForA.find((i) => i.id === personalA.id).checked, false);
|
||||||
|
|
||||||
|
const listForB = (await b.get(`/api/trips/${trip.id}/checklist`)).body.items;
|
||||||
|
const bPersonalStillChecked = listForB.find((i) => i.id === personalB.id);
|
||||||
|
assert.equal(bPersonalStillChecked.checked, true);
|
||||||
|
assert.equal(bPersonalStillChecked.checked_by, userB.id);
|
||||||
|
// Sanity: userA is distinct from userB so we know isolation, not coincidence.
|
||||||
|
assert.notEqual(userA.id, userB.id);
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
// top-level beforeEach/afterEach that touch only its own module-level
|
// top-level beforeEach/afterEach that touch only its own module-level
|
||||||
// app/tmpDir — keeping the files independent.
|
// app/tmpDir — keeping the files independent.
|
||||||
import './api.test.js';
|
import './api.test.js';
|
||||||
|
import './checklist.test.js';
|
||||||
|
import './checklist-suggestions.test.js';
|
||||||
import './costs.test.js';
|
import './costs.test.js';
|
||||||
import './flights.test.js';
|
import './flights.test.js';
|
||||||
import './rental.test.js';
|
import './rental.test.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user