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
+10
View File
@@ -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;
+278
View File
@@ -0,0 +1,278 @@
// Packing checklist card for the trip detail page (below Costs). Self-fetches
// via GET /api/trips/:id/checklist and re-renders itself in place after every
// mutation — it never triggers tctx.refreshTrip(), so ticking/adding/removing
// items never re-fetches the whole trip. The suggestions modal lives in its
// own module (checklistSuggestions.js) so this file stays small, mirroring how
// dayEditor.js splits out costForm/waypoints/rental.
import { api } from '../api.js';
import { el, mount, loading, errorBox, emptyState, toast } from '../dom.js';
import { enableChecklistReorder } from './dragdrop.js';
import { openSuggestionsModal } from './checklistSuggestions.js';
// The server's fixed category order (see docs/API.md) — offered first in the
// add-row category datalist, ahead of any custom categories already in use.
const FIXED_CATEGORIES = ['Documents', 'Clothing', 'Toiletries', 'Health', 'Electronics', 'Extras'];
export function renderChecklist(tctx) {
const state = { items: [] };
const section = el('section', { class: 'card checklist-section' });
// Refs into the current draw() so a checkbox toggle can patch just the
// progress bar + category count instead of rebuilding the whole card.
let progressLabelEl = null;
let progressFillEl = null;
const categoryCountEls = new Map();
// The add-row's text input is rebuilt by every draw(); track the current
// one so a successful submit can refocus it (the pre-reload element it
// closed over would already be detached by then).
let addTextInputEl = null;
mount(section, loading('Loading checklist…'));
reload();
async function reload() {
try {
const data = await api.checklist.list(tctx.tripId);
state.items = data.items || [];
draw();
} catch (err) {
mount(section, errorBox(err.message, reload));
}
}
function groupByCategory(items) {
const map = new Map();
for (const item of items) {
if (!map.has(item.category)) map.set(item.category, []);
map.get(item.category).push(item);
}
return map;
}
function updateProgressUI() {
const total = state.items.length;
const checkedCount = state.items.filter((i) => i.checked).length;
if (progressLabelEl) progressLabelEl.textContent = `${checkedCount} / ${total} packed`;
if (progressFillEl) progressFillEl.style.width = `${total ? Math.round((checkedCount / total) * 100) : 0}%`;
for (const [category, countEl] of categoryCountEls) {
const catItems = state.items.filter((i) => i.category === category);
countEl.textContent = `${catItems.filter((i) => i.checked).length}/${catItems.length}`;
}
}
function draw() {
progressLabelEl = null;
progressFillEl = null;
categoryCountEls.clear();
const items = state.items;
const total = items.length;
const checkedCount = items.filter((i) => i.checked).length;
const head = el(
'div',
{ class: 'section-head cal-section-head' },
el(
'div',
{},
el('h2', {}, 'Checklist'),
el('p', { class: 'muted' }, 'Shared items everyone can tick; 🔒 personal ones are just for you.'),
),
el(
'div',
{ class: 'checklist-actions' },
el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onUncheckAll }, 'Uncheck all'),
el('button', { class: 'btn btn-sm', type: 'button', onClick: onOpenSuggestions }, '💡 Suggestions'),
),
);
const body = [];
if (!total) {
body.push(emptyState('Nothing packed yet', 'Add an item below or grab some suggestions.'));
} else {
progressLabelEl = el('div', { class: 'checklist-progress-label' }, `${checkedCount} / ${total} packed`);
progressFillEl = el('div', {
class: 'checklist-progress-fill',
style: { width: `${Math.round((checkedCount / total) * 100)}%` },
});
body.push(
el(
'div',
{ class: 'checklist-progress' },
progressLabelEl,
el('div', { class: 'checklist-progress-bar' }, progressFillEl),
),
);
for (const [category, catItems] of groupByCategory(items)) {
const countEl = el('span', { class: 'checklist-category-count' },
`${catItems.filter((i) => i.checked).length}/${catItems.length}`);
categoryCountEls.set(category, countEl);
const list = el('div', { class: 'checklist-list' });
const rows = [];
for (const item of catItems) {
const { node, handle } = itemRow(item);
rows.push({ node, handle, item });
list.appendChild(node);
}
if (catItems.length > 1) enableChecklistReorder(rows, reload);
body.push(
el(
'div',
{ class: 'checklist-category' },
el('div', { class: 'checklist-category-head' }, el('h3', {}, category), countEl),
list,
),
);
}
}
mount(section, head, ...body, addRow());
}
// Returns { node, handle } for one item row: drag handle, checkbox, text,
// optional qty badge, 🔒 marker for personal items, delete button.
function itemRow(item) {
const checkbox = el('input', {
type: 'checkbox',
class: 'checklist-check',
checked: item.checked,
onChange: () => onToggle(item, checkbox, node),
});
const handle = el('span', {
class: 'entry-drag-handle', title: 'Drag to reorder', 'aria-hidden': 'true',
}, '⋮⋮');
const node = el(
'div',
{ class: `checklist-row${item.checked ? ' checked' : ''}` },
handle,
checkbox,
el('span', { class: 'checklist-row-text' }, item.text),
item.qty != null ? el('span', { class: 'checklist-qty' }, `×${item.qty}`) : null,
item.personal ? el('span', { class: 'checklist-lock', title: 'Personal item' }, '🔒') : null,
el('button', {
class: 'icon-btn danger', type: 'button', title: 'Delete', onClick: () => onDeleteItem(item),
}, '🗑'),
);
return { node, handle };
}
// Ticking a box should feel instant: patch the row + progress bar in place,
// PATCH the server, and revert + toast on failure — no full re-render.
function onToggle(item, checkbox, rowNode) {
const next = checkbox.checked;
const prev = item.checked;
item.checked = next;
rowNode.classList.toggle('checked', next);
updateProgressUI();
api.checklist.update(item.id, { checked: next }).catch((err) => {
item.checked = prev;
checkbox.checked = prev;
rowNode.classList.toggle('checked', prev);
updateProgressUI();
toast(err.message);
});
}
async function onDeleteItem(item) {
try {
await api.checklist.remove(item.id);
await reload();
} catch (err) {
toast(err.message);
}
}
async function onUncheckAll() {
try {
const res = await api.checklist.reset(tctx.tripId);
toast(`Unchecked ${res.unchecked} item${res.unchecked === 1 ? '' : 's'}`, 'success');
await reload();
} catch (err) {
toast(err.message);
}
}
function onOpenSuggestions() {
openSuggestionsModal(tctx, reload);
}
function categoryOptions() {
const used = new Set(state.items.map((i) => i.category).filter(Boolean));
const extras = [...used].filter((c) => !FIXED_CATEGORIES.includes(c)).sort((a, b) => a.localeCompare(b));
return [...FIXED_CATEGORIES, ...extras];
}
function addRow() {
const textInput = el('input', {
class: 'input input-sm', type: 'text', maxlength: '120', placeholder: 'Add an item…',
});
addTextInputEl = textInput;
const categoryInput = el('input', {
class: 'input input-sm checklist-add-category', type: 'text', maxlength: '40',
placeholder: 'Category', list: 'checklist-categories', value: FIXED_CATEGORIES[0],
});
const categoryList = el(
'datalist',
{ id: 'checklist-categories' },
...categoryOptions().map((c) => el('option', { value: c })),
);
const qtyInput = el('input', {
class: 'input input-sm checklist-add-qty', type: 'number', min: '1', max: '99', placeholder: 'Qty',
});
const personalToggle = el('input', { type: 'checkbox' });
const errorEl = el('p', { class: 'form-error' });
const addBtn = el('button', { class: 'btn btn-primary btn-sm', type: 'submit' }, '+ Add');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const text = textInput.value.trim();
if (!text) return (errorEl.textContent = 'Item text is required.');
if (text.length > 120) return (errorEl.textContent = 'Keep it under 120 characters.');
const category = categoryInput.value.trim();
if (category.length > 40) return (errorEl.textContent = 'Category must be 40 characters or fewer.');
const qtyRaw = qtyInput.value.trim();
let qty = null;
if (qtyRaw !== '') {
qty = Number(qtyRaw);
if (!Number.isInteger(qty) || qty < 1 || qty > 99) {
return (errorEl.textContent = 'Qty must be a whole number from 1 to 99.');
}
}
const payload = { text, personal: personalToggle.checked };
if (category) payload.category = category;
if (qty != null) payload.qty = qty;
addBtn.disabled = true;
try {
await api.checklist.create(tctx.tripId, payload);
textInput.value = '';
qtyInput.value = '';
toast('Item added', 'success');
await reload();
if (addTextInputEl) addTextInputEl.focus();
} catch (err) {
errorEl.textContent = err.message;
} finally {
addBtn.disabled = false;
}
}
return el(
'form',
{ class: 'checklist-add', onSubmit },
categoryList,
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Item'), textInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Category'), categoryInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Qty'), qtyInput),
el('label', { class: 'checklist-personal-toggle' }, personalToggle, '🔒 Personal'),
addBtn,
errorEl,
);
}
return section;
}
+129
View File
@@ -0,0 +1,129 @@
// "💡 Suggestions" modal for the checklist card — fetches the deterministic
// packing advice (GET .../checklist/suggestions), lets the user tick which
// ones to add and whether they go in as shared or personal items, then bulk
// adds them (POST .../checklist/suggestions). Kept as its own module so
// checklist.js stays small (mirrors dayEditor.js's costForm/waypoints split).
import { api } from '../api.js';
import { el, mount, loading, errorBox, toast } from '../dom.js';
export function openSuggestionsModal(tctx, onAdded) {
const overlay = el('div', { class: 'overlay overlay-center' });
const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true' });
overlay.appendChild(modal);
document.body.appendChild(overlay);
document.body.classList.add('no-scroll');
function close() {
document.body.classList.remove('no-scroll');
overlay.remove();
document.removeEventListener('keydown', onKey);
}
function onKey(e) {
if (e.key === 'Escape') close();
}
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
load();
async function load() {
mount(modal, modalHead(), loading('Loading suggestions…'));
try {
const data = await api.checklist.suggestions(tctx.tripId);
draw(data.suggestions || []);
} catch (err) {
mount(modal, modalHead(), errorBox(err.message, load));
}
}
function modalHead() {
return el(
'div',
{ class: 'slideover-head' },
el('h2', {}, '💡 Packing suggestions'),
el('button', { class: 'icon-btn', type: 'button', title: 'Close', onClick: close }, '×'),
);
}
function draw(suggestions) {
let scope = 'shared'; // 'shared' | 'personal' — applies to the whole batch
const checks = new Map(); // suggestion key -> its (enabled) checkbox
const groups = new Map();
for (const s of suggestions) {
if (!groups.has(s.category)) groups.set(s.category, []);
groups.get(s.category).push(s);
}
const listEl = el('div', { class: 'suggest-list' });
if (!suggestions.length) {
listEl.appendChild(el('p', { class: 'muted' }, 'No suggestions yet — add some entries to the trip first.'));
}
for (const [category, items] of groups) {
const rows = el('div', { class: 'suggest-rows' });
for (const s of items) {
rows.appendChild(suggestionRow(s, checks));
}
listEl.appendChild(el('div', { class: 'suggest-category' }, el('h3', {}, category), rows));
}
const sharedRadio = el('input', { type: 'radio', name: 'suggest-scope', checked: true });
const personalRadio = el('input', { type: 'radio', name: 'suggest-scope' });
sharedRadio.addEventListener('change', () => { if (sharedRadio.checked) scope = 'shared'; });
personalRadio.addEventListener('change', () => { if (personalRadio.checked) scope = 'personal'; });
const scopeRow = el(
'div',
{ class: 'suggest-scope' },
el('label', {}, sharedRadio, 'Shared'),
el('label', {}, personalRadio, '🔒 Personal to me'),
);
const errorEl = el('p', { class: 'form-error' });
const cancelBtn = el('button', { class: 'btn btn-ghost', type: 'button', onClick: close }, 'Cancel');
const addBtn = el('button', { class: 'btn btn-primary', type: 'button', onClick: onAddSelected }, 'Add selected');
async function onAddSelected() {
errorEl.textContent = '';
const keys = [...checks.entries()].filter(([, cb]) => cb.checked).map(([key]) => key);
if (!keys.length) return (errorEl.textContent = 'Pick at least one suggestion.');
addBtn.disabled = true;
addBtn.textContent = 'Adding…';
try {
await api.checklist.addSuggestions(tctx.tripId, keys, scope === 'personal');
toast(`Added ${keys.length} item${keys.length === 1 ? '' : 's'}`, 'success');
close();
await onAdded();
} catch (err) {
errorEl.textContent = err.message;
addBtn.disabled = false;
addBtn.textContent = 'Add selected';
}
}
mount(modal, modalHead(), listEl, suggestions.length ? scopeRow : null, errorEl, el('div', { class: 'form-actions' }, cancelBtn, addBtn));
}
// One suggestion row: checkbox (ticked + disabled if already added), text +
// qty, and the reason as muted subtext. Registers its checkbox in `checks`
// only when selectable (not already added), for onAddSelected to read.
function suggestionRow(s, checks) {
const disabled = !!s.added;
const check = el('input', {
type: 'checkbox', class: 'suggest-check', checked: disabled, disabled,
});
if (!disabled) checks.set(s.key, check);
return el(
'label',
{ class: `suggest-row${disabled ? ' added' : ''}` },
check,
el(
'div',
{ class: 'suggest-body' },
el('span', { class: 'suggest-text' }, s.text, s.qty != null ? el('span', { class: 'suggest-qty' }, `×${s.qty}`) : null),
el('div', { class: 'suggest-reason muted' }, s.reason),
),
);
}
}
+16
View File
@@ -206,3 +206,19 @@ export function enableTripReorder(rows, refresh) {
refresh,
);
}
// ---------- Checklist: reorder items within a category ----------
// `rows`: [{ node, handle, item }] — same mechanics as enableRowReorder,
// scoped to one category's rows (checklist.js calls this once per category,
// so the resulting sort_order values only need to rank correctly within that
// category — category is always the primary sort key server-side).
export function enableChecklistReorder(rows, refresh) {
enableReorder(
rows,
(item) => item.id,
(item) => item.sort_order,
(id, sort_order) => api.checklist.update(id, { sort_order }),
refresh,
);
}
+2 -1
View File
@@ -10,6 +10,7 @@ import { renderCalendar } from './calendar.js';
import { renderMap } from './map.js';
import { 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);