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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user