From cd769d9993d1eb2986c1b7e6c9eb6acf8082569a Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 17:26:50 +0200 Subject: [PATCH 01/13] fix(roadtrip): a booked night holds its check-in and heads its day A hotel entered under Days with a check-in at ten was reached at a quarter past twelve in road trip mode. Two things: the schedule read the check-in as a floor that held nothing once anything before it set the clock, and the server seated the night last unless something pinned later pulled it forward, so two stops without an hour stood ahead of it. The check-in now pins the night the way its own time would, in the plain schedule and in the daily window, where a check-in the drive cannot make is the conflict a missed pin is. The night is seated first on its day; only a stop with a clock at or before the check-in stays ahead of it, and the stops without one follow. A new check-in seats the night afresh, a change of notes leaves a dragged night where it is. A day that is nothing but its booked night stands among the days now, with the hotel and its check-in, instead of at the foot of the rail under "only X so far", which read as the hotel missing from the road trip. The client puts a stop the server seated at the front where the server put it, rather than appending it to draw second until the next reload. --- .../components/Roadtrip/RoadtripSidebar.tsx | 24 +++-- .../components/Roadtrip/useRoadtripRoutes.ts | 7 +- client/src/store/slices/remoteEventHandler.ts | 10 +- .../accommodations/accommodations.service.ts | 75 ++++++++------ .../nest/reservations/reservations.service.ts | 5 +- .../nest/roadtrip/roadtrip-plan.service.ts | 10 +- .../unit/nest/accommodations.service.test.ts | 98 +++++++++++++++---- shared/src/roadtrip/assemble.spec.ts | 44 +++++++++ shared/src/roadtrip/assemble.ts | 5 +- shared/src/roadtrip/dayWindow.spec.ts | 39 ++++++-- shared/src/roadtrip/dayWindow.ts | 6 +- shared/src/roadtrip/planning-types.ts | 2 + shared/src/roadtrip/roadtripModel.spec.ts | 52 ++++++---- shared/src/roadtrip/roadtripModel.ts | 66 ++++++------- 14 files changed, 307 insertions(+), 136 deletions(-) diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 222f2e7bc..f80292918 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1553,12 +1553,16 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on {formatDate(day.date, language)} ) : null} - - {t('roadtrip.leg.driveText', { - distance: formatDistance(day.distance / 1000, distanceUnit), - time: formatDurationShort(day.duration), - })} - + {/* A day that is only the night booked for it has no drive to sum up and no + stop to count; the hotel row below says everything it has to say. */} + {day.legs.length > 0 ? ( + + {t('roadtrip.leg.driveText', { + distance: formatDistance(day.distance / 1000, distanceUnit), + time: formatDurationShort(day.duration), + })} + + ) : null} {day.dayWarning ? ( @@ -1569,9 +1573,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on ) : null} - - {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })} - + {day.legs.length > 0 ? ( + + {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })} + + ) : null} {/* The other half of "where possible". The setting is a weighting, so a day with no untolled crossing comes back on the toll road — and the only thing worse than not avoiding it is not avoiding it silently, which reads as the diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index f185063c0..4ca36efb4 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -1,5 +1,5 @@ import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' -import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip' +import { assembleRoadtrip, foldRouteRun, standsAsDay, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip' import { useEffect, useMemo, useRef, useState } from 'react' import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator' import { resolveLegMode } from '../Planner/legMode' @@ -91,6 +91,7 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommoda // traveller's own statement about this stop, and the stay then runs until it. leaveAt: a.assignment_end_time ?? p.end_time ?? null, checkInTime: stay?.check_in ?? null, + night: stay !== undefined, dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null, endDay: a.end_day === true, legMode: a.leg_transport_mode ?? null, @@ -222,7 +223,7 @@ export function useRoadtripRoutes( stops, } }) - .filter(d => d.stops.length > 1) + .filter(d => standsAsDay(d.stops)) }, [days, assignments, accommodations]) const quietDays = useMemo(() => { @@ -240,7 +241,7 @@ export function useRoadtripRoutes( .filter((s): s is RoadtripStop => s !== null) .map((s, i) => ({ ...s, ownerIndex: i })), })) - .filter(d => d.stops.length < 2) + .filter(d => !standsAsDay(d.stops)) }, [days, assignments, accommodations]) // Only the geometry decides whether legs have to be re-fetched: renaming a place or diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index d7877ee61..e08b21e58 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -252,11 +252,17 @@ export const STATE_APPLIERS: Partial> } // Genuinely new — including a legitimate second assignment of a place - // already on this day (no temp version to reconcile). Append. + // already on this day (no temp version to reconcile). Seated where the server + // put it: a booked night lands at the front of its day, and the rows behind it + // were moved up one there. Appending it with that index would leave it tied + // with the old first row and drawn second until the next reload. + const ordered = existing.slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + const at = Math.max(0, Math.min(incoming.order_index ?? ordered.length, ordered.length)) + ordered.splice(at, 0, incoming) return { assignments: { ...state.assignments, - [dayKey]: [...existing, incoming], + [dayKey]: ordered.map((a, i) => (a.order_index === i ? a : { ...a, order_index: i })), } } }, diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts index b140feb9f..1d0eade62 100644 --- a/server/src/nest/accommodations/accommodations.service.ts +++ b/server/src/nest/accommodations/accommodations.service.ts @@ -164,9 +164,16 @@ export class AccommodationsService { return this.mirrorStay(accommodationId, placeId, dayId, checkIn); } - /** Carry a stay's own stop over to where the stay now is. */ - moveStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror { - return this.remirrorStay(accommodationId, placeId, dayId, checkIn); + /** Carry a stay's own stop over to where the stay now is. `checkInChanged` says the + * booking was given a new hour, which seats the night afresh (see remirrorStay). */ + moveStayStop( + accommodationId: number, + placeId: number | null, + dayId: number, + checkIn?: string | null, + opts: { checkInChanged?: boolean } = {}, + ): AccommodationMirror { + return this.remirrorStay(accommodationId, placeId, dayId, checkIn, opts); } /** Take back the stops of a stay that is being deleted elsewhere. */ @@ -277,17 +284,16 @@ export class AccommodationsService { /** * Where in the day the booked night belongs. * - * Last, unless the booking names an hour. A night usually ends the day, which is why - * that is the default — but a check-in at eleven says the traveller is at the desk - * before whatever they pinned to the afternoon, and dropping the stop behind it built - * a drive that visits the hotel after the sixteen-hundred it was booked around. - * - * Only stops that carry a clock of their own are compared. One without a time takes - * its position from the chain, not from the hour, so pushing past it would move a - * stop the traveller placed deliberately. + * First. The hotel is where the day is based: the traveller checks in and the stops + * they placed without an hour follow from there. Only a stop with a clock of its + * own can stand ahead of it, and only when that clock is at or before the check-in: + * a stop pinned to eight with a check-in at ten puts the night second, one pinned + * to the afternoon does not. It used to go last unless something pinned later + * pulled it forward, and a night booked for ten in the morning sat behind a whole + * day of unpinned stops, reached at a quarter past twelve. */ - private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number | undefined { - if (!checkIn) return undefined; + private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number { + if (!checkIn) return 0; // excludeId leaves the row being re-seated out of the chain it is measured // against. Without it a night parked at the end of the day can find itself. const rows = this.db.all<{ order_index: number; at: string | null }>(` @@ -295,18 +301,20 @@ export class AccommodationsService { FROM day_assignments da JOIN places p ON p.id = da.place_id WHERE da.day_id = ? AND da.id != ? ORDER BY da.order_index `, dayId, excludeId ?? -1); - const later = rows.find(row => row.at !== null && row.at > checkIn); - return later?.order_index; + let seat = 0; + for (const row of rows) if (row.at !== null && row.at <= checkIn) seat = row.order_index + 1; + return seat; } /** - * Whether the night already sits where its check-in says it should. + * Whether the night sits somewhere its check-in allows, on an edit that did not + * touch the check-in. * * Read off the chain rather than recomputed, because the index a fresh insert would * get is not the index this row already occupies. Two ways to be wrong: something * with a later hour ahead of it, or something with an earlier one behind it. Stops - * without an hour are passed over in both directions — their place in the chain is - * the traveller's doing, not a clock's. + * without an hour are passed over in both directions: the night may have been + * dragged past them on purpose, and a change of notes is no reason to undo that. */ private seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): boolean { if (!checkIn) return true; @@ -493,7 +501,7 @@ export class AccommodationsService { this.db.run('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?', dayId, placeId, end, stop.id); const seat = this.positionForCheckIn(dayId, checkIn, stop.id); - if (seat !== undefined && seat < end) { + if (seat < end) { this.db.run( 'UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?', dayId, seat, stop.id, @@ -519,9 +527,8 @@ export class AccommodationsService { if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror; const before = this.stopOrders([dayId]); - // Through AssignmentsService, so the stop lands at the end of the day with the - // order_index every other new assignment gets. Evening is where you arrive at a - // hotel, and the day planner has no drive to position it against anyway. + // Through AssignmentsService, seated where the check-in says (see + // positionForCheckIn), with everything behind it moved up one. // // The booking id goes in with the INSERT, not as an UPDATE afterwards: what this // returns is the row the answer hands the client, and stamping the id on later @@ -590,14 +597,24 @@ export class AccommodationsService { * * Runs inside the caller's transaction. */ - private remirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror { + private remirrorStay( + accommodationId: number, + placeId: number | null, + dayId: number, + checkIn?: string | null, + opts: { checkInChanged?: boolean } = {}, + ): AccommodationMirror { const own = this.ownStops(accommodationId); if (own.length === 0) return noMirror(); if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) { - // Same place, same day — but a check-in moved to a different hour can still put - // the night somewhere else in the chain, and a stop sitting after the afternoon - // it was booked around is the plan reading back wrong. - if (this.seatedByCheckIn(dayId, own[0].id, checkIn)) return noMirror(); + // Same place, same day. A check-in given a new hour seats the night afresh, the + // way booking it with that hour would have; any other edit leaves a stop where + // it is unless the clocks around it say it is in the wrong place, so a night the + // traveller dragged somewhere stays there through a change of notes. + const settled = opts.checkInChanged + ? this.positionForCheckIn(dayId, checkIn, own[0].id) === own[0].order_index + : this.seatedByCheckIn(dayId, own[0].id, checkIn); + if (settled) return noMirror(); } const mirror = noMirror(); @@ -689,7 +706,9 @@ export class AccommodationsService { 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?', newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id ); - return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn); + return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, { + checkInChanged: fields.check_in !== undefined && (fields.check_in || null) !== (existing.check_in || null), + }); }); // Sync check-in/out/confirmation to every linked reservation. The booking form diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 5d190e8ba..eb4ae18a1 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -846,6 +846,7 @@ export class ReservationsService { if (start_day_id && end_day_id) { this.requireResolvableStay(create_accommodation); if (resolvedAccId) { + const prior = this.db.get<{ check_in: string | null }>('SELECT check_in FROM day_accommodations WHERE id = ?', resolvedAccId); this.db.run( 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?', accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId @@ -853,7 +854,9 @@ export class ReservationsService { // The stay just moved. Its stop moves with it, or it is left sitting on a // day nobody sleeps there any more, hidden from the day list because it // still carries this booking's id and stranded in the middle of the drive. - stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in); + stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in, { + checkInChanged: (check_in || null) !== (prior?.check_in ?? null), + }); } else if (accPlaceId) { const accResult = this.db.run( 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)', diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts index 91a8a5f9b..7e99ceb05 100644 --- a/server/src/nest/roadtrip/roadtrip-plan.service.ts +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -22,6 +22,7 @@ import { type RoutedLeg, type SnappedWaypoint, type RouteAvoidClass, + standsAsDay, } from '@trek/shared/roadtrip'; interface StoredDay { @@ -32,6 +33,8 @@ interface StoredDay { default_transport_mode: string | null; } interface VisitRow { + /** The booking this stop stands for on its check-in day, when there is one. */ + stay_id: number | null; check_in: string | null; /** The drive does not read these two (#2357): they are the booking as get_roadtrip_context reports it. */ check_out: string | null; @@ -75,7 +78,7 @@ export class RoadtripPlanService { `SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng, COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time, p.duration_minutes, a.end_day, - a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.check_in, stay.check_out, checkout.day_number AS checkout_day + a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1) LEFT JOIN days checkout ON checkout.id = stay.end_day_id @@ -123,6 +126,7 @@ export class RoadtripPlanService { // planner makes in the browser (useRoadtripRoutes). leaveAt: v.end_time, checkInTime: v.check_in, + night: v.stay_id !== null, dwellMinutes: v.duration_minutes, endDay: v.end_day === 1, legMode: v.leg_transport_mode, @@ -245,8 +249,8 @@ export class RoadtripPlanService { preferences.roadtrip_range_km, ) || null; const calculated = assembleRoadtrip({ - plan: plan.filter((d) => d.stops.length > 1), - quietDays: plan.filter((d) => d.stops.length < 2), + plan: plan.filter((d) => standsAsDay(d.stops)), + quietDays: plan.filter((d) => !standsAsDay(d.stops)), window, allLegs, snapByDay, diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index 0fdd0e26c..c5223d820 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -615,7 +615,10 @@ describe('the day stop a booking implies', () => { expect(mirror.created.accommodation_id).toBe(accommodation.id); }); - it('ACC-022 the stop lands at the end of the day, where you arrive at a hotel', () => { + it('ACC-022 the stop lands first: the hotel is where the day is based', () => { + // It used to go last, and a night booked for the morning sat behind a whole day + // of unpinned stops. The stops the traveller placed without an hour follow the + // hotel; only a clock of their own can put one ahead of it (ACC-022c). const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -625,13 +628,12 @@ describe('the day stop a booking implies', () => { book(trip.id, hotel.id, day.id, day.id); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([museum.id, hotel.id]); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, museum.id]); + expect(stopsOn(day.id).map(a => a.order_index)).toEqual([0, 1]); }); it('ACC-022b a check-in puts the night before whatever is pinned to a later hour', () => { - // The default is last, because a night usually ends the day. A check-in at eleven - // says otherwise, and dropping the stop behind a stop pinned to the afternoon built - // a drive that reaches the hotel after the hour it was booked around. + // A stop pinned to the afternoon cannot stand ahead of a check-in at eleven. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -645,7 +647,7 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]); }); - it('ACC-022c a check-in after everything pinned stays last, like a night without one', () => { + it('ACC-022c a stop pinned to an earlier hour stays ahead of the check-in', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -659,18 +661,67 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, hotel.id]); }); - it('ACC-022d a stop without an hour of its own is never pushed past', () => { - // Its position came from the chain, not from a clock, so the traveller put it there. + it('ACC-022d a stop without an hour of its own follows the night, whatever the check-in', () => { + // A stop with no clock is planned from the hotel, not the other way round. This + // is the day from Discord: two unpinned stops and a night booked for ten in the + // morning, which the drive used to reach at a quarter past twelve. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - const untimed = createPlace(testDb, trip.id, { name: 'Hafen' }); - const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' }); - createDayAssignment(testDb, day.id, untimed.id); + const fuel = createPlace(testDb, trip.id, { name: 'Aral' }); + const farm = createPlace(testDb, trip.id, { name: 'Karls Erdbeerhof' }); + const hotel = createPlace(testDb, trip.id, { name: 'Rostock' }); + createDayAssignment(testDb, day.id, fuel.id); + createDayAssignment(testDb, day.id, farm.id); - svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }); + svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([untimed.id, hotel.id]); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, fuel.id, farm.id]); + }); + + it('ACC-022f the night goes behind the last stop whose own hour is at or before the check-in', () => { + // Pinned eight, unpinned, pinned two: a check-in at ten sits behind the eight and + // ahead of everything else, the unpinned stop included. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [early, loose, late, hotel] = ['Aral', 'Hafen', 'Museum', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); + const first = createDayAssignment(testDb, day.id, early.id); + createDayAssignment(testDb, day.id, loose.id); + const third = createDayAssignment(testDb, day.id, late.id); + testDb.prepare("UPDATE day_assignments SET assignment_time = '08:00' WHERE id = ?").run(first.id); + testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(third.id); + + svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); + + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([early.id, hotel.id, loose.id, late.id]); + }); + + it('ACC-022g a new check-in seats the night afresh, a change of notes leaves a dragged night alone', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [harbour, market, hotel] = ['Hafen', 'Markt', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); + createDayAssignment(testDb, day.id, harbour.id); + createDayAssignment(testDb, day.id, market.id); + const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id }) as any; + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]); + + // Dragged to the end of the day by hand. + const own = stopsOn(day.id).find(a => a.place_id === hotel.id)!; + testDb.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?').run(day.id, own.order_index); + testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]); + + let existing = svc.getAccommodation(accommodation.id, trip.id)!; + const quiet = svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' }) as any; + expect(quiet.mirror.moved).toBeNull(); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]); + + existing = svc.getAccommodation(accommodation.id, trip.id)!; + const reseated = svc.updateAccommodation(accommodation.id, existing, { check_in: '10:00' }) as any; + expect(reseated.mirror.moved).toMatchObject({ oldDayId: day.id, assignment: { id: own.id } }); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]); }); it('ACC-022e moving the check-in later re-seats the night', () => { @@ -887,7 +938,10 @@ describe('the day stop a booking implies', () => { createDayAssignment(testDb, day.id, dinner.id); const { accommodation } = book(trip.id, hotel.id, day.id, day.id); const stop = stopsOn(day.id).find((row: { place_id: number }) => row.place_id === hotel.id)!; - expect(stop.order_index).toBe(1); + // The backfill's placement, written by hand: the night behind the dinner. + testDb.prepare('UPDATE day_assignments SET order_index = 0 WHERE day_id = ? AND place_id = ?').run(day.id, dinner.id); + testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE id = ?').run(stop.id); + expect(stopsOn(day.id).map((row: { place_id: number }) => row.place_id)).toEqual([dinner.id, hotel.id]); const existing = svc.getAccommodation(accommodation.id, trip.id)!; const { mirror } = svc.updateAccommodation(accommodation.id, existing, { check_in: '15:00' }) as any; @@ -1051,13 +1105,13 @@ describe('the drawn roads a booking moves', () => { const pin = (assignmentId: number, time: string) => testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run(time, assignmentId); - /** A(0), B(1) pinned to noon, C(2): the day a check-in at eleven lands in the middle of. */ + /** A(0) pinned to nine, B(1) pinned to noon, C(2): the day a check-in at eleven lands in the middle of. */ function afternoonDay() { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const [a, b, c, hotel] = ['Harbour', 'Mercure', 'Museum', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name })); - createDayAssignment(testDb, day.id, a.id); + pin(createDayAssignment(testDb, day.id, a.id).id, '09:00'); pin(createDayAssignment(testDb, day.id, b.id).id, '12:00'); createDayAssignment(testDb, day.id, c.id); return { trip, day, a, b, c, hotel }; @@ -1091,20 +1145,22 @@ describe('the drawn roads a booking moves', () => { }]); }); - it('ACC-034b a night appended at the end of the day moves no road and reports none', () => { + it('ACC-034b a night seated behind every drawn road moves none and reports none', () => { const { trip, day, a, b, c, hotel } = afternoonDay(); const afterA = addVia(day.id, 0); const afterB = addVia(day.id, 1); - const { mirror } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id }) as any; + const { mirror } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }) as any; - expect(stopsOn(day.id)).toEqual([a.id, b.id, c.id, hotel.id]); + expect(stopsOn(day.id)).toEqual([a.id, b.id, hotel.id, c.id]); expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 0 }, { id: afterB, after_order_index: 1 }]); expect(mirror.vias).toBeUndefined(); }); it('ACC-034c re-seating the night by a later check-in re-pins the roads around it', () => { const { trip, day, a, b, c, hotel } = afternoonDay(); + // C pinned to the afternoon as well, so an evening check-in puts the night last. + testDb.prepare("UPDATE day_assignments SET assignment_time = '15:00' WHERE day_id = ? AND place_id = ?").run(day.id, c.id); const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }) as any; expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]); const afterA = addVia(day.id, 0); @@ -1134,7 +1190,7 @@ describe('the drawn roads a booking moves', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const [a, b, hotel] = ['Harbour', 'Mercure', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name })); - createDayAssignment(testDb, day.id, a.id); + pin(createDayAssignment(testDb, day.id, a.id).id, '09:00'); pin(createDayAssignment(testDb, day.id, b.id).id, '12:00'); const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }) as any; expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id]); @@ -1168,7 +1224,7 @@ describe('the drawn roads a booking moves', () => { const day1 = createDay(testDb, trip.id); const day2 = createDay(testDb, trip.id); const [a, b, c, hotel] = ['Harbour', 'Mercure', 'Museum', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name })); - createDayAssignment(testDb, day1.id, a.id); + pin(createDayAssignment(testDb, day1.id, a.id).id, '09:00'); pin(createDayAssignment(testDb, day1.id, b.id).id, '12:00'); pin(createDayAssignment(testDb, day2.id, c.id).id, '14:00'); const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day1.id, end_day_id: day1.id, check_in: '11:00' }) as any; diff --git a/shared/src/roadtrip/assemble.spec.ts b/shared/src/roadtrip/assemble.spec.ts index cd0bb5de2..58b12b81e 100644 --- a/shared/src/roadtrip/assemble.spec.ts +++ b/shared/src/roadtrip/assemble.spec.ts @@ -294,3 +294,47 @@ describe('assembleRoadtrip leave times', () => { expect(schedule.warnings).toContainEqual({ index: 2, code: 'late', minutes: 30 }); }); }); + +describe('assembleRoadtrip booked nights', () => { + // 90 km at the 40 seconds a kilometre `leg` drives is an hour. + const hour = 90; + + it('ROADTRIP-ASSEMBLE-012: a day that is only its booked night stands among the days', () => { + // The arrival day of a trip: nothing but the hotel, entered under Days with its + // check-in. It used to be filed under the quiet days at the foot of the rail, which + // reads as the hotel missing from the road trip altogether. + const routes = assemble([stop({ ownerIndex: 0, night: true, checkInTime: '15:00', stopType: 'hotel' })], []); + + expect(routes.quietDays).toEqual([]); + expect(routes.days).toHaveLength(1); + const day = routes.days[0]!; + expect(day.legs).toEqual([]); + expect(day.distance).toBe(0); + expect(day.schedule.entries[0]!).toMatchObject({ arrival: '15:00', anchored: true }); + }); + + it('ROADTRIP-ASSEMBLE-013: a lone stop that is no night stays a quiet day', () => { + const routes = assemble([stop({ ownerIndex: 0 })], []); + + expect(routes.days).toEqual([]); + expect(routes.quietDays).toHaveLength(1); + }); + + it('ROADTRIP-ASSEMBLE-014: the check-in holds the night and the day lines up behind it', () => { + // The day from Discord: two stops without an hour and the night booked for ten, + // which the drive used to reach at a quarter past twelve. Seated first by the + // server now, and held at ten by the schedule; the stops follow from there. + const stops = [ + stop({ ownerIndex: 0, night: true, checkInTime: '10:00', dwellMinutes: 60, stopType: 'hotel' }), + stop({ ownerIndex: 1, dwellMinutes: 30 }), + stop({ ownerIndex: 2, dwellMinutes: 60 }), + ]; + + const routes = assemble(stops, [hour, hour]); + + const { schedule } = routes.days[0]!; + expect(schedule.entries.map((e) => e.arrival)).toEqual(['10:00', '12:00', '13:30']); + expect(schedule.entries[0]!.anchored).toBe(true); + expect(schedule.warnings).toEqual([]); + }); +}); diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts index 58bab1de2..1f41abca1 100644 --- a/shared/src/roadtrip/assemble.ts +++ b/shared/src/roadtrip/assemble.ts @@ -21,6 +21,7 @@ import { refuelsRange, isServiceStopType, scheduleStopOf, + standsAsDay, type DryPoint, type DriveLimits, type VehicleKind, @@ -278,7 +279,7 @@ export function assembleRoadtrip({ dayWarning: drive.day, }); } - const drives = out.filter((d) => d.stops.length > 1 || !!d.spills?.length || d.stops.some((s) => s.automaticNight)); + const drives = out.filter((d) => standsAsDay(d.stops) || !!d.spills?.length || d.stops.some((s) => s.automaticNight)); const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap((day) => day.stops); const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => { const to = originalStops[position + 1]!; @@ -317,7 +318,7 @@ export function assembleRoadtrip({ ), quietDays: out - .filter((d) => d.stops.length < 2 && !d.spills?.length && !d.stops.some((s) => s.automaticNight)) + .filter((d) => !standsAsDay(d.stops) && !d.spills?.length && !d.stops.some((s) => s.automaticNight)) .map((d) => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })), loading, }; diff --git a/shared/src/roadtrip/dayWindow.spec.ts b/shared/src/roadtrip/dayWindow.spec.ts index 2bc82739d..365b01823 100644 --- a/shared/src/roadtrip/dayWindow.spec.ts +++ b/shared/src/roadtrip/dayWindow.spec.ts @@ -394,32 +394,51 @@ describe('daily travel window', () => { }); }); -it('waits for a check-in the drive overshoots rather than rushing it', () => { +it('holds a check-in the drive reaches within the minute a rounded leg can add', () => { const stops = [ stop(1, { time: '07:00', dwellMinutes: 60 }), stop(2, { checkInTime: '09:11' }), stop(3), ]; - const plan = calculate(stops, [72.5, 29]); + const plan = calculate(stops, [71.5, 29]); expect(plan.issue).toBeNull(); - // Arrives two minutes past the hour it may check in from, and is left there rather - // than pulled back to the round number. - expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:13'); + // Half a minute over is the rounding of the drive, not being late: the night keeps + // its check-in, the same grace a pinned stop gets. + expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:11'); const stops0 = plan.chains[0]!.stops; expect(stops0.at(-1)!.placeId).toBe(3); - // And carries straight on from there: nothing holds the traveller at the stop. - expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('09:42'); + expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('09:40'); }); -it('waits for check-in without treating it as a fixed appointment', () => { +it('does not rush a check-in the drive overshoots, and does not quietly move it either', () => { + // Two minutes past the check-in is past it. A pinned time out of reach is a + // conflict here, and a check-in is one of those now. + const stops = [ + stop(1, { time: '07:00', dwellMinutes: 60 }), + stop(2, { checkInTime: '09:11' }), + stop(3), + ]; + expect(calculate(stops, [72.5, 29]).issue).toBe('conflict'); +}); + +it('waits for a check-in the drive reaches early', () => { const plan = calculate([stop(1), stop(2, { checkInTime: '15:00' }), stop(3)], [60, 29]); expect(plan.issue).toBeNull(); - // The check-in is a door opening, not an appointment: the drive waits for it and then - // carries straight on, rather than the day being rebuilt around it. + // The night is held to its check-in the way a pinned stop is held to its time: the + // drive waits for it and carries on from there. expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('15:00'); + expect(plan.chains[0]!.schedule.entries[1]!.anchored).toBe(true); expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('15:29'); }); +it('treats a check-in the drive cannot make as the conflict a missed pin is', () => { + // A night booked for ten with two hours of driving pinned ahead of it. The window + // planner does not invent a plan around a time it cannot keep; it hands back to the + // plain schedule, which keeps the ten and says how late the drive is. + const plan = calculate([stop(1, { time: '09:00' }), stop(2, { checkInTime: '10:00' })], [120]); + expect(plan.issue).toBe('conflict'); +}); + describe('a time set to leave a stop, with daily travel times', () => { it('spends the hours until then rather than the stay', () => { const plan = calculate( diff --git a/shared/src/roadtrip/dayWindow.ts b/shared/src/roadtrip/dayWindow.ts index 4cc22be37..27d8d1125 100644 --- a/shared/src/roadtrip/dayWindow.ts +++ b/shared/src/roadtrip/dayWindow.ts @@ -207,7 +207,10 @@ export function planDayWindow( let previous: RoadtripStop | undefined; for (let i = 0; i < stops.length; i++) { const { stop, day } = stops[i]!; - const pin = parseClock(stop.time); + // A booked night's check-in pins the stop the way its own time would: the day is + // built to be there by then, and a drive that cannot make it is the same conflict + // a pinned stop out of reach is. + const pin = parseClock(stop.time) ?? parseClock(stop.checkInTime); const leave = parseClock(stop.leaveAt); // When the drive into this stop set out, on this day's clock: for one a night broke // up, the morning it went on. @@ -290,7 +293,6 @@ export function planDayWindow( const target = targets.get(number); if (target !== undefined && target < position) return failed('conflict'); if (pin !== null) clock = pin; - if (pin === null && number === day.dayNumber) clock = Math.max(clock, parseClock(stop.checkInTime) ?? 0); append(stop, clock); previous = stop; diff --git a/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts index dcf41bec9..18d143ab9 100644 --- a/shared/src/roadtrip/planning-types.ts +++ b/shared/src/roadtrip/planning-types.ts @@ -25,6 +25,8 @@ export interface RoadtripStop { dwellMinutes: number | null; checkInTime?: string | null; + /** A booked night's stop on its check-in day, whether or not the booking names an hour. */ + night?: boolean; endDay?: boolean; legMode: string | null; diff --git a/shared/src/roadtrip/roadtripModel.spec.ts b/shared/src/roadtrip/roadtripModel.spec.ts index d806b5eb5..14d5931e5 100644 --- a/shared/src/roadtrip/roadtripModel.spec.ts +++ b/shared/src/roadtrip/roadtripModel.spec.ts @@ -1064,7 +1064,6 @@ describe('scheduleStopOf', () => { it('turns a leave time into a departure', () => { expect(scheduleStopOf({ time: '10:00', checkInTime: null, dwellMinutes: 60, leaveAt: '14:00' })).toEqual({ anchor: '10:00', - earliest: null, dwellMinutes: 60, departureAt: 840, }); @@ -1073,11 +1072,21 @@ describe('scheduleStopOf', () => { it('leaves the departure out when there is no usable leave time', () => { expect(scheduleStopOf({ time: null, dwellMinutes: 30 })).toEqual({ anchor: null, - earliest: null, dwellMinutes: 30, }); expect(scheduleStopOf({ time: null, dwellMinutes: 30, leaveAt: 'soon' })).not.toHaveProperty('departureAt'); }); + + it('anchors a booked night on its check-in', () => { + expect(scheduleStopOf({ time: null, checkInTime: '10:00', dwellMinutes: 60 })).toEqual({ + anchor: '10:00', + dwellMinutes: 60, + }); + }); + + it('lets a time pinned on the stop itself win over the check-in', () => { + expect(scheduleStopOf({ time: '16:00', checkInTime: '15:00', dwellMinutes: 60 }).anchor).toBe('16:00'); + }); }); @@ -1104,19 +1113,19 @@ describe('hasChosenArrival', () => { }); }); -describe('a check-in is a door opening, not an appointment', () => { - // The hour a room becomes available. Reaching it later is reaching it; only a time - // somebody pinned to a stop can be missed. Read as an anchor it did the opposite: - // arriving at 16:14 was reported as 5 h 14 late against an 11:00 check-in, and the - // hotel was pushed onto the next day because 11:00 reads as earlier than the 15:00 - // before it. +describe('a check-in holds a booked night the way a pinned time does', () => { + // The hour the traveller said they are at the hotel. It used to be a floor, waited + // for when the drive got there first and silent otherwise, and once anything before + // it set the clock it held nothing: a night booked for ten in the morning was + // reported reached at a quarter past twelve, with the rest of the day lined up + // behind that. The check-in goes in as the anchor now (see scheduleStopOf), so + // these run on the anchor the way the rail does. it('waits for it when the drive gets there first', () => { const schedule = computeSchedule( - [{ anchor: '09:00', dwellMinutes: 0 }, { anchor: null, earliest: '15:00', dwellMinutes: 0 }], + [{ anchor: '09:00', dwellMinutes: 0 }, { anchor: '15:00', dwellMinutes: 0 }], [3600], ); expect(schedule.entries[1]!.arrival).toBe('15:00'); - // Waiting for a door is waiting for a time somebody named, so it is printed as one. expect(schedule.entries[1]!.anchored).toBe(true); expect(schedule.warnings).toEqual([]); }); @@ -1126,7 +1135,7 @@ describe('a check-in is a door opening, not an appointment', () => { // Read only as a floor, the chain worked the hotel backwards out of the afternoon // and put it at 13:46: true enough as arithmetic, and nothing anybody asked for. const schedule = computeSchedule( - [{ anchor: null, earliest: '10:00', dwellMinutes: 60 }, { anchor: '15:00', dwellMinutes: 60 }], + [{ anchor: '10:00', dwellMinutes: 60 }, { anchor: '15:00', dwellMinutes: 60 }], [14 * 60], ); expect(schedule.entries[0]!.arrival).toBe('10:00'); @@ -1135,18 +1144,21 @@ describe('a check-in is a door opening, not an appointment', () => { expect(schedule.warnings).toEqual([]); }); - it('is simply arrived at when the drive gets there later', () => { + it('keeps its hour and says how late the drive is when it gets there later', () => { + // Two stops, then the hotel booked for ten: the drive reaches it at 12:17. The + // night stays at ten and the day is lined up behind that, and the finding says + // by how much the drive misses it, the way it would for any pinned stop. const schedule = computeSchedule( - [{ anchor: '15:00', dwellMinutes: 60 }, { anchor: null, earliest: '11:00', dwellMinutes: 60 }], - [14 * 60], + [{ anchor: '08:00', dwellMinutes: 30 }, { anchor: null, dwellMinutes: 60 }, { anchor: '10:00', dwellMinutes: 60 }], + [89 * 60, 78 * 60], ); - expect(schedule.entries[1]!.arrival).toBe('16:14'); - // Nothing was decided here, so it reads as computed. - expect(schedule.entries[1]!.anchored).toBe(false); - // And nothing was missed. - expect(schedule.warnings).toEqual([]); + expect(schedule.entries[1]!.arrival).toBe('09:59'); + expect(schedule.entries[2]!.arrival).toBe('10:00'); + expect(schedule.entries[2]!.anchored).toBe(true); + expect(schedule.entries[2]!.departure).toBe('11:00'); + expect(schedule.warnings).toEqual([{ index: 2, code: 'late', minutes: 137 }]); // Still the same day, which is what the day split reads. - expect(schedule.entries[1]!.dayOffset).toBe(0); + expect(schedule.entries[2]!.dayOffset).toBe(0); }); it('still reports a pinned time that cannot be made', () => { diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts index 8e7b65422..a70d360ab 100644 --- a/shared/src/roadtrip/roadtripModel.ts +++ b/shared/src/roadtrip/roadtripModel.ts @@ -42,12 +42,12 @@ export function formatDurationShort(seconds: number): string { * Whether this stop's arrival is a time somebody chose, rather than one the chain * worked out from the stop before it. * - * A check-in counts. Every schedule already anchors on `time ?? checkInTime`, so a - * booked night starts its day exactly like a pinned stop does; asking only about - * `time` here made the rail print that chosen hour in the grey it reserves for - * computed ones. One function because the answer was spelled out separately in each - * of the three schedulers, and two of them spelled it differently from the anchor - * they had just used. + * A check-in counts. Every schedule anchors on `time ?? checkInTime`, so a booked + * night holds its hour exactly like a pinned stop does; asking only about `time` + * here made the rail print that chosen hour in the grey it reserves for computed + * ones. One function because the answer was spelled out separately in each of the + * three schedulers, and two of them spelled it differently from the anchor they had + * just used. */ export function hasChosenArrival(stop: { time?: string | null; checkInTime?: string | null; automaticNight?: unknown }): boolean { if (stop.automaticNight) return false; @@ -83,16 +83,9 @@ export interface ScheduleStop { */ departureAt?: number; /** A time somebody fixed this stop to. The chain restarts from it, and arriving - * after it is being late. */ + * after it is being late. A booked night's check-in is one of these. */ anchor: string | null; - /** - * The earliest this stop can be entered, when something says so — a check-in is - * the hour a room becomes available, not an appointment. Arriving before it means - * waiting for it; arriving after it means arriving, with nothing to report. - */ - earliest?: string | null; - dwellMinutes: number | null; } @@ -222,6 +215,13 @@ export function leaveAfter( /** * The part of a road trip stop the schedule reads. * + * A booked night's check-in is the hour the traveller said they are at the hotel, so + * it holds the stop the way a pinned time does: the day is built around it, and a + * drive that gets there later is late. Read as a mere floor it held nothing once + * anything before it set the clock, and a night booked for ten in the morning was + * reported reached at a quarter past twelve, with the rest of the day lined up + * behind that. A time pinned on the stop itself still wins. + * * A visit's end time is when the drive leaves it. It is the traveller's own statement * about this visit, unlike the check-out that used to feed `departureAt` (the LATEST a * room has to be handed back, which is why #2357 took it out of the drive). @@ -231,8 +231,7 @@ export function scheduleStopOf( ): ScheduleStop { const leave = parseClock(stop.leaveAt); return { - anchor: stop.time ?? null, - earliest: stop.checkInTime ?? null, + anchor: stop.time ?? stop.checkInTime ?? null, dwellMinutes: stop.dwellMinutes, ...(leave === null ? {} : { departureAt: leave }), }; @@ -267,22 +266,7 @@ export function computeSchedule( for (let i = 0; i < stops.length; i++) { const stop = stops[i]!; const anchor = parseClock(stop.anchor); - const resolved = resolveArrival(anchor, cursor, dayOffset); - // A door that opens at eleven is not an appointment at eleven. Reaching the stop - // later than that is simply reaching it; only a time somebody pinned can be missed. - const opens = parseClock(stop.earliest ?? null); - // With nothing before it deciding the hour, the door IS the hour: a night booked - // to check in at ten starts the day at ten rather than being worked backwards out - // of whatever comes after it. Reached later, it is only a floor, and a floor below - // the arrival changes nothing. - const waited = anchor === null && opens !== null - ? resolved.arrival === null - ? opens + dayOffset * DAY_MINUTES - : opens + Math.round((resolved.arrival - opens) / DAY_MINUTES) * DAY_MINUTES - : null; - const held = waited !== null && (resolved.arrival === null || waited > resolved.arrival); - const arrival = held ? waited : resolved.arrival; - const lateBy = resolved.lateBy; + const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset); if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy }); if (arrival === null) { @@ -302,9 +286,7 @@ export function computeSchedule( if (offset > dayOffset) dayOffset = offset; arrivals[i]! = arrival; - // Ink for a time somebody decided: a pinned one always, a check-in only where the - // drive actually had to wait for it. - anchored[i]! = anchor !== null || held; + anchored[i]! = anchor !== null; let departure = arrival + (stop.dwellMinutes ?? 0); if (stop.departureAt !== undefined) { @@ -385,6 +367,20 @@ export function computeSchedule( return { entries, warnings, endsAt }; } +/** + * Whether the rail lists this day among the days, rather than as a quiet placeholder + * asking for stops. + * + * Two stops make a drive. A booked night makes a day on its own: the day you arrive + * somewhere and go no further, or the one you check in at ten and set out from. It + * used to need company, and a hotel entered under Days for the arrival day was filed + * at the bottom of the rail under "only X so far", which reads as the hotel missing + * from the road trip altogether. + */ +export function standsAsDay(stops: readonly { night?: boolean }[]): boolean { + return stops.length > 1 || stops.some((stop) => stop.night === true); +} + export function splitIntoRuns(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] { const runs: { stops: T[]; mode: string }[] = []; let current: T[] = []; From a86e4b560a229cee6cec1c4547b6bbeb90e5dcfb Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 17:49:54 +0200 Subject: [PATCH 02/13] test(accommodations): a quiet edit still corrects a night the clocks contradict Covers seatedByCheckIn with a check-in set, which the re-seat on a new check-in no longer passes through, so the accommodations function ratchet held. --- .../unit/nest/accommodations.service.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index c5223d820..758a363d1 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -743,6 +743,38 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map(a => a.place_id)).toEqual([afternoon.id, hotel.id]); }); + it('ACC-022h an edit that leaves the check-in alone still corrects a night the clocks contradict', () => { + // Dragged behind a stop pinned to the afternoon, with a check-in at ten: a change of + // notes puts it back ahead of that stop, because a night sitting after an afternoon it + // was booked before is the plan reading back wrong. Dragged behind a stop without an + // hour, the same edit leaves it where the traveller put it. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [harbour, museum, hotel] = ['Hafen', 'Museum', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); + createDayAssignment(testDb, day.id, harbour.id); + const pinned = createDayAssignment(testDb, day.id, museum.id); + testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(pinned.id); + const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }) as any; + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); + const own = stopsOn(day.id).find(a => a.place_id === hotel.id)!; + + // Behind the harbour only: allowed, and left alone. + testDb.prepare('UPDATE day_assignments SET order_index = 0 WHERE day_id = ? AND place_id = ?').run(day.id, harbour.id); + testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE id = ?').run(own.id); + let existing = svc.getAccommodation(accommodation.id, trip.id)!; + expect((svc.updateAccommodation(accommodation.id, existing, { notes: 'late' }) as any).mirror.moved).toBeNull(); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, hotel.id, museum.id]); + + // Behind the afternoon: contradicted by the clocks, and seated afresh, which is + // the front, ahead of the harbour that has no hour of its own. + testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE day_id = ? AND place_id = ?').run(day.id, museum.id); + testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id); + existing = svc.getAccommodation(accommodation.id, trip.id)!; + expect((svc.updateAccommodation(accommodation.id, existing, { notes: 'later' }) as any).mirror.moved).not.toBeNull(); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); + }); + it('ACC-023 the place is typed as lodging, so the rail draws it as a service stop', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); From 417d0445b94cf1d6b2e9447aa0631ad549ca6854 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 18:45:37 +0200 Subject: [PATCH 03/13] fix(roadtrip): seat the nights booked before this release where their check-in says A booked night leads its day now, but the stops the bookings put on their check-in day before this release sit last, and the backfill for older bookings appended them too. Every trip planned before the change kept that order until somebody edited the check-in. A migration seats every night a booking owns by the same rule the service applies: first, behind only a stop whose own clock is at or before the check-in. The drawn roads follow the stops they were drawn after, by the rules the service uses when it moves a night. Stops the traveller placed themselves are not touched. Re-runnable. --- server/src/db/migrations.ts | 14 ++ server/src/db/reseat-booked-nights.ts | 115 ++++++++++ .../tests/unit/db/booked-night-reseat.test.ts | 211 ++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 server/src/db/reseat-booked-nights.ts create mode 100644 server/tests/unit/db/booked-night-reseat.test.ts diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index ceaf909d2..596ea745f 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -2,6 +2,7 @@ import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared'; import { readEnv } from '../app-config'; import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto'; import { seedDocumentProviders } from './document-provider-seed'; +import { reseatBookedNights } from './reseat-booked-nights'; import Database from 'better-sqlite3'; import fs from 'fs'; @@ -5212,6 +5213,19 @@ function runMigrations(db: Database.Database): void { ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries; `); }, + + /* + * Seat every booked night where its check-in says, the way a night booked + * today is seated. The night leads its day now; the stops a booking put on + * their check-in day before this release sit last, and the trips people + * already have would keep that order until somebody edits the check-in. + * The rules, and the drawn roads that follow the stops, are in + * reseat-booked-nights.ts. Re-runnable. + */ + () => { + const seated = reseatBookedNights(db); + if (seated > 0) console.log(`[DB] Seated ${seated} booked night(s) at the head of their day`); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/reseat-booked-nights.ts b/server/src/db/reseat-booked-nights.ts new file mode 100644 index 000000000..458e969ed --- /dev/null +++ b/server/src/db/reseat-booked-nights.ts @@ -0,0 +1,115 @@ +import Database from 'better-sqlite3'; + +type DayRow = { id: number; at: string | null; located: number }; +type Via = { id: number; after_order_index: number; sequence: number }; + +/** + * Seat every booked night where its check-in says, the way a night booked today is + * seated (AccommodationsService.positionForCheckIn). + * + * The stop a booking puts on its check-in day used to go last unless a stop pinned to + * a later hour pulled it forward, and the backfill that gave older bookings their stop + * appended it too. The night leads its day now: first, behind only a stop whose own + * clock is at or before the check-in, with the stops that carry no hour following it. + * Without this step every trip planned before the change keeps the old order until + * somebody edits the check-in. + * + * Only the stops a booking owns move (accommodation_id). A stop the traveller placed + * themselves and then booked a night on is theirs, wherever it sits. The drawn roads + * (roadtrip_vias) are pinned to positions among the day's located stops, so they are + * carried along by the rules the service applies (AccommodationsService.reanchorVias): + * a via follows the stop it was drawn after, a via behind a stop that is last stays + * with it, and one left without a leg goes. + * + * Runs inside the migration's transaction. Re-runnable: a night already in its seat is + * passed over. Returns how many nights moved. + */ +export function reseatBookedNights(db: Database.Database): number { + const nights = db.prepare(` + SELECT da.id, da.day_id, a.check_in + FROM day_assignments da + JOIN day_accommodations a ON a.id = da.accommodation_id + ORDER BY da.day_id, da.order_index, da.id + `).all() as Array<{ id: number; day_id: number; check_in: string | null }>; + const dayRows = db.prepare(` + SELECT da.id, COALESCE(da.assignment_time, p.place_time) AS at, + (p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located + FROM day_assignments da JOIN places p ON p.id = da.place_id + WHERE da.day_id = ? + ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC + `); + const setIndex = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?'); + const viasOf = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?'); + const dropVia = db.prepare('DELETE FROM roadtrip_vias WHERE id = ?'); + const moveVia = db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ?'); + const setSequence = db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ?'); + + let seated = 0; + for (const night of nights) { + const rows = dayRows.all(night.day_id) as DayRow[]; + const own = rows.findIndex((row) => row.id === night.id); + if (own < 0) continue; + const others = rows.filter((row) => row.id !== night.id); + let seat = 0; + if (night.check_in) { + others.forEach((row, i) => { + if (row.at !== null && row.at <= night.check_in!) seat = i + 1; + }); + } + const next = [...others.slice(0, seat), rows[own]!, ...others.slice(seat)]; + if (next.every((row, i) => row.id === rows[i]!.id)) continue; + + const previousLocated = rows.filter((row) => row.located).map((row) => row.id); + next.forEach((row, i) => setIndex.run(i, row.id)); + seated += 1; + + const nextLocated = next.filter((row) => row.located).map((row) => row.id); + const vias = viasOf.all(night.day_id) as Via[]; + if (!vias.length) continue; + const previousLast = previousLocated.length - 1; + const nextLast = nextLocated.length - 1; + const lastStayed = previousLast >= 0 && previousLocated[previousLast] === nextLocated[nextLast]; + const landed = new Set(); + const kept: Array = []; + for (const via of vias) { + const leg = lastStayed && via.after_order_index === previousLast + ? nextLast + : legAfter(via.after_order_index, previousLocated, nextLocated); + if (leg === null) { + dropVia.run(via.id); + continue; + } + if (leg !== via.after_order_index) { + moveVia.run(leg, via.id); + landed.add(via.id); + } + kept.push({ ...via, was: via.after_order_index, after_order_index: leg }); + } + // Two legs that merged carry two sequence series side by side. Renumbered the way + // the service does it: the earlier leg's points first, then by their old sequence, + // then by id. Only a leg that received a via is touched. + const byLeg = new Map>(); + for (const via of kept) byLeg.set(via.after_order_index, [...(byLeg.get(via.after_order_index) ?? []), via]); + for (const onLeg of byLeg.values()) { + if (!onLeg.some((via) => landed.has(via.id))) continue; + onLeg.sort((a, b) => a.was - b.was || a.sequence - b.sequence || a.id - b.id); + onLeg.forEach((via, i) => { + if (via.sequence !== i) setSequence.run(i, via.id); + }); + } + } + return seated; +} + +/** + * The leg a via pinned behind the n-th stop of the old order is on in the new one + * (AccommodationsService.legAfter). Null when no leg is left for it. + */ +function legAfter(index: number, previousIds: number[], nextIds: number[]): number | null { + if (index > previousIds.length - 1) return null; + let at = index; + while (at >= 0 && !nextIds.includes(previousIds[at]!)) at -= 1; + if (at < 0) return null; + const next = nextIds.indexOf(previousIds[at]!); + return next >= nextIds.length - 1 ? null : next; +} diff --git a/server/tests/unit/db/booked-night-reseat.test.ts b/server/tests/unit/db/booked-night-reseat.test.ts new file mode 100644 index 000000000..14246ef25 --- /dev/null +++ b/server/tests/unit/db/booked-night-reseat.test.ts @@ -0,0 +1,211 @@ +/** + * The step that seats every booked night where its check-in says. + * + * A night's stop used to go last on its check-in day unless a stop pinned to a later + * hour pulled it forward, and the backfill for older bookings appended it as well. The + * night leads its day now (AccommodationsService.positionForCheckIn), and this step + * brings the trips planned under the old rule along, drawn roads included. + * + * The rules live in reseat-booked-nights.ts and are tested there directly on a fully + * migrated database: the columns the step reads (assignment_time, accommodation_id) + * and the vias table come from migrations, so seeding before runMigrations is not an + * option, and rewinding schema_version would tie this file to the tail of the + * append-only array. + */ +import { describe, it, expect } from 'vitest'; +import Database from 'better-sqlite3'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { reseatBookedNights } from '../../../src/db/reseat-booked-nights'; + +function freshDb() { + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + createTables(db); + runMigrations(db); + db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'u', 'u@test.local', 'x', 'user')").run(); + db.prepare("INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')").run(); + db.prepare("INSERT INTO days (id, trip_id, day_number, date) VALUES (1, 1, 1, '2026-10-02')").run(); + return db; +} + +let nextPlace = 1; +function place(db: Database.Database, name: string, time: string | null = null, located = true) { + const id = nextPlace++; + db.prepare('INSERT INTO places (id, trip_id, name, lat, lng, place_time) VALUES (?, 1, ?, ?, ?, ?)') + .run(id, name, located ? 50 + id / 100 : null, located ? 10 : null, time); + return id; +} + +let nextStop = 1; +function stop(db: Database.Database, placeId: number, orderIndex: number, time: string | null = null, accommodationId: number | null = null) { + const id = nextStop++; + db.prepare('INSERT INTO day_assignments (id, day_id, place_id, order_index, assignment_time, accommodation_id) VALUES (?, 1, ?, ?, ?, ?)') + .run(id, placeId, orderIndex, time, accommodationId); + return id; +} + +function night(db: Database.Database, placeId: number, checkIn: string | null) { + return Number(db.prepare('INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (1, ?, 1, 1, ?)') + .run(placeId, checkIn).lastInsertRowid); +} + +const order = (db: Database.Database) => + (db.prepare('SELECT place_id FROM day_assignments WHERE day_id = 1 ORDER BY order_index').all() as { place_id: number }[]).map((r) => r.place_id); +const indexes = (db: Database.Database) => + (db.prepare('SELECT order_index FROM day_assignments WHERE day_id = 1 ORDER BY order_index').all() as { order_index: number }[]).map((r) => r.order_index); +const vias = (db: Database.Database) => + db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = 1 ORDER BY after_order_index, sequence, id').all() as + { id: number; after_order_index: number; sequence: number }[]; +const via = (db: Database.Database, afterOrderIndex: number, sequence = 0) => + Number(db.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (1, ?, ?, 50, 10)').run(afterOrderIndex, sequence).lastInsertRowid); + +describe('booked night reseat', () => { + it('RESEAT-001: a night appended behind stops without an hour leads the day', () => { + const db = freshDb(); + const [fuel, farm, hotel] = [place(db, 'Aral'), place(db, 'Karls'), place(db, 'Rostock')]; + stop(db, fuel, 0); + stop(db, farm, 1); + stop(db, hotel, 2, null, night(db, hotel, '10:00')); + + expect(reseatBookedNights(db)).toBe(1); + + expect(order(db)).toEqual([hotel, fuel, farm]); + expect(indexes(db)).toEqual([0, 1, 2]); + }); + + it('RESEAT-002: a night without a check-in leads the day too', () => { + const db = freshDb(); + const [museum, hotel] = [place(db, 'Museum'), place(db, 'Adlon')]; + stop(db, museum, 0); + stop(db, hotel, 1, null, night(db, hotel, null)); + + reseatBookedNights(db); + + expect(order(db)).toEqual([hotel, museum]); + }); + + it('RESEAT-003: a stop with its own hour at or before the check-in stays ahead', () => { + const db = freshDb(); + const [early, loose, late, hotel] = [place(db, 'Aral'), place(db, 'Hafen'), place(db, 'Museum'), place(db, 'Rostock')]; + stop(db, early, 0, '08:00'); + stop(db, loose, 1); + // The place's own hour counts as much as the visit's. + stop(db, late, 2, null); + db.prepare("UPDATE places SET place_time = '14:00' WHERE id = ?").run(late); + stop(db, hotel, 3, null, night(db, hotel, '10:00')); + + reseatBookedNights(db); + + expect(order(db)).toEqual([early, hotel, loose, late]); + }); + + it('RESEAT-004: a night already in its seat, and a stop the traveller placed, are left alone', () => { + const db = freshDb(); + const [hotel, museum, own] = [place(db, 'Adlon'), place(db, 'Museum'), place(db, 'Campsite')]; + stop(db, hotel, 0, null, night(db, hotel, '15:00')); + stop(db, museum, 1); + // Booked in road trip mode: the stop is the traveller's, the booking owns no row. + night(db, own, '18:00'); + stop(db, own, 2); + + expect(reseatBookedNights(db)).toBe(0); + + expect(order(db)).toEqual([hotel, museum, own]); + }); + + it('RESEAT-005: the drawn roads follow the stops they were drawn after', () => { + const db = freshDb(); + const [a, b, hotel] = [place(db, 'Harbour'), place(db, 'Mercure'), place(db, 'Rostock')]; + stop(db, a, 0); + stop(db, b, 1); + stop(db, hotel, 2, null, night(db, hotel, '10:00')); + const afterA = via(db, 0); + const afterB = via(db, 1); + const afterHotel = via(db, 2); + + reseatBookedNights(db); + + // The rules the service applies when it moves a night: a via follows the stop + // it was drawn after, so A's road is on leg one now and the hotel's on leg + // zero; B is last now and has no leg to keep a via on, so its road goes. + expect(order(db)).toEqual([hotel, a, b]); + expect(vias(db)).toEqual([ + { id: afterHotel, after_order_index: 0, sequence: 0 }, + { id: afterA, after_order_index: 1, sequence: 0 }, + ]); + expect(vias(db).map((v) => v.id)).not.toContain(afterB); + }); + + it('RESEAT-006: a road behind the last stop stays with it, and the roads ahead follow their stops', () => { + // The hotel moves from the middle to the front. C is last before and after, so the + // drive into the next day stays behind it; the hotel's road follows the hotel to + // leg zero and A's follows A to leg one. Nothing merges in a pure reorder. + const db = freshDb(); + const [a, hotel, c] = [place(db, 'Harbour'), place(db, 'Rostock'), place(db, 'Museum')]; + stop(db, a, 0); + stop(db, hotel, 1, null, night(db, hotel, '10:00')); + stop(db, c, 2); + const afterHotel = via(db, 1); + const afterA = via(db, 0); + const intoTomorrow = via(db, 2); + + reseatBookedNights(db); + + expect(order(db)).toEqual([hotel, a, c]); + expect(vias(db)).toEqual([ + { id: afterHotel, after_order_index: 0, sequence: 0 }, + { id: afterA, after_order_index: 1, sequence: 0 }, + { id: intoTomorrow, after_order_index: 2, sequence: 0 }, + ]); + }); + + it('RESEAT-007: a stop without coordinates is not counted among the positions the roads are pinned to', () => { + const db = freshDb(); + const [a, ghost, hotel] = [place(db, 'Harbour'), place(db, 'No map', null, false), place(db, 'Rostock')]; + stop(db, a, 0); + stop(db, ghost, 1); + stop(db, hotel, 2, null, night(db, hotel, '10:00')); + const afterA = via(db, 0); + + reseatBookedNights(db); + + expect(order(db)).toEqual([hotel, a, ghost]); + // Located stops went [A, hotel] to [hotel, A]: A is last now, so its road has + // no leg left. + expect(vias(db).map((v) => v.id)).not.toContain(afterA); + }); + + it('RESEAT-008: runs again without moving anything', () => { + const db = freshDb(); + const [fuel, hotel] = [place(db, 'Aral'), place(db, 'Rostock')]; + stop(db, fuel, 0); + stop(db, hotel, 1, null, night(db, hotel, '10:00')); + reseatBookedNights(db); + const once = order(db); + + expect(reseatBookedNights(db)).toBe(0); + expect(order(db)).toEqual(once); + }); + + it('RESEAT-009: the migration runs the step on a database being upgraded', () => { + // Seeded through the tables the base schema has, so the step fires on its normal + // pass: the backfill for older bookings appends the night last first, then the + // reseat brings it to the front. + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + createTables(db); + db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'u', 'u@test.local', 'x', 'user')").run(); + db.prepare("INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')").run(); + db.prepare("INSERT INTO days (id, trip_id, day_number, date) VALUES (1, 1, 1, '2026-10-02')").run(); + db.prepare("INSERT INTO places (id, trip_id, name, lat, lng) VALUES (901, 1, 'Aral', 50.1, 10), (902, 1, 'Rostock', 50.2, 10)").run(); + db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (1, 901, 0)').run(); + db.prepare("INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (1, 902, 1, 1, '10:00')").run(); + + runMigrations(db); + + expect(order(db)).toEqual([902, 901]); + }); +}); From be9a38e3b0395519c5afc109fec900a527920923 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 18:48:26 +0200 Subject: [PATCH 04/13] fix(accommodations): a second night on the day counts by its check-in Two bookings on one day used to settle by whichever was seated last, because the other night's stop carries no hour of its own. Its check-in counts now, in the service and in the migration alike. --- server/src/db/reseat-booked-nights.ts | 5 ++++- .../nest/accommodations/accommodations.service.ts | 8 ++++++-- server/tests/unit/db/booked-night-reseat.test.ts | 12 ++++++++++++ .../tests/unit/nest/accommodations.service.test.ts | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/server/src/db/reseat-booked-nights.ts b/server/src/db/reseat-booked-nights.ts index 458e969ed..cfe904449 100644 --- a/server/src/db/reseat-booked-nights.ts +++ b/server/src/db/reseat-booked-nights.ts @@ -31,10 +31,13 @@ export function reseatBookedNights(db: Database.Database): number { JOIN day_accommodations a ON a.id = da.accommodation_id ORDER BY da.day_id, da.order_index, da.id `).all() as Array<{ id: number; day_id: number; check_in: string | null }>; + // Another night on the same day counts by its check-in, so two bookings on one + // day settle by the clock instead of by whichever was seated last. const dayRows = db.prepare(` - SELECT da.id, COALESCE(da.assignment_time, p.place_time) AS at, + SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at, (p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located FROM day_assignments da JOIN places p ON p.id = da.place_id + LEFT JOIN day_accommodations other ON other.id = da.accommodation_id WHERE da.day_id = ? ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC `); diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts index 1d0eade62..a7403fe7d 100644 --- a/server/src/nest/accommodations/accommodations.service.ts +++ b/server/src/nest/accommodations/accommodations.service.ts @@ -296,9 +296,12 @@ export class AccommodationsService { if (!checkIn) return 0; // excludeId leaves the row being re-seated out of the chain it is measured // against. Without it a night parked at the end of the day can find itself. + // Another booked night on the day counts by its check-in, so two bookings on + // one day settle by the clock. const rows = this.db.all<{ order_index: number; at: string | null }>(` - SELECT da.order_index, COALESCE(da.assignment_time, p.place_time) AS at + SELECT da.order_index, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at FROM day_assignments da JOIN places p ON p.id = da.place_id + LEFT JOIN day_accommodations other ON other.id = da.accommodation_id WHERE da.day_id = ? AND da.id != ? ORDER BY da.order_index `, dayId, excludeId ?? -1); let seat = 0; @@ -319,8 +322,9 @@ export class AccommodationsService { private seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): boolean { if (!checkIn) return true; const rows = this.db.all<{ id: number; at: string | null }>(` - SELECT da.id, COALESCE(da.assignment_time, p.place_time) AS at + SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at FROM day_assignments da JOIN places p ON p.id = da.place_id + LEFT JOIN day_accommodations other ON other.id = da.accommodation_id WHERE da.day_id = ? ORDER BY da.order_index `, dayId); const own = rows.findIndex(row => row.id === ownId); diff --git a/server/tests/unit/db/booked-night-reseat.test.ts b/server/tests/unit/db/booked-night-reseat.test.ts index 14246ef25..58797af18 100644 --- a/server/tests/unit/db/booked-night-reseat.test.ts +++ b/server/tests/unit/db/booked-night-reseat.test.ts @@ -177,6 +177,18 @@ describe('booked night reseat', () => { expect(vias(db).map((v) => v.id)).not.toContain(afterA); }); + it('RESEAT-010: two nights on one day settle by their check-ins', () => { + const db = freshDb(); + const [later, earlier] = [place(db, 'Rue de Paris'), place(db, 'Brandenburger Tor')]; + stop(db, later, 0, null, night(db, later, '12:00')); + stop(db, earlier, 1, null, night(db, earlier, '10:00')); + + reseatBookedNights(db); + + expect(order(db)).toEqual([earlier, later]); + expect(reseatBookedNights(db)).toBe(0); + }); + it('RESEAT-008: runs again without moving anything', () => { const db = freshDb(); const [fuel, hotel] = [place(db, 'Aral'), place(db, 'Rostock')]; diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index 758a363d1..d017e2332 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -743,6 +743,20 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map(a => a.place_id)).toEqual([afternoon.id, hotel.id]); }); + it('ACC-022i a second night on the day counts by its check-in', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [noon, morning] = ['Rue de Paris', 'Brandenburger Tor'].map(name => createPlace(testDb, trip.id, { name })); + svc.createAccommodation(trip.id, { place_id: noon.id, start_day_id: day.id, end_day_id: day.id, check_in: '12:00' }); + svc.createAccommodation(trip.id, { place_id: morning.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id]); + + const evening = createPlace(testDb, trip.id, { name: 'Late' }); + svc.createAccommodation(trip.id, { place_id: evening.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id, evening.id]); + }); + it('ACC-022h an edit that leaves the check-in alone still corrects a night the clocks contradict', () => { // Dragged behind a stop pinned to the afternoon, with a check-in at ten: a change of // notes puts it back ahead of that stop, because a night sitting after an afternoon it From 84b73d1a8362f718aebe3b66f1f4f2ac36ac551a Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 19:34:47 +0200 Subject: [PATCH 05/13] fix(planner): the last day scrolls into view beside the road trip switch With the road trip addon on, the desktop panel puts the Days / Road trip switch above the day list. The list was sized by height alone, so it ran the switch's height past the panel's clipped edge and the last day could never be scrolled into view. It is a flex child of the panel now and yields that height; where nothing sits above it, the height still fills the panel. --- client/src/components/Planner/DayPlanSidebar.test.tsx | 11 +++++++++++ client/src/components/Planner/DayPlanSidebar.tsx | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index c2cd1d9c9..7a6b3f884 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -223,6 +223,17 @@ describe('DayPlanSidebar', () => { expect(document.body).toBeInTheDocument() }) + it('FE-PLANNER-DAYPLAN-001b: the panel yields to whatever the desktop shell puts above it', () => { + // With the Days / Road trip switch above it, a panel sized by height alone ran the + // switch's height past the clipped edge and the last day could never be scrolled + // into view. jsdom lays nothing out, so the sizing itself is the assertion. + const { container } = render() + const panel = container.firstElementChild as HTMLElement + expect(panel.style.flex).toBe('1 1 0%') + expect(panel.style.minHeight).toBe('0px') + expect(panel.style.height).toBe('100%') + }) + it('FE-PLANNER-DAYPLAN-002: renders day titles', () => { const day = buildDay({ title: 'Amsterdam Day', date: '2025-06-01' }) render() diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index ff565277b..f0296f8d3 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1525,7 +1525,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP } return ( -
+ // Sized as a flex child as well as by height: the desktop panel puts the Days / Road + // trip switch above this, and at height 100% alone the list ran the switch's height + // past the panel's clipped edge, so the last day could never be scrolled into view. + // Where nothing sits above it (the mobile shell), the height still fills the panel. +
{/* Toolbar */} Date: Sun, 20 Sep 2026 19:43:52 +0200 Subject: [PATCH 06/13] fix(collab): a shared link can be edited, and its glyph opens it A link in the Collab tab could only be deleted and added again once its title or address needed a correction; the update route has taken both all along. The add dialog edits a link handed to it now, behind a pencil on each row. The external-link glyph next to a row that was already a link read as a button doing nothing; it is a link to the address itself now. Closes #2414 --- .../components/Collab/CollabLinks.test.tsx | 39 +++++++++++++++ client/src/components/Collab/CollabLinks.tsx | 48 ++++++++++++++----- shared/src/i18n/ar/collab.ts | 2 + shared/src/i18n/br/collab.ts | 2 + shared/src/i18n/ca/collab.ts | 2 + shared/src/i18n/cs/collab.ts | 2 + shared/src/i18n/de/collab.ts | 2 + shared/src/i18n/en/collab.ts | 2 + shared/src/i18n/es/collab.ts | 2 + shared/src/i18n/fr/collab.ts | 2 + shared/src/i18n/gr/collab.ts | 2 + shared/src/i18n/hu/collab.ts | 2 + shared/src/i18n/id/collab.ts | 2 + shared/src/i18n/it/collab.ts | 2 + shared/src/i18n/ja/collab.ts | 2 + shared/src/i18n/ko/collab.ts | 2 + shared/src/i18n/nl/collab.ts | 2 + shared/src/i18n/pl/collab.ts | 2 + shared/src/i18n/ru/collab.ts | 2 + shared/src/i18n/sv/collab.ts | 2 + shared/src/i18n/tr/collab.ts | 2 + shared/src/i18n/uk/collab.ts | 2 + shared/src/i18n/vi/collab.ts | 2 + shared/src/i18n/zh-TW/collab.ts | 2 + shared/src/i18n/zh/collab.ts | 2 + 25 files changed, 120 insertions(+), 13 deletions(-) diff --git a/client/src/components/Collab/CollabLinks.test.tsx b/client/src/components/Collab/CollabLinks.test.tsx index bb7db9940..f134d6819 100644 --- a/client/src/components/Collab/CollabLinks.test.tsx +++ b/client/src/components/Collab/CollabLinks.test.tsx @@ -118,6 +118,45 @@ describe('CollabLinks', () => { expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument(); }); + it('FE-COMP-LINKS-009: a link can be edited in place, title and address alike', async () => { + // #2414: a link used to be delete-and-add once it needed a correction. + const user = userEvent.setup(); + let put: Record | null = null; + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })), + http.put('/api/trips/1/collab/links/1', async ({ request }) => { + put = (await request.json()) as Record; + return HttpResponse.json({ link: buildLink({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' }) }); + }), + ); + render(); + await user.click(await screen.findByRole('button', { name: /edit link|collab\.links\.edit/i })); + const title = await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i); + expect(title).toHaveValue('Ferry timetable'); + await user.clear(title); + await user.type(title, 'Ferry timetable 2026'); + const url = screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i); + expect(url).toHaveValue('https://ferries.example/timetable'); + await user.clear(url); + await user.type(url, 'https://ferries.example/2026'); + await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i })); + + await waitFor(() => expect(put).toEqual({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' })); + await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument()); + expect(await screen.findByText('Ferry timetable 2026')).toBeInTheDocument(); + expect(screen.getByText('https://ferries.example/2026')).toBeInTheDocument(); + }); + + it('FE-COMP-LINKS-010: the external-link glyph opens the address in a new tab', async () => { + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })), + ); + render(); + const open = await screen.findByRole('link', { name: /open link|collab\.links\.open/i }); + expect(open).toHaveAttribute('href', 'https://ferries.example/timetable'); + expect(open).toHaveAttribute('target', '_blank'); + }); + it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => { // collab_edit reserved for the owner, on somebody else's trip. seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } }); diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index 564eacdde..43f285c67 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -1,6 +1,6 @@ import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react' +import { ExternalLink, Link2, Pencil, Pin, Plus, Trash2, X } from 'lucide-react' import { collabApi } from '../../api/client' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' @@ -27,14 +27,16 @@ function LinkIcon({ url, title }: { url: string; title: string }) { } /** - * The add form as a modal, like the poll and note panels next to it. Inline it - * pushed the list down and gave the panel a second header, which is what made - * this tab read as a different app from its four siblings. + * The add and edit form as a modal, like the poll and note panels next to it. + * Inline it pushed the list down and gave the panel a second header, which is + * what made this tab read as a different app from its four siblings. With a + * link handed in it edits that link (#2414): a link used to be delete-and-add + * once its title or address needed a correction. */ -function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise }) { +function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () => void; onSave: (link: { title: string; url: string }) => Promise }) { const { t } = useTranslation() - const [title, setTitle] = useState('') - const [url, setUrl] = useState('') + const [title, setTitle] = useState(link?.title ?? '') + const [url, setUrl] = useState(link?.url ?? '') const [busy, setBusy] = useState(false) const valid = title.trim().length > 0 && url.trim().length > 0 @@ -43,10 +45,10 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l if (!valid || busy) return setBusy(true) try { - await onCreate({ title: title.trim(), url: url.trim() }) + await onSave({ title: title.trim(), url: url.trim() }) onClose() } catch { - // add() already said what went wrong; the form stays open with the input in it. + // The caller already said what went wrong; the form stays open with the input in it. } finally { setBusy(false) } @@ -66,7 +68,7 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l
{ if (e.target === e.currentTarget) onClose() }}>
-

{t('collab.links.add')}

+

{t(link ? 'collab.links.edit' : 'collab.links.add')}

@@ -99,6 +101,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) { const toast = useToast() const [links, setLinks] = useState([]) const [showForm, setShowForm] = useState(false) + const [editing, setEditing] = useState(null) // Both are new objects on every render, so depending on them would reload the // list after each keystroke and drop a link that was just added back out of it. @@ -168,6 +171,17 @@ export default function CollabLinks({ tripId }: { tripId: number }) { } } + const edit = async (link: CollabLink, data: { title: string; url: string }) => { + try { + const d = await collabApi.updateLink(tripId, link.id, data) + setLinks(v => v.map(x => x.id === link.id ? d.link : x)) + } catch (err) { + console.error('Failed to edit collab link:', err) + toast.error(t('common.error')) + throw err + } + } + const remove = async (id: number) => { try { await collabApi.deleteLink(tripId, id) @@ -210,8 +224,15 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
{link.title}
{link.url}
-
- {/* Create Modal */} - {showForm && setShowForm(false)} onCreate={add} />} + {/* Create and edit modals */} + {showForm && setShowForm(false)} onSave={add} />} + {editing && setEditing(null)} onSave={data => edit(editing, data)} />}
) } diff --git a/shared/src/i18n/ar/collab.ts b/shared/src/i18n/ar/collab.ts index 1cb6e4ae3..4c41584a1 100644 --- a/shared/src/i18n/ar/collab.ts +++ b/shared/src/i18n/ar/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'تثبيت الرابط', 'collab.links.unpin': 'إلغاء تثبيت الرابط', 'collab.links.delete': 'حذف الرابط', + 'collab.links.edit': 'تعديل الرابط', + 'collab.links.open': 'فتح الرابط', 'collab.whatsNext.title': 'ما التالي', 'collab.whatsNext.today': 'اليوم', 'collab.whatsNext.tomorrow': 'غدًا', diff --git a/shared/src/i18n/br/collab.ts b/shared/src/i18n/br/collab.ts index 1c3163f5b..630e72674 100644 --- a/shared/src/i18n/br/collab.ts +++ b/shared/src/i18n/br/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Fixar link', 'collab.links.unpin': 'Desafixar link', 'collab.links.delete': 'Excluir link', + 'collab.links.edit': 'Editar link', + 'collab.links.open': 'Abrir link', 'collab.whatsNext.title': 'Próximos passos', 'collab.whatsNext.today': 'Hoje', 'collab.whatsNext.tomorrow': 'Amanhã', diff --git a/shared/src/i18n/ca/collab.ts b/shared/src/i18n/ca/collab.ts index 8a4a117cc..acddf0dd3 100644 --- a/shared/src/i18n/ca/collab.ts +++ b/shared/src/i18n/ca/collab.ts @@ -15,6 +15,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Fixa l\'enllaç', 'collab.links.unpin': 'Desfixa l\'enllaç', 'collab.links.delete': 'Suprimeix l\'enllaç', + 'collab.links.edit': 'Edita l\'enllaç', + 'collab.links.open': 'Obre l\'enllaç', 'collab.whatsNext.title': 'Què ve ara', 'collab.whatsNext.today': 'Avui', 'collab.whatsNext.tomorrow': 'Demà', diff --git a/shared/src/i18n/cs/collab.ts b/shared/src/i18n/cs/collab.ts index a77dd7ecc..55f71fa69 100644 --- a/shared/src/i18n/cs/collab.ts +++ b/shared/src/i18n/cs/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Připnout odkaz', 'collab.links.unpin': 'Odepnout odkaz', 'collab.links.delete': 'Smazat odkaz', + 'collab.links.edit': 'Upravit odkaz', + 'collab.links.open': 'Otevřít odkaz', 'collab.whatsNext.title': 'Co následuje', 'collab.whatsNext.today': 'Dnes', 'collab.whatsNext.tomorrow': 'Zítra', diff --git a/shared/src/i18n/de/collab.ts b/shared/src/i18n/de/collab.ts index c86fb542d..453b2396c 100644 --- a/shared/src/i18n/de/collab.ts +++ b/shared/src/i18n/de/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Link anheften', 'collab.links.unpin': 'Link lösen', 'collab.links.delete': 'Link löschen', + 'collab.links.edit': 'Link bearbeiten', + 'collab.links.open': 'Link öffnen', 'collab.whatsNext.title': 'Nächste', 'collab.whatsNext.today': 'Heute', 'collab.whatsNext.tomorrow': 'Morgen', diff --git a/shared/src/i18n/en/collab.ts b/shared/src/i18n/en/collab.ts index 7e52cd538..b7a9fc9f9 100644 --- a/shared/src/i18n/en/collab.ts +++ b/shared/src/i18n/en/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Pin link', 'collab.links.unpin': 'Unpin link', 'collab.links.delete': 'Delete link', + 'collab.links.edit': 'Edit link', + 'collab.links.open': 'Open link', 'collab.whatsNext.title': "What's Next", 'collab.whatsNext.today': 'Today', 'collab.whatsNext.tomorrow': 'Tomorrow', diff --git a/shared/src/i18n/es/collab.ts b/shared/src/i18n/es/collab.ts index 13d0343ab..c2d2ce1b5 100644 --- a/shared/src/i18n/es/collab.ts +++ b/shared/src/i18n/es/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Fijar enlace', 'collab.links.unpin': 'Desfijar enlace', 'collab.links.delete': 'Eliminar enlace', + 'collab.links.edit': 'Editar enlace', + 'collab.links.open': 'Abrir enlace', 'collab.whatsNext.title': 'Qué viene ahora', 'collab.whatsNext.today': 'Hoy', 'collab.whatsNext.tomorrow': 'Mañana', diff --git a/shared/src/i18n/fr/collab.ts b/shared/src/i18n/fr/collab.ts index 04312f926..c37f1063e 100644 --- a/shared/src/i18n/fr/collab.ts +++ b/shared/src/i18n/fr/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Épingler le lien', 'collab.links.unpin': 'Désépingler le lien', 'collab.links.delete': 'Supprimer le lien', + 'collab.links.edit': 'Modifier le lien', + 'collab.links.open': 'Ouvrir le lien', 'collab.whatsNext.title': 'À venir', 'collab.whatsNext.today': "Aujourd'hui", 'collab.whatsNext.tomorrow': 'Demain', diff --git a/shared/src/i18n/gr/collab.ts b/shared/src/i18n/gr/collab.ts index 2fb4ba5a1..7d5688fd8 100644 --- a/shared/src/i18n/gr/collab.ts +++ b/shared/src/i18n/gr/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Καρφίτσωμα συνδέσμου', 'collab.links.unpin': 'Ξεκαρφίτσωμα συνδέσμου', 'collab.links.delete': 'Διαγραφή συνδέσμου', + 'collab.links.edit': 'Επεξεργασία συνδέσμου', + 'collab.links.open': 'Άνοιγμα συνδέσμου', 'collab.whatsNext.title': 'Τι Επόμενο', 'collab.whatsNext.today': 'Σήμερα', 'collab.whatsNext.tomorrow': 'Αύριο', diff --git a/shared/src/i18n/hu/collab.ts b/shared/src/i18n/hu/collab.ts index 07cb30a96..13b86ea13 100644 --- a/shared/src/i18n/hu/collab.ts +++ b/shared/src/i18n/hu/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Link rögzítése', 'collab.links.unpin': 'Link feloldása', 'collab.links.delete': 'Link törlése', + 'collab.links.edit': 'Link szerkesztése', + 'collab.links.open': 'Link megnyitása', 'collab.whatsNext.title': 'Mi következik', 'collab.whatsNext.today': 'Ma', 'collab.whatsNext.tomorrow': 'Holnap', diff --git a/shared/src/i18n/id/collab.ts b/shared/src/i18n/id/collab.ts index 1f15afc33..4c2b426ef 100644 --- a/shared/src/i18n/id/collab.ts +++ b/shared/src/i18n/id/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Sematkan tautan', 'collab.links.unpin': 'Lepas sematan tautan', 'collab.links.delete': 'Hapus tautan', + 'collab.links.edit': 'Edit tautan', + 'collab.links.open': 'Buka tautan', 'collab.whatsNext.title': 'Berikutnya', 'collab.whatsNext.today': 'Hari ini', 'collab.whatsNext.tomorrow': 'Besok', diff --git a/shared/src/i18n/it/collab.ts b/shared/src/i18n/it/collab.ts index 0bf2c0d92..a3f588b82 100644 --- a/shared/src/i18n/it/collab.ts +++ b/shared/src/i18n/it/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Fissa link', 'collab.links.unpin': 'Sblocca link', 'collab.links.delete': 'Elimina link', + 'collab.links.edit': 'Modifica link', + 'collab.links.open': 'Apri link', 'collab.whatsNext.title': "Cosa c'è dopo", 'collab.whatsNext.today': 'Oggi', 'collab.whatsNext.tomorrow': 'Domani', diff --git a/shared/src/i18n/ja/collab.ts b/shared/src/i18n/ja/collab.ts index fadbec1f9..bc54cb6f4 100644 --- a/shared/src/i18n/ja/collab.ts +++ b/shared/src/i18n/ja/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'リンクを固定', 'collab.links.unpin': 'リンクの固定を解除', 'collab.links.delete': 'リンクを削除', + 'collab.links.edit': 'リンクを編集', + 'collab.links.open': 'リンクを開く', 'collab.whatsNext.title': '次にすること', 'collab.whatsNext.today': '今日', 'collab.whatsNext.tomorrow': '明日', diff --git a/shared/src/i18n/ko/collab.ts b/shared/src/i18n/ko/collab.ts index 96839286d..bdc367907 100644 --- a/shared/src/i18n/ko/collab.ts +++ b/shared/src/i18n/ko/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': '링크 고정', 'collab.links.unpin': '링크 고정 해제', 'collab.links.delete': '링크 삭제', + 'collab.links.edit': '링크 편집', + 'collab.links.open': '링크 열기', 'collab.whatsNext.title': '다음 할 일', 'collab.whatsNext.today': '오늘', 'collab.whatsNext.tomorrow': '내일', diff --git a/shared/src/i18n/nl/collab.ts b/shared/src/i18n/nl/collab.ts index 3567ced61..1abb00e60 100644 --- a/shared/src/i18n/nl/collab.ts +++ b/shared/src/i18n/nl/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Link vastmaken', 'collab.links.unpin': 'Link losmaken', 'collab.links.delete': 'Link verwijderen', + 'collab.links.edit': 'Link bewerken', + 'collab.links.open': 'Link openen', 'collab.whatsNext.title': 'Wat komt er', 'collab.whatsNext.today': 'Vandaag', 'collab.whatsNext.tomorrow': 'Morgen', diff --git a/shared/src/i18n/pl/collab.ts b/shared/src/i18n/pl/collab.ts index f605554bd..de4fb3598 100644 --- a/shared/src/i18n/pl/collab.ts +++ b/shared/src/i18n/pl/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Przypnij link', 'collab.links.unpin': 'Odepnij link', 'collab.links.delete': 'Usuń link', + 'collab.links.edit': 'Edytuj link', + 'collab.links.open': 'Otwórz link', 'collab.whatsNext.title': 'Co dalej', 'collab.whatsNext.today': 'Dzisiaj', 'collab.whatsNext.tomorrow': 'Jutro', diff --git a/shared/src/i18n/ru/collab.ts b/shared/src/i18n/ru/collab.ts index 41611e33d..f7c7c2881 100644 --- a/shared/src/i18n/ru/collab.ts +++ b/shared/src/i18n/ru/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Закрепить ссылку', 'collab.links.unpin': 'Открепить ссылку', 'collab.links.delete': 'Удалить ссылку', + 'collab.links.edit': 'Изменить ссылку', + 'collab.links.open': 'Открыть ссылку', 'collab.whatsNext.title': 'Что дальше', 'collab.whatsNext.today': 'Сегодня', 'collab.whatsNext.tomorrow': 'Завтра', diff --git a/shared/src/i18n/sv/collab.ts b/shared/src/i18n/sv/collab.ts index 676ca815a..bf5badd2c 100644 --- a/shared/src/i18n/sv/collab.ts +++ b/shared/src/i18n/sv/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Fäst länk', 'collab.links.unpin': 'Lossa länk', 'collab.links.delete': 'Ta bort länk', + 'collab.links.edit': 'Redigera länk', + 'collab.links.open': 'Öppna länk', 'collab.whatsNext.title': 'Vad händer härnäst?', 'collab.whatsNext.today': 'Idag', 'collab.whatsNext.tomorrow': 'I morgon', diff --git a/shared/src/i18n/tr/collab.ts b/shared/src/i18n/tr/collab.ts index 569047ec9..95ad138a2 100644 --- a/shared/src/i18n/tr/collab.ts +++ b/shared/src/i18n/tr/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Bağlantıyı sabitle', 'collab.links.unpin': 'Sabitlemeyi kaldır', 'collab.links.delete': 'Bağlantıyı sil', + 'collab.links.edit': 'Bağlantıyı düzenle', + 'collab.links.open': 'Bağlantıyı aç', 'collab.whatsNext.title': "What's Next", 'collab.whatsNext.today': 'Bugün', 'collab.whatsNext.tomorrow': 'Yarın', diff --git a/shared/src/i18n/uk/collab.ts b/shared/src/i18n/uk/collab.ts index f343ad14b..df9ed1bb0 100644 --- a/shared/src/i18n/uk/collab.ts +++ b/shared/src/i18n/uk/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Закріпити посилання', 'collab.links.unpin': 'Відкріпити посилання', 'collab.links.delete': 'Видалити посилання', + 'collab.links.edit': 'Редагувати посилання', + 'collab.links.open': 'Відкрити посилання', 'collab.whatsNext.title': 'Що далі', 'collab.whatsNext.today': 'Сьогодні', 'collab.whatsNext.tomorrow': 'Завтра', diff --git a/shared/src/i18n/vi/collab.ts b/shared/src/i18n/vi/collab.ts index 603da8a4c..c1a26a10e 100644 --- a/shared/src/i18n/vi/collab.ts +++ b/shared/src/i18n/vi/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Ghim liên kết', 'collab.links.unpin': 'Bỏ ghim liên kết', 'collab.links.delete': 'Xóa liên kết', + 'collab.links.edit': 'Chỉnh sửa liên kết', + 'collab.links.open': 'Mở liên kết', 'collab.whatsNext.title': 'Tiếp theo là gì', 'collab.whatsNext.today': 'Hôm nay', 'collab.whatsNext.tomorrow': 'Ngày mai', diff --git a/shared/src/i18n/zh-TW/collab.ts b/shared/src/i18n/zh-TW/collab.ts index c26f74bff..e146932ff 100644 --- a/shared/src/i18n/zh-TW/collab.ts +++ b/shared/src/i18n/zh-TW/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': '釘選連結', 'collab.links.unpin': '取消釘選連結', 'collab.links.delete': '刪除連結', + 'collab.links.edit': '編輯連結', + 'collab.links.open': '開啟連結', 'collab.whatsNext.title': '接下來', 'collab.whatsNext.today': '今天', 'collab.whatsNext.tomorrow': '明天', diff --git a/shared/src/i18n/zh/collab.ts b/shared/src/i18n/zh/collab.ts index f9ad4a001..adbf3de50 100644 --- a/shared/src/i18n/zh/collab.ts +++ b/shared/src/i18n/zh/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': '固定链接', 'collab.links.unpin': '取消固定链接', 'collab.links.delete': '删除链接', + 'collab.links.edit': '编辑链接', + 'collab.links.open': '打开链接', 'collab.whatsNext.title': '接下来', 'collab.whatsNext.today': '今天', 'collab.whatsNext.tomorrow': '明天', From 8d70d875b3035882af5db9dba47182ebc2fba8d1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 19:50:06 +0200 Subject: [PATCH 07/13] style(collab): shared links as chips One chip per link, flowing like tags, instead of a column of rows with four grey glyphs each. The chip is the link, with the favicon and the host beside the title; edit, pin and delete are folded into its tail and unfold on hover or keyboard focus, and stay unfolded where there is no hover. A pinned chip carries the accent. --- .../components/Collab/CollabLinks.test.tsx | 11 +- client/src/components/Collab/CollabLinks.tsx | 63 ++++++----- client/src/index.css | 105 ++++++++++++++++++ 3 files changed, 148 insertions(+), 31 deletions(-) diff --git a/client/src/components/Collab/CollabLinks.test.tsx b/client/src/components/Collab/CollabLinks.test.tsx index f134d6819..4b2d7e7c3 100644 --- a/client/src/components/Collab/CollabLinks.test.tsx +++ b/client/src/components/Collab/CollabLinks.test.tsx @@ -63,7 +63,8 @@ describe('CollabLinks', () => { ); render(); expect(await screen.findByText('Ferry timetable')).toBeInTheDocument(); - expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument(); + // The chip shows the host, not the whole address, and links to the address itself. + expect(screen.getByText('ferries.example')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable'); }); @@ -144,17 +145,19 @@ describe('CollabLinks', () => { await waitFor(() => expect(put).toEqual({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' })); await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument()); expect(await screen.findByText('Ferry timetable 2026')).toBeInTheDocument(); - expect(screen.getByText('https://ferries.example/2026')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /ferry timetable 2026/i })).toHaveAttribute('href', 'https://ferries.example/2026'); }); - it('FE-COMP-LINKS-010: the external-link glyph opens the address in a new tab', async () => { + it('FE-COMP-LINKS-010: the chip is the link and opens the address in a new tab', async () => { server.use( http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })), ); render(); - const open = await screen.findByRole('link', { name: /open link|collab\.links\.open/i }); + const open = await screen.findByRole('link', { name: /ferry timetable/i }); expect(open).toHaveAttribute('href', 'https://ferries.example/timetable'); expect(open).toHaveAttribute('target', '_blank'); + // The host stands beside the title; the whole address would not fit a chip. + expect(screen.getByText('ferries.example')).toBeInTheDocument(); }); it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => { diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index 43f285c67..be716b693 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -18,12 +18,20 @@ interface CollabLink { pinned: boolean | number } -function LinkIcon({ url, title }: { url: string; title: string }) { +function LinkIcon({ url }: { url: string }) { const [failed, setFailed] = useState(false) let favicon = '' try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ } - if (!favicon || failed) return - return setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} /> + return ( + + ) +} + +/** The part of the address worth a chip's width: the host, without a leading www. */ +function hostOf(url: string): string { + try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url } } /** @@ -211,35 +219,36 @@ export default function CollabLinks({ tripId }: { tripId: number }) { )}
- {/* Content */} -
+ {/* Content: one chip per link, flowing like tags (see .collab-link-chip). The chip + is the link, with the host beside the title; the actions unfold from its tail. */} +
{links.length === 0 ? ( ) : ( -
+
{links.map(link => ( -
- - -
{link.title}
-
{link.url}
+
+ + + + {link.title} + {hostOf(link.url)} + + - {/* The glyph used to be decoration next to a row that was already a link, - which read as a button that did nothing (#2414). */} - - - {canEdit && <> - - - - } + {canEdit && ( + + + + + + )}
))}
diff --git a/client/src/index.css b/client/src/index.css index 56b6f5440..31c4f658e 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1375,6 +1375,111 @@ img[alt="TREK"] { justify-content: center; } +/* Shared links in the Collab tab: one chip per link, flowing like tags. The chip is + the link; edit, pin and delete sit folded into its tail and unfold on hover or + keyboard focus, and stay unfolded where there is no hover to speak of. */ +.collab-link-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 5px 8px 5px 5px; + border-radius: 999px; + background: var(--bg-secondary); + border: 1px solid var(--border-faint); + box-shadow: var(--shadow-sm); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; +} +.collab-link-chip:hover, .collab-link-chip:focus-within { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + border-color: var(--border-primary); +} +.collab-link-chip--pinned { + background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.collab-link-chip__main { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + color: var(--text-primary); + text-decoration: none; + outline: none; +} +.collab-link-chip__icon { + position: relative; + display: grid; + place-items: center; + width: 24px; + height: 24px; + flex: 0 0 24px; + border-radius: 50%; + overflow: hidden; + background: var(--bg-card); + color: var(--text-faint); +} +.collab-link-chip__icon img { width: 16px; height: 16px; object-fit: contain; } +.collab-link-chip__text { display: flex; align-items: baseline; gap: 6px; min-width: 0; } +.collab-link-chip__title { + font-size: calc(12.5px * var(--fs-scale-body, 1)); + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} +.collab-link-chip__host { + font-size: calc(10.5px * var(--fs-scale-caption, 1)); + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} +.collab-link-chip__open { + flex: 0 0 auto; + color: var(--text-faint); + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease; +} +.collab-link-chip:hover .collab-link-chip__open, .collab-link-chip:focus-within .collab-link-chip__open { opacity: 1; } +.collab-link-chip__main:hover .collab-link-chip__open { color: var(--text-primary); } +.collab-link-chip__actions { + display: flex; + align-items: center; + gap: 1px; + max-width: 0; + opacity: 0; + overflow: hidden; + transition: max-width 0.2s ease, opacity 0.15s ease, margin-left 0.2s ease; +} +.collab-link-chip:hover .collab-link-chip__actions, .collab-link-chip:focus-within .collab-link-chip__actions { + max-width: 96px; + opacity: 1; + margin-left: 2px; +} +.collab-link-chip__action { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-faint); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} +.collab-link-chip__action:hover, .collab-link-chip__action:focus-visible { background: var(--bg-hover); color: var(--text-primary); } +.collab-link-chip__action--on { color: var(--accent); } +@media (hover: none) { + .collab-link-chip__actions { max-width: 96px; opacity: 1; margin-left: 2px; } + .collab-link-chip__open { opacity: 1; } +} + /* Markdown in Collab Notes */ .collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; } .collab-note-md em, .collab-note-md-full em { font-style: italic !important; } From 97a2ea1748a69b4e648024ca288b90feb2eb9c02 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 19:52:40 +0200 Subject: [PATCH 08/13] style(collab): the chip actions keep their width Unfolding them reflowed the row under the pointer, and a chip that grows as you reach for it is a chip you cannot catch. They sit faint and come forward on hover instead. --- client/src/index.css | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/client/src/index.css b/client/src/index.css index 31c4f658e..5540f4909 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1376,8 +1376,10 @@ img[alt="TREK"] { } /* Shared links in the Collab tab: one chip per link, flowing like tags. The chip is - the link; edit, pin and delete sit folded into its tail and unfold on hover or - keyboard focus, and stay unfolded where there is no hover to speak of. */ + the link; edit, pin and delete sit quietly in its tail and come forward on hover + or keyboard focus. They keep their width throughout: unfolding them reflowed the + row under the pointer, and a chip that grows as you reach for it is a chip you + cannot catch. */ .collab-link-chip { display: inline-flex; align-items: center; @@ -1451,16 +1453,11 @@ img[alt="TREK"] { display: flex; align-items: center; gap: 1px; - max-width: 0; - opacity: 0; - overflow: hidden; - transition: max-width 0.2s ease, opacity 0.15s ease, margin-left 0.2s ease; -} -.collab-link-chip:hover .collab-link-chip__actions, .collab-link-chip:focus-within .collab-link-chip__actions { - max-width: 96px; - opacity: 1; margin-left: 2px; + opacity: 0.45; + transition: opacity 0.15s ease; } +.collab-link-chip:hover .collab-link-chip__actions, .collab-link-chip:focus-within .collab-link-chip__actions { opacity: 1; } .collab-link-chip__action { display: grid; place-items: center; @@ -1476,7 +1473,7 @@ img[alt="TREK"] { .collab-link-chip__action:hover, .collab-link-chip__action:focus-visible { background: var(--bg-hover); color: var(--text-primary); } .collab-link-chip__action--on { color: var(--accent); } @media (hover: none) { - .collab-link-chip__actions { max-width: 96px; opacity: 1; margin-left: 2px; } + .collab-link-chip__actions { opacity: 1; } .collab-link-chip__open { opacity: 1; } } From 67dff455da966b6ebb9ac57e10fc09af3b264be7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 20:04:39 +0200 Subject: [PATCH 09/13] style(collab): link chips two to a row across the panel A chip takes half the panel now, one to a row where the panel is narrower than a container query's 420px; the title and the host give way inside it instead of capping their width. --- client/src/components/Collab/CollabLinks.tsx | 8 ++--- client/src/index.css | 38 ++++++++++++++------ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index be716b693..f8fd2056e 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -219,13 +219,13 @@ export default function CollabLinks({ tripId }: { tripId: number }) { )}
- {/* Content: one chip per link, flowing like tags (see .collab-link-chip). The chip - is the link, with the host beside the title; the actions unfold from its tail. */} -
+ {/* Content: one chip per link, two to a row (see .collab-link-chip). The chip is + the link, with the host beside the title; the actions sit in its tail. */} +
{links.length === 0 ? ( ) : ( -
+
{links.map(link => (
diff --git a/client/src/index.css b/client/src/index.css index 5540f4909..6187b76e3 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1375,16 +1375,27 @@ img[alt="TREK"] { justify-content: center; } -/* Shared links in the Collab tab: one chip per link, flowing like tags. The chip is - the link; edit, pin and delete sit quietly in its tail and come forward on hover - or keyboard focus. They keep their width throughout: unfolding them reflowed the - row under the pointer, and a chip that grows as you reach for it is a chip you - cannot catch. */ +/* Shared links in the Collab tab: one chip per link, two to a row across the full + width, one to a row where the panel is narrow. The chip is the link; edit, pin and + delete sit quietly in its tail and come forward on hover or keyboard focus. They + keep their width throughout: unfolding them reflowed the row under the pointer, + and a chip that grows as you reach for it is a chip you cannot catch. */ +.collab-link-grid { + container-type: inline-size; +} +.collab-link-grid__cells { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +@container (max-width: 420px) { + .collab-link-grid__cells { grid-template-columns: minmax(0, 1fr); } +} .collab-link-chip { - display: inline-flex; + display: flex; align-items: center; gap: 6px; - max-width: 100%; + min-width: 0; padding: 5px 8px 5px 5px; border-radius: 999px; background: var(--bg-secondary); @@ -1405,6 +1416,7 @@ img[alt="TREK"] { display: flex; align-items: center; gap: 7px; + flex: 1 1 auto; min-width: 0; color: var(--text-primary); text-decoration: none; @@ -1423,7 +1435,7 @@ img[alt="TREK"] { color: var(--text-faint); } .collab-link-chip__icon img { width: 16px; height: 16px; object-fit: contain; } -.collab-link-chip__text { display: flex; align-items: baseline; gap: 6px; min-width: 0; } +.collab-link-chip__text { display: flex; align-items: baseline; gap: 6px; min-width: 0; flex: 1 1 auto; } .collab-link-chip__title { font-size: calc(12.5px * var(--fs-scale-body, 1)); font-weight: 600; @@ -1431,7 +1443,8 @@ img[alt="TREK"] { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 200px; + flex: 0 1 auto; + min-width: 0; } .collab-link-chip__host { font-size: calc(10.5px * var(--fs-scale-caption, 1)); @@ -1439,7 +1452,9 @@ img[alt="TREK"] { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 120px; + flex: 0 1 auto; + min-width: 0; + max-width: 45%; } .collab-link-chip__open { flex: 0 0 auto; @@ -1452,8 +1467,9 @@ img[alt="TREK"] { .collab-link-chip__actions { display: flex; align-items: center; + flex: 0 0 auto; gap: 1px; - margin-left: 2px; + margin-left: auto; opacity: 0.45; transition: opacity 0.15s ease; } From 6b09a8bc11ea599b13d4939b1642bfdb91891202 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 20:07:39 +0200 Subject: [PATCH 10/13] style(collab): the title of a link chip gives way last Two to a row, the title and the host were shrinking together into two ellipses. The host shrinks first now, the open glyph takes the favicon's place on hover instead of room of its own, and the actions are a touch smaller. --- client/src/components/Collab/CollabLinks.tsx | 2 +- client/src/index.css | 34 ++++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index f8fd2056e..a9fb3f217 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -25,6 +25,7 @@ function LinkIcon({ url }: { url: string }) { return ( ) } @@ -234,7 +235,6 @@ export default function CollabLinks({ tripId }: { tripId: number }) { {link.title} {hostOf(link.url)} - {canEdit && ( diff --git a/client/src/index.css b/client/src/index.css index 6187b76e3..c6bd9548c 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1435,7 +1435,23 @@ img[alt="TREK"] { color: var(--text-faint); } .collab-link-chip__icon img { width: 16px; height: 16px; object-fit: contain; } +.collab-link-chip__icon > * { transition: opacity 0.15s ease; } +/* Reaching for the chip turns its favicon into the open-in-new-tab glyph: the + affordance without a glyph of its own taking room from the title. */ +.collab-link-chip__go { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--text-primary); + opacity: 0; +} +.collab-link-chip__main:hover .collab-link-chip__go, .collab-link-chip__main:focus-visible .collab-link-chip__go { opacity: 1; } +.collab-link-chip__main:hover .collab-link-chip__icon > :not(.collab-link-chip__go), +.collab-link-chip__main:focus-visible .collab-link-chip__icon > :not(.collab-link-chip__go) { opacity: 0; } .collab-link-chip__text { display: flex; align-items: baseline; gap: 6px; min-width: 0; flex: 1 1 auto; } +/* The title gives way last: the host shrinks first, down to a few letters and the + ellipsis, before the title loses a character. */ .collab-link-chip__title { font-size: calc(12.5px * var(--fs-scale-body, 1)); font-weight: 600; @@ -1452,18 +1468,9 @@ img[alt="TREK"] { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex: 0 1 auto; - min-width: 0; - max-width: 45%; + flex: 0 8 auto; + min-width: 2.5em; } -.collab-link-chip__open { - flex: 0 0 auto; - color: var(--text-faint); - opacity: 0; - transition: opacity 0.15s ease, color 0.15s ease; -} -.collab-link-chip:hover .collab-link-chip__open, .collab-link-chip:focus-within .collab-link-chip__open { opacity: 1; } -.collab-link-chip__main:hover .collab-link-chip__open { color: var(--text-primary); } .collab-link-chip__actions { display: flex; align-items: center; @@ -1477,8 +1484,8 @@ img[alt="TREK"] { .collab-link-chip__action { display: grid; place-items: center; - width: 24px; - height: 24px; + width: 22px; + height: 22px; border: 0; border-radius: 50%; background: transparent; @@ -1490,7 +1497,6 @@ img[alt="TREK"] { .collab-link-chip__action--on { color: var(--accent); } @media (hover: none) { .collab-link-chip__actions { opacity: 1; } - .collab-link-chip__open { opacity: 1; } } /* Markdown in Collab Notes */ From 8845f73eb94e20cc5dec70d908597df4eba918dd Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 20:09:22 +0200 Subject: [PATCH 11/13] style(collab): the host gives way before the title does, all of it --- client/src/index.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/index.css b/client/src/index.css index c6bd9548c..a804822c9 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1468,7 +1468,7 @@ img[alt="TREK"] { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex: 0 8 auto; + flex: 0 100 auto; min-width: 2.5em; } .collab-link-chip__actions { From 9bd9ff4a80080ba8c9006271aa4f306886c2c13b Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 20:11:10 +0200 Subject: [PATCH 12/13] style(collab): a link chip's title takes its width first, the host gets the rest --- client/src/index.css | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/client/src/index.css b/client/src/index.css index a804822c9..ace630308 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1449,9 +1449,17 @@ img[alt="TREK"] { .collab-link-chip__main:hover .collab-link-chip__go, .collab-link-chip__main:focus-visible .collab-link-chip__go { opacity: 1; } .collab-link-chip__main:hover .collab-link-chip__icon > :not(.collab-link-chip__go), .collab-link-chip__main:focus-visible .collab-link-chip__icon > :not(.collab-link-chip__go) { opacity: 0; } -.collab-link-chip__text { display: flex; align-items: baseline; gap: 6px; min-width: 0; flex: 1 1 auto; } -/* The title gives way last: the host shrinks first, down to a few letters and the - ellipsis, before the title loses a character. */ +/* Title first, host with what is left: a max-content track takes its full width + before the fr track sees any, and shrinks only once the title alone no longer + fits. Two flex items shrank together and both ended in an ellipsis. */ +.collab-link-chip__text { + display: grid; + grid-template-columns: minmax(0, max-content) minmax(0, 1fr); + align-items: baseline; + column-gap: 6px; + min-width: 0; + flex: 1 1 auto; +} .collab-link-chip__title { font-size: calc(12.5px * var(--fs-scale-body, 1)); font-weight: 600; @@ -1459,7 +1467,6 @@ img[alt="TREK"] { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex: 0 1 auto; min-width: 0; } .collab-link-chip__host { @@ -1468,8 +1475,7 @@ img[alt="TREK"] { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex: 0 100 auto; - min-width: 2.5em; + min-width: 0; } .collab-link-chip__actions { display: flex; From bf0507ddb50a467993321693bdad7ab0596f2b69 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 20:28:23 +0200 Subject: [PATCH 13/13] fix(planner): Google hours that share a half of the day convert whole to 24h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google writes a range that stays in one half of the day with the meridiem on the closing time only, "1:00 – 3:00 PM" for one to three in the afternoon. The 24-hour conversion only saw times carrying their own AM/PM, so the opener stayed "1:00" beside a converted "15:00". The closing meridiem is handed back to an opener that has none before converting; a range spelled on both sides, the 12h display and lines without a meridiem are untouched. Closes #2412 --- .../Planner/placeHoursFormat.test.ts | 56 +++++++++++++++++++ .../components/Planner/placeHoursFormat.ts | 12 +++- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 client/src/components/Planner/placeHoursFormat.test.ts diff --git a/client/src/components/Planner/placeHoursFormat.test.ts b/client/src/components/Planner/placeHoursFormat.test.ts new file mode 100644 index 000000000..1f7af1898 --- /dev/null +++ b/client/src/components/Planner/placeHoursFormat.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat' + +describe('convertHoursLine', () => { + it('FE-PLANNER-HOURS-001: a range that stays in one half of the day carries the meridiem back to its opener', () => { + // Google writes "1:00 – 3:00 PM" for one to three in the afternoon (#2412). The + // opener used to stay "1:00" next to a converted "15:00". + expect(convertHoursLine('Wednesday: 1:00 – 3:00 PM, 5:00 – 8:00 PM', '24h')) + .toBe('Wednesday: 13:00 – 15:00, 17:00 – 20:00') + }) + + it('FE-PLANNER-HOURS-002: a range spelled on both sides is converted as written', () => { + expect(convertHoursLine('Monday: 11:00 AM – 1:00 PM', '24h')).toBe('Monday: 11:00 – 13:00') + expect(convertHoursLine('Friday: 5:00 PM – 2:00 AM', '24h')).toBe('Friday: 17:00 – 02:00') + }) + + it('FE-PLANNER-HOURS-003: twelve stays twelve in the afternoon and turns to zero after midnight', () => { + expect(convertHoursLine('Sunday: 12:00 – 3:00 PM', '24h')).toBe('Sunday: 12:00 – 15:00') + expect(convertHoursLine('Sunday: 12:00 – 1:00 AM', '24h')).toBe('Sunday: 00:00 – 01:00') + }) + + it('FE-PLANNER-HOURS-004: a morning range keeps its morning', () => { + expect(convertHoursLine('Tuesday: 8:00 – 11:30 AM', '24h')).toBe('Tuesday: 08:00 – 11:30') + }) + + it('FE-PLANNER-HOURS-005: a plain hyphen separates the same way the en dash does', () => { + expect(convertHoursLine('Thursday: 1:00-3:00 PM', '24h')).toBe('Thursday: 13:00-15:00') + }) + + it('FE-PLANNER-HOURS-006: the 12h display and lines without a meridiem are untouched', () => { + expect(convertHoursLine('Wednesday: 1:00 – 3:00 PM', '12h')).toBe('Wednesday: 1:00 – 3:00 PM') + expect(convertHoursLine('Montag: 11:30–23:00', '24h')).toBe('Montag: 11:30–23:00') + expect(convertHoursLine('Saturday: Open 24 hours', '24h')).toBe('Saturday: Open 24 hours') + expect(convertHoursLine('', '24h')).toBe('') + }) + + it('FE-PLANNER-HOURS-007: 24h lines become 12h, with the German "Uhr" dropped', () => { + expect(convertHoursLine('Montag: 09:00 – 21:00 Uhr', '12h')).toBe('Montag: 9:00 AM – 9:00 PM') + expect(convertHoursLine('Monday: 00:30 – 12:00', '12h')).toBe('Monday: 12:30 AM – 12:00 PM') + }) +}) + +describe('splitHoursLine', () => { + it('FE-PLANNER-HOURS-008: splits on the first colon only, the fullwidth one included', () => { + expect(splitHoursLine('Monday: 09:00-18:00')).toEqual(['Monday', '09:00-18:00']) + expect(splitHoursLine('月曜日:9:00~18:00')).toEqual(['月曜日', '9:00~18:00']) + expect(splitHoursLine('Open 24 hours')).toEqual(['', 'Open 24 hours']) + }) +}) + +describe('isUnknownHoursLine', () => { + it('FE-PLANNER-HOURS-009: a bare question mark is the tag saying nothing', () => { + expect(isUnknownHoursLine(' ? ')).toBe(true) + expect(isUnknownHoursLine('09:00-18:00')).toBe(false) + }) +}) diff --git a/client/src/components/Planner/placeHoursFormat.ts b/client/src/components/Planner/placeHoursFormat.ts index 02f08b8a6..656584bb5 100644 --- a/client/src/components/Planner/placeHoursFormat.ts +++ b/client/src/components/Planner/placeHoursFormat.ts @@ -31,8 +31,18 @@ export function convertHoursLine(line: string, timeFormat: string): string { }) } if (timeFormat !== '12h' && hasAmPm) { + // Google spells a range that stays in one half of the day with the meridiem on + // the closing time only: "1:00 – 3:00 PM" is one to three in the afternoon. The + // conversion below only sees times that carry their own AM/PM, so the opener + // stayed "1:00" next to a "15:00" (#2412). Hand the closing meridiem back to an + // opener that has none. It fires only where nothing but the dash sits between + // the two times, so "11:00 AM – 1:00 PM" keeps both halves as written. + const paired = line.replace( + /(\d{1,2}:\d{2})(\s*[–—-]\s*)(\d{1,2}:\d{2}\s*)(AM|PM)\b/gi, + (_, open, sep, close, p) => `${open} ${p}${sep}${close}${p}`, + ) // 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00" - return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => { + return paired.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => { let hour = Number.parseInt(h) if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12 if (p.toUpperCase() === 'AM' && hour === 12) hour = 0