Add drag & drop for entries and per-stay band colours

Calendar chips can be dragged onto another in-range day (multi-day entries shift end_date by the same delta in one PATCH); day-editor rows get a drag handle to reorder within a day, patching sort_order only for changed positions. Continues/dropoff ghost chips and stay bands are not draggable; touch devices fall back to the existing click/edit flow. Each stay band now gets a deterministic colour from an 8-hue palette by chronological index, mirrored as dots in the summary Areas list.
This commit is contained in:
2026-07-19 02:09:26 +07:00
parent 2eda9e4f6d
commit b066e7b885
6 changed files with 225 additions and 11 deletions
+22 -3
View File
@@ -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
+19 -3
View File
@@ -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.
+128
View File
@@ -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);
}
}
+29 -2
View File
@@ -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'}`,
+11 -3
View File
@@ -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);
}