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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+128
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'}`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user