Initial release: collaborative trip planner

Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
This commit is contained in:
2026-07-18 23:15:29 +07:00
commit fe89bb2b1c
54 changed files with 8565 additions and 0 deletions
+443
View File
@@ -0,0 +1,443 @@
// Slide-over panel for a single day: lists existing entries (edit/delete) and
// a form to add/update one, including a debounced geocode-backed location
// autocomplete. On any change it calls tctx.refreshTrip() so the calendar,
// map and summary update; while open it registers tctx._onModalRefresh so
// this panel re-renders itself from the freshly fetched trip data too.
import { api } from '../api.js';
import { el, clear, mount, toast } from '../dom.js';
import {
ENTRY_TYPE_LIST,
splitModeLabel,
typeInfo,
formatFullDate,
formatTimeRange,
formatMoney,
flightChain,
hasSegments,
hasRental,
} from '../format.js';
import { createFlightRoute, renderSegmentLines } from './segments.js';
import { createRentalDetails, renderRentalLine } from './rental.js';
import { createCostForm } from './costForm.js';
export function openDayEditor(tctx, date) {
// A form-state object for the entry currently being added/edited.
let editing = null; // entry id being edited, or null for a new entry
let loc = null; // { name, lat, lng } | null
const members = () => tctx.trip.members || [];
const currency = () => (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
const memberName = (id) => {
const m = members().find((x) => x.id === id);
return m ? m.display_name : `user ${id}`;
};
const overlay = el('div', { class: 'overlay' });
const panel = el('aside', { class: 'slideover', role: 'dialog', 'aria-modal': 'true' });
overlay.appendChild(panel);
document.body.appendChild(overlay);
document.body.classList.add('no-scroll');
function close() {
tctx._onModalRefresh = null;
document.body.classList.remove('no-scroll');
overlay.remove();
document.removeEventListener('keydown', onKey);
window.removeEventListener('hashchange', close);
}
function onKey(e) {
if (e.key === 'Escape') close();
}
document.addEventListener('keydown', onKey);
// Self-close on any navigation so the overlay never orphans over another view.
window.addEventListener('hashchange', close);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
// Re-render this panel whenever the underlying trip data changes.
tctx._onModalRefresh = () => draw();
function entriesForDate() {
return (tctx.trip.entries || []).filter((e) => e.date === date);
}
function draw() {
const dayEntries = entriesForDate();
const header = el(
'div',
{ class: 'slideover-head' },
el(
'div',
{},
el('h2', {}, formatFullDate(date)),
el('p', { class: 'muted' }, dayEntries.length
? `${dayEntries.length} ${dayEntries.length === 1 ? 'entry' : 'entries'}`
: 'No entries yet'),
),
el('button', { class: 'icon-btn', title: 'Close', onClick: close }, '×'),
);
const list = el('div', { class: 'entry-list' });
if (!dayEntries.length) {
list.appendChild(el('p', { class: 'muted entry-empty' }, 'Nothing planned for this day yet.'));
} else {
for (const entry of dayEntries) list.appendChild(entryRow(entry));
}
mount(panel, header, list, formSection(dayEntries));
panel.scrollTop = 0;
}
function entryRow(entry) {
const info = typeInfo(entry.type);
const time = formatTimeRange(entry.start_time, entry.end_time);
const hasPrice = entry.price != null;
const flight = hasSegments(entry);
return el(
'div',
{ class: 'entry-row', style: { '--chip': info.color } },
el('span', { class: 'entry-icon' }, info.icon),
el(
'div',
{ class: 'entry-body' },
el(
'div',
{ class: 'entry-title-row' },
el('span', { class: 'entry-title' }, entry.title),
hasPrice
? el('span', { class: 'entry-price' }, formatMoney(entry.price, currency(), { compact: true }))
: null,
),
el(
'div',
{ class: 'entry-sub muted' },
info.label,
time ? ` · ${time}` : '',
flight ? ` · ✈️ ${flightChain(entry.segments)}` : '',
!flight && entry.location_name ? ` · 📍 ${entry.location_name}` : '',
),
flight ? renderSegmentLines(entry.segments) : null,
hasRental(entry) ? renderRentalLine(entry.rental) : null,
hasPrice
? el(
'div',
{ class: 'entry-cost muted' },
`💰 ${splitModeLabel(entry.split_mode)}`,
entry.paid_by != null ? ` · paid by ${memberName(entry.paid_by)}` : ' · no payer set',
)
: null,
entry.details ? el('div', { class: 'entry-details' }, entry.details) : null,
),
el(
'div',
{ class: 'entry-actions' },
el('button', { class: 'icon-btn', title: 'Edit', onClick: () => startEdit(entry) }, '✎'),
el('button', { class: 'icon-btn danger', title: 'Delete', onClick: () => onDelete(entry) }, '🗑'),
),
);
}
function startEdit(entry) {
editing = entry.id;
loc = entry.lat != null && entry.lng != null
? { name: entry.location_name || '', lat: entry.lat, lng: entry.lng }
: null;
draw();
// Populate fields from the entry after (re)draw.
fields.type.value = entry.type;
fields.title.value = entry.title;
fields.details.value = entry.details || '';
fields.start.value = entry.start_time || '';
fields.end.value = entry.end_time || '';
fields.cost.prefill(entry);
// Flight segments (load() triggers the flight-route onChange -> UI sync).
fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []);
fields.rentalDetails.load(entry.rental || null);
renderLoc();
panel.querySelector('.entry-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
fields.title.focus();
}
// Field references for the current form render, so edit can populate them.
let fields = {};
function formSection(dayEntries) {
const typeSelect = el(
'select',
{ class: 'input' },
...ENTRY_TYPE_LIST.map((t) => el('option', { value: t.value }, `${t.icon} ${t.label}`)),
);
const titleInput = el('input', { class: 'input', type: 'text', maxlength: '200', placeholder: 'Title (e.g. Flight BKK → CNX)' });
const detailsInput = el('textarea', { class: 'input', rows: '2', placeholder: 'Details (optional)' });
const startInput = el('input', { class: 'input', type: 'time' });
const endInput = el('input', { class: 'input', type: 'time' });
const locWrap = el('div', { class: 'loc-field' });
const locInput = el('input', {
class: 'input',
type: 'text',
placeholder: 'Search a place (OpenStreetMap)…',
autocomplete: 'off',
});
const locResults = el('div', { class: 'loc-results' });
const locSelected = el('div', { class: 'loc-selected' });
locWrap.append(locInput, locResults, locSelected);
const locationField = el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Location'), locWrap);
// ----- Flight route subsection (shown only for flight entries) -----
// The sub-modules fire onChange during construction (load of initial
// state), before the section elements below exist — gate until wired.
let typeUIReady = false;
const flightRoute = createFlightRoute({ onChange: () => { if (typeUIReady) syncTypeUI(); } });
const flightSection = el(
'div',
{ class: 'flight-section' },
el('div', { class: 'cost-heading' }, 'Flight route (optional)'),
el('p', { class: 'hint muted' }, 'Add legs for a multi-stop flight; airports plot the route on the map.'),
flightRoute.node,
);
// ----- Rental details subsection (shown only for rental entries) -----
const rentalDetails = createRentalDetails({ entryDate: date, onChange: () => { if (typeUIReady) syncTypeUI(); } });
const rentalSection = el(
'div',
{ class: 'rental-section' },
el('div', { class: 'cost-heading' }, 'Rental details'),
rentalDetails.node,
);
// Show the flight route for flights and the rental block for rentals; hide
// the generic location field only once flight segments exist (route then
// comes from the airport coords). Rentals keep the location field — their
// pickup/dropoff are plain text and don't feed the route.
function syncTypeUI() {
const type = typeSelect.value;
flightSection.style.display = type === 'flight' ? '' : 'none';
rentalSection.style.display = type === 'rental' ? '' : 'none';
locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : '';
}
typeSelect.addEventListener('change', syncTypeUI);
typeUIReady = true;
syncTypeUI();
// ----- Cost subsection (self-contained module) -----
const costForm = createCostForm({ members: members(), currency: currency() });
fields = {
type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput,
locInput, locResults, locSelected,
cost: costForm, flightRoute, rentalDetails,
};
wireGeocode(locInput, locResults);
const errorEl = el('p', { class: 'form-error' });
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, editing ? 'Save entry' : 'Add entry');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const title = titleInput.value.trim();
if (!title) return (errorEl.textContent = 'Title is required.');
const payload = {
date,
type: typeSelect.value,
title,
details: detailsInput.value.trim(),
start_time: startInput.value || null,
end_time: endInput.value || null,
location_name: loc ? loc.name : null,
lat: loc ? loc.lat : null,
lng: loc ? loc.lng : null,
};
if (!editing) {
payload.sort_order = dayEntries.length;
}
// Flight segments (flight entries only). Clear them otherwise so changing
// an entry's type away from flight drops any prior segments.
if (typeSelect.value === 'flight') {
const res = flightRoute.read();
if (res.error) return (errorEl.textContent = res.error);
payload.segments = res.segments; // array or null
// When segments exist, the route comes from the airports — clear the
// generic location so it doesn't add a stray stop.
if (res.segments) {
payload.location_name = null;
payload.lat = null;
payload.lng = null;
}
} else {
payload.segments = null;
}
// Rental details (rental entries only). The entry's own date follows the
// pickup date so it lives on the pickup day.
if (typeSelect.value === 'rental') {
const res = rentalDetails.read();
if (res.error) return (errorEl.textContent = res.error);
payload.rental = res.rental; // object or null
if (res.pickupDate) payload.date = res.pickupDate;
} else {
payload.rental = null;
}
// Cost fields: price is the toggle. When set, send the full cost set;
// when blank, send price:null (clears any prior cost) and omit the rest.
const costRes = costForm.read();
if (costRes.error) return (errorEl.textContent = costRes.error);
payload.price = costRes.price != null ? costRes.price : null;
if (costRes.price != null) {
payload.paid_by = costRes.paid_by;
payload.split_mode = costRes.split_mode;
payload.participants = costRes.participants;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Saving…';
try {
if (editing) await api.entries.update(editing, payload);
else await api.entries.create(tctx.tripId, payload);
toast(editing ? 'Entry updated' : 'Entry added', 'success');
editing = null;
loc = null;
await tctx.refreshTrip(); // triggers _onModalRefresh -> draw()
} catch (err) {
errorEl.textContent = err.message;
submitBtn.disabled = false;
submitBtn.textContent = editing ? 'Save entry' : 'Add entry';
}
}
const cancelEdit = editing
? el('button', {
class: 'btn btn-ghost',
type: 'button',
onClick: () => { editing = null; loc = null; draw(); },
}, 'Cancel edit')
: null;
const form = el(
'form',
{ class: 'entry-form', onSubmit },
el('h3', {}, editing ? 'Edit entry' : 'Add entry'),
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Type'), typeSelect),
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Title'), titleInput),
),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Details'), detailsInput),
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start time'), startInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End time'), endInput),
),
flightSection,
rentalSection,
locationField,
el('div', { class: 'cost-heading' }, 'Cost (optional)'),
costForm.node,
errorEl,
el('div', { class: 'form-actions' }, cancelEdit, submitBtn),
);
renderLoc();
syncTypeUI();
return form;
}
function renderLoc() {
const box = fields.locSelected;
if (!box) return;
clear(box);
if (loc) {
box.appendChild(
el(
'div',
{ class: 'loc-chip' },
el('span', {}, `📍 ${loc.name}`),
el('button', {
class: 'loc-clear',
type: 'button',
title: 'Clear location',
onClick: () => { loc = null; fields.locInput.value = ''; renderLoc(); },
}, '×'),
),
);
}
}
function wireGeocode(input, resultsBox) {
let timer = null;
let seq = 0;
input.addEventListener('input', () => {
const q = input.value.trim();
clearTimeout(timer);
if (q.length < 2) {
clear(resultsBox);
return;
}
timer = setTimeout(async () => {
const mySeq = ++seq;
resultsBox.classList.add('loading');
try {
const data = await api.geocode(q);
if (mySeq !== seq) return; // a newer query superseded this one
showResults(data.results || [], resultsBox);
} catch (err) {
if (mySeq !== seq) return;
clear(resultsBox);
toast(err.message || 'Location search failed');
} finally {
resultsBox.classList.remove('loading');
}
}, 400);
});
}
function showResults(results, resultsBox) {
clear(resultsBox);
if (!results.length) {
resultsBox.appendChild(el('div', { class: 'loc-empty muted' }, 'No matches'));
return;
}
for (const r of results) {
resultsBox.appendChild(
el(
'button',
{
class: 'loc-result',
type: 'button',
onClick: () => {
loc = { name: r.name, lat: r.lat, lng: r.lng };
fields.locInput.value = '';
clear(resultsBox);
renderLoc();
},
},
el('span', { class: 'loc-result-name' }, r.name),
),
);
}
}
async function onDelete(entry) {
if (!window.confirm(`Delete "${entry.title}"?`)) return;
try {
await api.entries.remove(entry.id);
toast('Entry deleted', 'success');
if (editing === entry.id) { editing = null; loc = null; }
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
}
}
draw();
}