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:
+2
-1
@@ -270,8 +270,9 @@ test('entry CRUD and full-row shape', async () => {
|
||||
const entry = create.body.entry;
|
||||
assert.deepEqual(
|
||||
Object.keys(entry).sort(),
|
||||
['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', 'trip_id', 'type'].sort()
|
||||
);
|
||||
assert.equal(entry.end_date, null);
|
||||
assert.equal(entry.details, '');
|
||||
assert.equal(entry.lat, 18.79);
|
||||
assert.equal(entry.price, null);
|
||||
|
||||
@@ -10,3 +10,4 @@ import './api.test.js';
|
||||
import './costs.test.js';
|
||||
import './flights.test.js';
|
||||
import './rental.test.js';
|
||||
import './stays.test.js';
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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';
|
||||
|
||||
let tmpDir;
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-stays-'));
|
||||
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: 'S', start_date: '2026-08-01', end_date: '2026-08-31' })).body.trip;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// end_date validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('end_date: accepted (>= date), rejected (< date), cleared via null', 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', end_date: '2026-08-03', type: 'stay', title: 'Venice' });
|
||||
assert.equal(ok.status, 201);
|
||||
assert.equal(ok.body.entry.end_date, '2026-08-03');
|
||||
|
||||
const bad = await agent.post(base).send({ date: '2026-08-05', end_date: '2026-08-02', type: 'stay', title: 'X' });
|
||||
assert.equal(bad.status, 400);
|
||||
|
||||
const cleared = await agent.patch(`/api/entries/${ok.body.entry.id}`).send({ end_date: null });
|
||||
assert.equal(cleared.status, 200);
|
||||
assert.equal(cleared.body.entry.end_date, null);
|
||||
});
|
||||
|
||||
test('end_date: PATCHing date past a stored end_date is rejected', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const entry = (await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'Venice',
|
||||
})).body.entry;
|
||||
|
||||
// Moving date to 08-10 leaves stored end_date (08-03) < date -> 400.
|
||||
const conflict = await agent.patch(`/api/entries/${entry.id}`).send({ date: '2026-08-10' });
|
||||
assert.equal(conflict.status, 400);
|
||||
|
||||
// Moving date and end_date together is fine.
|
||||
const ok = await agent.patch(`/api/entries/${entry.id}`).send({ date: '2026-08-10', end_date: '2026-08-12' });
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(ok.body.entry.date, '2026-08-10');
|
||||
assert.equal(ok.body.entry.end_date, '2026-08-12');
|
||||
});
|
||||
|
||||
test('a >24h flight round-trips end_date', 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', end_date: '2026-08-02', type: 'flight', title: 'SYD-LHR',
|
||||
start_time: '21:00', end_time: '05:30',
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.body.entry.date, '2026-08-01');
|
||||
assert.equal(res.body.entry.end_date, '2026-08-02');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stay type + areas summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('stay entries accepted; summary reports stays count and areas in order', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
|
||||
// Venice 3 days (name from location_name), then Berlin 7 days (name from title fallback).
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'Venice stay',
|
||||
location_name: 'Venice', sort_order: 0,
|
||||
});
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-04', end_date: '2026-08-10', type: 'stay', title: 'Berlin', sort_order: 1,
|
||||
});
|
||||
// A single-day stay (no end_date) -> days 1.
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-11', type: 'stay', title: 'Layover', location_name: 'Doha',
|
||||
});
|
||||
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.summary.stays, 3);
|
||||
assert.deepEqual(res.body.summary.areas, [
|
||||
{ name: 'Venice', days: 3 },
|
||||
{ name: 'Berlin', days: 7 },
|
||||
{ name: 'Doha', days: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('stay with location feeds the route as one stop', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', end_date: '2026-08-05', type: 'stay', title: 'Venice',
|
||||
location_name: 'Venice', lat: 45.44, lng: 12.32,
|
||||
});
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.body.stops.length, 1);
|
||||
assert.equal(res.body.stops[0].lat, 45.44);
|
||||
assert.equal(res.body.summary.stays, 1);
|
||||
});
|
||||
Reference in New Issue
Block a user