From 8de40db40e053ba7158874ca1e2f3026b5c4d21e Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 10:05:24 +0200 Subject: [PATCH] fix(calendar): a timed stop without an end time ends after the place duration A stop with a start time but no end time went out as a VEVENT without DTEND. Calendar clients fill in their own default length for that, mostly an hour, so a 20 minute stop showed up as an hour. The event now ends at start plus the place's duration_minutes when that is set and above zero, carried over midnight like the stay markers. An explicit end time still wins, so start and end under an hour stay as they were. The trip download, both calendar feeds and the MCP export share this builder. Refs #2641 --- .../repositories/Reservations.repository.ts | 9 ++-- server/src/nest/calendar/calendar.service.ts | 33 ++++++++---- server/tests/e2e/trips.e2e.test.ts | 30 +++++++++++ .../tests/unit/nest/calendar.service.test.ts | 51 +++++++++++++++++++ 4 files changed, 109 insertions(+), 14 deletions(-) diff --git a/server/src/db/repositories/Reservations.repository.ts b/server/src/db/repositories/Reservations.repository.ts index e5f41d71f..08dac3c27 100644 --- a/server/src/db/repositories/Reservations.repository.ts +++ b/server/src/db/repositories/Reservations.repository.ts @@ -174,9 +174,10 @@ type CalendarReservationKyselyDB = Pick { 'p.address as place_address', 'p.lat as place_lat', 'p.lng as place_lng', + 'p.duration_minutes as place_duration_minutes', eb.fn.coalesce('da.assignment_time', 'p.place_time').as('effective_time'), eb.fn.coalesce('da.assignment_end_time', 'p.end_time').as('effective_end_time'), ]) diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index 4e6f741aa..8cd0f5432 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -141,6 +141,19 @@ function groupByDay(rows: T[]): Map { } return byDay; } + +/** A wall clock `minutes` later, carried over midnight onto the following date. */ +function plusMinutes(date: string, time: string, minutes: number): { date: string; time: string } { + const [h, m] = time.slice(0, 5).split(':').map(Number); + const total = h * 60 + m + minutes; + const dayShift = Math.floor(total / (24 * 60)); + const rest = ((total % (24 * 60)) + 24 * 60) % (24 * 60); + return { + date: dayShift ? addDays(date, dayShift) : date, + time: `${String(Math.floor(rest / 60)).padStart(2, '0')}:${String(rest % 60).padStart(2, '0')}`, + }; +} + /** Ten years at most: a span longer than that is a typo, not a trip. */ const MAX_SCAN_DAYS = 3660; @@ -353,6 +366,15 @@ export class CalendarService { ev += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00'); if (a.effective_end_time) { ev += dtLine('DTEND', a.effective_end_time, zone, day.date + 'T00:00'); + } else if (a.place_duration_minutes && a.place_duration_minutes > 0) { + // Without an end the event would have no length, and clients put their + // own default (mostly an hour) in its place, so a 20 minute stop came out + // as an hour (#2641). The place's duration says how long it lasts. + const start = a.effective_time ?? ''; + if (/^\d{2}:\d{2}/.test(start)) { + const stop = plusMinutes(day.date, start, a.place_duration_minutes); + ev += dtLine('DTEND', stop.time, zone, stop.date + 'T00:00'); + } } ev += `SUMMARY:${esc(a.place_name)}\r\n`; let desc = ''; @@ -563,17 +585,6 @@ export class CalendarService { */ const HANDOVER_MINUTES = 60; - const plusMinutes = (date: string, time: string, minutes: number): { date: string; time: string } => { - const [h, m] = time.slice(0, 5).split(':').map(Number); - const total = h * 60 + m + minutes; - const dayShift = Math.floor(total / (24 * 60)); - const rest = ((total % (24 * 60)) + 24 * 60) % (24 * 60); - return { - date: dayShift ? addDays(date, dayShift) : date, - time: `${String(Math.floor(rest / 60)).padStart(2, '0')}:${String(rest % 60).padStart(2, '0')}`, - }; - }; - interface WindowSide { date: string; time: string | null; diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index b876429ff..e16999abc 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -41,8 +41,11 @@ import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TripsModule } from '../../src/nest/trips/trips.module'; +import { makeDayAssignment } from '../helpers/factories/itinerary'; +import { makePlace } from '../helpers/factories/places'; import { countRows, deleteRows, findRow, findRows, insertRow } from '../helpers/factories/rows'; import { setUserSetting } from '../helpers/factories/settings'; +import { makeDay } from '../helpers/factories/trips'; import { makeUser } from '../helpers/factories/users'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; @@ -286,6 +289,33 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { expect(res.text).toContain('BEGIN:VCALENDAR'); }); + // A stop with a start time and no end ends after the place's duration, so a + // 20 minute stop does not turn into the client's default hour (#2641). + it('200 export.ics ends a stop without an end time after its duration', async () => { + const tripId = await insertRow(orm, Trips, { + user: 1, + title: 'Short Stops', + start_date: '2026-11-02', + end_date: '2026-11-02', + }); + const day = await makeDay(orm, tripId, { date: '2026-11-02' }); + const place = await makePlace(orm, tripId, { + name: 'Cafe', + lat: 52.52, + lng: 13.405, + place_time: '18:00', + duration_minutes: 20, + }); + await makeDayAssignment(orm, day.id, place.id); + + const res = await request(server).get(`/api/trips/${tripId}/export.ics`).set('Cookie', sessionCookie(1)); + + expect(res.status).toBe(200); + expect(res.text).toContain( + 'DTSTART;TZID=Europe/Berlin:20261102T180000\r\nDTEND;TZID=Europe/Berlin:20261102T182000\r\n', + ); + }); + it('200 bundle for an accessible trip (real member list)', async () => { const tripId = await seedTrip('B'); const res = await request(server).get(`/api/trips/${tripId}/bundle`).set('Cookie', sessionCookie(1)); diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 07d27b587..1541cb149 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -390,6 +390,57 @@ describe('exportICS', () => { expect(ics).not.toContain('DTSTART:20250602T090000'); }); + describe('end of a timed stop (#2641)', () => { + const timedStop = async (place: Record, assignment: Record = {}) => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Short Stops' }); + const day = createDay(testDb, trip.id, { date: '2025-06-02' }); + const created = createPlace(testDb, trip.id, { name: 'Cafe', lat: 52.52, lng: 13.405 }); + await updateRows(orm, Places, { id: created.id }, place); + const a = createDayAssignment(testDb, day.id, created.id); + if (Object.keys(assignment).length) await updateRows(orm, DayAssignments, { id: a.id }, assignment); + return (await svc.exportICS(trip.id)).ics; + }; + + it('CAL-2641a: a start time without an end ends after the place duration', async () => { + const ics = await timedStop({ place_time: '18:00', end_time: null, duration_minutes: 20 }); + + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T180000\r\nDTEND;TZID=Europe/Berlin:20250602T182000\r\n'); + }); + + it('CAL-2641b: a place on the default duration gets an explicit hour', async () => { + const ics = await timedStop({ place_time: '09:30', end_time: null, duration_minutes: 60 }); + + expect(ics).toContain('DTEND;TZID=Europe/Berlin:20250602T103000'); + }); + + it('CAL-2641c: an end time under an hour wins over the duration', async () => { + const ics = await timedStop({ place_time: '10:00', end_time: '10:30', duration_minutes: 60 }); + + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T100000\r\nDTEND;TZID=Europe/Berlin:20250602T103000\r\n'); + expect(ics).not.toContain('DTEND;TZID=Europe/Berlin:20250602T110000'); + }); + + it('CAL-2641d: an assignment start and end under an hour stay as set', async () => { + const ics = await timedStop({ duration_minutes: 90 }, { assignment_time: '16:00', assignment_end_time: '16:20' }); + + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T160000\r\nDTEND;TZID=Europe/Berlin:20250602T162000\r\n'); + }); + + it('CAL-2641e: a duration running past midnight ends on the next day', async () => { + const ics = await timedStop({ duration_minutes: 45 }, { assignment_time: '23:40' }); + + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T234000\r\nDTEND;TZID=Europe/Berlin:20250603T002500\r\n'); + }); + + it.each([null, 0])('CAL-2641f: without a usable duration (%s) no end is invented', async (duration) => { + const ics = await timedStop({ place_time: '18:00', end_time: null, duration_minutes: duration }); + + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T180000'); + expect(ics).not.toContain('DTEND;TZID='); + }); + }); + it('CAL-011: a reservation_time with seconds is kept as is and a date-only end time emits no DTEND', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Second Precision' });