// 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; }