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:
@@ -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) {
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user