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:
+17
-7
@@ -45,13 +45,23 @@ entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||
paid_by INTEGER REFERENCES users(id), -- who paid (null = unassigned)
|
||||
split_mode TEXT NOT NULL DEFAULT 'equal', -- 'equal' | 'own' | 'payer'
|
||||
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)
|
||||
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))
|
||||
-- no rows for an entry = "all trip members participate" (dynamic default)
|
||||
```
|
||||
|
||||
Entry `type` ∈ `flight | immigration | travel | hotel | activity | rental | stay | note`.
|
||||
Entry `type` ∈ `flight | transport | activity | rental | stay | note`.
|
||||
|
||||
**Legacy types** (removed 2026-07): `hotel`, `travel`, `immigration`. Idempotent data migrations run at startup: `hotel` rows become `stay`, `travel` rows become `transport`, `immigration` rows become `activity` with the title prefixed `🛂 `. POST/PATCH with a legacy type is a 400 (`invalid entry type`).
|
||||
|
||||
### Transport entries
|
||||
|
||||
`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 `"<from short name> → <to short name>"` (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.
|
||||
|
||||
### Multi-day entries & stays
|
||||
|
||||
@@ -155,11 +165,11 @@ User JSON shape everywhere: `{id, display_name}`.
|
||||
|
||||
| Method & path | Body | Response |
|
||||
|---|---|---|
|
||||
| `POST /api/trips/:id/entries` | `{date, end_date?, type, title, details?, start_time?, end_time?, location_name?, lat?, lng?, sort_order?, price?, paid_by?, split_mode?, participants?, segments?}` | `201 {entry}`; validates type enum, date format, end_date null or valid date ≥ date, title non-empty ≤200 chars; lat/lng must both be present or both absent, lat ∈ [-90,90], lng ∈ [-180,180]; price null or number ≥ 0; paid_by null or a trip member's user id; split_mode ∈ `equal\|own\|payer` (`payer` requires paid_by); participants null/[] (= all members) or array of trip-member user ids; segments per "Flight segments" above |
|
||||
| `POST /api/trips/:id/entries` | `{date, end_date?, type, title, details?, start_time?, end_time?, location_name?, lat?, lng?, sort_order?, price?, paid_by?, split_mode?, participants?, segments?}` | `201 {entry}`; validates type enum, date format, end_date null or valid date ≥ date, title non-empty ≤200 chars; lat/lng must both be present or both absent, lat ∈ [-90,90], lng ∈ [-180,180]; price null or number ≥ 0; paid_by null or a trip member's user id; split_mode ∈ `equal\|own\|payer` (`payer` requires paid_by); participants null/[] (= all members) or array of trip-member user ids; segments per "Flight segments" above; transport_mode per "Transport entries" above (POSTing a stay may auto-create a transport entry, see same section) |
|
||||
| `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}` 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}` where `participants` is an array of user ids (`[]` = all members) and `segments` is the parsed array or `null`.
|
||||
|
||||
### Route & summary (computed)
|
||||
|
||||
@@ -173,7 +183,7 @@ Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title,
|
||||
"totalKm": 587.3,
|
||||
"summary": {
|
||||
"days": 10, "nights": 9,
|
||||
"flights": 2, "flightSegments": 4, "hotels": 3, "travelLegs": 1, "activities": 4,
|
||||
"flights": 2, "flightSegments": 4, "transports": 1, "activities": 4,
|
||||
"rentals": 1, "includedKm": 1500,
|
||||
"stays": 2, "areas": [{"name": "Venice", "days": 3}, {"name": "Berlin", "days": 7}],
|
||||
"kmAir": 0, "kmDriven": 587.3,
|
||||
@@ -182,9 +192,9 @@ Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title,
|
||||
}
|
||||
```
|
||||
|
||||
- `stops` = entries having lat/lng, ordered by `(date, sort_order, id)`; flight entries with coordinate-bearing segments are expanded into airport stops instead (see "Flight segments" — `kind: "airport"`, includes `code`).
|
||||
- `stops` = entries having lat/lng, ordered by `(date, sort_order, id)`; flight entries with coordinate-bearing segments are expanded into airport stops instead (see "Flight segments" — `kind: "airport"`, includes `code`). Stops also carry `transport_mode` (the entry's value, or null) so the map can show mode-specific icons.
|
||||
- `legs` = consecutive stop pairs; skip zero-distance pairs (< 0.05 km) — still include the stop, just no leg.
|
||||
- Every leg has `mode`: `"air"` when BOTH endpoints are `kind:"airport"` stops expanded from the SAME flight entry (i.e. actual flight segments); `"ground"` for everything else (hotel→airport transfers, city-to-city drives). `summary.kmAir` / `summary.kmDriven` are the per-mode sums (1 decimal). `kmDriven` is the rough rental-car figure: great-circle, so real road km will be somewhat higher.
|
||||
- Every leg has `mode`: `"air"` when BOTH endpoints are `kind:"airport"` stops expanded from the SAME flight entry (i.e. actual flight segments); `"ground"` for everything else (stay→airport transfers, city-to-city drives). `summary.kmAir` / `summary.kmDriven` are the per-mode sums (1 decimal). `kmDriven` is the rough rental-car figure: great-circle, so real road km will be somewhat higher.
|
||||
- `days` = inclusive count from start_date to end_date; `nights = days - 1` (0 for single-day trips).
|
||||
- `locations` = unique `location_name` values in stop order.
|
||||
- km rounded to 1 decimal.
|
||||
@@ -197,7 +207,7 @@ Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title,
|
||||
{
|
||||
"currency": "USD",
|
||||
"totalCost": 1450.0,
|
||||
"byType": { "flight": 800.0, "travel": 300.0, "hotel": 350.0 },
|
||||
"byType": { "flight": 800.0, "transport": 300.0, "stay": 350.0 },
|
||||
"perUser": [
|
||||
{ "userId": 1, "displayName": "brave-otter", "share": 725.0, "paid": 950.0, "net": 225.0 },
|
||||
{ "userId": 2, "displayName": "calm-heron", "share": 725.0, "paid": 500.0, "net": -225.0 }
|
||||
|
||||
@@ -4,7 +4,7 @@ A self-hosted, multi-user web tool for collaboratively planning trips. Runs as a
|
||||
|
||||
## Core Idea
|
||||
|
||||
Multiple people log in to the same instance. Anyone can create a trip by picking a name and a date range. The tool generates a day-by-day calendar for that range, and every member of the trip can fill in what happens on each day — flights, hotel stays, border crossings, drives, activities. The trip is visualized on an interactive map with the route drawn between stops, distances per leg, and an overall summary (days, nights, flights, total km).
|
||||
Multiple people log in to the same instance. Anyone can create a trip by picking a name and a date range. The tool generates a day-by-day calendar for that range, and every member of the trip can fill in what happens on each day — flights, stays, transports, activities. The trip is visualized on an interactive map with the route drawn between stops, distances per leg, and an overall summary (days, nights, flights, total km).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -24,13 +24,15 @@ Multiple people log in to the same instance. Anyone can create a trip by picking
|
||||
|
||||
| Entry type | Typical fields |
|
||||
|---|---|
|
||||
| ✈️ Flight | flight no., from/to airports, departure/arrival time |
|
||||
| 🛂 Immigration / border | location, notes (visa, documents) |
|
||||
| 🚗 Travel / transfer | mode (car/train/bus/boat), from → to |
|
||||
| 🏨 Hotel stay | hotel name, check-in/check-out, booking ref |
|
||||
| 📍 Activity / sightseeing | place, time, notes |
|
||||
| 🏙️ Stay (area block / accommodation) | location, from → until dates, hotel name in title, price |
|
||||
| 🚆 Transport | mode (train/bus/ferry/taxi/drive), from → to; auto-created between consecutive stays |
|
||||
| ✈️ Flight | flight no., from/to airports, departure/arrival time, multi-leg segments |
|
||||
| 🚙 Rental car | pickup/dropoff, included km |
|
||||
| 📝 Note | free text |
|
||||
|
||||
(Legacy `hotel`/`travel`/`immigration` entries are migrated automatically at startup: hotel → stay, travel → transport, immigration → activity with a 🛂 title prefix.)
|
||||
|
||||
- Every entry can have a **location** (searched via OpenStreetMap geocoding — type "Chiang Mai" and pick from suggestions; lat/lng stored automatically).
|
||||
- Entries show as compact chips inside the day cell; multiple entries per day, ordered.
|
||||
|
||||
@@ -41,7 +43,7 @@ Multiple people log in to the same instance. Anyone can create a trip by picking
|
||||
- **Distance per leg** (km, great-circle) shown on the route and in a leg-by-leg list.
|
||||
|
||||
### 5. Costs & Splitting
|
||||
- Every entry can carry a **price** (flights, hotels, car rental, train tickets, activities…), in the trip's currency (one currency per trip, no FX conversion).
|
||||
- Every entry can carry a **price** (flights, stays, car rental, train tickets, activities…), in the trip's currency (one currency per trip, no FX conversion).
|
||||
- Each priced entry records **who paid** and how it's **split**:
|
||||
- `equal` — total split equally among selected participants (e.g. rental car 50/50)
|
||||
- `own` — price is per person, everyone pays their own (e.g. flights)
|
||||
@@ -50,7 +52,7 @@ Multiple people log in to the same instance. Anyone can create a trip by picking
|
||||
|
||||
### 6. Summary
|
||||
- Total days and nights.
|
||||
- Number of flights, hotel stays, travel legs.
|
||||
- Number of flights, stays, transports.
|
||||
- **Total distance in km** across the whole trip.
|
||||
- Countries/locations visited (from entry locations).
|
||||
|
||||
|
||||
+22
-3
@@ -6,12 +6,10 @@
|
||||
// is roughly most-common-first with Activity as the default for new entries.
|
||||
export const ENTRY_TYPES = {
|
||||
activity: { label: 'Activity', icon: '📍', color: '#059669' },
|
||||
hotel: { label: 'Hotel', icon: '🏨', color: '#db2777' },
|
||||
stay: { label: 'Stay', icon: '🏙️', color: '#f59e0b' },
|
||||
travel: { label: 'Travel', icon: '🚗', color: '#d97706' },
|
||||
transport: { label: 'Transport', icon: '🚆', color: '#d97706' },
|
||||
flight: { label: 'Flight', icon: '✈️', color: '#2563eb' },
|
||||
rental: { label: 'Rental car', icon: '🚙', color: '#0891b2' },
|
||||
immigration: { label: 'Immigration', icon: '🛂', color: '#7c3aed' },
|
||||
note: { label: 'Note', icon: '📝', color: '#64748b' },
|
||||
};
|
||||
|
||||
@@ -20,10 +18,31 @@ export const ENTRY_TYPE_LIST = Object.entries(ENTRY_TYPES).map(([value, meta]) =
|
||||
...meta,
|
||||
}));
|
||||
|
||||
// Transport entries may carry an optional transport_mode; this is its own
|
||||
// select (shown only for type === 'transport'), separate from ENTRY_TYPES.
|
||||
export const TRANSPORT_MODES = [
|
||||
{ value: 'train', label: 'Train', icon: '🚆' },
|
||||
{ value: 'bus', label: 'Bus', icon: '🚌' },
|
||||
{ value: 'ferry', label: 'Ferry', icon: '⛴️' },
|
||||
{ value: 'taxi', label: 'Taxi', icon: '🚕' },
|
||||
{ value: 'drive', label: 'Drive', icon: '🚗' },
|
||||
{ value: 'other', label: 'Other', icon: '➡️' },
|
||||
];
|
||||
|
||||
export function typeInfo(type) {
|
||||
return ENTRY_TYPES[type] || { label: type || 'Entry', icon: '•', color: '#64748b' };
|
||||
}
|
||||
|
||||
// Icon for an entry: a transport entry with a mode shows the mode's icon,
|
||||
// otherwise falls back to the type's icon.
|
||||
export function entryIcon(entry) {
|
||||
if (entry && entry.type === 'transport' && entry.transport_mode) {
|
||||
const mode = TRANSPORT_MODES.find((m) => m.value === entry.transport_mode);
|
||||
if (mode) return mode.icon;
|
||||
}
|
||||
return typeInfo(entry && entry.type).icon;
|
||||
}
|
||||
|
||||
// Split modes with the human labels the day-editor select shows.
|
||||
export const SPLIT_MODES = [
|
||||
{ value: 'equal', label: 'Split equally' },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { el } from '../dom.js';
|
||||
import {
|
||||
ENTRY_TYPES,
|
||||
typeInfo,
|
||||
entryIcon,
|
||||
parseYMD,
|
||||
ymd,
|
||||
addDays,
|
||||
@@ -189,7 +190,7 @@ function chip(entry, currency) {
|
||||
style: { '--chip': info.color },
|
||||
title: `${info.label}: ${chain ? `${entry.title} (${chain})` : entry.title}${hasPrice ? ` · ${formatMoney(entry.price, currency, { compact: true })}` : ''}`,
|
||||
},
|
||||
el('span', { class: 'chip-icon' }, info.icon),
|
||||
el('span', { class: 'chip-icon' }, entryIcon(entry)),
|
||||
el('span', { class: 'chip-text' }, label),
|
||||
hasPrice
|
||||
? el('span', { class: 'chip-price' }, formatMoney(entry.price, currency, { compact: true }))
|
||||
@@ -239,7 +240,7 @@ function dropoffChip(entry, tctx) {
|
||||
tabindex: '0',
|
||||
title: `Rental dropoff${car ? `: ${car}` : ''} — opens the pickup day`,
|
||||
},
|
||||
el('span', { class: 'chip-icon' }, info.icon),
|
||||
el('span', { class: 'chip-icon' }, entryIcon(entry)),
|
||||
el('span', { class: 'chip-text' }, 'dropoff'),
|
||||
);
|
||||
const open = (e) => { e.stopPropagation(); tctx.openDay(entry.date); };
|
||||
|
||||
@@ -7,6 +7,7 @@ import { api } from '../api.js';
|
||||
import { el, clear, mount, toast } from '../dom.js';
|
||||
import {
|
||||
ENTRY_TYPE_LIST,
|
||||
TRANSPORT_MODES,
|
||||
splitModeLabel,
|
||||
typeInfo,
|
||||
formatDate,
|
||||
@@ -183,6 +184,7 @@ export function openDayEditor(tctx, date) {
|
||||
fields.start.value = entry.start_time || '';
|
||||
fields.end.value = entry.end_time || '';
|
||||
fields.endDate.value = entry.end_date || '';
|
||||
fields.mode.value = entry.transport_mode || '';
|
||||
fields.cost.prefill(entry);
|
||||
// Flight segments (load() triggers the flight-route onChange -> UI sync).
|
||||
fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []);
|
||||
@@ -223,6 +225,18 @@ export function openDayEditor(tctx, date) {
|
||||
locWrap.append(locInput, locResults, locSelected);
|
||||
const locationField = el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Location'), locWrap);
|
||||
|
||||
// ----- Transport mode (shown only for transport entries) -----
|
||||
// Declared before the flight/rental subsections below: their onChange
|
||||
// callbacks can call syncTypeUI() during construction (see the TDZ note
|
||||
// below it), so anything syncTypeUI touches must already exist.
|
||||
const modeSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
el('option', { value: '' }, '—'),
|
||||
...TRANSPORT_MODES.map((m) => el('option', { value: m.value }, `${m.icon} ${m.label}`)),
|
||||
);
|
||||
const modeField = el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Mode'), modeSelect);
|
||||
|
||||
// ----- Flight route subsection (shown only for flight entries) -----
|
||||
// The sub-modules fire onChange during construction (load of initial
|
||||
// state), before the section elements below exist — gate until wired.
|
||||
@@ -253,6 +267,7 @@ export function openDayEditor(tctx, date) {
|
||||
const type = typeSelect.value;
|
||||
flightSection.style.display = type === 'flight' ? '' : 'none';
|
||||
rentalSection.style.display = type === 'rental' ? '' : 'none';
|
||||
modeField.style.display = type === 'transport' ? '' : 'none';
|
||||
locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : '';
|
||||
// Stays emphasise an end date ("until"); other types call it "End date".
|
||||
endDateLabel.textContent = type === 'stay' ? 'Until' : 'End date';
|
||||
@@ -267,7 +282,7 @@ export function openDayEditor(tctx, date) {
|
||||
|
||||
fields = {
|
||||
type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput,
|
||||
endDate: endDateInput,
|
||||
endDate: endDateInput, mode: modeSelect,
|
||||
locInput, locResults, locSelected,
|
||||
cost: costForm, flightRoute, rentalDetails,
|
||||
};
|
||||
@@ -334,6 +349,10 @@ export function openDayEditor(tctx, date) {
|
||||
payload.rental = null;
|
||||
}
|
||||
|
||||
// Transport mode (transport entries only). Clear it otherwise so
|
||||
// changing an entry's type away from transport drops any prior mode.
|
||||
payload.transport_mode = typeSelect.value === 'transport' ? (modeSelect.value || null) : null;
|
||||
|
||||
// end_date must be on/after the effective start date (rental may have
|
||||
// moved payload.date to the pickup date above).
|
||||
if (payload.end_date && payload.end_date < payload.date) {
|
||||
@@ -383,6 +402,7 @@ export function openDayEditor(tctx, date) {
|
||||
'div',
|
||||
{ class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Type'), typeSelect),
|
||||
modeField,
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Title'), titleInput),
|
||||
),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Details'), detailsInput),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// per-leg km labels, and a leg-by-leg list. Graceful empty state when the
|
||||
// trip has no located entries yet. Uses the /route response from tctx.route.
|
||||
import { el } from '../dom.js';
|
||||
import { typeInfo } from '../format.js';
|
||||
import { typeInfo, entryIcon } from '../format.js';
|
||||
|
||||
export function renderMap(tctx) {
|
||||
const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
|
||||
@@ -65,7 +65,7 @@ function initMap(mapDiv, route, stops) {
|
||||
const info = typeInfo(stop.type);
|
||||
L.marker(points[i], { icon: numberedIcon(L, i + 1, info.color) })
|
||||
.addTo(map)
|
||||
.bindPopup(popupHtml(stop, info));
|
||||
.bindPopup(popupHtml(stop, info, entryIcon(stop)));
|
||||
});
|
||||
|
||||
// One polyline per measured leg, styled by mode (air = dashed blue, ground =
|
||||
@@ -128,9 +128,9 @@ function kmLabel(L, km) {
|
||||
}
|
||||
|
||||
// Built from server-provided fields; escape to keep the popup injection-safe.
|
||||
function popupHtml(stop, info) {
|
||||
function popupHtml(stop, info, icon) {
|
||||
const isAirport = stop.kind === 'airport' && stop.code;
|
||||
const heading = isAirport ? `${info.icon} ${esc(stop.code)}` : `${info.icon} ${esc(stop.title)}`;
|
||||
const heading = isAirport ? `${icon} ${esc(stop.code)}` : `${icon} ${esc(stop.title)}`;
|
||||
const sub = isAirport
|
||||
? `${esc(info.label)} · ${esc(stop.date)}${stop.title ? ` · ${esc(stop.title)}` : ''}`
|
||||
: `${esc(info.label)} · ${esc(stop.date)}`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Summary panel built from the /route response's `summary` block:
|
||||
// days, nights, flights, hotels, travel legs, activities, total km, and the
|
||||
// days, nights, flights, transports, activities, total km, and the
|
||||
// list of locations in visit order.
|
||||
import { el } from '../dom.js';
|
||||
import { stayColor } from './stayBands.js';
|
||||
@@ -7,7 +7,7 @@ import { stayColor } from './stayBands.js';
|
||||
export function renderSummary(tctx) {
|
||||
const route = tctx.route || {};
|
||||
const s = route.summary || {
|
||||
days: 0, nights: 0, flights: 0, flightSegments: 0, hotels: 0, travelLegs: 0, activities: 0, locations: [],
|
||||
days: 0, nights: 0, flights: 0, flightSegments: 0, transports: 0, activities: 0, locations: [],
|
||||
};
|
||||
const totalKm = route.totalKm || 0;
|
||||
// Show the leg count under the Flights tile only when a flight has segments.
|
||||
@@ -31,9 +31,8 @@ export function renderSummary(tctx) {
|
||||
tile('🗓️', s.days, s.days === 1 ? 'Day' : 'Days'),
|
||||
tile('🌙', s.nights, s.nights === 1 ? 'Night' : 'Nights'),
|
||||
tile('✈️', s.flights, 'Flights', flightSub),
|
||||
tile('🏨', s.hotels, 'Hotels'),
|
||||
tile('🚗', s.travelLegs, 'Travel legs'),
|
||||
tile('📍', s.activities, 'Activities'),
|
||||
s.transports > 0 ? tile('🚆', s.transports, s.transports === 1 ? 'Transport' : 'Transports') : null,
|
||||
s.rentals > 0 ? tile('🚙', s.rentals, s.rentals === 1 ? 'Rental' : 'Rentals') : null,
|
||||
s.stays > 0 ? tile('🏙️', s.stays, s.stays === 1 ? 'Stay' : 'Stays') : null,
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+19
-7
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@ import './costs.test.js';
|
||||
import './flights.test.js';
|
||||
import './rental.test.js';
|
||||
import './stays.test.js';
|
||||
import './transport.test.js';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
Reference in New Issue
Block a user