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
+278
View File
@@ -0,0 +1,278 @@
// Packing checklist card for the trip detail page (below Costs). Self-fetches
// via GET /api/trips/:id/checklist and re-renders itself in place after every
// mutation — it never triggers tctx.refreshTrip(), so ticking/adding/removing
// items never re-fetches the whole trip. The suggestions modal lives in its
// own module (checklistSuggestions.js) so this file stays small, mirroring how
// dayEditor.js splits out costForm/waypoints/rental.
import { api } from '../api.js';
import { el, mount, loading, errorBox, emptyState, toast } from '../dom.js';
import { enableChecklistReorder } from './dragdrop.js';
import { openSuggestionsModal } from './checklistSuggestions.js';
// The server's fixed category order (see docs/API.md) — offered first in the
// add-row category datalist, ahead of any custom categories already in use.
const FIXED_CATEGORIES = ['Documents', 'Clothing', 'Toiletries', 'Health', 'Electronics', 'Extras'];
export function renderChecklist(tctx) {
const state = { items: [] };
const section = el('section', { class: 'card checklist-section' });
// Refs into the current draw() so a checkbox toggle can patch just the
// progress bar + category count instead of rebuilding the whole card.
let progressLabelEl = null;
let progressFillEl = null;
const categoryCountEls = new Map();
// The add-row's text input is rebuilt by every draw(); track the current
// one so a successful submit can refocus it (the pre-reload element it
// closed over would already be detached by then).
let addTextInputEl = null;
mount(section, loading('Loading checklist…'));
reload();
async function reload() {
try {
const data = await api.checklist.list(tctx.tripId);
state.items = data.items || [];
draw();
} catch (err) {
mount(section, errorBox(err.message, reload));
}
}
function groupByCategory(items) {
const map = new Map();
for (const item of items) {
if (!map.has(item.category)) map.set(item.category, []);
map.get(item.category).push(item);
}
return map;
}
function updateProgressUI() {
const total = state.items.length;
const checkedCount = state.items.filter((i) => i.checked).length;
if (progressLabelEl) progressLabelEl.textContent = `${checkedCount} / ${total} packed`;
if (progressFillEl) progressFillEl.style.width = `${total ? Math.round((checkedCount / total) * 100) : 0}%`;
for (const [category, countEl] of categoryCountEls) {
const catItems = state.items.filter((i) => i.category === category);
countEl.textContent = `${catItems.filter((i) => i.checked).length}/${catItems.length}`;
}
}
function draw() {
progressLabelEl = null;
progressFillEl = null;
categoryCountEls.clear();
const items = state.items;
const total = items.length;
const checkedCount = items.filter((i) => i.checked).length;
const head = el(
'div',
{ class: 'section-head cal-section-head' },
el(
'div',
{},
el('h2', {}, 'Checklist'),
el('p', { class: 'muted' }, 'Shared items everyone can tick; 🔒 personal ones are just for you.'),
),
el(
'div',
{ class: 'checklist-actions' },
el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onUncheckAll }, 'Uncheck all'),
el('button', { class: 'btn btn-sm', type: 'button', onClick: onOpenSuggestions }, '💡 Suggestions'),
),
);
const body = [];
if (!total) {
body.push(emptyState('Nothing packed yet', 'Add an item below or grab some suggestions.'));
} else {
progressLabelEl = el('div', { class: 'checklist-progress-label' }, `${checkedCount} / ${total} packed`);
progressFillEl = el('div', {
class: 'checklist-progress-fill',
style: { width: `${Math.round((checkedCount / total) * 100)}%` },
});
body.push(
el(
'div',
{ class: 'checklist-progress' },
progressLabelEl,
el('div', { class: 'checklist-progress-bar' }, progressFillEl),
),
);
for (const [category, catItems] of groupByCategory(items)) {
const countEl = el('span', { class: 'checklist-category-count' },
`${catItems.filter((i) => i.checked).length}/${catItems.length}`);
categoryCountEls.set(category, countEl);
const list = el('div', { class: 'checklist-list' });
const rows = [];
for (const item of catItems) {
const { node, handle } = itemRow(item);
rows.push({ node, handle, item });
list.appendChild(node);
}
if (catItems.length > 1) enableChecklistReorder(rows, reload);
body.push(
el(
'div',
{ class: 'checklist-category' },
el('div', { class: 'checklist-category-head' }, el('h3', {}, category), countEl),
list,
),
);
}
}
mount(section, head, ...body, addRow());
}
// Returns { node, handle } for one item row: drag handle, checkbox, text,
// optional qty badge, 🔒 marker for personal items, delete button.
function itemRow(item) {
const checkbox = el('input', {
type: 'checkbox',
class: 'checklist-check',
checked: item.checked,
onChange: () => onToggle(item, checkbox, node),
});
const handle = el('span', {
class: 'entry-drag-handle', title: 'Drag to reorder', 'aria-hidden': 'true',
}, '⋮⋮');
const node = el(
'div',
{ class: `checklist-row${item.checked ? ' checked' : ''}` },
handle,
checkbox,
el('span', { class: 'checklist-row-text' }, item.text),
item.qty != null ? el('span', { class: 'checklist-qty' }, `×${item.qty}`) : null,
item.personal ? el('span', { class: 'checklist-lock', title: 'Personal item' }, '🔒') : null,
el('button', {
class: 'icon-btn danger', type: 'button', title: 'Delete', onClick: () => onDeleteItem(item),
}, '🗑'),
);
return { node, handle };
}
// Ticking a box should feel instant: patch the row + progress bar in place,
// PATCH the server, and revert + toast on failure — no full re-render.
function onToggle(item, checkbox, rowNode) {
const next = checkbox.checked;
const prev = item.checked;
item.checked = next;
rowNode.classList.toggle('checked', next);
updateProgressUI();
api.checklist.update(item.id, { checked: next }).catch((err) => {
item.checked = prev;
checkbox.checked = prev;
rowNode.classList.toggle('checked', prev);
updateProgressUI();
toast(err.message);
});
}
async function onDeleteItem(item) {
try {
await api.checklist.remove(item.id);
await reload();
} catch (err) {
toast(err.message);
}
}
async function onUncheckAll() {
try {
const res = await api.checklist.reset(tctx.tripId);
toast(`Unchecked ${res.unchecked} item${res.unchecked === 1 ? '' : 's'}`, 'success');
await reload();
} catch (err) {
toast(err.message);
}
}
function onOpenSuggestions() {
openSuggestionsModal(tctx, reload);
}
function categoryOptions() {
const used = new Set(state.items.map((i) => i.category).filter(Boolean));
const extras = [...used].filter((c) => !FIXED_CATEGORIES.includes(c)).sort((a, b) => a.localeCompare(b));
return [...FIXED_CATEGORIES, ...extras];
}
function addRow() {
const textInput = el('input', {
class: 'input input-sm', type: 'text', maxlength: '120', placeholder: 'Add an item…',
});
addTextInputEl = textInput;
const categoryInput = el('input', {
class: 'input input-sm checklist-add-category', type: 'text', maxlength: '40',
placeholder: 'Category', list: 'checklist-categories', value: FIXED_CATEGORIES[0],
});
const categoryList = el(
'datalist',
{ id: 'checklist-categories' },
...categoryOptions().map((c) => el('option', { value: c })),
);
const qtyInput = el('input', {
class: 'input input-sm checklist-add-qty', type: 'number', min: '1', max: '99', placeholder: 'Qty',
});
const personalToggle = el('input', { type: 'checkbox' });
const errorEl = el('p', { class: 'form-error' });
const addBtn = el('button', { class: 'btn btn-primary btn-sm', type: 'submit' }, '+ Add');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const text = textInput.value.trim();
if (!text) return (errorEl.textContent = 'Item text is required.');
if (text.length > 120) return (errorEl.textContent = 'Keep it under 120 characters.');
const category = categoryInput.value.trim();
if (category.length > 40) return (errorEl.textContent = 'Category must be 40 characters or fewer.');
const qtyRaw = qtyInput.value.trim();
let qty = null;
if (qtyRaw !== '') {
qty = Number(qtyRaw);
if (!Number.isInteger(qty) || qty < 1 || qty > 99) {
return (errorEl.textContent = 'Qty must be a whole number from 1 to 99.');
}
}
const payload = { text, personal: personalToggle.checked };
if (category) payload.category = category;
if (qty != null) payload.qty = qty;
addBtn.disabled = true;
try {
await api.checklist.create(tctx.tripId, payload);
textInput.value = '';
qtyInput.value = '';
toast('Item added', 'success');
await reload();
if (addTextInputEl) addTextInputEl.focus();
} catch (err) {
errorEl.textContent = err.message;
} finally {
addBtn.disabled = false;
}
}
return el(
'form',
{ class: 'checklist-add', onSubmit },
categoryList,
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Item'), textInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Category'), categoryInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Qty'), qtyInput),
el('label', { class: 'checklist-personal-toggle' }, personalToggle, '🔒 Personal'),
addBtn,
errorEl,
);
}
return section;
}