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:
+11
-2
@@ -48,6 +48,7 @@ entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||
segments TEXT, -- JSON array, flight entries only (see below)
|
||||
rental TEXT, -- JSON object, rental entries only (see below)
|
||||
transport_mode TEXT, -- transport entries only (see below)
|
||||
auto_ref TEXT, -- JSON {from,to} stay ids; non-null = auto-created transport (see below)
|
||||
created_at TEXT DEFAULT current_timestamp)
|
||||
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
|
||||
PRIMARY KEY (entry_id, user_id))
|
||||
@@ -62,7 +63,15 @@ Entry `type` ∈ `flight | transport | activity | rental | stay | note`.
|
||||
|
||||
`transport` covers ground/sea travel (the old `travel` type). Optional `transport_mode` ∈ `train | bus | ferry | taxi | drive | other` (nullable; drives the icon in the UI). Validation: only allowed when the effective `type === 'transport'` (else 400 `transport_mode is only allowed on transport entries`); PATCH `transport_mode: null` clears it. Entry JSON always includes `transport_mode` (string or null).
|
||||
|
||||
**Auto-transport between stays**: when a `stay` entry is POSTed and it has a chronological neighbour stay (the nearest stay before and/or after it, ordered by `date`), the server auto-creates one `transport` entry per neighbour pair — titled `"<from short name> → <to short name>"` (short name = first comma-segment of the stay's `location_name`, fallback `title`), dated on the **later** stay's `date`, `transport_mode` null, no price/location, `sort_order` 0. Skipped when any `transport` or `flight` entry already exists with `date` between the earlier stay's end (`end_date` or `date`) and the later stay's `date` (inclusive). One-shot: fires only on stay **creation** (never PATCH), so deleting an auto-created transport does not resurrect it. The POST response is unchanged (`201 {entry}` = the stay); clients should refetch the entry list.
|
||||
**Auto-transport between stays**: when a `stay` entry is POSTed and it has a chronological neighbour stay (the nearest stay before and/or after it, ordered by `date`), the server auto-creates one `transport` entry per neighbour pair — titled `"<from short name> → <to short name>"` (short name = first comma-segment of the stay's `location_name`, fallback `title`), dated on the **later** stay's `date`, `transport_mode` null, no price/location, `sort_order` 0, and `auto_ref` set to `{"from": <earlier stay id>, "to": <later stay id>}` (stored JSON, returned parsed; null on every other entry — the marker for "auto-created"). Skipped when any `transport` or `flight` entry already exists with `date` between the earlier stay's end (`end_date` or `date`) and the later stay's `date` (inclusive). One-shot: fires only on stay **creation** (never PATCH), so deleting an auto-created transport does not resurrect it. The POST response is unchanged (`201 {entry}` = the stay); clients should refetch the entry list.
|
||||
|
||||
**Regenerating auto-transports** — `POST /api/trips/:id/transports/regenerate` (any member, no body) reconciles auto transports after stays have been moved/reshuffled:
|
||||
|
||||
1. Compute the **desired pairs**: stays in chronological `(date, id)` order → each adjacent pair `(earlier, later)`, skipping pairs whose window (`earlier.end_date||date` .. `later.date`, inclusive; skip if inverted/overlapping) contains a **manual** transport (`auto_ref` null) or any flight.
|
||||
2. Existing auto transports (`type='transport'`, `auto_ref` non-null) whose `{from,to}` matches a desired pair are **kept but re-synced**: `date` set to the pair's current transition date (`later.date`), title regenerated to the current `"A → B"`; `transport_mode`, price/split, times, details are preserved. Auto transports matching no desired pair are **deleted**.
|
||||
3. Missing desired pairs get a fresh auto transport (same shape as creation-time).
|
||||
|
||||
Response: `200 {created, updated, deleted}` (counts). Auto transports from databases predating `auto_ref` have it null and are treated as manual (never touched). Stays MAY overlap in dates (two locations on the same day, e.g. temporarily while reshuffling) — overlapping adjacent pairs simply produce no auto transport, and the calendar renders overlapping stays in stacked band lanes.
|
||||
|
||||
### Multi-day entries & stays
|
||||
|
||||
@@ -171,7 +180,7 @@ User JSON shape everywhere: `{id, display_name}`.
|
||||
| `PATCH /api/entries/:id` | any subset of the above | `200 {entry}` (member of the entry's trip required; `participants` replaces the whole set) |
|
||||
| `DELETE /api/entries/:id` | — | `204` (also deletes its entry_participants rows) |
|
||||
|
||||
Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments, rental, transport_mode}` where `participants` is an array of user ids (`[]` = all members) and `segments` is the parsed array or `null`.
|
||||
Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments, rental, transport_mode, auto_ref}` where `participants` is an array of user ids (`[]` = all members), `segments` is the parsed array or `null`, and `auto_ref` is the parsed `{from,to}` object or `null`. `auto_ref` is server-managed (not accepted in POST/PATCH bodies; editing an auto transport keeps its `auto_ref`).
|
||||
|
||||
### Route & summary (computed)
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ textarea.input { resize: vertical; }
|
||||
.page { max-width: 1180px; margin: 0 auto; padding: 1.4rem; display: flex; flex-direction: column; gap: 1.2rem; }
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.section-head { margin-bottom: 0.8rem; }
|
||||
.cal-section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.form-slot:empty { display: none; }
|
||||
|
||||
/* ---------- Auth ---------- */
|
||||
@@ -272,6 +273,7 @@ textarea.input { resize: vertical; }
|
||||
.cal-band-draggable:active { cursor: grabbing; }
|
||||
.cal-band.dragging { opacity: 0.4; }
|
||||
.cal-day-drop { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand); background: var(--brand-soft); }
|
||||
.cal-week-bands.cal-bands-drop { outline: 2px dashed var(--brand); outline-offset: 2px; border-radius: 6px; }
|
||||
|
||||
/* ---------- Detail grid (map + summary) ---------- */
|
||||
.detail-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 1.2rem; align-items: start; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
||||
segments TEXT,
|
||||
rental TEXT,
|
||||
transport_mode TEXT,
|
||||
auto_ref TEXT,
|
||||
created_at TEXT DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
@@ -73,6 +74,7 @@ const MIGRATIONS = [
|
||||
{ table: 'entries', column: 'rental', ddl: 'ALTER TABLE entries ADD COLUMN rental TEXT' },
|
||||
{ table: 'entries', column: 'end_date', ddl: 'ALTER TABLE entries ADD COLUMN end_date TEXT' },
|
||||
{ table: 'entries', column: 'transport_mode', ddl: 'ALTER TABLE entries ADD COLUMN transport_mode TEXT' },
|
||||
{ table: 'entries', column: 'auto_ref', ddl: 'ALTER TABLE entries ADD COLUMN auto_ref TEXT' },
|
||||
{ table: 'trip_members', column: 'sort_order', ddl: 'ALTER TABLE trip_members ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0' },
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { membership } from '../util/access.js';
|
||||
import { ENTRY_COLUMNS, attachParticipants } from '../util/entrySerialize.js';
|
||||
import { validateSegments } from '../util/segments.js';
|
||||
import { validateRental } from '../util/rental.js';
|
||||
import { autoTransportTitle } from '../util/autoTransport.js';
|
||||
|
||||
const ENTRY_TYPES = new Set(['flight', 'transport', 'activity', 'rental', 'stay', 'note']);
|
||||
const TRANSPORT_MODES = new Set(['train', 'bus', 'ferry', 'taxi', 'drive', 'other']);
|
||||
@@ -234,8 +235,8 @@ export default function entriesRoutes(db) {
|
||||
AND date >= ? AND date <= ? LIMIT 1`
|
||||
);
|
||||
const insertAutoTransport = db.prepare(
|
||||
`INSERT INTO entries (trip_id, date, type, title, details, sort_order, split_mode)
|
||||
VALUES (?, ?, 'transport', ?, '', 0, 'equal')`
|
||||
`INSERT INTO entries (trip_id, date, type, title, details, sort_order, split_mode, auto_ref)
|
||||
VALUES (?, ?, 'transport', ?, '', 0, 'equal', ?)`
|
||||
);
|
||||
|
||||
const memberIdSet = (tripId) => new Set(getMemberIds.all(tripId).map((r) => r.user_id));
|
||||
@@ -247,12 +248,6 @@ export default function entriesRoutes(db) {
|
||||
}
|
||||
}
|
||||
|
||||
// Short name for an auto-transport title: first comma-segment of the stay's
|
||||
// location_name, falling back to its title.
|
||||
function shortName(stay) {
|
||||
return (stay.location_name || stay.title).split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Auto-create a transport entry bridging `earlier` -> `later` (both stay
|
||||
// rows) unless a transport/flight already covers the gap, or the window is
|
||||
// inverted (overlapping stays).
|
||||
@@ -261,8 +256,12 @@ export default function entriesRoutes(db) {
|
||||
const windowEnd = later.date;
|
||||
if (windowStart > windowEnd) return;
|
||||
if (existsTransportOrFlightInWindow.get(tripId, windowStart, windowEnd)) return;
|
||||
const title = `${shortName(earlier)} → ${shortName(later)}`;
|
||||
insertAutoTransport.run(tripId, later.date, title);
|
||||
insertAutoTransport.run(
|
||||
tripId,
|
||||
later.date,
|
||||
autoTransportTitle(earlier, later),
|
||||
JSON.stringify({ from: earlier.id, to: later.id })
|
||||
);
|
||||
}
|
||||
|
||||
// Fires once, on stay creation: bridges the new stay to its nearest
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
parseRental,
|
||||
} from '../util/entrySerialize.js';
|
||||
import { generateJoinCode, formatJoinCode, normalizeCode } from '../util/token.js';
|
||||
import { regenerateAutoTransports } from '../util/autoTransport.js';
|
||||
|
||||
const MAX_RANGE_DAYS = 365;
|
||||
const MIN_LEG_KM = 0.05;
|
||||
@@ -295,6 +296,15 @@ export default function tripsRoutes(db) {
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// POST /api/trips/:id/transports/regenerate (any member) — reconcile
|
||||
// auto-created transports against the trip's current stays.
|
||||
router.post('/:id/transports/regenerate', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
const result = db.transaction(() => regenerateAutoTransports(db, ctx.tripId))();
|
||||
res.status(200).json(result);
|
||||
});
|
||||
|
||||
// GET /api/trips/:id/route (computed legs + summary)
|
||||
router.get('/:id/route', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Auto-created transport entries bridging chronologically adjacent stays.
|
||||
// Shared between entries.js (creation-time auto-transport, fires once when a
|
||||
// stay is POSTed) and trips.js (the regenerate endpoint, which reconciles
|
||||
// auto transports after stays have been moved/added/removed). See
|
||||
// "Auto-transport between stays" / "Regenerating auto-transports" in
|
||||
// docs/API.md.
|
||||
|
||||
// Short name for an auto-transport title: first comma-segment of the stay's
|
||||
// location_name, falling back to its title.
|
||||
export function shortName(stay) {
|
||||
return (stay.location_name || stay.title).split(',')[0].trim();
|
||||
}
|
||||
|
||||
export function autoTransportTitle(earlier, later) {
|
||||
return `${shortName(earlier)} → ${shortName(later)}`;
|
||||
}
|
||||
|
||||
// Reconcile auto-created transport entries against the trip's current stays.
|
||||
// Returns { created, updated, deleted }. Caller is responsible for wrapping
|
||||
// this in a db.transaction.
|
||||
export function regenerateAutoTransports(db, tripId) {
|
||||
const stays = db
|
||||
.prepare(
|
||||
`SELECT id, date, end_date, location_name, title FROM entries
|
||||
WHERE trip_id = ? AND type = 'stay' ORDER BY date, id`
|
||||
)
|
||||
.all(tripId);
|
||||
|
||||
// Manual coverage: any flight, or any transport entry with no auto_ref
|
||||
// (a human-created transport), blocks auto-transport for a window.
|
||||
const blockers = db
|
||||
.prepare(
|
||||
`SELECT date FROM entries WHERE trip_id = ?
|
||||
AND (type = 'flight' OR (type = 'transport' AND auto_ref IS NULL))`
|
||||
)
|
||||
.all(tripId);
|
||||
|
||||
const desired = [];
|
||||
for (let i = 0; i < stays.length - 1; i++) {
|
||||
const earlier = stays[i];
|
||||
const later = stays[i + 1];
|
||||
const windowStart = earlier.end_date || earlier.date;
|
||||
const windowEnd = later.date;
|
||||
if (windowStart > windowEnd) continue; // inverted/overlapping window
|
||||
const blocked = blockers.some((b) => b.date >= windowStart && b.date <= windowEnd);
|
||||
if (blocked) continue;
|
||||
desired.push({ from: earlier.id, to: later.id, earlier, later });
|
||||
}
|
||||
|
||||
// Existing auto transports (auto_ref non-null and parseable); unparseable
|
||||
// auto_ref is treated as manual (never touched, per predating-databases rule).
|
||||
const existingRows = db
|
||||
.prepare(
|
||||
`SELECT id, auto_ref FROM entries WHERE trip_id = ? AND type = 'transport' AND auto_ref IS NOT NULL`
|
||||
)
|
||||
.all(tripId);
|
||||
const existingAuto = [];
|
||||
for (const row of existingRows) {
|
||||
let ref;
|
||||
try {
|
||||
ref = JSON.parse(row.auto_ref);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!ref || typeof ref !== 'object' || !Number.isInteger(ref.from) || !Number.isInteger(ref.to)) {
|
||||
continue;
|
||||
}
|
||||
existingAuto.push({ id: row.id, from: ref.from, to: ref.to });
|
||||
}
|
||||
|
||||
const updateStmt = db.prepare('UPDATE entries SET date = ?, title = ? WHERE id = ?');
|
||||
const deleteStmt = db.prepare('DELETE FROM entries WHERE id = ?');
|
||||
const deleteParticipantsStmt = db.prepare('DELETE FROM entry_participants WHERE entry_id = ?');
|
||||
const insertStmt = db.prepare(
|
||||
`INSERT INTO entries (trip_id, date, type, title, details, sort_order, split_mode, auto_ref)
|
||||
VALUES (?, ?, 'transport', ?, '', 0, 'equal', ?)`
|
||||
);
|
||||
|
||||
function removeEntry(id) {
|
||||
deleteParticipantsStmt.run(id);
|
||||
deleteStmt.run(id);
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
const matchedIds = new Set();
|
||||
|
||||
for (const pair of desired) {
|
||||
const matches = existingAuto
|
||||
.filter((e) => e.from === pair.from && e.to === pair.to)
|
||||
.sort((a, b) => a.id - b.id);
|
||||
if (matches.length > 0) {
|
||||
const [keep, ...dupes] = matches;
|
||||
matchedIds.add(keep.id);
|
||||
updateStmt.run(pair.later.date, autoTransportTitle(pair.earlier, pair.later), keep.id);
|
||||
updated++;
|
||||
for (const dupe of dupes) {
|
||||
matchedIds.add(dupe.id);
|
||||
removeEntry(dupe.id);
|
||||
deleted++;
|
||||
}
|
||||
} else {
|
||||
insertStmt.run(
|
||||
tripId,
|
||||
pair.later.date,
|
||||
autoTransportTitle(pair.earlier, pair.later),
|
||||
JSON.stringify({ from: pair.from, to: pair.to })
|
||||
);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of existingAuto) {
|
||||
if (!matchedIds.has(e.id)) {
|
||||
removeEntry(e.id);
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
return { created, updated, deleted };
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export const ENTRY_COLUMNS =
|
||||
'id, trip_id, date, end_date, type, title, details, start_time, end_time, ' +
|
||||
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental, transport_mode';
|
||||
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental, transport_mode, auto_ref';
|
||||
|
||||
// Parse the stored segments JSON text into an array, or null if absent/invalid.
|
||||
export function parseSegments(value) {
|
||||
@@ -28,6 +28,19 @@ export function parseRental(value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the stored auto_ref JSON text into a {from,to} object, or null if
|
||||
// absent/invalid. Non-null marks an entry as auto-created (see "Auto-transport
|
||||
// between stays" in docs/API.md).
|
||||
export function parseAutoRef(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function attachParticipants(db, row) {
|
||||
if (!row) return row;
|
||||
const rows = db
|
||||
@@ -36,6 +49,7 @@ export function attachParticipants(db, row) {
|
||||
row.participants = rows.map((r) => r.user_id);
|
||||
row.segments = parseSegments(row.segments);
|
||||
row.rental = parseRental(row.rental);
|
||||
row.auto_ref = parseAutoRef(row.auto_ref);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -325,7 +325,7 @@ test('entry CRUD and full-row shape', async () => {
|
||||
const entry = create.body.entry;
|
||||
assert.deepEqual(
|
||||
Object.keys(entry).sort(),
|
||||
['date', 'end_date', 'details', 'end_time', 'id', 'lat', 'lng', 'location_name', 'paid_by', 'participants', 'price', 'rental', 'segments', 'sort_order', 'split_mode', 'start_time', 'title', 'transport_mode', 'trip_id', 'type'].sort()
|
||||
['auto_ref', 'date', 'end_date', 'details', 'end_time', 'id', 'lat', 'lng', 'location_name', 'paid_by', 'participants', 'price', 'rental', 'segments', 'sort_order', 'split_mode', 'start_time', 'title', 'transport_mode', 'trip_id', 'type'].sort()
|
||||
);
|
||||
assert.equal(entry.end_date, null);
|
||||
assert.equal(entry.details, '');
|
||||
|
||||
@@ -178,6 +178,8 @@ test('auto-transport: creates a bridging entry between two consecutive stays', a
|
||||
assert.equal(transports[0].title, 'Venice → Berlin');
|
||||
assert.equal(transports[0].date, berlin.date);
|
||||
assert.equal(transports[0].sort_order, 0);
|
||||
assert.ok(transports[0].auto_ref);
|
||||
assert.equal(transports[0].auto_ref.to, berlin.id);
|
||||
|
||||
const route = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(route.body.summary.transports, 1);
|
||||
@@ -275,3 +277,158 @@ test('auto-transport: deleting it is not resurrected by an unrelated later stay'
|
||||
list = (await agent.get(base)).body.entries;
|
||||
assert.ok(!list.some((e) => e.type === 'transport' && e.title === 'Venice → Berlin'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regenerating auto-transports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('auto_ref cannot be set via POST/PATCH bodies', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
const posted = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'transport', title: 'x', auto_ref: { from: 1, to: 2 },
|
||||
});
|
||||
assert.equal(posted.status, 201);
|
||||
assert.equal(posted.body.entry.auto_ref, null);
|
||||
|
||||
const patched = await agent.patch(`/api/entries/${posted.body.entry.id}`).send({
|
||||
auto_ref: { from: 1, to: 2 },
|
||||
});
|
||||
assert.equal(patched.status, 200);
|
||||
assert.equal(patched.body.entry.auto_ref, null);
|
||||
});
|
||||
|
||||
test('regenerate: non-member gets 404', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const { agent: other } = await createAccount();
|
||||
|
||||
const res = await other.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
test('regenerate: re-dates and re-titles the auto transport after a stay moves, preserving transport_mode', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
await agent.post(base).send({
|
||||
date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'V', location_name: 'Venice',
|
||||
});
|
||||
const berlin = (await agent.post(base).send({
|
||||
date: '2026-08-04', type: 'stay', title: 'B', location_name: 'Berlin',
|
||||
})).body.entry;
|
||||
|
||||
let list = (await agent.get(base)).body.entries;
|
||||
const auto = list.find((e) => e.type === 'transport');
|
||||
assert.ok(auto);
|
||||
await agent.patch(`/api/entries/${auto.id}`).send({ transport_mode: 'train' });
|
||||
|
||||
// Move Berlin later; still the second stay -> same (from,to) pair.
|
||||
await agent.patch(`/api/entries/${berlin.id}`).send({ date: '2026-08-20' });
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { created: 0, updated: 1, deleted: 0 });
|
||||
|
||||
list = (await agent.get(base)).body.entries;
|
||||
const updated = list.find((e) => e.type === 'transport');
|
||||
assert.equal(updated.id, auto.id);
|
||||
assert.equal(updated.date, '2026-08-20');
|
||||
assert.equal(updated.title, 'Venice → Berlin');
|
||||
assert.equal(updated.transport_mode, 'train');
|
||||
});
|
||||
|
||||
test('regenerate: deletes an auto transport orphaned by a deleted stay', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
await agent.post(base).send({
|
||||
date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'V', location_name: 'Venice',
|
||||
});
|
||||
const berlin = (await agent.post(base).send({
|
||||
date: '2026-08-04', type: 'stay', title: 'B', location_name: 'Berlin',
|
||||
})).body.entry;
|
||||
|
||||
await agent.delete(`/api/entries/${berlin.id}`);
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { created: 0, updated: 0, deleted: 1 });
|
||||
|
||||
const list = (await agent.get(base)).body.entries;
|
||||
assert.equal(list.filter((e) => e.type === 'transport').length, 0);
|
||||
});
|
||||
|
||||
test('regenerate: recreates a missing auto transport', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
await agent.post(base).send({
|
||||
date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'V', location_name: 'Venice',
|
||||
});
|
||||
await agent.post(base).send({
|
||||
date: '2026-08-04', type: 'stay', title: 'B', location_name: 'Berlin',
|
||||
});
|
||||
|
||||
let list = (await agent.get(base)).body.entries;
|
||||
const auto = list.find((e) => e.type === 'transport');
|
||||
await agent.delete(`/api/entries/${auto.id}`);
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { created: 1, updated: 0, deleted: 0 });
|
||||
|
||||
list = (await agent.get(base)).body.entries;
|
||||
const recreated = list.find((e) => e.type === 'transport');
|
||||
assert.ok(recreated);
|
||||
assert.equal(recreated.title, 'Venice → Berlin');
|
||||
assert.ok(recreated.auto_ref);
|
||||
});
|
||||
|
||||
test('regenerate: respects manual transport coverage — creates nothing and does not delete it', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
await agent.post(base).send({
|
||||
date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'V', location_name: 'Venice',
|
||||
});
|
||||
const manual = (await agent.post(base).send({
|
||||
date: '2026-08-03', type: 'transport', title: 'Manual train',
|
||||
})).body.entry;
|
||||
await agent.post(base).send({
|
||||
date: '2026-08-04', type: 'stay', title: 'B', location_name: 'Berlin',
|
||||
});
|
||||
|
||||
// No auto transport was created at stay-creation time (manual already covers the window).
|
||||
let list = (await agent.get(base)).body.entries;
|
||||
assert.equal(list.filter((e) => e.type === 'transport').length, 1);
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { created: 0, updated: 0, deleted: 0 });
|
||||
|
||||
list = (await agent.get(base)).body.entries;
|
||||
const transports = list.filter((e) => e.type === 'transport');
|
||||
assert.equal(transports.length, 1);
|
||||
assert.equal(transports[0].id, manual.id);
|
||||
assert.equal(transports[0].auto_ref, null);
|
||||
});
|
||||
|
||||
test('regenerate: overlapping stays (inverted window) produce no pair and do not crash', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
await agent.post(base).send({ date: '2026-08-01', end_date: '2026-08-10', type: 'stay', title: 'Region' });
|
||||
await agent.post(base).send({ date: '2026-08-05', type: 'stay', title: 'City inside' });
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { created: 0, updated: 0, deleted: 0 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user