Add trip checklists with rule-based packing advice

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

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

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

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

docs/API.md documents the full contract. 113/113 tests pass.
This commit is contained in:
2026-08-03 18:18:25 +07:00
parent e2c3089c25
commit e342cd9a91
16 changed files with 1913 additions and 2 deletions
+73 -1
View File
@@ -16,7 +16,7 @@ All endpoints are JSON over REST, prefixed with `/api`. This document is the **b
- `src/server/app.js` — builds and **exports** the Express app (`export function createApp(dbPath)` and `export default` a ready app is fine, but `createApp` must exist for tests). - `src/server/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 199; `personal` boolean (default `false`) → sets `user_id` to the caller; `checked` boolean (default `false`); `sort_order` integer (default: max within the trip's list + 1). Member of the trip required (else `404`). |
| `PATCH /api/checklist/:itemId` | any subset of `{text, category, qty, checked, personal, sort_order}` | `200 {item}` — same validation. Ticking (`checked: true`) sets `checked_by` to the caller; `checked: false` clears it. `personal: true` claims the item for the caller (`user_id` = caller), `personal: false` makes it shared (`user_id` null). |
| `DELETE /api/checklist/:itemId` | — | `204` |
| `POST /api/trips/:id/checklist/reset` | `{}` | `200 {unchecked: <count>}` — unticks every item **visible to the caller** (shared + own personal) and clears their `checked_by`. For re-using a list on the return trip. |
Item routes resolve the trip via the item, then require membership. A non-existent item, an item in a trip the caller is not a member of, **or another user's personal item** all return `404 {"error":"not found"}` (no leaking).
**Packing advice (suggestions)** — deterministic, offline rules derived from the trip itself (duration, months, entry types, destination latitudes/countries). No external service, no LLM.
`GET /api/trips/:id/checklist/suggestions``200`
```json
{
"suggestions": [
{ "key": "doc-passport", "text": "Passport (valid 6+ months)", "category": "Documents",
"qty": null, "reason": "You have 2 flights", "added": true },
{ "key": "clothing-tshirts", "text": "T-shirts", "category": "Clothing",
"qty": 8, "reason": "9 nights", "added": false }
],
"context": { "days": 10, "nights": 9, "months": [8], "countries": ["Thailand"],
"climate": ["tropical"], "flights": 2, "rentals": 1, "transportModes": ["ferry"] }
}
```
- `key` is a stable identifier (kebab-case) — it is what `POST` takes and what gets stored in `checklist_items.suggestion_key`.
- `added` = an item with that `suggestion_key` already exists among the items visible to the caller (shared or own personal), so the UI can grey it out.
- `reason` is a short human string explaining why it was suggested ("9 nights", "Ferry crossing", "Thailand uses type A/B/C sockets"). Suggestions are ordered by category (same fixed order as items).
- Rules live in `src/server/util/packing.js` as a pure `buildSuggestions({trip, entries, days, nights})` so they are unit-testable and stable across calls with the same input. Rough rule set: always-on basics (ID/passport, cards+cash, medication, toothbrush, phone+charger, water bottle, day bag); nights-scaled clothing quantities (`qty = min(nights + 1, 10)` for t-shirts/underwear/socks, laundry kit over 7 nights); flight entries → liquids ≤100 ml, power bank in cabin, check-in done, plus neck pillow/compression socks on long-haul (any air leg > 5000 km); rental entries → driving licence, international driving permit, phone mount; transport modes → ferry ⇒ motion-sickness tablets, train ⇒ snacks + luggage lock; destination latitude/month → tropical (|lat| < 23.5) ⇒ sun cream, insect repellent, rain jacket, rehydration salts; cold (|lat| > 55 year-round, or |lat| ≥ 35 when the trip falls in that hemisphere's winter — the latitude floor stops a December trip to Bangkok being tagged both tropical and cold) ⇒ warm layers, hat + gloves; ≥3 stays ⇒ packing cubes; destination country → power-adapter suggestion naming the socket types (small built-in country table; unknown/mixed ⇒ "Universal travel adapter"). Countries come from the last comma-segment of `location_name` and from flight-segment airport codes. For flights only the **arrival** airport of each segment counts (for both climate and country): the first segment's departure airport is where the traveller starts out, not a destination — counting it would put "warm layers, hat & gloves" and a home-country plug adapter on a December Frankfurt→Bangkok beach trip. Flying back into a cold place later is still covered, since that arrival is a segment's `to`.
`POST /api/trips/:id/checklist/suggestions` `{keys: ["doc-passport", …], personal?: false}``201 {created: [item…], skipped: ["doc-passport"]}`
- Bulk-adds the given suggestions as checklist items (unchecked, `suggestion_key` set, appended in the current suggestion order). `keys` must be a non-empty array of ≤60 strings; an unknown key → `400 {"error":"unknown suggestion key: <key>"}`.
- Keys already present among the caller's visible items go to `skipped` instead of being duplicated.
### Route & summary (computed) ### 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.
+66
View File
@@ -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%; }
}
+1
View File
@@ -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>
+10
View File
@@ -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;
+278
View File
@@ -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;
}
+129
View File
@@ -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),
),
);
}
}
+16
View File
@@ -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,
);
}
+2 -1
View File
@@ -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);
+2
View File
@@ -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());
+53
View File
@@ -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"] }
}
+15
View File
@@ -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
+352
View File
@@ -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;
}
+295
View File
@@ -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 };
}
+302
View File
@@ -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);
});
+317
View File
@@ -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);
});
+2
View File
@@ -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';