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

266 lines
8.8 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.
// Orchestrates the trip page: header + calendar, then map + summary.
// Owns the shared trip context passed to the calendar, map, summary and
// day-editor sub-views: { state, navigate, tripId, trip, route, refreshTrip,
// openDay }. After any mutation, refreshTrip() re-fetches the trip and its
// derived /route data so all three panels stay in sync.
import { api } from '../api.js';
import { el, clear, mount, loading, errorBox, toast } from '../dom.js';
import { formatRange, daysBetweenInclusive, pluralize, groupCode } from '../format.js';
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';
export function renderTripDetail(container, ctx, id) {
const tctx = {
...ctx,
tripId: id,
trip: null,
route: null,
costs: null,
_onModalRefresh: null,
};
mount(container, loading('Loading trip…'));
init();
async function load() {
const [trip, route, costs] = await Promise.all([
api.trips.get(id),
api.trips.route(id),
api.trips.costs(id),
]);
tctx.trip = trip;
tctx.route = route;
tctx.costs = costs;
}
tctx.refreshTrip = async () => {
try {
await load();
draw();
if (typeof tctx._onModalRefresh === 'function') tctx._onModalRefresh();
} catch (err) {
toast(err.message);
}
};
tctx.openDay = (date) => openDayEditor(tctx, date);
async function init() {
try {
await load();
draw();
} catch (err) {
clear(container);
if (err.status === 404) {
toast('Trip not found (or you are not a member).');
ctx.navigate('#/trips');
return;
}
mount(container, errorBox(err.message, init));
}
}
function draw() {
const page = el('div', { class: 'page' });
page.appendChild(renderHeader());
page.appendChild(renderCountdown(tctx));
page.appendChild(renderCalendar(tctx));
page.appendChild(
el(
'div',
{ class: 'detail-grid' },
renderMap(tctx),
el('div', { class: 'detail-side' }, renderSummary(tctx), renderCosts(tctx), renderChecklist(tctx)),
),
);
mount(container, page);
}
function renderHeader() {
const { trip, members } = tctx.trip;
const isOwner = trip.owner_id === tctx.state.user.id;
const dayCount = daysBetweenInclusive(trip.start_date, trip.end_date);
const header = el('div', { class: 'trip-header card' });
const titleRow = el(
'div',
{ class: 'trip-header-top' },
el(
'div',
{},
el(
'a',
{ class: 'back-link', href: '#/trips' },
'← All trips',
),
el('h1', { class: 'trip-title' }, trip.name),
el(
'p',
{ class: 'trip-subtitle muted' },
'📅 ', formatRange(trip.start_date, trip.end_date),
' · ', pluralize(dayCount, 'day', 'days'),
' · ', el('span', { class: 'currency-tag' }, trip.currency || 'USD'),
),
),
el(
'div',
{ class: 'trip-header-actions' },
el('button', { class: 'btn btn-ghost', onClick: () => toggleEdit(header) }, '✎ Edit'),
isOwner
? el('button', { class: 'btn btn-danger-ghost', onClick: onDelete }, 'Delete')
: null,
),
);
const membersRow = el(
'div',
{ class: 'members-row' },
el('span', { class: 'members-label' }, 'Members:'),
...members.map((m) =>
el(
'span',
{ class: `member-chip${m.role === 'owner' ? ' member-owner' : ''}` },
el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()),
m.display_name,
isOwner && m.role !== 'owner'
? el('button', {
class: 'member-remove',
title: `Remove ${m.display_name}`,
onClick: () => onRemoveMember(m),
}, '×')
: null,
),
),
);
header.appendChild(titleRow);
header.appendChild(joinCodeRow(trip, isOwner));
header.appendChild(membersRow);
return header;
}
// Share code for inviting others — display only, not a credential.
function joinCodeRow(trip, isOwner) {
const grouped = groupCode(trip.join_code, 4);
const copyBtn = el('button', { class: 'btn btn-sm', type: 'button', title: 'Copy join code' }, '📋 Copy');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(grouped);
toast('Join code copied', 'success');
} catch {
toast('Copy failed — select and copy it manually');
}
});
async function onRegenerate() {
if (!window.confirm('Regenerate the join code? The old code will stop working immediately.')) return;
try {
await api.trips.regenerateJoinCode(tctx.tripId);
toast('Join code regenerated', 'success');
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
}
}
return el(
'div',
{ class: 'joincode-row' },
el('span', { class: 'members-label' }, 'Join code:'),
el('span', { class: 'joincode-chip' }, grouped),
copyBtn,
isOwner
? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onRegenerate }, '↻ Regenerate')
: null,
);
}
function toggleEdit(header) {
const existing = header.querySelector('.trip-edit');
if (existing) {
existing.remove();
return;
}
const { trip } = tctx.trip;
const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', value: trip.name });
const startInput = el('input', { class: 'input', type: 'date', value: trip.start_date });
const endInput = el('input', { class: 'input', type: 'date', value: trip.end_date });
const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: trip.currency || 'USD', 'aria-label': 'Currency code' });
const errorEl = el('p', { class: 'form-error' });
const saveBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Save changes');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const name = nameInput.value.trim();
const start_date = startInput.value;
const end_date = endInput.value;
const currency = currencyInput.value.trim().toUpperCase() || 'USD';
if (!name) return (errorEl.textContent = 'Name cannot be empty.');
if (end_date < start_date) return (errorEl.textContent = 'End date must be on or after the start date.');
if (!/^[A-Z]{3}$/.test(currency)) return (errorEl.textContent = 'Currency must be a 3-letter code, e.g. USD.');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
try {
await api.trips.update(tctx.tripId, { name, start_date, end_date, currency });
toast('Trip updated', 'success');
await tctx.refreshTrip();
} catch (err) {
errorEl.textContent = err.message;
saveBtn.disabled = false;
saveBtn.textContent = 'Save changes';
}
}
const editBox = el(
'form',
{ class: 'trip-edit', onSubmit },
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput),
),
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput),
el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput),
),
el('p', { class: 'hint muted' }, 'Changing the range regenerates the calendar; entries outside the new range are kept and flagged.'),
errorEl,
el('div', { class: 'form-actions' }, saveBtn),
);
header.appendChild(editBox);
}
async function onRemoveMember(member) {
if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return;
try {
await api.trips.removeMember(tctx.tripId, member.id);
toast(`${member.display_name} removed`, 'success');
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
}
}
async function onDelete() {
const { trip } = tctx.trip;
if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return;
try {
await api.trips.remove(tctx.tripId);
toast('Trip deleted', 'success');
ctx.navigate('#/trips');
} catch (err) {
toast(err.message);
}
}
}