Files
trip-plan/public/js/views/checklistSuggestions.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

130 lines
5.0 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.
// "💡 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),
),
);
}
}