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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user