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:
2026-07-19 17:47:21 +07:00
parent b066e7b885
commit d393e16ae1
17 changed files with 469 additions and 62 deletions
+19 -7
View File
@@ -270,7 +270,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', 'trip_id', 'type'].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()
);
assert.equal(entry.end_date, null);
assert.equal(entry.details, '');
@@ -321,6 +321,17 @@ test('entry validation: type, title, lat/lng pairing and ranges', async () => {
assert.equal(ok.status, 201);
});
test('legacy entry types (hotel, travel, immigration) are rejected', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
for (const type of ['hotel', 'travel', 'immigration']) {
const res = await agent.post(base).send({ date: '2026-08-01', type, title: 'x' });
assert.equal(res.status, 400, `expected ${type} to be rejected`);
}
});
test('non-member cannot add or view entries', async () => {
const owner = await createAccount();
const guest = await createAccount();
@@ -348,13 +359,13 @@ test('route computes legs, totalKm and summary counts', async () => {
});
// Chiang Mai
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'hotel', title: 'Hotel CNX', sort_order: 1,
date: '2026-08-01', type: 'stay', title: 'Hotel CNX', sort_order: 1,
location_name: 'Chiang Mai', lat: 18.7883, lng: 98.9853,
});
// A second flight + hotel + activities + a travel leg (no coords) to exercise counts
// A second flight + stay + activities + a transport leg (no coords) to exercise counts
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'flight', title: 'F2' });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'hotel', title: 'H2' });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'travel', title: 'Drive' });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'stay', title: 'H2' });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'transport', title: 'Drive' });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-03', type: 'activity', title: 'A1' });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-03', type: 'activity', title: 'A2' });
@@ -371,8 +382,9 @@ test('route computes legs, totalKm and summary counts', async () => {
assert.equal(s.days, 10);
assert.equal(s.nights, 9);
assert.equal(s.flights, 2);
assert.equal(s.hotels, 2);
assert.equal(s.travelLegs, 1);
assert.equal(s.transports, 1);
assert.equal(s.hotels, undefined);
assert.equal(s.travelLegs, undefined);
assert.equal(s.activities, 2);
assert.deepEqual(s.locations, ['Bangkok', 'Chiang Mai']);
});
+8 -8
View File
@@ -82,12 +82,12 @@ test('costs: equal split with payer produces net balances and a settlement', asy
const ben = await createAccount();
const trip = await costTrip(anna, [ben]);
// anna pays 950 (hotel), ben pays 500 (travel); both split equally between the two.
// anna pays 950 (stay), ben pays 500 (transport); both split equally between the two.
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'hotel', title: 'Hotel', price: 950, paid_by: anna.user.id,
date: '2026-08-01', type: 'stay', title: 'Hotel', price: 950, paid_by: anna.user.id,
});
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-02', type: 'travel', title: 'Van', price: 500, paid_by: ben.user.id,
date: '2026-08-02', type: 'transport', title: 'Van', price: 500, paid_by: ben.user.id,
});
const res = await anna.agent.get(`/api/trips/${trip.id}/costs`);
@@ -95,7 +95,7 @@ test('costs: equal split with payer produces net balances and a settlement', asy
const c = res.body;
assert.equal(c.currency, 'USD');
assert.equal(c.totalCost, 1450);
assert.deepEqual(c.byType, { hotel: 950, travel: 500 });
assert.deepEqual(c.byType, { stay: 950, transport: 500 });
assert.equal(c.unassigned, 0);
const a = findUser(c, anna.user.id);
@@ -159,7 +159,7 @@ test('costs: participants subset only splits among the chosen members', async ()
// 90 split equally between anna & ben only (carol excluded), anna pays.
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'travel', title: 'Taxi', price: 90, paid_by: anna.user.id,
date: '2026-08-01', type: 'transport', title: 'Taxi', price: 90, paid_by: anna.user.id,
participants: [anna.user.id, ben.user.id],
});
@@ -179,13 +179,13 @@ test('costs: equal with no payer accumulates into unassigned', async () => {
const trip = await costTrip(anna, [ben]);
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'hotel', title: 'Hotel', price: 200, // paid_by omitted (null)
date: '2026-08-01', type: 'stay', title: 'Hotel', price: 200, // paid_by omitted (null)
});
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
assert.equal(c.totalCost, 200);
assert.equal(c.unassigned, 200);
assert.deepEqual(c.byType, { hotel: 200 });
assert.deepEqual(c.byType, { stay: 200 });
for (const u of c.perUser) {
assert.deepEqual([u.share, u.paid, u.net], [100, 0, -100]);
}
@@ -207,7 +207,7 @@ test('computeCosts: greedy settlement matches largest debtor with largest credit
const c = computeCosts({
currency: 'USD',
members,
entries: [{ type: 'hotel', price: 300, paid_by: 1, split_mode: 'equal', participants: [] }],
entries: [{ type: 'stay', price: 300, paid_by: 1, split_mode: 'equal', participants: [] }],
});
assert.equal(c.totalCost, 300);
assert.equal(findUser(c, 1).displayName, 'brave-otter');
+2 -2
View File
@@ -148,7 +148,7 @@ test('route: mixed ground transfers + air segments split kmAir / kmDriven', asyn
// Hotel near CNX (ground), then the CNX-BKK-DXB flight, then a hotel near DXB.
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'hotel', title: 'CNX Hotel', sort_order: 0,
date: '2026-08-01', type: 'stay', title: 'CNX Hotel', sort_order: 0,
location_name: 'Chiang Mai', lat: 18.79, lng: 98.99,
});
await agent.post(`/api/trips/${trip.id}/entries`).send({
@@ -159,7 +159,7 @@ test('route: mixed ground transfers + air segments split kmAir / kmDriven', asyn
],
});
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-02', type: 'hotel', title: 'DXB Hotel', sort_order: 2,
date: '2026-08-02', type: 'stay', title: 'DXB Hotel', sort_order: 2,
location_name: 'Dubai', lat: 25.2, lng: 55.27,
});
+1
View File
@@ -11,3 +11,4 @@ import './costs.test.js';
import './flights.test.js';
import './rental.test.js';
import './stays.test.js';
import './transport.test.js';
+1 -1
View File
@@ -68,7 +68,7 @@ test('rental: rejected on non-rental entries', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'hotel', title: 'Nope', rental: RENTAL,
date: '2026-08-01', type: 'activity', title: 'Nope', rental: RENTAL,
});
assert.equal(res.status, 400);
});
+277
View File
@@ -0,0 +1,277 @@
import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import request from 'supertest';
import { createApp } from '../src/server/app.js';
import { applyDataMigrations } from '../src/server/db.js';
let tmpDir;
let app;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-transport-'));
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
});
afterEach(() => {
try {
if (app.locals.db) app.locals.db.close();
} catch {
/* ignore */
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
async function createAccount() {
const agent = request.agent(app);
const res = await agent.post('/api/auth/account').send({});
assert.equal(res.status, 201);
return { agent, user: res.body.user };
}
async function makeTrip(agent) {
return (await agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-09-30' })).body.trip;
}
// ---------------------------------------------------------------------------
// transport_mode validation
// ---------------------------------------------------------------------------
test('transport_mode: accepted on transport entries, returned in JSON', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const ok = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'Bus', transport_mode: 'bus' });
assert.equal(ok.status, 201);
assert.equal(ok.body.entry.transport_mode, 'bus');
const absent = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x' });
assert.equal(absent.status, 201);
assert.equal(absent.body.entry.transport_mode, null);
});
test('transport_mode: rejected on non-transport entries', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const res = await agent.post(base).send({ date: '2026-08-01', type: 'activity', title: 'x', transport_mode: 'bus' });
assert.equal(res.status, 400);
});
test('transport_mode: enum enforced', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const res = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x', transport_mode: 'rocket' });
assert.equal(res.status, 400);
for (const mode of ['train', 'bus', 'ferry', 'taxi', 'drive', 'other']) {
const ok = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: mode, transport_mode: mode });
assert.equal(ok.status, 201, `expected ${mode} to be accepted`);
}
});
test('transport_mode: PATCH accepts a mode, null clears it, rejected when entry is not transport', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const entry = (await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x' })).body.entry;
const patched = await agent.patch(`/api/entries/${entry.id}`).send({ transport_mode: 'train' });
assert.equal(patched.status, 200);
assert.equal(patched.body.entry.transport_mode, 'train');
const cleared = await agent.patch(`/api/entries/${entry.id}`).send({ transport_mode: null });
assert.equal(cleared.status, 200);
assert.equal(cleared.body.entry.transport_mode, null);
const note = (await agent.post(base).send({ date: '2026-08-01', type: 'note', title: 'n' })).body.entry;
const rejected = await agent.patch(`/api/entries/${note.id}`).send({ transport_mode: 'taxi' });
assert.equal(rejected.status, 400);
});
test('route stops include transport_mode (entry value, or null for airport stops)', 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', type: 'transport', title: 'Bus to X', transport_mode: 'bus',
location_name: 'Somewhere', lat: 10, lng: 20,
});
await agent.post(base).send({
date: '2026-08-02', type: 'flight', title: 'CNX-BKK',
segments: [
{ from: { code: 'CNX', lat: 18.77, lng: 98.96 }, to: { code: 'BKK', lat: 13.68, lng: 100.75 } },
],
});
const res = await agent.get(`/api/trips/${trip.id}/route`);
const stops = res.body.stops;
const transportStop = stops.find((s) => s.type === 'transport');
assert.ok(transportStop);
assert.equal(transportStop.transport_mode, 'bus');
const airportStops = stops.filter((s) => s.kind === 'airport');
assert.ok(airportStops.length > 0);
assert.ok(airportStops.every((s) => s.transport_mode === null));
});
// ---------------------------------------------------------------------------
// Data migrations (legacy types -> new types)
// ---------------------------------------------------------------------------
test('data migrations: hotel/travel/immigration convert to stay/transport/activity', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const db = app.locals.db;
const insertLegacy = db.prepare(
`INSERT INTO entries (trip_id, date, type, title, details, sort_order, split_mode)
VALUES (?, ?, ?, ?, '', 0, 'equal')`
);
insertLegacy.run(trip.id, '2026-08-01', 'hotel', 'Old Hotel');
insertLegacy.run(trip.id, '2026-08-02', 'travel', 'Old Travel');
insertLegacy.run(trip.id, '2026-08-03', 'immigration', 'Border crossing');
applyDataMigrations(db);
const rows = db.prepare('SELECT type, title FROM entries WHERE trip_id = ? ORDER BY date').all(trip.id);
assert.deepEqual(rows, [
{ type: 'stay', title: 'Old Hotel' },
{ type: 'transport', title: 'Old Travel' },
{ type: 'activity', title: '🛂 Border crossing' },
]);
// Idempotent: no legacy rows remain, so re-running is a no-op.
applyDataMigrations(db);
const rowsAgain = db.prepare('SELECT type, title FROM entries WHERE trip_id = ? ORDER BY date').all(trip.id);
assert.deepEqual(rowsAgain, rows);
});
// ---------------------------------------------------------------------------
// Auto-transport between stays
// ---------------------------------------------------------------------------
test('auto-transport: creates a bridging entry between two consecutive stays', 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: 'Berlin',
})).body.entry;
const list = (await agent.get(base)).body.entries;
const transports = list.filter((e) => e.type === 'transport');
assert.equal(transports.length, 1);
assert.equal(transports[0].title, 'Venice → Berlin');
assert.equal(transports[0].date, berlin.date);
assert.equal(transports[0].sort_order, 0);
const route = await agent.get(`/api/trips/${trip.id}/route`);
assert.equal(route.body.summary.transports, 1);
});
test('auto-transport: title uses the first comma-segment of location_name', 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, Italy',
});
await agent.post(base).send({
date: '2026-08-04', type: 'stay', title: 'B', location_name: 'Berlin, Germany',
});
const list = (await agent.get(base)).body.entries;
const auto = list.find((e) => e.type === 'transport');
assert.equal(auto.title, 'Venice → Berlin');
});
test('auto-transport: skipped when a flight already covers the window', 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: 'Venice' });
await agent.post(base).send({ date: '2026-08-04', type: 'flight', title: 'VCE-TXL' });
await agent.post(base).send({ date: '2026-08-04', type: 'stay', title: 'Berlin' });
const list = (await agent.get(base)).body.entries;
assert.equal(list.filter((e) => e.type === 'transport').length, 0);
});
test('auto-transport: overlapping stays (inverted window) are skipped', 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 list = (await agent.get(base)).body.entries;
assert.equal(list.filter((e) => e.type === 'transport').length, 0);
});
test('auto-transport: a stay inserted between two existing stays bridges gaps not already covered', 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-08', end_date: '2026-08-10', type: 'stay', title: 'B', location_name: 'Berlin',
});
let list = (await agent.get(base)).body.entries;
assert.equal(list.filter((e) => e.type === 'transport').length, 1);
assert.ok(list.some((e) => e.type === 'transport' && e.title === 'Venice → Berlin' && e.date === '2026-08-08'));
// Prague, inserted in between: bridges Venice->Prague (gap not covered);
// Prague->Berlin is skipped because the existing Venice->Berlin transport
// (dated 2026-08-08) already falls inside that window.
await agent.post(base).send({
date: '2026-08-05', type: 'stay', title: 'P', location_name: 'Prague',
});
list = (await agent.get(base)).body.entries;
const transports = list.filter((e) => e.type === 'transport');
assert.equal(transports.length, 2);
assert.ok(transports.some((e) => e.title === 'Venice → Prague' && e.date === '2026-08-05'));
assert.ok(!transports.some((e) => e.title === 'Prague → Berlin'));
});
test('auto-transport: deleting it is not resurrected by an unrelated later 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: 'Venice', location_name: 'Venice' });
await agent.post(base).send({ date: '2026-08-04', type: 'stay', title: 'Berlin', location_name: 'Berlin' });
let list = (await agent.get(base)).body.entries;
const auto = list.find((e) => e.type === 'transport' && e.title === 'Venice → Berlin');
assert.ok(auto);
assert.equal((await agent.delete(`/api/entries/${auto.id}`)).status, 204);
// Unrelated stay earlier in time -- touches a different gap entirely, and
// auto-transport only fires on stay creation (never as a background pass).
await agent.post(base).send({ date: '2026-07-01', type: 'stay', title: 'Bangkok', location_name: 'Bangkok' });
list = (await agent.get(base)).body.entries;
assert.ok(!list.some((e) => e.type === 'transport' && e.title === 'Venice → Berlin'));
});