diff --git a/public/css/styles.css b/public/css/styles.css index 09ea5bd..19f636a 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -255,6 +255,12 @@ textarea.input { resize: vertical; } .chip-icon { flex-shrink: 0; } .chip-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +/* Drag & drop: chips are drag sources, in-range days are drop targets. */ +.cal-chip-draggable { cursor: grab; } +.cal-chip-draggable:active { cursor: grabbing; } +.cal-chip.dragging { opacity: 0.4; } +.cal-day-drop { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand); background: var(--brand-soft); } + /* ---------- Detail grid (map + summary) ---------- */ .detail-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 1.2rem; align-items: start; } .map-section, .summary-section { display: flex; flex-direction: column; } @@ -294,6 +300,8 @@ textarea.input { resize: vertical; } .loc-block h3 { margin-bottom: 0.5rem; } .loc-order { margin: 0; padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; } .area-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; } +.area-item { display: flex; align-items: center; gap: 0.4rem; } +.area-dot { flex-shrink: 0; width: 0.7rem; height: 0.7rem; border-radius: 50%; display: inline-block; } /* ---------- Slide-over day editor ---------- */ .overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.45); z-index: 50; display: flex; justify-content: flex-end; animation: fade 0.15s ease; } @@ -315,6 +323,14 @@ textarea.input { resize: vertical; } .entry-details { font-size: 0.84rem; color: var(--text-muted); margin-top: 0.2rem; white-space: pre-wrap; } .entry-actions { display: flex; gap: 0.25rem; } +/* Drag-to-reorder: left grip handle plus top/bottom insertion indicators + (inset box-shadow lines so they don't shift the row's layout). */ +.entry-drag-handle { flex-shrink: 0; display: flex; align-items: center; align-self: stretch; padding: 0 0.1rem; cursor: grab; color: var(--text-faint); font-size: 0.95rem; line-height: 1; letter-spacing: -1px; user-select: none; } +.entry-drag-handle:active { cursor: grabbing; } +.entry-row.dragging { opacity: 0.4; } +.entry-row.drop-before { box-shadow: inset 0 3px 0 var(--brand); } +.entry-row.drop-after { box-shadow: inset 0 -3px 0 var(--brand); } + .entry-form { display: flex; flex-direction: column; gap: 0.8rem; padding-top: 1rem; border-top: 1px solid var(--border); } .loc-field { position: relative; } .loc-results { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-sm); margin-top: 0.3rem; overflow: hidden; } diff --git a/public/js/views/calendar.js b/public/js/views/calendar.js index cce7f0e..ee3b305 100644 --- a/public/js/views/calendar.js +++ b/public/js/views/calendar.js @@ -16,6 +16,7 @@ import { isMultiDay, } from '../format.js'; import { computeStayLayout, renderWeekBands } from './stayBands.js'; +import { makeChipDraggable, makeDayDropTarget } from './dragdrop.js'; const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; @@ -54,6 +55,8 @@ export function renderCalendar(tctx) { } const stayLayout = computeStayLayout(entries); + // id -> entry, for the drop handler to resolve the dragged chip. + const entriesById = new Map(entries.map((e) => [e.id, e])); const rangeStart = parseYMD(trip.start_date); const rangeEnd = parseYMD(trip.end_date); @@ -97,17 +100,23 @@ export function renderCalendar(tctx) { if (bands) weekEl.appendChild(bands); const daysRow = el('div', { class: 'cal-week-days' }); for (const date of week) { - daysRow.appendChild(dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, tctx, currency)); + daysRow.appendChild(dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, entriesById, tctx, currency)); } weekEl.appendChild(daysRow); cal.appendChild(weekEl); } + // A cancelled drag (released outside a cell) fires dragend on the source chip, + // which bubbles up here — sweep any lingering drop highlight. + cal.addEventListener('dragend', () => { + for (const n of cal.querySelectorAll('.cal-day-drop')) n.classList.remove('cal-day-drop'); + }); + section.appendChild(cal); return section; } -function dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, tctx, currency) { +function dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, entriesById, tctx, currency) { const key = ymd(date); const inRange = date >= rangeStart && date <= rangeEnd; // Stays render as bands, not chips, so exclude them from the day cell. @@ -158,6 +167,12 @@ function dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, }); } + // Only in-range days accept dropped entries; new sort_order appends after the + // day's current entries (byDate holds every entry, incl. stays, for that day). + if (inRange) { + makeDayDropTarget(cell, key, (byDate.get(key) || []).length, entriesById, tctx); + } + return cell; } @@ -167,7 +182,7 @@ function chip(entry, currency) { const chain = hasSegments(entry) ? flightChain(entry.segments) : ''; const car = hasRental(entry) ? [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ') : ''; const label = chain || car || entry.title; - return el( + const node = el( 'div', { class: 'cal-chip', @@ -180,6 +195,10 @@ function chip(entry, currency) { ? el('span', { class: 'chip-price' }, formatMoney(entry.price, currency, { compact: true })) : null, ); + // Regular chips are drag sources (move the entry to another day). The + // "…continues" and "dropoff" ghost chips and stay bands stay non-draggable. + makeChipDraggable(node, entry); + return node; } // Ghost chip on the days a multi-day entry spans after its start. Clicking diff --git a/public/js/views/dayEditor.js b/public/js/views/dayEditor.js index e725f1b..03fab16 100644 --- a/public/js/views/dayEditor.js +++ b/public/js/views/dayEditor.js @@ -22,6 +22,7 @@ import { 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. @@ -87,21 +88,35 @@ export function openDayEditor(tctx, date) { 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)); + // 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; } - function entryRow(entry) { + // 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); - return el( + 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', @@ -142,6 +157,7 @@ export function openDayEditor(tctx, date) { 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. diff --git a/public/js/views/dragdrop.js b/public/js/views/dragdrop.js new file mode 100644 index 0000000..87a105c --- /dev/null +++ b/public/js/views/dragdrop.js @@ -0,0 +1,128 @@ +// HTML5 drag-and-drop helpers, no libraries. Two flows: +// • Calendar — drag a day chip onto another in-range day to move that entry +// (shifts end_date by the same delta so multi-day spans stay intact). +// • Day editor — drag a row's handle to reorder entries within one day. +// Touch devices don't fire HTML5 DnD events, so both flows are desktop-only and +// degrade to the existing click/edit behaviour with no polyfill. +import { api } from '../api.js'; +import { toast } from '../dom.js'; +import { parseYMD, ymd, addDays } from '../format.js'; + +const DND_MIME = 'text/plain'; + +function dayDelta(fromYmd, toYmd) { + return Math.round((parseYMD(toYmd) - parseYMD(fromYmd)) / 86400000); +} + +// ---------- Calendar: move an entry to another day ---------- + +// Mark a calendar chip as a drag source carrying its entry id. +export function makeChipDraggable(node, entry) { + node.setAttribute('draggable', 'true'); + node.classList.add('cal-chip-draggable'); + node.addEventListener('dragstart', (e) => { + e.dataTransfer.setData(DND_MIME, String(entry.id)); + e.dataTransfer.effectAllowed = 'move'; + node.classList.add('dragging'); + }); + node.addEventListener('dragend', () => node.classList.remove('dragging')); +} + +// Wire an in-range day cell as a drop target. On drop the dragged entry moves to +// `targetDate` with sort_order = `targetCount` (appended after that day's +// entries); an entry with an end_date has it shifted by the same day-delta in +// the SAME patch (the backend rejects date > stored end_date otherwise). +export function makeDayDropTarget(cell, targetDate, targetCount, entriesById, tctx) { + cell.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + cell.classList.add('cal-day-drop'); + }); + cell.addEventListener('dragleave', (e) => { + if (!cell.contains(e.relatedTarget)) cell.classList.remove('cal-day-drop'); + }); + cell.addEventListener('drop', async (e) => { + e.preventDefault(); + cell.classList.remove('cal-day-drop'); + const id = Number(e.dataTransfer.getData(DND_MIME)); + if (!id) return; + const entry = entriesById.get(id); + if (!entry || entry.date === targetDate) return; // dropped on its own day + const patch = { date: targetDate, sort_order: targetCount }; + if (entry.end_date) { + const delta = dayDelta(entry.date, targetDate); + patch.end_date = ymd(addDays(parseYMD(entry.end_date), delta)); + } + try { + await api.entries.update(id, patch); + await tctx.refreshTrip(); + } catch (err) { + toast(err.message); + } + }); +} + +// ---------- Day editor: reorder entries within a day ---------- + +// `rows`: [{ node, handle, entry }] in current display order. Dragging a row's +// handle over another row shows a top/bottom insertion indicator; on drop the +// new order is computed and sort_order PATCHed for ONLY the entries whose index +// changed, then `refresh()` re-renders (the editor stays open on the same day). +export function enableRowReorder(rows, refresh) { + let draggingId = null; + + const clearAll = () => { + for (const r of rows) r.node.classList.remove('drop-before', 'drop-after', 'dragging'); + }; + + for (const { node, handle, entry } of rows) { + handle.setAttribute('draggable', 'true'); + handle.addEventListener('dragstart', (e) => { + draggingId = entry.id; + e.dataTransfer.setData(DND_MIME, String(entry.id)); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setDragImage(node, 0, 0); + node.classList.add('dragging'); + }); + handle.addEventListener('dragend', () => { draggingId = null; clearAll(); }); + + node.addEventListener('dragover', (e) => { + if (draggingId == null || entry.id === draggingId) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = node.getBoundingClientRect(); + const after = e.clientY - rect.top > rect.height / 2; + node.classList.toggle('drop-after', after); + node.classList.toggle('drop-before', !after); + }); + node.addEventListener('dragleave', (e) => { + if (!node.contains(e.relatedTarget)) node.classList.remove('drop-before', 'drop-after'); + }); + node.addEventListener('drop', async (e) => { + if (draggingId == null || entry.id === draggingId) return; + e.preventDefault(); + const after = node.classList.contains('drop-after'); + const movedId = draggingId; + clearAll(); + draggingId = null; + await commitReorder(rows, movedId, entry.id, after, refresh); + }); + } +} + +async function commitReorder(rows, movedId, targetId, after, refresh) { + const moved = rows.find((r) => r.entry.id === movedId).entry; + const ordered = rows.map((r) => r.entry).filter((e) => e.id !== movedId); + const targetIndex = ordered.findIndex((e) => e.id === targetId); + ordered.splice(after ? targetIndex + 1 : targetIndex, 0, moved); + try { + for (let i = 0; i < ordered.length; i++) { + if (ordered[i].sort_order !== i) { + await api.entries.update(ordered[i].id, { sort_order: i }); + } + } + await refresh(); + } catch (err) { + toast(err.message); + } +} diff --git a/public/js/views/stayBands.js b/public/js/views/stayBands.js index 0cd2ac6..cde1c6a 100644 --- a/public/js/views/stayBands.js +++ b/public/js/views/stayBands.js @@ -5,7 +5,29 @@ import { el } from '../dom.js'; import { parseYMD, daysBetweenInclusive, typeInfo, stayShortName, entrySpanDays } from '../format.js'; -// Returns { stays: [{entry,start,end,name,days,lane}], laneCount }. +// Per-instance band palette: each stay gets its own hue (not the generic stay +// type colour) so overlapping/adjacent areas read apart. Muted-but-distinct +// tones that harmonise with the entry-type palette and keep #334155 text +// readable on their color-mix(…20%, white) band backgrounds. Assignment is +// deterministic — index in the chronologically-sorted stays list % 8 — so a +// stay keeps its colour across re-renders, week rows, and the Areas list. +export const STAY_PALETTE = [ + '#f59e0b', // amber + '#14b8a6', // teal + '#8b5cf6', // violet + '#f43f5e', // rose + '#0ea5e9', // sky + '#65a30d', // lime + '#f97316', // orange + '#6366f1', // indigo +]; + +// Deterministic colour for the Nth stay in chronological order. +export function stayColor(index) { + return STAY_PALETTE[((index % STAY_PALETTE.length) + STAY_PALETTE.length) % STAY_PALETTE.length]; +} + +// Returns { stays: [{entry,start,end,name,days,lane,color}], laneCount }. export function computeStayLayout(entries) { const stays = entries .filter((e) => e.type === 'stay') @@ -16,9 +38,14 @@ export function computeStayLayout(entries) { name: stayShortName(e), days: entrySpanDays(e), lane: 0, + color: null, })) .sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : a.entry.id - b.entry.id)); + // Colour by chronological index (same ordering summary.areas uses), assigned + // here next to lanes so every consumer of the layout shares one source. + stays.forEach((s, i) => { s.color = stayColor(i); }); + const laneEnds = []; // last end date (ymd) occupying each lane for (const s of stays) { // A lane is free if its last stay ended strictly before this one starts. @@ -58,7 +85,7 @@ export function renderWeekBands(weekYmd, layout, tctx) { const band = el('div', { class: `cal-band${roundLeft ? ' round-l' : ''}${roundRight ? ' round-r' : ''}`, - style: { gridColumn: `${col + 1} / span ${span}`, gridRow: String(s.lane + 1), '--chip': info.color }, + style: { gridColumn: `${col + 1} / span ${span}`, gridRow: String(s.lane + 1), '--chip': s.color }, role: 'button', tabindex: '0', title: `${info.icon} ${s.name} · ${s.days} ${s.days === 1 ? 'day' : 'days'}`, diff --git a/public/js/views/summary.js b/public/js/views/summary.js index e624d64..56d5c80 100644 --- a/public/js/views/summary.js +++ b/public/js/views/summary.js @@ -2,6 +2,7 @@ // days, nights, flights, hotels, travel legs, activities, total km, and the // list of locations in visit order. import { el } from '../dom.js'; +import { stayColor } from './stayBands.js'; export function renderSummary(tctx) { const route = tctx.route || {}; @@ -76,9 +77,16 @@ export function renderSummary(tctx) { if (areas.length) { const areaBlock = el('div', { class: 'loc-block' }, el('h3', {}, 'Areas')); const ul = el('ul', { class: 'area-list' }); - for (const a of areas) { - ul.appendChild(el('li', {}, `🏙️ ${a.name} — ${a.days} ${a.days === 1 ? 'day' : 'days'}`)); - } + // areas arrive in the same chronological order as computeStayLayout sorts + // stays, so index i shares the band colour (stayColor(i)). + areas.forEach((a, i) => { + ul.appendChild(el( + 'li', + { class: 'area-item' }, + el('span', { class: 'area-dot', style: { background: stayColor(i) } }), + `🏙️ ${a.name} — ${a.days} ${a.days === 1 ? 'day' : 'days'}`, + )); + }); areaBlock.appendChild(ul); section.appendChild(areaBlock); }