Add transport sync button and allow dropping stays onto band-covered days
- Auto-created transports carry auto_ref {from,to} stay ids; new
POST /api/trips/:id/transports/regenerate reconciles them against the
current stay order (re-date/re-title kept bridges preserving mode and
price, delete orphans, create missing) without touching manual
transports or flight-covered gaps
- Sync transports button in the calendar header with result toast
- The week band strip is now a drop target resolving the day from the
pointer position, so stays can be dropped onto spots covered by other
stays; overlapping stays stack in band lanes
This commit is contained in:
+1
-1
@@ -325,7 +325,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', 'transport_mode', 'trip_id', 'type'].sort()
|
||||
['auto_ref', '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, '');
|
||||
|
||||
@@ -178,6 +178,8 @@ test('auto-transport: creates a bridging entry between two consecutive stays', a
|
||||
assert.equal(transports[0].title, 'Venice → Berlin');
|
||||
assert.equal(transports[0].date, berlin.date);
|
||||
assert.equal(transports[0].sort_order, 0);
|
||||
assert.ok(transports[0].auto_ref);
|
||||
assert.equal(transports[0].auto_ref.to, berlin.id);
|
||||
|
||||
const route = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(route.body.summary.transports, 1);
|
||||
@@ -275,3 +277,158 @@ test('auto-transport: deleting it is not resurrected by an unrelated later stay'
|
||||
list = (await agent.get(base)).body.entries;
|
||||
assert.ok(!list.some((e) => e.type === 'transport' && e.title === 'Venice → Berlin'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regenerating auto-transports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('auto_ref cannot be set via POST/PATCH bodies', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
const posted = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'transport', title: 'x', auto_ref: { from: 1, to: 2 },
|
||||
});
|
||||
assert.equal(posted.status, 201);
|
||||
assert.equal(posted.body.entry.auto_ref, null);
|
||||
|
||||
const patched = await agent.patch(`/api/entries/${posted.body.entry.id}`).send({
|
||||
auto_ref: { from: 1, to: 2 },
|
||||
});
|
||||
assert.equal(patched.status, 200);
|
||||
assert.equal(patched.body.entry.auto_ref, null);
|
||||
});
|
||||
|
||||
test('regenerate: non-member gets 404', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const { agent: other } = await createAccount();
|
||||
|
||||
const res = await other.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
test('regenerate: re-dates and re-titles the auto transport after a stay moves, preserving transport_mode', 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: 'B', location_name: 'Berlin',
|
||||
})).body.entry;
|
||||
|
||||
let list = (await agent.get(base)).body.entries;
|
||||
const auto = list.find((e) => e.type === 'transport');
|
||||
assert.ok(auto);
|
||||
await agent.patch(`/api/entries/${auto.id}`).send({ transport_mode: 'train' });
|
||||
|
||||
// Move Berlin later; still the second stay -> same (from,to) pair.
|
||||
await agent.patch(`/api/entries/${berlin.id}`).send({ date: '2026-08-20' });
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { created: 0, updated: 1, deleted: 0 });
|
||||
|
||||
list = (await agent.get(base)).body.entries;
|
||||
const updated = list.find((e) => e.type === 'transport');
|
||||
assert.equal(updated.id, auto.id);
|
||||
assert.equal(updated.date, '2026-08-20');
|
||||
assert.equal(updated.title, 'Venice → Berlin');
|
||||
assert.equal(updated.transport_mode, 'train');
|
||||
});
|
||||
|
||||
test('regenerate: deletes an auto transport orphaned by a deleted 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: 'V', location_name: 'Venice',
|
||||
});
|
||||
const berlin = (await agent.post(base).send({
|
||||
date: '2026-08-04', type: 'stay', title: 'B', location_name: 'Berlin',
|
||||
})).body.entry;
|
||||
|
||||
await agent.delete(`/api/entries/${berlin.id}`);
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { created: 0, updated: 0, deleted: 1 });
|
||||
|
||||
const list = (await agent.get(base)).body.entries;
|
||||
assert.equal(list.filter((e) => e.type === 'transport').length, 0);
|
||||
});
|
||||
|
||||
test('regenerate: recreates a missing auto transport', 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-04', type: 'stay', title: 'B', location_name: 'Berlin',
|
||||
});
|
||||
|
||||
let list = (await agent.get(base)).body.entries;
|
||||
const auto = list.find((e) => e.type === 'transport');
|
||||
await agent.delete(`/api/entries/${auto.id}`);
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { created: 1, updated: 0, deleted: 0 });
|
||||
|
||||
list = (await agent.get(base)).body.entries;
|
||||
const recreated = list.find((e) => e.type === 'transport');
|
||||
assert.ok(recreated);
|
||||
assert.equal(recreated.title, 'Venice → Berlin');
|
||||
assert.ok(recreated.auto_ref);
|
||||
});
|
||||
|
||||
test('regenerate: respects manual transport coverage — creates nothing and does not delete it', 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 manual = (await agent.post(base).send({
|
||||
date: '2026-08-03', type: 'transport', title: 'Manual train',
|
||||
})).body.entry;
|
||||
await agent.post(base).send({
|
||||
date: '2026-08-04', type: 'stay', title: 'B', location_name: 'Berlin',
|
||||
});
|
||||
|
||||
// No auto transport was created at stay-creation time (manual already covers the window).
|
||||
let list = (await agent.get(base)).body.entries;
|
||||
assert.equal(list.filter((e) => e.type === 'transport').length, 1);
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { created: 0, updated: 0, deleted: 0 });
|
||||
|
||||
list = (await agent.get(base)).body.entries;
|
||||
const transports = list.filter((e) => e.type === 'transport');
|
||||
assert.equal(transports.length, 1);
|
||||
assert.equal(transports[0].id, manual.id);
|
||||
assert.equal(transports[0].auto_ref, null);
|
||||
});
|
||||
|
||||
test('regenerate: overlapping stays (inverted window) produce no pair and do not crash', 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 res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { created: 0, updated: 0, deleted: 0 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user