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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user