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:
2026-07-20 10:22:49 +07:00
parent 71a158aa51
commit 36c4e4f306
14 changed files with 425 additions and 38 deletions
+1 -1
View File
@@ -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, '');
+157
View File
@@ -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 });
});