Files
trip-plan/public/js/views/dayEditor.js
T
grabowski 36c4e4f306 Add transport sync button and allow dropping stays onto band-covered days
- Auto-created transports carry auto_ref {from,to} stay ids; new
  POST /api/trips/:id/transports/regenerate reconciles them against the
  current stay order (re-date/re-title kept bridges preserving mode and
  price, delete orphans, create missing) without touching manual
  transports or flight-covered gaps
- Sync transports button in the calendar header with result toast
- The week band strip is now a drop target resolving the day from the
  pointer position, so stays can be dropped onto spots covered by other
  stays; overlapping stays stack in band lanes
2026-07-20 10:22:49 +07:00

520 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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}` : '',
entry.auto_ref ? ' · ↻ auto' : '',
),
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();
}