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
This commit is contained in:
@@ -71,6 +71,7 @@ export const api = {
|
||||
removeMember: (id, userId) => del(`/api/trips/${id}/members/${userId}`),
|
||||
route: (id) => get(`/api/trips/${id}/route`),
|
||||
costs: (id) => get(`/api/trips/${id}/costs`),
|
||||
regenerateTransports: (id) => post(`/api/trips/${id}/transports/regenerate`, {}),
|
||||
},
|
||||
entries: {
|
||||
create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Calendar grid for the trip's date range. Real weeks as rows (Mon–Sun
|
||||
// columns); days outside the range are greyed. Each in-range day shows its
|
||||
// entries as compact, type-coloured chips. Clicking a day opens the editor.
|
||||
import { el } from '../dom.js';
|
||||
import { el, toast } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
ENTRY_TYPES,
|
||||
typeInfo,
|
||||
@@ -68,9 +69,14 @@ export function renderCalendar(tctx) {
|
||||
{ class: 'card calendar-section' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'section-head' },
|
||||
el('h2', {}, 'Calendar'),
|
||||
el('p', { class: 'muted' }, 'Click a day to add or edit entries.'),
|
||||
{ class: 'section-head cal-section-head' },
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el('h2', {}, 'Calendar'),
|
||||
el('p', { class: 'muted' }, 'Click a day to add or edit entries.'),
|
||||
),
|
||||
syncTransportsButton(tctx),
|
||||
),
|
||||
legend(),
|
||||
);
|
||||
@@ -97,7 +103,7 @@ export function renderCalendar(tctx) {
|
||||
for (const week of weeks) {
|
||||
// Each week is a stay-bands strip (all-day bars) above a row of day cells.
|
||||
const weekEl = el('div', { class: 'cal-week' });
|
||||
const bands = renderWeekBands(week.map(ymd), stayLayout, tctx);
|
||||
const bands = renderWeekBands(week.map(ymd), stayLayout, entriesById, tctx);
|
||||
if (bands) weekEl.appendChild(bands);
|
||||
const daysRow = el('div', { class: 'cal-week-days' });
|
||||
for (const date of week) {
|
||||
@@ -111,12 +117,39 @@ export function renderCalendar(tctx) {
|
||||
// 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');
|
||||
for (const n of cal.querySelectorAll('.cal-bands-drop')) n.classList.remove('cal-bands-drop');
|
||||
});
|
||||
|
||||
section.appendChild(cal);
|
||||
return section;
|
||||
}
|
||||
|
||||
// "↻ Sync transports" — reconciles auto-created transport entries after stays
|
||||
// have been dragged/reshuffled (see docs/API.md, "Regenerating auto-transports").
|
||||
function syncTransportsButton(tctx) {
|
||||
const btn = el('button', { class: 'btn btn-sm btn-ghost', type: 'button' }, '↻ Sync transports');
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Syncing…';
|
||||
try {
|
||||
const res = await api.trips.regenerateTransports(tctx.tripId);
|
||||
const parts = [];
|
||||
if (res.created) parts.push(`${res.created} added`);
|
||||
if (res.updated) parts.push(`${res.updated} updated`);
|
||||
if (res.deleted) parts.push(`${res.deleted} removed`);
|
||||
toast(parts.length ? `Transports synced: ${parts.join(', ')}` : 'Transports already in sync', 'success');
|
||||
await tctx.refreshTrip();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
function dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, entriesById, tctx, currency) {
|
||||
const key = ymd(date);
|
||||
const inRange = date >= rangeStart && date <= rangeEnd;
|
||||
|
||||
@@ -137,6 +137,7 @@ export function openDayEditor(tctx, date) {
|
||||
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,
|
||||
|
||||
Vendored
+50
-17
@@ -46,10 +46,29 @@ export function makeBandDraggable(node, entry) {
|
||||
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).
|
||||
// Shared move-commit: PATCHes the dragged entry to `targetDate` with the given
|
||||
// `sortOrder`, shifting `end_date` by the same day-delta in the SAME patch (the
|
||||
// backend rejects date > stored end_date otherwise). No-ops when dropped back
|
||||
// on its own day. Used by both the day-cell and week-bands drop targets below.
|
||||
async function commitEntryMove(id, targetDate, sortOrder, entriesById, tctx) {
|
||||
const entry = entriesById.get(id);
|
||||
if (!entry || entry.date === targetDate) return; // dropped on its own day
|
||||
const patch = { date: targetDate, sort_order: sortOrder };
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
export function makeDayDropTarget(cell, targetDate, targetCount, entriesById, tctx) {
|
||||
cell.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -64,19 +83,33 @@ export function makeDayDropTarget(cell, targetDate, targetCount, entriesById, tc
|
||||
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);
|
||||
}
|
||||
await commitEntryMove(id, targetDate, targetCount, entriesById, tctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Wire a week's stay-bands strip as a drop target, so dragging a chip/band
|
||||
// onto a spot already covered by another stay's band still moves the entry
|
||||
// (day cells sit below the bands strip and are otherwise unreachable there).
|
||||
// The target day is derived from the pointer's x-position across the 7-column
|
||||
// strip; the moved entry is appended (sort_order 0 — bands don't track a
|
||||
// day's chip order).
|
||||
export function makeWeekBandsDropTarget(strip, weekYmd, entriesById, tctx) {
|
||||
strip.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
strip.classList.add('cal-bands-drop');
|
||||
});
|
||||
strip.addEventListener('dragleave', (e) => {
|
||||
if (!strip.contains(e.relatedTarget)) strip.classList.remove('cal-bands-drop');
|
||||
});
|
||||
strip.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
strip.classList.remove('cal-bands-drop');
|
||||
const id = Number(e.dataTransfer.getData(DND_MIME));
|
||||
if (!id) return;
|
||||
const rect = strip.getBoundingClientRect();
|
||||
const idx = Math.min(6, Math.max(0, Math.floor((e.clientX - rect.left) / (rect.width / 7))));
|
||||
await commitEntryMove(id, weekYmd[idx], 0, entriesById, tctx);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// partitioning) so a stay keeps the same vertical position across weeks.
|
||||
import { el } from '../dom.js';
|
||||
import { parseYMD, daysBetweenInclusive, typeInfo, stayShortName, entrySpanDays } from '../format.js';
|
||||
import { makeBandDraggable } from './dragdrop.js';
|
||||
import { makeBandDraggable, makeWeekBandsDropTarget } from './dragdrop.js';
|
||||
|
||||
// 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
|
||||
@@ -63,7 +63,10 @@ export function computeStayLayout(entries) {
|
||||
}
|
||||
|
||||
// Bands strip for one week (7 ymd strings). Returns null if no stay intersects.
|
||||
export function renderWeekBands(weekYmd, layout, tctx) {
|
||||
// `entriesById`/`tctx` are threaded through to wire the strip itself as a drop
|
||||
// target (see makeWeekBandsDropTarget) so dragging onto a spot already covered
|
||||
// by a band still moves the dragged entry.
|
||||
export function renderWeekBands(weekYmd, layout, entriesById, tctx) {
|
||||
const weekStart = weekYmd[0];
|
||||
const weekEnd = weekYmd[6];
|
||||
const inWeek = layout.stays.filter((s) => s.start <= weekEnd && s.end >= weekStart);
|
||||
@@ -103,6 +106,7 @@ export function renderWeekBands(weekYmd, layout, tctx) {
|
||||
makeBandDraggable(band, s.entry);
|
||||
strip.appendChild(band);
|
||||
}
|
||||
makeWeekBandsDropTarget(strip, weekYmd, entriesById, tctx);
|
||||
return strip;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user