Rework entry types: merge hotel into stay, transport with modes, auto-transport
- Type set is now activity/stay/transport/flight/rental/note; hotel, travel and immigration are removed with idempotent startup data migrations (hotel->stay, travel->transport, immigration->activity with flag prefix) - Transport entries carry an optional mode (train/bus/ferry/taxi/drive/other) that drives the chip/map icon; route stops expose transport_mode - Creating a stay auto-creates a bridging transport to its neighbouring stays unless a transport/flight already covers the gap (one-shot) - Summary: transports count replaces hotels/travelLegs
This commit is contained in:
@@ -45,6 +45,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
||||
split_mode TEXT NOT NULL DEFAULT 'equal',
|
||||
segments TEXT,
|
||||
rental TEXT,
|
||||
transport_mode TEXT,
|
||||
created_at TEXT DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
@@ -70,6 +71,7 @@ const MIGRATIONS = [
|
||||
{ table: 'entries', column: 'segments', ddl: 'ALTER TABLE entries ADD COLUMN segments 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: 'transport_mode', ddl: 'ALTER TABLE entries ADD COLUMN transport_mode TEXT' },
|
||||
];
|
||||
|
||||
function applyMigrations(db) {
|
||||
@@ -79,6 +81,15 @@ function applyMigrations(db) {
|
||||
}
|
||||
}
|
||||
|
||||
// Data migrations for the 2026-07 entry-type rework (hotel/travel/immigration
|
||||
// removed in favour of stay/transport/activity). Naturally idempotent: after
|
||||
// the first run no rows of the legacy types remain, so re-running is a no-op.
|
||||
export function applyDataMigrations(db) {
|
||||
db.exec("UPDATE entries SET type = 'stay' WHERE type = 'hotel'");
|
||||
db.exec("UPDATE entries SET type = 'transport' WHERE type = 'travel'");
|
||||
db.exec("UPDATE entries SET type = 'activity', title = '🛂 ' || title WHERE type = 'immigration'");
|
||||
}
|
||||
|
||||
// Open (or create) the SQLite database at dbPath and ensure the schema exists.
|
||||
export function openDb(dbPath) {
|
||||
const db = new Database(dbPath);
|
||||
@@ -86,5 +97,6 @@ export function openDb(dbPath) {
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(SCHEMA);
|
||||
applyMigrations(db);
|
||||
applyDataMigrations(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,8 @@ import { ENTRY_COLUMNS, attachParticipants } from '../util/entrySerialize.js';
|
||||
import { validateSegments } from '../util/segments.js';
|
||||
import { validateRental } from '../util/rental.js';
|
||||
|
||||
const ENTRY_TYPES = new Set([
|
||||
'flight',
|
||||
'immigration',
|
||||
'travel',
|
||||
'hotel',
|
||||
'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 SPLIT_MODES = new Set(['equal', 'own', 'payer']);
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
@@ -199,6 +191,22 @@ function validateEntry(body, { partial, existing, memberIds }) {
|
||||
}
|
||||
}
|
||||
|
||||
// transport_mode: transport-only (null clears it).
|
||||
if (has('transport_mode')) {
|
||||
if (body.transport_mode === null) {
|
||||
fields.transport_mode = null;
|
||||
} else {
|
||||
const effType = 'type' in fields ? fields.type : existing?.type;
|
||||
if (effType !== 'transport') {
|
||||
return { error: 'transport_mode is only allowed on transport entries' };
|
||||
}
|
||||
if (!TRANSPORT_MODES.has(body.transport_mode)) {
|
||||
return { error: 'transport_mode must be one of train, bus, ferry, taxi, drive, other' };
|
||||
}
|
||||
fields.transport_mode = body.transport_mode;
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, participants, hasParticipants: has('participants') };
|
||||
}
|
||||
|
||||
@@ -217,6 +225,18 @@ export default function entriesRoutes(db) {
|
||||
'INSERT OR IGNORE INTO entry_participants (entry_id, user_id) VALUES (?, ?)'
|
||||
);
|
||||
const clearParticipants = db.prepare('DELETE FROM entry_participants WHERE entry_id = ?');
|
||||
const getStays = db.prepare(
|
||||
`SELECT id, date, end_date, location_name, title FROM entries
|
||||
WHERE trip_id = ? AND type = 'stay' ORDER BY date, id`
|
||||
);
|
||||
const existsTransportOrFlightInWindow = db.prepare(
|
||||
`SELECT 1 FROM entries WHERE trip_id = ? AND type IN ('transport', 'flight')
|
||||
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')`
|
||||
);
|
||||
|
||||
const memberIdSet = (tripId) => new Set(getMemberIds.all(tripId).map((r) => r.user_id));
|
||||
|
||||
@@ -227,6 +247,36 @@ 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).
|
||||
function maybeCreateAutoTransport(tripId, earlier, later) {
|
||||
const windowStart = earlier.end_date || earlier.date;
|
||||
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);
|
||||
}
|
||||
|
||||
// Fires once, on stay creation: bridges the new stay to its nearest
|
||||
// chronological neighbour stay(s) before/after with an auto-transport entry.
|
||||
function autoTransportForNewStay(tripId, newStayId) {
|
||||
const stays = getStays.all(tripId);
|
||||
const idx = stays.findIndex((s) => s.id === newStayId);
|
||||
if (idx === -1) return;
|
||||
const before = idx > 0 ? stays[idx - 1] : null;
|
||||
const after = idx < stays.length - 1 ? stays[idx + 1] : null;
|
||||
if (before) maybeCreateAutoTransport(tripId, before, stays[idx]);
|
||||
if (after) maybeCreateAutoTransport(tripId, stays[idx], after);
|
||||
}
|
||||
|
||||
// GET /api/trips/:id/entries
|
||||
router.get('/trips/:id/entries', (req, res) => {
|
||||
const tripId = Number(req.params.id);
|
||||
@@ -256,8 +306,9 @@ export default function entriesRoutes(db) {
|
||||
.prepare(
|
||||
`INSERT INTO entries
|
||||
(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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental,
|
||||
transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
tripId,
|
||||
@@ -276,11 +327,13 @@ export default function entriesRoutes(db) {
|
||||
f.paid_by ?? null,
|
||||
f.split_mode ?? 'equal',
|
||||
f.segments ?? null,
|
||||
f.rental ?? null
|
||||
f.rental ?? null,
|
||||
f.transport_mode ?? null
|
||||
);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
// participants provided as an array -> store rows; null/absent -> all members.
|
||||
if (Array.isArray(check.participants)) writeParticipants(id, check.participants);
|
||||
if (f.type === 'stay') autoTransportForNewStay(tripId, id);
|
||||
return attachParticipants(db, getEntry.get(id));
|
||||
})();
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ function buildStops(entries) {
|
||||
location_name: a.name,
|
||||
lat: a.lat,
|
||||
lng: a.lng,
|
||||
transport_mode: null,
|
||||
});
|
||||
}
|
||||
} else if (e.lat !== null && e.lng !== null) {
|
||||
@@ -69,6 +70,7 @@ function buildStops(entries) {
|
||||
location_name: e.location_name,
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
transport_mode: e.transport_mode ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -355,8 +357,7 @@ export default function tripsRoutes(db) {
|
||||
nights: Math.max(0, days - 1),
|
||||
flights: countType('flight'),
|
||||
flightSegments,
|
||||
hotels: countType('hotel'),
|
||||
travelLegs: countType('travel'),
|
||||
transports: countType('transport'),
|
||||
activities: countType('activity'),
|
||||
rentals: countType('rental'),
|
||||
stays: countType('stay'),
|
||||
|
||||
@@ -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';
|
||||
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental, transport_mode';
|
||||
|
||||
// Parse the stored segments JSON text into an array, or null if absent/invalid.
|
||||
export function parseSegments(value) {
|
||||
|
||||
Reference in New Issue
Block a user