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.
444 lines
16 KiB
JavaScript
444 lines
16 KiB
JavaScript
// 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();
|
||
}
|