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)
|
segments TEXT, -- JSON array, flight entries only (see below)
|
||||||
rental TEXT, -- JSON object, rental entries only (see below)
|
rental TEXT, -- JSON object, rental entries only (see below)
|
||||||
transport_mode TEXT, -- transport 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)
|
created_at TEXT DEFAULT current_timestamp)
|
||||||
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
|
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
|
||||||
PRIMARY KEY (entry_id, user_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).
|
`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
|
### 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) |
|
| `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) |
|
| `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)
|
### 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 { 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; }
|
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||||
.section-head { margin-bottom: 0.8rem; }
|
.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; }
|
.form-slot:empty { display: none; }
|
||||||
|
|
||||||
/* ---------- Auth ---------- */
|
/* ---------- Auth ---------- */
|
||||||
@@ -272,6 +273,7 @@ textarea.input { resize: vertical; }
|
|||||||
.cal-band-draggable:active { cursor: grabbing; }
|
.cal-band-draggable:active { cursor: grabbing; }
|
||||||
.cal-band.dragging { opacity: 0.4; }
|
.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-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 (map + summary) ---------- */
|
||||||
.detail-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 1.2rem; align-items: start; }
|
.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}`),
|
removeMember: (id, userId) => del(`/api/trips/${id}/members/${userId}`),
|
||||||
route: (id) => get(`/api/trips/${id}/route`),
|
route: (id) => get(`/api/trips/${id}/route`),
|
||||||
costs: (id) => get(`/api/trips/${id}/costs`),
|
costs: (id) => get(`/api/trips/${id}/costs`),
|
||||||
|
regenerateTransports: (id) => post(`/api/trips/${id}/transports/regenerate`, {}),
|
||||||
},
|
},
|
||||||
entries: {
|
entries: {
|
||||||
create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload),
|
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
|
// 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
|
// 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.
|
// 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 {
|
import {
|
||||||
ENTRY_TYPES,
|
ENTRY_TYPES,
|
||||||
typeInfo,
|
typeInfo,
|
||||||
@@ -68,10 +69,15 @@ export function renderCalendar(tctx) {
|
|||||||
{ class: 'card calendar-section' },
|
{ class: 'card calendar-section' },
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'section-head' },
|
{ class: 'section-head cal-section-head' },
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{},
|
||||||
el('h2', {}, 'Calendar'),
|
el('h2', {}, 'Calendar'),
|
||||||
el('p', { class: 'muted' }, 'Click a day to add or edit entries.'),
|
el('p', { class: 'muted' }, 'Click a day to add or edit entries.'),
|
||||||
),
|
),
|
||||||
|
syncTransportsButton(tctx),
|
||||||
|
),
|
||||||
legend(),
|
legend(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,7 +103,7 @@ export function renderCalendar(tctx) {
|
|||||||
for (const week of weeks) {
|
for (const week of weeks) {
|
||||||
// Each week is a stay-bands strip (all-day bars) above a row of day cells.
|
// Each week is a stay-bands strip (all-day bars) above a row of day cells.
|
||||||
const weekEl = el('div', { class: 'cal-week' });
|
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);
|
if (bands) weekEl.appendChild(bands);
|
||||||
const daysRow = el('div', { class: 'cal-week-days' });
|
const daysRow = el('div', { class: 'cal-week-days' });
|
||||||
for (const date of week) {
|
for (const date of week) {
|
||||||
@@ -111,12 +117,39 @@ export function renderCalendar(tctx) {
|
|||||||
// which bubbles up here — sweep any lingering drop highlight.
|
// which bubbles up here — sweep any lingering drop highlight.
|
||||||
cal.addEventListener('dragend', () => {
|
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-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);
|
section.appendChild(cal);
|
||||||
return section;
|
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) {
|
function dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, entriesById, tctx, currency) {
|
||||||
const key = ymd(date);
|
const key = ymd(date);
|
||||||
const inRange = date >= rangeStart && date <= rangeEnd;
|
const inRange = date >= rangeStart && date <= rangeEnd;
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export function openDayEditor(tctx, date) {
|
|||||||
time ? ` · ${time}` : '',
|
time ? ` · ${time}` : '',
|
||||||
flight ? ` · ✈️ ${flightChain(entry.segments)}` : '',
|
flight ? ` · ✈️ ${flightChain(entry.segments)}` : '',
|
||||||
!flight && entry.location_name ? ` · 📍 ${entry.location_name}` : '',
|
!flight && entry.location_name ? ` · 📍 ${entry.location_name}` : '',
|
||||||
|
entry.auto_ref ? ' · ↻ auto' : '',
|
||||||
),
|
),
|
||||||
isMultiDay(entry) ? el('div', { class: 'entry-span muted' }, spanText(entry)) : null,
|
isMultiDay(entry) ? el('div', { class: 'entry-span muted' }, spanText(entry)) : null,
|
||||||
flight ? renderSegmentLines(entry.segments) : null,
|
flight ? renderSegmentLines(entry.segments) : null,
|
||||||
|
|||||||
Vendored
+49
-16
@@ -46,10 +46,29 @@ export function makeBandDraggable(node, entry) {
|
|||||||
node.addEventListener('dragend', () => node.classList.remove('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
|
// Shared move-commit: PATCHes the dragged entry to `targetDate` with the given
|
||||||
// `targetDate` with sort_order = `targetCount` (appended after that day's
|
// `sortOrder`, shifting `end_date` by the same day-delta in the SAME patch (the
|
||||||
// entries); an entry with an end_date has it shifted by the same day-delta in
|
// backend rejects date > stored end_date otherwise). No-ops when dropped back
|
||||||
// the SAME patch (the backend rejects date > stored end_date otherwise).
|
// 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) {
|
export function makeDayDropTarget(cell, targetDate, targetCount, entriesById, tctx) {
|
||||||
cell.addEventListener('dragover', (e) => {
|
cell.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -64,19 +83,33 @@ export function makeDayDropTarget(cell, targetDate, targetCount, entriesById, tc
|
|||||||
cell.classList.remove('cal-day-drop');
|
cell.classList.remove('cal-day-drop');
|
||||||
const id = Number(e.dataTransfer.getData(DND_MIME));
|
const id = Number(e.dataTransfer.getData(DND_MIME));
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const entry = entriesById.get(id);
|
await commitEntryMove(id, targetDate, targetCount, entriesById, tctx);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// partitioning) so a stay keeps the same vertical position across weeks.
|
||||||
import { el } from '../dom.js';
|
import { el } from '../dom.js';
|
||||||
import { parseYMD, daysBetweenInclusive, typeInfo, stayShortName, entrySpanDays } from '../format.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
|
// 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
|
// 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.
|
// 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 weekStart = weekYmd[0];
|
||||||
const weekEnd = weekYmd[6];
|
const weekEnd = weekYmd[6];
|
||||||
const inWeek = layout.stays.filter((s) => s.start <= weekEnd && s.end >= weekStart);
|
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);
|
makeBandDraggable(band, s.entry);
|
||||||
strip.appendChild(band);
|
strip.appendChild(band);
|
||||||
}
|
}
|
||||||
|
makeWeekBandsDropTarget(strip, weekYmd, entriesById, tctx);
|
||||||
return strip;
|
return strip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||||||
segments TEXT,
|
segments TEXT,
|
||||||
rental TEXT,
|
rental TEXT,
|
||||||
transport_mode TEXT,
|
transport_mode TEXT,
|
||||||
|
auto_ref TEXT,
|
||||||
created_at TEXT DEFAULT current_timestamp
|
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: '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: '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: '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' },
|
{ 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 { ENTRY_COLUMNS, attachParticipants } from '../util/entrySerialize.js';
|
||||||
import { validateSegments } from '../util/segments.js';
|
import { validateSegments } from '../util/segments.js';
|
||||||
import { validateRental } from '../util/rental.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 ENTRY_TYPES = new Set(['flight', 'transport', 'activity', 'rental', 'stay', 'note']);
|
||||||
const TRANSPORT_MODES = new Set(['train', 'bus', 'ferry', 'taxi', 'drive', 'other']);
|
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`
|
AND date >= ? AND date <= ? LIMIT 1`
|
||||||
);
|
);
|
||||||
const insertAutoTransport = db.prepare(
|
const insertAutoTransport = db.prepare(
|
||||||
`INSERT INTO entries (trip_id, date, type, title, details, sort_order, split_mode)
|
`INSERT INTO entries (trip_id, date, type, title, details, sort_order, split_mode, auto_ref)
|
||||||
VALUES (?, ?, 'transport', ?, '', 0, 'equal')`
|
VALUES (?, ?, 'transport', ?, '', 0, 'equal', ?)`
|
||||||
);
|
);
|
||||||
|
|
||||||
const memberIdSet = (tripId) => new Set(getMemberIds.all(tripId).map((r) => r.user_id));
|
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
|
// Auto-create a transport entry bridging `earlier` -> `later` (both stay
|
||||||
// rows) unless a transport/flight already covers the gap, or the window is
|
// rows) unless a transport/flight already covers the gap, or the window is
|
||||||
// inverted (overlapping stays).
|
// inverted (overlapping stays).
|
||||||
@@ -261,8 +256,12 @@ export default function entriesRoutes(db) {
|
|||||||
const windowEnd = later.date;
|
const windowEnd = later.date;
|
||||||
if (windowStart > windowEnd) return;
|
if (windowStart > windowEnd) return;
|
||||||
if (existsTransportOrFlightInWindow.get(tripId, windowStart, windowEnd)) return;
|
if (existsTransportOrFlightInWindow.get(tripId, windowStart, windowEnd)) return;
|
||||||
const title = `${shortName(earlier)} → ${shortName(later)}`;
|
insertAutoTransport.run(
|
||||||
insertAutoTransport.run(tripId, later.date, title);
|
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
|
// Fires once, on stay creation: bridges the new stay to its nearest
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
parseRental,
|
parseRental,
|
||||||
} from '../util/entrySerialize.js';
|
} from '../util/entrySerialize.js';
|
||||||
import { generateJoinCode, formatJoinCode, normalizeCode } from '../util/token.js';
|
import { generateJoinCode, formatJoinCode, normalizeCode } from '../util/token.js';
|
||||||
|
import { regenerateAutoTransports } from '../util/autoTransport.js';
|
||||||
|
|
||||||
const MAX_RANGE_DAYS = 365;
|
const MAX_RANGE_DAYS = 365;
|
||||||
const MIN_LEG_KM = 0.05;
|
const MIN_LEG_KM = 0.05;
|
||||||
@@ -295,6 +296,15 @@ export default function tripsRoutes(db) {
|
|||||||
res.status(204).end();
|
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)
|
// GET /api/trips/:id/route (computed legs + summary)
|
||||||
router.get('/:id/route', (req, res) => {
|
router.get('/:id/route', (req, res) => {
|
||||||
const ctx = requireMember(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 =
|
export const ENTRY_COLUMNS =
|
||||||
'id, trip_id, date, end_date, type, title, details, start_time, end_time, ' +
|
'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.
|
// Parse the stored segments JSON text into an array, or null if absent/invalid.
|
||||||
export function parseSegments(value) {
|
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) {
|
export function attachParticipants(db, row) {
|
||||||
if (!row) return row;
|
if (!row) return row;
|
||||||
const rows = db
|
const rows = db
|
||||||
@@ -36,6 +49,7 @@ export function attachParticipants(db, row) {
|
|||||||
row.participants = rows.map((r) => r.user_id);
|
row.participants = rows.map((r) => r.user_id);
|
||||||
row.segments = parseSegments(row.segments);
|
row.segments = parseSegments(row.segments);
|
||||||
row.rental = parseRental(row.rental);
|
row.rental = parseRental(row.rental);
|
||||||
|
row.auto_ref = parseAutoRef(row.auto_ref);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -325,7 +325,7 @@ test('entry CRUD and full-row shape', async () => {
|
|||||||
const entry = create.body.entry;
|
const entry = create.body.entry;
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.keys(entry).sort(),
|
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.end_date, null);
|
||||||
assert.equal(entry.details, '');
|
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].title, 'Venice → Berlin');
|
||||||
assert.equal(transports[0].date, berlin.date);
|
assert.equal(transports[0].date, berlin.date);
|
||||||
assert.equal(transports[0].sort_order, 0);
|
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`);
|
const route = await agent.get(`/api/trips/${trip.id}/route`);
|
||||||
assert.equal(route.body.summary.transports, 1);
|
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;
|
list = (await agent.get(base)).body.entries;
|
||||||
assert.ok(!list.some((e) => e.type === 'transport' && e.title === 'Venice → Berlin'));
|
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