diff --git a/docs/API.md b/docs/API.md index 0e4191f..dc9a0cb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -16,7 +16,7 @@ All endpoints are JSON over REST, prefixed with `/api`. This document is the **b - `src/server/app.js` — builds and **exports** the Express app (`export function createApp(dbPath)` and `export default` a ready app is fine, but `createApp` must exist for tests). - `src/server/index.js` — reads env (`PORT` default 3000, `DATA_DIR` default `./data`, `SESSION_SECRET` default dev value with console warning), ensures DATA_DIR exists, starts listener, serves `public/` statically. - `src/server/db.js` — `better-sqlite3` connection + schema creation (idempotent `CREATE TABLE IF NOT EXISTS`). -- `src/server/routes/` — `auth.js`, `trips.js`, `entries.js`, `geocode.js`. +- `src/server/routes/` — `auth.js`, `trips.js`, `entries.js`, `geocode.js`, `checklist.js`. - `src/server/util/distance.js` — `haversineKm(lat1, lng1, lat2, lng2)` returns km (number). ## Data model (SQLite) @@ -54,6 +54,16 @@ entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id), entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id), PRIMARY KEY (entry_id, user_id)) -- no rows for an entry = "all trip members participate" (dynamic default) +checklist_items (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id), + user_id INTEGER REFERENCES users(id), -- null = shared trip item; else personal to that user + text TEXT NOT NULL, -- what to pack/do, ≤120 chars + category TEXT NOT NULL DEFAULT 'General', + qty INTEGER, -- optional count (null = unspecified) + checked INTEGER NOT NULL DEFAULT 0, -- 0/1 + checked_by INTEGER REFERENCES users(id),-- who ticked it (null when unchecked) + sort_order INTEGER NOT NULL DEFAULT 0, + suggestion_key TEXT, -- non-null = added from packing advice (dedupe key) + created_at TEXT DEFAULT current_timestamp) ``` Entry `type` ∈ `flight | transport | activity | rental | stay | note`. @@ -187,6 +197,67 @@ User JSON shape everywhere: `{id, display_name}`. Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments, rental, transport_mode, auto_ref, waypoints}` where `participants` is an array of user ids (`[]` = all members), `segments` is the parsed array or `null`, `auto_ref` is the parsed `{from,to}` object or `null`, and `waypoints` is the parsed `[{lat,lng,name?}]` array or `null`. `auto_ref` is server-managed (not accepted in POST/PATCH bodies; editing an auto transport keeps its `auto_ref`). +### Checklist (packing list & advice) + +Each trip has one flat checklist whose items are grouped by a free-text `category` ("Documents", "Clothing", …). Items are either **shared** (`user_id` null — every member sees and can tick them, e.g. "First-aid kit") or **personal** (`user_id` = the owner — only that user sees them, e.g. their own medication). There is no separate list entity: `category` is the grouping. + +`GET /api/trips/:id/checklist` → `200` + +```json +{ + "items": [ + { "id": 7, "trip_id": 3, "text": "Passport (valid 6+ months)", "category": "Documents", + "qty": null, "checked": true, "checked_by": 1, "personal": false, + "user_id": null, "sort_order": 0, "suggestion_key": "doc-passport" }, + { "id": 9, "trip_id": 3, "text": "T-shirts", "category": "Clothing", + "qty": 8, "checked": false, "checked_by": null, "personal": true, + "user_id": 1, "sort_order": 1, "suggestion_key": "clothing-tshirts" } + ], + "progress": { "total": 2, "checked": 1, "byCategory": [ { "category": "Documents", "total": 1, "checked": 1 } ] } +} +``` + +- Returns shared items **plus the caller's own personal items** — never another member's personal items. +- Item JSON is always the full row plus the derived boolean `personal` (`user_id !== null`); `checked` is a real boolean (not 0/1) and `qty`/`checked_by`/`suggestion_key` are `null` when unset. +- Order: by `category` first (fixed order `Documents, Clothing, Toiletries, Health, Electronics, Extras`, then any other category alphabetically), then `(sort_order, id)`. +- `progress.byCategory` follows the same category order and covers only categories present in `items`. + +| Method & path | Body | Response | +|---|---|---| +| `POST /api/trips/:id/checklist` | `{text, category?, qty?, personal?, checked?, sort_order?}` | `201 {item}` — validates: `text` non-empty ≤120 chars after trim; `category` ≤40 chars after trim (default `General`); `qty` null or integer 1–99; `personal` boolean (default `false`) → sets `user_id` to the caller; `checked` boolean (default `false`); `sort_order` integer (default: max within the trip's list + 1). Member of the trip required (else `404`). | +| `PATCH /api/checklist/:itemId` | any subset of `{text, category, qty, checked, personal, sort_order}` | `200 {item}` — same validation. Ticking (`checked: true`) sets `checked_by` to the caller; `checked: false` clears it. `personal: true` claims the item for the caller (`user_id` = caller), `personal: false` makes it shared (`user_id` null). | +| `DELETE /api/checklist/:itemId` | — | `204` | +| `POST /api/trips/:id/checklist/reset` | `{}` | `200 {unchecked: }` — 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: "}`. +- Keys already present among the caller's visible items go to `skipped` instead of being duplicated. + ### Route & summary (computed) `GET /api/trips/:id/route` → @@ -267,3 +338,4 @@ Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept - SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id`) is the expected design, so no server-side fallback is needed. - Leaflet 1.9.x via unpkg CDN in `index.html`. - Session cookie is httpOnly; frontend detects auth state via `GET /api/auth/me` on load. +- Checklist UI lives in `public/js/views/checklist.js`, rendered as a card in the trip detail side column (below Costs). It shows a progress bar, items grouped by category with a checkbox / qty / 🔒-personal marker per row, inline add, drag-reorder (`dragdrop.js` `enableReorder`, desktop-only like the rest), an "Uncheck all" action, and a "💡 Suggestions" modal listing the advice with per-item checkboxes and "Add selected". Ticking a box PATCHes optimistically and re-syncs on failure. diff --git a/public/css/checklist.css b/public/css/checklist.css new file mode 100644 index 0000000..e81df3d --- /dev/null +++ b/public/css/checklist.css @@ -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%; } +} diff --git a/public/index.html b/public/index.html index bd7f61f..79ed0d0 100644 --- a/public/index.html +++ b/public/index.html @@ -22,6 +22,7 @@ +
diff --git a/public/js/api.js b/public/js/api.js index c4afc41..d95a9a8 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -84,6 +84,16 @@ export const api = { }, geocode: (q) => get(`/api/geocode?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; diff --git a/public/js/views/checklist.js b/public/js/views/checklist.js new file mode 100644 index 0000000..34766c2 --- /dev/null +++ b/public/js/views/checklist.js @@ -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; +} diff --git a/public/js/views/checklistSuggestions.js b/public/js/views/checklistSuggestions.js new file mode 100644 index 0000000..b3ab55d --- /dev/null +++ b/public/js/views/checklistSuggestions.js @@ -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), + ), + ); + } +} diff --git a/public/js/views/dragdrop.js b/public/js/views/dragdrop.js index 5670323..a883ba9 100644 --- a/public/js/views/dragdrop.js +++ b/public/js/views/dragdrop.js @@ -206,3 +206,19 @@ export function enableTripReorder(rows, 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, + ); +} diff --git a/public/js/views/tripDetail.js b/public/js/views/tripDetail.js index 6e7a9bb..2c42aa3 100644 --- a/public/js/views/tripDetail.js +++ b/public/js/views/tripDetail.js @@ -10,6 +10,7 @@ import { renderCalendar } from './calendar.js'; import { renderMap } from './map.js'; import { renderSummary } from './summary.js'; import { renderCosts } from './costs.js'; +import { renderChecklist } from './checklist.js'; import { renderCountdown } from './flipclock.js'; import { openDayEditor } from './dayEditor.js'; @@ -74,7 +75,7 @@ export function renderTripDetail(container, ctx, id) { 'div', { class: 'detail-grid' }, 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); diff --git a/src/server/app.js b/src/server/app.js index 1cb90aa..42fda42 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -7,6 +7,7 @@ import { requireAuth } from './auth.js'; import authRoutes from './routes/auth.js'; import tripsRoutes from './routes/trips.js'; import entriesRoutes from './routes/entries.js'; +import checklistRoutes from './routes/checklist.js'; import geocodeRoutes from './routes/geocode.js'; import airportsRoutes from './routes/airports.js'; import directionsRoutes from './routes/directions.js'; @@ -50,6 +51,7 @@ export function createApp(options = {}) { app.use('/api/auth', authRoutes(db)); app.use('/api/trips', requireAuth, tripsRoutes(db)); 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/airports', requireAuth, airportsRoutes()); app.use('/api/directions', requireAuth, directionsRoutes()); diff --git a/src/server/data/countryPlugs.json b/src/server/data/countryPlugs.json new file mode 100644 index 0000000..f1af64c --- /dev/null +++ b/src/server/data/countryPlugs.json @@ -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"] } +} diff --git a/src/server/db.js b/src/server/db.js index 3e0da31..f0bb8a6 100644 --- a/src/server/db.js +++ b/src/server/db.js @@ -58,9 +58,24 @@ CREATE TABLE IF NOT EXISTS entry_participants ( 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_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_checklist_trip ON checklist_items(trip_id, category, sort_order, id); `; // Columns added after the initial release: CREATE TABLE IF NOT EXISTS never diff --git a/src/server/routes/checklist.js b/src/server/routes/checklist.js new file mode 100644 index 0000000..496e07b --- /dev/null +++ b/src/server/routes/checklist.js @@ -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; +} diff --git a/src/server/util/packing.js b/src/server/util/packing.js new file mode 100644 index 0000000..8e1b3b8 --- /dev/null +++ b/src/server/util/packing.js @@ -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 }; +} diff --git a/tests/checklist-suggestions.test.js b/tests/checklist-suggestions.test.js new file mode 100644 index 0000000..9d86963 --- /dev/null +++ b/tests/checklist-suggestions.test.js @@ -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); +}); diff --git a/tests/checklist.test.js b/tests/checklist.test.js new file mode 100644 index 0000000..76830f3 --- /dev/null +++ b/tests/checklist.test.js @@ -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); +}); diff --git a/tests/index.js b/tests/index.js index 1c44396..45cb707 100644 --- a/tests/index.js +++ b/tests/index.js @@ -7,6 +7,8 @@ // top-level beforeEach/afterEach that touch only its own module-level // app/tmpDir — keeping the files independent. import './api.test.js'; +import './checklist.test.js'; +import './checklist-suggestions.test.js'; import './costs.test.js'; import './flights.test.js'; import './rental.test.js';