Add multi-day entries and area stay blocks

Entries gain an optional inclusive end_date (backfilled via migration): flights and travel longer than 24h show a continuation marker on following days, and a new stay entry type marks a time frame in one area (e.g. 3 days Venice) rendered as continuous bands across the calendar weeks. Stays feed the map route as stops; the route summary gains stays count and a chronological areas list with day spans. 49 API tests.
This commit is contained in:
2026-07-19 00:30:14 +07:00
parent 1d86c68665
commit 154f56a0a0
14 changed files with 416 additions and 26 deletions
+2
View File
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY,
trip_id INTEGER NOT NULL REFERENCES trips(id),
date TEXT NOT NULL,
end_date TEXT,
type TEXT NOT NULL,
title TEXT NOT NULL,
details TEXT DEFAULT '',
@@ -68,6 +69,7 @@ const MIGRATIONS = [
{ table: 'entries', column: 'split_mode', ddl: "ALTER TABLE entries ADD COLUMN split_mode TEXT NOT NULL DEFAULT 'equal'" },
{ 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' },
];
function applyMigrations(db) {
+24 -3
View File
@@ -12,6 +12,7 @@ const ENTRY_TYPES = new Set([
'hotel',
'activity',
'rental',
'stay',
'note',
]);
const SPLIT_MODES = new Set(['equal', 'own', 'payer']);
@@ -44,6 +45,25 @@ function validateEntry(body, { partial, existing, memberIds }) {
}
fields.date = body.date;
}
// end_date: optional inclusive end, null clears, must be >= the (effective) date.
// ISO YYYY-MM-DD strings compare correctly lexicographically.
const effDate = 'date' in fields ? fields.date : existing?.date;
if (has('end_date')) {
if (body.end_date === null) {
fields.end_date = null;
} else {
if (!isValidDateStr(body.end_date)) {
return { error: 'end_date must be a valid YYYY-MM-DD date' };
}
if (effDate && body.end_date < effDate) {
return { error: 'end_date must be on or after date' };
}
fields.end_date = body.end_date;
}
} else if (partial && 'date' in fields && existing?.end_date && existing.end_date < fields.date) {
// Moving date past a stored end_date would invert the range.
return { error: 'end_date would be before the new date; clear or update end_date' };
}
if (!partial || has('title')) {
if (typeof body.title !== 'string' || body.title.trim() === '') {
return { error: 'title is required' };
@@ -190,7 +210,7 @@ export default function entriesRoutes(db) {
`SELECT ${ENTRY_COLUMNS} FROM entries WHERE trip_id = ? ORDER BY date, sort_order, id`
);
const getEntryRow = db.prepare(
'SELECT trip_id, split_mode, paid_by, type FROM entries WHERE id = ?'
'SELECT trip_id, split_mode, paid_by, type, date, end_date FROM entries WHERE id = ?'
);
const getMemberIds = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?');
const insertParticipant = db.prepare(
@@ -235,13 +255,14 @@ export default function entriesRoutes(db) {
const info = db
.prepare(
`INSERT INTO entries
(trip_id, date, type, title, details, start_time, end_time,
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
tripId,
f.date,
f.end_date ?? null,
f.type,
f.title,
f.details ?? '',
+9
View File
@@ -331,6 +331,13 @@ export default function tripsRoutes(db) {
}
}
if (includedKm !== null) includedKm = Math.round(includedKm * 10) / 10;
// Stay "area blocks" in chronological order (allEntries is date-ordered).
const areas = allEntries
.filter((e) => e.type === 'stay')
.map((e) => ({
name: e.location_name || e.title,
days: e.end_date ? daysInclusive(e.date, e.end_date) : 1,
}));
const days = daysInclusive(trip.start_date, trip.end_date);
const locations = [];
for (const s of stops) {
@@ -352,6 +359,8 @@ export default function tripsRoutes(db) {
travelLegs: countType('travel'),
activities: countType('activity'),
rentals: countType('rental'),
stays: countType('stay'),
areas,
kmAir: Math.round(kmAir * 10) / 10,
kmDriven: Math.round(kmDriven * 10) / 10,
includedKm,
+1 -1
View File
@@ -3,7 +3,7 @@
// "all trip members participate") and a parsed `segments` array (or null).
export const ENTRY_COLUMNS =
'id, trip_id, date, type, title, details, start_time, end_time, ' +
'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';
// Parse the stored segments JSON text into an array, or null if absent/invalid.