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