// 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, TRANSPORT_MODES, splitModeLabel, typeInfo, formatDate, formatFullDate, formatTimeRange, formatMoney, flightChain, hasSegments, hasRental, isMultiDay, entrySpanDays, } from '../format.js'; import { createFlightRoute, renderSegmentLines } from './segments.js'; import { createRentalDetails, renderRentalLine } from './rental.js'; import { createCostForm } from './costForm.js'; import { enableRowReorder } from './dragdrop.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 { // Reordering only makes sense with 2+ entries — then each row gets a drag // handle and dropping recomputes sort_order (see enableRowReorder). const reorderable = dayEntries.length > 1; const rows = []; for (const entry of dayEntries) { const { node, handle } = entryRow(entry, reorderable); rows.push({ node, handle, entry }); list.appendChild(node); } if (reorderable) enableRowReorder(rows, () => tctx.refreshTrip()); } mount(panel, header, list, formSection(dayEntries)); panel.scrollTop = 0; } // Returns { node, handle } — handle is the drag grip (null unless reorderable). function entryRow(entry, reorderable) { const info = typeInfo(entry.type); const time = formatTimeRange(entry.start_time, entry.end_time); const hasPrice = entry.price != null; const flight = hasSegments(entry); const handle = reorderable ? el('span', { class: 'entry-drag-handle', title: 'Drag to reorder', 'aria-hidden': 'true' }, '⋮⋮') : null; const node = el( 'div', { class: 'entry-row', style: { '--chip': info.color } }, handle, 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}` : '', ), isMultiDay(entry) ? el('div', { class: 'entry-span muted' }, spanText(entry)) : null, 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) }, '🗑'), ), ); return { node, handle }; } // "until 8 Aug · 3 days" for stays; "5 Aug 20:50 → 7 Aug 06:30" otherwise. function spanText(entry) { if (entry.type === 'stay') { return `until ${formatDate(entry.end_date)} · ${entrySpanDays(entry)} days`; } const a = `${formatDate(entry.date)}${entry.start_time ? ` ${entry.start_time}` : ''}`; const b = `${formatDate(entry.end_date)}${entry.end_time ? ` ${entry.end_time}` : ''}`; return `${a} → ${b}`; } 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.endDate.value = entry.end_date || ''; fields.mode.value = entry.transport_mode || ''; 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' }); // Optional end date for multi-day entries (all types). Relabeled "Until" // for stays via syncTypeUI. const endDateInput = el('input', { class: 'input', type: 'date', min: date }); const endDateLabel = el('span', { class: 'field-label' }, 'End date'); const endDateField = el('label', { class: 'field' }, endDateLabel, endDateInput); 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); // ----- Transport mode (shown only for transport entries) ----- // Declared before the flight/rental subsections below: their onChange // callbacks can call syncTypeUI() during construction (see the TDZ note // below it), so anything syncTypeUI touches must already exist. const modeSelect = el( 'select', { class: 'input' }, el('option', { value: '' }, '—'), ...TRANSPORT_MODES.map((m) => el('option', { value: m.value }, `${m.icon} ${m.label}`)), ); const modeField = el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Mode'), modeSelect); // ----- 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'; modeField.style.display = type === 'transport' ? '' : 'none'; locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : ''; // Stays emphasise an end date ("until"); other types call it "End date". endDateLabel.textContent = type === 'stay' ? 'Until' : 'End date'; locationField.classList.toggle('field-emphasis', type === 'stay'); } 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, endDate: endDateInput, mode: modeSelect, 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 isStay = typeSelect.value === 'stay'; let title = titleInput.value.trim(); if (!title) { // Stays may leave the title blank — default to the location's short name. if (isStay && loc) title = loc.name.split(',')[0].trim(); else return (errorEl.textContent = isStay ? 'Give the stay a title or pick a location.' : 'Title is required.'); } const payload = { date, end_date: endDateInput.value || null, 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; } // Transport mode (transport entries only). Clear it otherwise so // changing an entry's type away from transport drops any prior mode. payload.transport_mode = typeSelect.value === 'transport' ? (modeSelect.value || null) : null; // end_date must be on/after the effective start date (rental may have // moved payload.date to the pickup date above). if (payload.end_date && payload.end_date < payload.date) { return (errorEl.textContent = 'End date must be on or after the start date.'); } // 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), modeField, 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), endDateField, ), 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(); }