mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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
This commit is contained in:
@@ -174,9 +174,10 @@ type CalendarReservationKyselyDB = Pick<DB, 'reservations' | 'places' | 'day_acc
|
||||
/**
|
||||
* CL4 (`CalendarService.buildTripCalendar`'s assignment read, for every dated
|
||||
* day at once) — `SELECT da.*, p.name as place_name, p.address as
|
||||
* place_address, p.lat as place_lat, p.lng as place_lng,
|
||||
* COALESCE(da.assignment_time, p.place_time) as effective_time,
|
||||
* COALESCE(da.assignment_end_time, p.end_time) as effective_end_time FROM
|
||||
* place_address, p.lat as place_lat, p.lng as place_lng, p.duration_minutes
|
||||
* as place_duration_minutes, COALESCE(da.assignment_time, p.place_time) as
|
||||
* effective_time, COALESCE(da.assignment_end_time, p.end_time) as
|
||||
* effective_end_time FROM
|
||||
* day_assignments da JOIN places p ON da.place_id = p.id WHERE da.day_id IN
|
||||
* (...) AND da.accommodation_id IS NULL ORDER BY da.order_index, da.created_at,
|
||||
* da.id` — the booked-night stop (its own accommodation-linked row) is
|
||||
@@ -188,6 +189,7 @@ export interface CalendarStopRow extends DayAssignmentRow {
|
||||
place_address: string | null;
|
||||
place_lat: number | null;
|
||||
place_lng: number | null;
|
||||
place_duration_minutes: number | null;
|
||||
effective_time: string | null;
|
||||
effective_end_time: string | null;
|
||||
}
|
||||
@@ -1004,6 +1006,7 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
|
||||
'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'),
|
||||
])
|
||||
|
||||
@@ -141,6 +141,19 @@ function groupByDay<T extends { day_id: number }>(rows: T[]): Map<number, T[]> {
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -390,6 +390,57 @@ describe('exportICS', () => {
|
||||
expect(ics).not.toContain('DTSTART:20250602T090000');
|
||||
});
|
||||
|
||||
describe('end of a timed stop (#2641)', () => {
|
||||
const timedStop = async (place: Record<string, unknown>, assignment: Record<string, unknown> = {}) => {
|
||||
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' });
|
||||
|
||||
Reference in New Issue
Block a user