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
+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),
),
);
}
}