Add transport sync button and allow dropping stays onto band-covered days

- Auto-created transports carry auto_ref {from,to} stay ids; new
  POST /api/trips/:id/transports/regenerate reconciles them against the
  current stay order (re-date/re-title kept bridges preserving mode and
  price, delete orphans, create missing) without touching manual
  transports or flight-covered gaps
- Sync transports button in the calendar header with result toast
- The week band strip is now a drop target resolving the day from the
  pointer position, so stays can be dropped onto spots covered by other
  stays; overlapping stays stack in band lanes
This commit is contained in:
2026-07-20 10:22:49 +07:00
parent 71a158aa51
commit 36c4e4f306
14 changed files with 425 additions and 38 deletions
+2
View File
@@ -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' },
];
+9 -10
View File
@@ -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
View File
@@ -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);
+122
View File
@@ -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 };
}
+15 -1
View File
@@ -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;
}