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:
2026-07-20 10:22:49 +07:00
parent 71a158aa51
commit 36c4e4f306
14 changed files with 425 additions and 38 deletions
+1
View File
@@ -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),
+38 -5
View File
@@ -1,7 +1,8 @@
// Calendar grid for the trip's date range. Real weeks as rows (MonSun
// 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;
+1
View File
@@ -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,
+50 -17
View File
@@ -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);
});
}
+6 -2
View File
@@ -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;
}