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.
279 lines
10 KiB
JavaScript
279 lines
10 KiB
JavaScript
// 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;
|
||
}
|