diff --git a/docs/API.md b/docs/API.md index 57caa3a..16c2c4b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 `""` (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 `""` (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": , "to": }` (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) diff --git a/public/css/styles.css b/public/css/styles.css index 771ed03..e5d1875 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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; } diff --git a/public/js/api.js b/public/js/api.js index badf1b9..4700464 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -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), diff --git a/public/js/views/calendar.js b/public/js/views/calendar.js index 63cddca..c8fec53 100644 --- a/public/js/views/calendar.js +++ b/public/js/views/calendar.js @@ -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; diff --git a/public/js/views/dayEditor.js b/public/js/views/dayEditor.js index 54086df..f154083 100644 --- a/public/js/views/dayEditor.js +++ b/public/js/views/dayEditor.js @@ -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, diff --git a/public/js/views/dragdrop.js b/public/js/views/dragdrop.js index 709e681..5670323 100644 --- a/public/js/views/dragdrop.js +++ b/public/js/views/dragdrop.js @@ -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); }); } diff --git a/public/js/views/stayBands.js b/public/js/views/stayBands.js index e0d178e..76622e4 100644 --- a/public/js/views/stayBands.js +++ b/public/js/views/stayBands.js @@ -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; } diff --git a/src/server/db.js b/src/server/db.js index 793cb06..ae781e9 100644 --- a/src/server/db.js +++ b/src/server/db.js @@ -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' }, ]; diff --git a/src/server/routes/entries.js b/src/server/routes/entries.js index 2b2bf95..d41c47b 100644 --- a/src/server/routes/entries.js +++ b/src/server/routes/entries.js @@ -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 diff --git a/src/server/routes/trips.js b/src/server/routes/trips.js index 0929fff..1d6f1a8 100644 --- a/src/server/routes/trips.js +++ b/src/server/routes/trips.js @@ -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); diff --git a/src/server/util/autoTransport.js b/src/server/util/autoTransport.js new file mode 100644 index 0000000..2898965 --- /dev/null +++ b/src/server/util/autoTransport.js @@ -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 }; +} diff --git a/src/server/util/entrySerialize.js b/src/server/util/entrySerialize.js index 8c05a35..10f910c 100644 --- a/src/server/util/entrySerialize.js +++ b/src/server/util/entrySerialize.js @@ -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; } diff --git a/tests/api.test.js b/tests/api.test.js index e42b89f..5093815 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -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, ''); diff --git a/tests/transport.test.js b/tests/transport.test.js index a090168..f6fb7eb 100644 --- a/tests/transport.test.js +++ b/tests/transport.test.js @@ -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 }); +});