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.
266 lines
8.8 KiB
JavaScript
266 lines
8.8 KiB
JavaScript
// 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);
|
||
}
|
||
}
|
||
}
|