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' });