Files
trip-plan/public/js/views/checklist.js
T
grabowski e342cd9a91 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.
2026-08-03 18:18:25 +07:00

279 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}