@@ -444,6 +447,28 @@ describe('MTripSheets', () => {
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null)
})
+ it('FE-MOB-SHOST-030: the delete-day confirm names the day, lists what goes with it and runs the planner confirmation', () => {
+ const lines = [
+ { key: 'stay-9', icon: BedDouble, tone: 'danger' as const, text: 'Stay at Harbour Hotel', hint: 'Cancelled with its booking.' },
+ { key: 'places', icon: MapPin, tone: 'neutral' as const, text: 'Planned places: 2' },
+ ]
+ const { planner } = renderHost({ deleteDayId: 12, deleteDayTitle: 'Delete Tue, Oct 13?', deleteDayLines: lines })
+
+ const confirm = screen.getByTestId('stub-confirm')
+ expect(confirm).toHaveAttribute('data-title', 'Delete Tue, Oct 13?')
+ expect(confirm).toHaveAttribute('data-danger', 'true')
+ expect(confirm).toHaveAttribute('data-confirm', 'dayplan.deleteDay')
+ expect(confirm).toHaveTextContent('dayplan.deleteDayBody')
+ const list = screen.getByRole('list', { name: 'Delete Tue, Oct 13?' })
+ expect(list).toHaveTextContent('Stay at Harbour Hotel')
+ expect(list).toHaveTextContent('Planned places: 2')
+
+ fireEvent.click(screen.getByText('confirm delete'))
+ expect(planner.confirmDeleteDay).toHaveBeenCalledTimes(1)
+ fireEvent.click(screen.getByText('cancel delete'))
+ expect(planner.setDeleteDayId).toHaveBeenCalledWith(null)
+ })
+
it('FE-MOB-SHOST-027: the place edit sheet owns the confirm while its own form is open', () => {
renderHost({ deletePlaceId: 101, showPlaceForm: true })
expect(screen.queryByTestId('stub-confirm')).not.toBeInTheDocument()
diff --git a/client/tests/unit/remoteEventHandler/days.test.ts b/client/tests/unit/remoteEventHandler/days.test.ts
index 5495fde9c..3abe05baa 100644
--- a/client/tests/unit/remoteEventHandler/days.test.ts
+++ b/client/tests/unit/remoteEventHandler/days.test.ts
@@ -78,6 +78,47 @@ describe('remoteEventHandler > days', () => {
expect('20' in dayNotes).toBe(true);
});
+ // A delete now closes the gap on the server and keeps the dates on their slots;
+ // the collaborators run the same reducer the deleting tab ran optimistically.
+ it('FE-WSEVT-DAY-014: day:deleted then day:reordered leaves the same numbering and dates as the server', () => {
+ useTripStore.setState({
+ trip: buildTrip({ id: 7 }),
+ selectedDayId: 20,
+ days: [
+ buildDay({ id: 10, day_number: 1, date: '2026-06-01' }),
+ buildDay({ id: 20, day_number: 2, date: '2026-06-02' }),
+ buildDay({ id: 30, day_number: 3, date: '2026-06-03' }),
+ buildDay({ id: 40, day_number: 4, date: null }),
+ ],
+ refreshDays: vi.fn(async () => {}),
+ loadReservations: vi.fn(async () => {}),
+ });
+
+ useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 20 });
+ const afterDelete = useTripStore.getState();
+ expect(afterDelete.days.map(d => [d.id, d.day_number, d.date])).toEqual([
+ [10, 1, '2026-06-01'], [30, 2, '2026-06-02'], [40, 3, '2026-06-03'],
+ ]);
+ expect(afterDelete.selectedDayId).toBeNull();
+
+ useTripStore.getState().handleRemoteEvent({ type: 'day:reordered', orderedIds: [10, 30, 40] });
+ expect(useTripStore.getState().days.map(d => [d.id, d.day_number])).toEqual([[10, 1], [30, 2], [40, 3]]);
+ });
+
+ it('FE-WSEVT-DAY-015: day:deleted asks the planner to reload its stays, which a deleted day can cancel', () => {
+ seedData();
+ const refresh = vi.fn();
+ window.addEventListener('accommodations:refresh', refresh);
+ try {
+ useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 10 });
+ expect(refresh).toHaveBeenCalledTimes(1);
+ useTripStore.getState().handleRemoteEvent({ type: 'day:updated', day: buildDay({ id: 20 }) });
+ expect(refresh).toHaveBeenCalledTimes(1);
+ } finally {
+ window.removeEventListener('accommodations:refresh', refresh);
+ }
+ });
+
// The reorder is applied optimistically from orderedIds, then the authoritative
// dates + re-stamped booking times are pulled (#589).
describe('day:reordered', () => {
diff --git a/server/src/nest/days/day-removal.service.ts b/server/src/nest/days/day-removal.service.ts
new file mode 100644
index 000000000..3d8e8b023
--- /dev/null
+++ b/server/src/nest/days/day-removal.service.ts
@@ -0,0 +1,233 @@
+import { Injectable } from '@nestjs/common';
+import type { RoadtripDayBoundary } from '@trek/shared';
+import { DatabaseService } from '../database/database.service';
+import { AccommodationsService, type AccommodationMirror, type MirrorSender } from '../accommodations/accommodations.service';
+import { AssignmentsService } from '../assignments/assignments.service';
+import { DaysService } from './days.service';
+
+/** A day that has to stay where it is; REST answers 400, MCP a tool error, a plugin BadParams. */
+export class DayDeleteError extends Error {}
+
+/** The refusal every surface gives for the last day of a trip, word for word. */
+export const LAST_DAY_MESSAGE = 'A trip needs at least one day.';
+
+/** What deleting one day did to the trip, for the surface that announces it. */
+export interface DayRemoval {
+ dayId: number;
+ /** The days that are left, in their new order. Their day_number is their position now. */
+ orderedIds: number[];
+ /** Stays that checked in or out on the day. They are cancelled with it. */
+ stayIds: number[];
+ /** The bookings those stays brought, deleted with them. */
+ reservationIds: number[];
+ /** The expenses written against those bookings. */
+ budgetItemIds: number[];
+ /** What cancelling each stay did to the day plan of the days that are left. */
+ mirrors: AccommodationMirror[];
+ /** The road trip day boundaries after they moved up with their days, or null when none changed. */
+ boundaries: RoadtripDayBoundary[] | null;
+ /** The trip's new last date when the day took it along, or null when the range stayed. */
+ endDate: string | null;
+ /** The trip re-read in list shape: its day count changed, and maybe its end date. */
+ trip: unknown;
+}
+
+/**
+ * How a surface sends a removal. `all` reaches every socket of the trip, the one
+ * that asked included; `others` leaves that one out because its own answer
+ * already carries the change. The MCP tools and the plugin RPC have no socket
+ * of their own, so they hand the same sender twice.
+ */
+export interface DayRemovalSenders {
+ all: MirrorSender;
+ others: MirrorSender;
+ socketId?: string;
+}
+
+type DayRow = { id: number; day_number: number; date: string | null };
+
+/**
+ * Deleting a day, for the three surfaces that offer it (REST, MCP, plugin RPC).
+ *
+ * The delete used to be a bare DELETE: the day numbers kept a hole, the dates
+ * stayed where they were, a stay checking in or out on the day vanished by
+ * cascade while its booking and that booking's expense stayed behind pointing
+ * nowhere, and the journey was never told. It is the exact reverse of an insert
+ * now. The later days move up one place, and on a dated trip the dates stay
+ * pinned to their positions the way a reorder keeps them: every later day, with
+ * its bookings, takes the date one slot earlier. A day without a date takes the
+ * last date if the trip has one; otherwise the last date goes, and the trip ends
+ * one day earlier, which is what insert does the other way round.
+ *
+ * Its own class rather than a method on DaysService because it needs the
+ * accommodations and assignments services, and DaysService is built by hand in
+ * enough suites that a wider constructor there would ripple through all of them.
+ */
+@Injectable()
+export class DayRemovalService {
+ constructor(
+ private readonly db: DatabaseService,
+ private readonly days: DaysService,
+ private readonly accommodations: AccommodationsService,
+ private readonly assignments: AssignmentsService,
+ ) {}
+
+ /**
+ * Delete the day in one transaction. Nothing is written when it throws, and it
+ * throws a DayDeleteError for the last day of a trip. The journey catches up
+ * afterwards, outside the transaction, and a failure there does not undo the delete.
+ */
+ remove(tripId: string | number, dayId: string | number, viewer: { userId: number; socketId?: string }): DayRemoval {
+ const trip = Number(tripId);
+ const id = Number(dayId);
+
+ const removal = this.db.transaction(() => {
+ const rows = this.db.all('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number', trip);
+ const target = rows.find(r => r.id === id);
+ if (!target) throw new DayDeleteError('Day not found');
+ if (rows.length <= 1) throw new DayDeleteError(LAST_DAY_MESSAGE);
+
+ const cancelled = this.cancelStays(trip, id);
+ const boundariesBefore = this.boundaries(trip);
+
+ // The boundary drawn on this day goes with it; the day row takes its
+ // assignments, notes, stops, roads and booking positions along by cascade.
+ this.db.run('DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?', trip, target.day_number);
+ this.db.run('DELETE FROM days WHERE id = ?', id);
+ this.shiftBoundaries(trip, target.day_number);
+
+ const remaining = rows.filter(r => r.id !== id);
+ const endDate = this.renumber(trip, rows, remaining);
+
+ const boundariesAfter = this.boundaries(trip);
+ const boundariesChanged = JSON.stringify(boundariesAfter) !== JSON.stringify(boundariesBefore);
+
+ return {
+ dayId: id,
+ orderedIds: remaining.map(r => r.id),
+ ...cancelled,
+ boundaries: boundariesChanged ? boundariesAfter : null,
+ endDate,
+ };
+ });
+
+ // After the commit, the way an assignment route does it: the journey mirrors
+ // the planned stops, and the day's stops are gone. reconcile() swallows its own
+ // failures, so a journey problem cannot turn a finished delete into an error.
+ this.assignments.reconcile(trip, viewer.socketId);
+ return { ...removal, trip: this.days.getTripForViewer(trip, viewer.userId) };
+ }
+
+ /**
+ * The one place a removal is fanned out. The collaborators drop the day first
+ * and then renumber off day:reordered, which also makes them pull the re-dated
+ * days and re-stamped bookings. The cancelled stays follow with what they did to
+ * the other days, then the rows they took with them.
+ */
+ announce(tripId: string | number, removal: DayRemoval, senders: DayRemovalSenders): void {
+ const { all, others, socketId } = senders;
+ others('day:deleted', { dayId: removal.dayId });
+ others('day:reordered', { orderedIds: removal.orderedIds });
+ for (const mirror of removal.mirrors) this.accommodations.announceMirror(tripId, mirror, all, socketId);
+ // Without a socket id, as when a place takes its nights with it: the Bookings
+ // list and the Costs total of the deleting tab hold these rows too.
+ for (const reservationId of removal.reservationIds) all('reservation:deleted', { reservationId });
+ for (const itemId of removal.budgetItemIds) all('budget:deleted', { itemId });
+ for (const accommodationId of removal.stayIds) others('accommodation:deleted', { accommodationId });
+ if (removal.boundaries) all('roadtripBoundary:changed', { boundaries: removal.boundaries });
+ // The day count changed, and the end date when the last date went: the trip
+ // header of every other open copy of the trip reads both.
+ if (removal.trip) others('trip:updated', { trip: removal.trip });
+ }
+
+ /**
+ * Cancel every stay that checks in or out on the day, the way deleting it by
+ * hand does: the partner booking, its expense and the stop it wrote go too.
+ * Left to the cascade, the stay row alone would go and the rest stay behind.
+ * A stay that only runs across the day keeps standing.
+ */
+ private cancelStays(tripId: number, dayId: number) {
+ const stays = this.db.all<{ id: number }>(
+ 'SELECT id FROM day_accommodations WHERE trip_id = ? AND (start_day_id = ? OR end_day_id = ?) ORDER BY id',
+ tripId, dayId, dayId,
+ );
+ const cancelled = { stayIds: [] as number[], reservationIds: [] as number[], budgetItemIds: [] as number[], mirrors: [] as AccommodationMirror[] };
+ for (const stay of stays) {
+ const gone = this.accommodations.deleteAccommodation(stay.id);
+ cancelled.stayIds.push(stay.id);
+ cancelled.reservationIds.push(...gone.linkedReservationIds);
+ cancelled.budgetItemIds.push(...gone.deletedBudgetItemIds);
+ cancelled.mirrors.push(withoutDay(gone.mirror, dayId));
+ }
+ return cancelled;
+ }
+
+ private boundaries(tripId: number): RoadtripDayBoundary[] {
+ return this.db.all(
+ 'SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number',
+ tripId,
+ );
+ }
+
+ /**
+ * Boundaries are keyed by day number, not by day, so the ones after the deleted
+ * day move up with their days. One at a time and in ascending order: the key is
+ * the primary key, and a CHECK keeps it at 1 or above, so the negative two-step
+ * the days use is not available here.
+ */
+ private shiftBoundaries(tripId: number, deletedNumber: number): void {
+ const later = this.db.all<{ day_number: number }>(
+ 'SELECT day_number FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number > ? ORDER BY day_number',
+ tripId, deletedNumber,
+ );
+ const move = this.db.prepare('UPDATE roadtrip_day_boundaries SET day_number = ? WHERE trip_id = ? AND day_number = ?');
+ for (const row of later) move.run(row.day_number - 1, tripId, row.day_number);
+ }
+
+ /**
+ * Close the gap and keep the dates on their positions: position i takes the
+ * i-th date of the trip as it was, the deleted day's date included. Bookings on
+ * a day that changed date are re-stamped onto it. When there are fewer days left
+ * than dates, the last date is gone, and a dated trip ends on the new last one.
+ * Returns that new end date, or null when the range stayed.
+ */
+ private renumber(tripId: number, rows: DayRow[], remaining: DayRow[]): string | null {
+ // ISO dates sort as plain strings.
+ const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort();
+ const setNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
+ const setNumberAndDate = this.db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
+
+ // Two phases, to get past UNIQUE(trip_id, day_number) on the way.
+ remaining.forEach((r, i) => setNumber.run(-(i + 1), r.id));
+ const oldDateById = new Map(remaining.map(r => [r.id, r.date]));
+ const newDateById = new Map();
+ remaining.forEach((r, i) => {
+ const date = sortedDates[i] ?? null;
+ setNumberAndDate.run(i + 1, date, r.id);
+ newDateById.set(r.id, date);
+ });
+ if (sortedDates.length > 0) this.days.restampReservationDates(tripId, oldDateById, newDateById);
+
+ if (remaining.length >= sortedDates.length) return null;
+ const range = this.db.get<{ start_date: string | null; end_date: string | null }>(
+ 'SELECT start_date, end_date FROM trips WHERE id = ?', tripId,
+ );
+ if (!range?.start_date || !range.end_date) return null;
+ const endDate = sortedDates[remaining.length - 1];
+ this.db.run('UPDATE trips SET end_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', endDate, tripId);
+ return endDate;
+ }
+}
+
+/**
+ * A cancelled stay's mirror without the deleted day. Its stop there went with the
+ * day, and the collaborators drop the whole day on day:deleted, so announcing that
+ * stop, the day's order or its roads on top would name a day nobody holds anymore.
+ */
+function withoutDay(mirror: AccommodationMirror, dayId: number): AccommodationMirror {
+ return {
+ ...mirror,
+ removed: mirror.removed.filter(stop => stop.dayId !== dayId),
+ ...(mirror.vias ? { vias: mirror.vias.filter(day => day.dayId !== dayId) } : {}),
+ };
+}
diff --git a/server/src/nest/days/days.controller.ts b/server/src/nest/days/days.controller.ts
index 37380d2ef..4aa34d716 100644
--- a/server/src/nest/days/days.controller.ts
+++ b/server/src/nest/days/days.controller.ts
@@ -12,6 +12,7 @@ import {
} from '@nestjs/common';
import type { User } from '../../types';
import { DaysService, DayReorderError } from './days.service';
+import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service';
import { DayCreateDto, DayReorderDto, DayTransportDto, DayUpdateDto } from './days.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -32,7 +33,7 @@ import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.g
// go through that method, and this keeps the two from drifting.
@UseGuards(JwtAuthGuard, TripAccessGuard)
export class DaysController {
- constructor(private readonly days: DaysService) {}
+ constructor(private readonly days: DaysService, private readonly removal: DayRemovalService) {}
@Get()
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
@@ -131,8 +132,21 @@ export class DaysController {
if (!this.days.getDay(id, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
- this.days.remove(id);
- this.days.broadcast(tripId, 'day:deleted', { dayId: Number(id) }, socketId);
- return { success: true };
+ let removal: DayRemoval;
+ try {
+ removal = this.removal.remove(tripId, id, { userId: user.id, socketId });
+ } catch (err) {
+ // The last day of a trip stays; nothing was written.
+ if (err instanceof DayDeleteError) throw new HttpException({ error: err.message }, 400);
+ throw err;
+ }
+ this.removal.announce(tripId, removal, {
+ all: (event, payload) => this.days.broadcast(tripId, event, payload, undefined),
+ others: (event, payload) => this.days.broadcast(tripId, event, payload, socketId),
+ socketId,
+ });
+ // `trip` is additive: the socket echo skips this tab, and the trip header
+ // shows the day count and, when the last date went, the new end date.
+ return { success: true, trip: removal.trip };
}
}
diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts
index 25bc75baf..789f80660 100644
--- a/server/src/nest/days/days.mcp.ts
+++ b/server/src/nest/days/days.mcp.ts
@@ -12,6 +12,8 @@ import {
} from '@trek/shared';
import type { DayCreateRequest, DayReorderRequest, DayUpdateRequest } from '@trek/shared';
import { DaysService, DayReorderError } from './days.service';
+import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service';
+import type { MirrorSender } from '../accommodations/accommodations.service';
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
@@ -41,6 +43,7 @@ export class DaysMcp {
private readonly days: DaysService,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
+ private readonly removal: DayRemovalService,
) {}
@Tool({
@@ -144,7 +147,7 @@ export class DaysMcp {
@Tool({
name: 'delete_day',
- description: 'Delete a day from a trip.',
+ description: 'Delete a day from a trip. The places planned on it stay in the place list of the trip; its notes, title and description are deleted; bookings on it stay on the trip without a day. A stay that checks in or out on the day is cancelled together with its booking and the expense of that booking, while a stay that only runs across the day is kept. The later days move up one place. On a dated trip the dates stay on their positions, so every later day and the bookings on it move one date earlier; a day without a date then takes the last date, and when there is none the trip ends one day earlier. The last day of a trip cannot be deleted.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
@@ -157,10 +160,18 @@ export class DaysMcp {
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.');
- this.days.remove(dayId);
- // REST parity shape ({ dayId }) — the client reads payload.dayId, so the { id }
- // variant never removed the day from collaborator screens.
- this.guards.safeBroadcast(tripId, 'day:deleted', { dayId });
+ let removal: DayRemoval;
+ try {
+ removal = this.removal.remove(tripId, dayId, { userId: ctx.userId });
+ } catch (err) {
+ // The last day stays, and the caller can act on the sentence.
+ if (err instanceof DayDeleteError) return errorResult(err.message);
+ throw err;
+ }
+ // The same fan-out as REST, day:deleted ({ dayId }, the shape the client reads)
+ // first. A tool has no socket of its own, so every screen hears all of it.
+ const send: MirrorSender = (event, payload) => this.guards.safeBroadcast(tripId, event, payload as Record);
+ this.removal.announce(tripId, removal, { all: send, others: send });
return ok({ success: true });
}
diff --git a/server/src/nest/days/days.module.ts b/server/src/nest/days/days.module.ts
index 6eb6b9c1b..1f8fa6bc3 100644
--- a/server/src/nest/days/days.module.ts
+++ b/server/src/nest/days/days.module.ts
@@ -3,6 +3,9 @@ import { DaysController } from './days.controller';
import { DaysService } from './days.service';
import { DaysMcp } from './days.mcp';
import { DaysRpc } from './days.rpc';
+import { DayRemovalService } from './day-removal.service';
+import { AccommodationsDomainModule } from '../accommodations/accommodations-domain.module';
+import { AssignmentsDomainModule } from '../assignments/assignments-domain.module';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { PermissionsModule } from '../permissions/permissions.module';
@@ -19,11 +22,19 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
*
* Day notes used to live here too, with their own full file set; they are their
* own domain now (day-notes/).
+ *
+ * Deleting a day cancels the stays that check in or out on it and lets the
+ * journey catch up, so DayRemovalService needs the accommodations and
+ * assignments services. Both domain modules are leaves (neither reaches days or
+ * places), and PlacesModule already brings them in, so the edge adds no cycle.
*/
@Module({
- imports: [McpSharedModule, PermissionsModule, QueryHelpersModule, PlacesModule, AuthModule, RealtimeModule, PluginGuardsModule],
+ imports: [
+ McpSharedModule, PermissionsModule, QueryHelpersModule, PlacesModule, AuthModule, RealtimeModule, PluginGuardsModule,
+ AccommodationsDomainModule, AssignmentsDomainModule,
+ ],
controllers: [DaysController],
- providers: [DaysService, DaysMcp, DaysRpc],
+ providers: [DaysService, DayRemovalService, DaysMcp, DaysRpc],
exports: [DaysService],
})
export class DaysModule {}
diff --git a/server/src/nest/days/days.rpc.ts b/server/src/nest/days/days.rpc.ts
index bd9f820be..dc3310342 100644
--- a/server/src/nest/days/days.rpc.ts
+++ b/server/src/nest/days/days.rpc.ts
@@ -6,6 +6,8 @@ import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { DaysService } from './days.service';
+import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service';
+import type { MirrorSender } from '../accommodations/accommodations.service';
const DAY_EDIT_ACTION = 'day_edit';
@@ -21,6 +23,7 @@ export class DaysRpc {
private readonly days: DaysService,
private readonly realtime: RealtimeService,
private readonly guards: PluginGuards,
+ private readonly removal: DayRemovalService,
) {}
@PluginMethod('days.create', { permission: 'db:write:days' })
@@ -59,8 +62,15 @@ export class DaysRpc {
const actor = this.guards.requireActor(ctx, 'day');
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
if (!this.days.getDay(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
- this.days.remove(dayId);
- this.realtime.broadcast(tripId, 'day:deleted', { dayId });
+ let removal: DayRemoval;
+ try {
+ removal = this.removal.remove(tripId, dayId, { userId: actor });
+ } catch (err) {
+ if (err instanceof DayDeleteError) throw new BadParams(err.message);
+ throw err;
+ }
+ const send: MirrorSender = (event, payload) => this.realtime.broadcast(tripId, event, payload);
+ this.removal.announce(tripId, removal, { all: send, others: send });
return { deleted: true };
}
}
diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts
index d9c7cdf04..c42e6587f 100644
--- a/server/src/nest/days/days.service.ts
+++ b/server/src/nest/days/days.service.ts
@@ -6,6 +6,7 @@ import { PermissionsService } from '../permissions/permissions.service';
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
import { carryVias, locatedStopIds, reseatOwnStop } from '../accommodations/night-seat';
import { formatAssignmentWithPlace } from '../common/rowShape';
+import { TRIP_SELECT } from '../trips/trip-select';
import type { AssignmentRow, Day, DayNote, User } from '../../types';
type Trip = TripAccess;
@@ -259,8 +260,13 @@ export class DaysService {
return { ...updatedDay, assignments: this.getAssignmentsForDay(id) };
}
- remove(id: string | number): void {
- this.db.run('DELETE FROM days WHERE id = ?', id);
+ /**
+ * The trip in list shape, as the trip routes answer it, for a day write that
+ * changed the trip itself (its end date, its day count). Read through the leaf
+ * query rather than TripsService, which imports this class.
+ */
+ getTripForViewer(tripId: string | number, userId: number): unknown {
+ return this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
}
// -------------------------------------------------------------------------
diff --git a/server/src/nest/trips/trip-select.ts b/server/src/nest/trips/trip-select.ts
new file mode 100644
index 000000000..eedf050fc
--- /dev/null
+++ b/server/src/nest/trips/trip-select.ts
@@ -0,0 +1,39 @@
+/**
+ * The trip row in list shape, and the one field that must never leave with it.
+ *
+ * A leaf on purpose: it imports nothing. TripsService imports DaysService, so a
+ * day write that has to hand its collaborators the changed trip (a day deleted
+ * off the end of a dated trip shortens it) cannot import trips.service.ts
+ * without closing a module cycle. Reading the same query from here keeps the
+ * two surfaces from drifting apart instead of copying it into the days domain.
+ */
+
+/**
+ * Strips `feed_token` from a trip row on its way out.
+ *
+ * The column is the sole credential for the anonymous /api/feed/trip/:token.ics
+ * route, and `SELECT t.*` hands it to every reader of the trip. Gating the
+ * token endpoint on `share_manage` means nothing while any member can read the
+ * same value out of the trip payload, so the two go together. No TREK client
+ * reads the field (it is absent from client/ and shared/ entirely).
+ */
+export function withoutFeedToken(row: T): T {
+ if (row && typeof row === 'object') delete (row as Record).feed_token;
+ return row;
+}
+
+// `NULL AS feed_token` after `t.*` rather than an explicit column list: the
+// duplicate name wins in the row object, so the credential is blanked once here
+// instead of at each of the nine call sites, and the next migration that adds a
+// column does not have to remember to extend a hand-maintained list.
+export const TRIP_SELECT = `
+ SELECT t.*,
+ NULL AS feed_token,
+ (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
+ (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
+ CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
+ u.username as owner_username,
+ (SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
+ FROM trips t
+ JOIN users u ON u.id = t.user_id
+`;
diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts
index 77c1656ed..bc2e1497c 100644
--- a/server/src/nest/trips/trips.service.ts
+++ b/server/src/nest/trips/trips.service.ts
@@ -13,6 +13,7 @@ import { UnsplashService } from '../unsplash/unsplash.service';
import { StorageService } from '../storage/storage.service';
import { SettingsService } from '../settings/settings.service';
import { NotFoundError, ValidationError } from '../common/domain-errors';
+import { TRIP_SELECT } from './trip-select';
export const MS_PER_DAY = 86400000;
@@ -29,35 +30,10 @@ function assertTripSpan(startDate: string, endDate: string) {
if (span > MAX_TRIP_DAYS) throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`);
}
-/**
- * Strips `feed_token` from a trip row on its way out.
- *
- * The column is the sole credential for the anonymous /api/feed/trip/:token.ics
- * route, and `SELECT t.*` hands it to every reader of the trip. Gating the
- * token endpoint on `share_manage` means nothing while any member can read the
- * same value out of the trip payload, so the two go together. No TREK client
- * reads the field (it is absent from client/ and shared/ entirely).
- */
-export function withoutFeedToken(row: T): T {
- if (row && typeof row === 'object') delete (row as Record).feed_token;
- return row;
-}
-
-// `NULL AS feed_token` after `t.*` rather than an explicit column list: the
-// duplicate name wins in the row object, so the credential is blanked once here
-// instead of at each of the nine call sites, and the next migration that adds a
-// column does not have to remember to extend a hand-maintained list.
-export const TRIP_SELECT = `
- SELECT t.*,
- NULL AS feed_token,
- (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
- (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
- CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
- u.username as owner_username,
- (SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
- FROM trips t
- JOIN users u ON u.id = t.user_id
-`;
+// The list-shape query and the feed-token scrub live in a leaf file so the days
+// domain can read the same trip row without importing this module (which
+// imports DaysService). Re-exported here so every existing importer is unchanged.
+export { TRIP_SELECT, withoutFeedToken } from './trip-select';
interface CreateTripData {
title: string;
diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts
index cf345162e..b0fd48c65 100644
--- a/server/tests/e2e/days.e2e.test.ts
+++ b/server/tests/e2e/days.e2e.test.ts
@@ -23,7 +23,8 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
avatar TEXT);`);
- tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, end_date TEXT);');
+ // start_date + updated_at: deleting a dated day can end the trip a day earlier.
+ tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, start_date TEXT, end_date TEXT, feed_token TEXT, updated_at TEXT);');
tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);');
// The tables DaysService really queries (real SQL, no service mock).
tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
@@ -54,6 +55,9 @@ const { db } = vi.hoisted(() => {
metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE reservation_endpoints (id INTEGER PRIMARY KEY AUTOINCREMENT, reservation_id INTEGER NOT NULL,
local_date TEXT);`);
+ // A deleted day moves the road trip boundaries after it up with their days.
+ tmp.exec(`CREATE TABLE roadtrip_day_boundaries (trip_id INTEGER NOT NULL, day_number INTEGER NOT NULL CHECK (day_number >= 1),
+ from_assignment_id INTEGER NOT NULL, to_assignment_id INTEGER, fraction REAL NOT NULL, PRIMARY KEY (trip_id, day_number));`);
// StorageRegistryService (behind StorageModule, now in this module chain) reads
// this at onModuleInit.
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
@@ -197,13 +201,38 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', (
expect(bad.body).toEqual({ error: 'orderedIds must be a permutation of the trip day ids.' });
});
- it('200 delete day removes the row', async () => {
+ it('200 delete day removes the row, closes the gap and answers with the trip', async () => {
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (7, 1, ?)').run('Delete');
const id = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 1)').run().lastInsertRowid);
+ const kept = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 2)').run().lastInsertRowid);
const res = await request(server).delete(`/api/trips/7/days/${id}`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
- expect(res.body).toEqual({ success: true });
+ expect(res.body).toMatchObject({ success: true, trip: { id: 7, day_count: 1, is_owner: 1 } });
expect(db.prepare('SELECT * FROM days WHERE id = ?').get(id)).toBeUndefined();
+ expect(db.prepare('SELECT day_number FROM days WHERE id = ?').get(kept)).toEqual({ day_number: 1 });
+ });
+
+ it('200 delete a dated day with no spare day ends the trip a day earlier', async () => {
+ db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (8, 1, ?, ?, ?)').run('Shrink', '2026-09-01', '2026-09-02');
+ const first = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (8, 1, ?)').run('2026-09-01').lastInsertRowid);
+ const second = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (8, 2, ?)').run('2026-09-02').lastInsertRowid);
+ const res = await request(server).delete(`/api/trips/8/days/${first}`).set('Cookie', sessionCookie(1));
+ expect(res.status).toBe(200);
+ expect(res.body.trip).toMatchObject({ id: 8, end_date: '2026-09-01', day_count: 1 });
+ expect(db.prepare('SELECT day_number, date FROM days WHERE id = ?').get(second)).toEqual({ day_number: 1, date: '2026-09-01' });
+ });
+
+ it('400 delete the last day of a trip, 403 without day_edit', async () => {
+ db.prepare('INSERT INTO trips (id, user_id, title) VALUES (9, 1, ?)').run('Last');
+ const only = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (9, 1)').run().lastInsertRowid);
+ const last = await request(server).delete(`/api/trips/9/days/${only}`).set('Cookie', sessionCookie(1));
+ expect(last.status).toBe(400);
+ expect(last.body).toEqual({ error: 'A trip needs at least one day.' });
+ expect(db.prepare('SELECT id FROM days WHERE id = ?').get(only)).toEqual({ id: only });
+ checkPermission.mockReturnValue(false);
+ const forbidden = await request(server).delete(`/api/trips/9/days/${only}`).set('Cookie', sessionCookie(1));
+ expect(forbidden.status).toBe(403);
+ expect(forbidden.body).toEqual({ error: 'No permission' });
});
it('201 create note (real insert: trim, empty-string coercions), 400 on over-long text (before access)', async () => {
diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts
index a9225e39c..b587d6f0f 100644
--- a/server/tests/helpers/mcp-test-controllers.ts
+++ b/server/tests/helpers/mcp-test-controllers.ts
@@ -23,6 +23,7 @@ import { DayNotesMcp } from '../../src/nest/day-notes/day-notes.mcp';
import { DayNotesService } from '../../src/nest/day-notes/day-notes.service';
import { DaysMcp } from '../../src/nest/days/days.mcp';
import { DaysService } from '../../src/nest/days/days.service';
+import { DayRemovalService } from '../../src/nest/days/day-removal.service';
import { MapsMcp } from '../../src/nest/maps/maps.mcp';
import { WeatherMcp } from '../../src/nest/weather/weather.mcp';
import { WeatherService } from '../../src/nest/weather/weather.service';
@@ -177,6 +178,8 @@ export function createMcpTestRegistry(): McpRegistry {
);
// Built after it: a hotel booking writes the stay's day stop through this one.
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadRepository(dbService), accommodationsService);
+ // Deleting a day cancels the stays on it through the same accommodations service.
+ const dayRemovalService = new DayRemovalService(dbService, daysService, accommodationsService, assignmentsService);
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService, budgetService), permissionsService, realtimeService, notificationsStub());
const tripsService = new TripsService(
dbService,
@@ -223,7 +226,7 @@ export function createMcpTestRegistry(): McpRegistry {
new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(dbService), addonsService, guards),
new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards),
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards),
- new DaysMcp(daysService, authService, guards),
+ new DaysMcp(daysService, authService, guards, dayRemovalService),
new RoadtripMcp(new RoadtripService(dbService, realtimeService), dbService, guards, authService, addonsService),
new RoadtripPreferencesMcp(new RoadtripPreferencesService(dbService, realtimeService), authService, addonsService, dbService, guards),
new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage), authService, guards),
diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts
index 6235ac0d9..02f4604b6 100644
--- a/server/tests/helpers/plugin-host.ts
+++ b/server/tests/helpers/plugin-host.ts
@@ -21,6 +21,7 @@ import { TodoService } from '../../src/nest/todo/todo.service';
import { PackingService } from '../../src/nest/packing/packing.service';
import { DayNotesService } from '../../src/nest/day-notes/day-notes.service';
import { DaysService } from '../../src/nest/days/days.service';
+import { DayRemovalService } from '../../src/nest/days/day-removal.service';
import { AssignmentsService } from '../../src/nest/assignments/assignments.service';
import { LlmConfigResolver } from '../../src/nest/llm-parse/llm-config.resolver';
import { SettingsService } from '../../src/nest/settings/settings.service';
@@ -127,7 +128,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
new PackingRpc(packing, realtime, guards),
new FilesRpc(files, realtime, dbs, guards, generalStorage),
new PlacesRpc(places, journey, realtime, guards),
- new DaysRpc(days, realtime, guards),
+ new DaysRpc(days, realtime, guards, new DayRemovalService(dbs, days, accommodations, assignments)),
new AccommodationsRpc(accommodations, realtime, guards),
new ItineraryRpc(assignments, realtime, guards),
new TripsRpc(trips, reservations, days, membership, dbs, realtime, guards, accommodations, members),
diff --git a/server/tests/helpers/rpc-host-deps.ts b/server/tests/helpers/rpc-host-deps.ts
index 5f63ba54e..ba97a361d 100644
--- a/server/tests/helpers/rpc-host-deps.ts
+++ b/server/tests/helpers/rpc-host-deps.ts
@@ -69,7 +69,7 @@ export function allRpcControllers(): object[] {
new PackingRpc(anyService(), anyService(), anyService()),
new FilesRpc(anyService(), anyService(), anyService(), anyService(), anyService()),
new PlacesRpc(anyService(), anyService(), anyService(), anyService()),
- new DaysRpc(anyService(), anyService(), anyService()),
+ new DaysRpc(anyService(), anyService(), anyService(), anyService()),
new ItineraryRpc(anyService(), anyService(), anyService()),
new TripsRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()),
new CostsRpc(anyService(), anyService(), anyService(), anyService(), anyService()),
diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts
index 9ae7d7e24..828b79f2a 100644
--- a/server/tests/helpers/test-db.ts
+++ b/server/tests/helpers/test-db.ts
@@ -42,6 +42,7 @@ const RESET_TABLES = [
// one case into the next.
'roadtrip_day_tracks',
'roadtrip_vias',
+ 'roadtrip_day_boundaries',
// Dawarich (#2279). The suggestions reference places and bucket_list, so they
// are cleared before both; the connection row hangs off the user.
'dawarich_visit_suggestions',
diff --git a/server/tests/integration/days.test.ts b/server/tests/integration/days.test.ts
index e0bbaf6de..ea8d9605d 100644
--- a/server/tests/integration/days.test.ts
+++ b/server/tests/integration/days.test.ts
@@ -1,6 +1,6 @@
/**
* Days & Accommodations API integration tests.
- * Covers DAY-001 through DAY-006 and ACCOM-001 through ACCOM-003.
+ * Covers DAY-001 through DAY-010 and ACCOM-001 through ACCOM-006.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
@@ -267,6 +267,7 @@ describe('Delete day', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const day = createDay(testDb, trip.id);
+ createDay(testDb, trip.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${day.id}`)
@@ -290,6 +291,74 @@ describe('Delete day', () => {
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
+
+ it('DAY-007: the days after a deleted one close up, and the answer carries the trip with its new day count', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Trip' });
+ const [a, b, c] = [createDay(testDb, trip.id), createDay(testDb, trip.id), createDay(testDb, trip.id)];
+
+ const res = await request(app)
+ .delete(`/api/trips/${trip.id}/days/${b.id}`)
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({ success: true, trip: { id: trip.id, day_count: 2 } });
+ const list = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
+ expect(list.body.days.map((d: { id: number; day_number: number }) => [d.id, d.day_number])).toEqual([[a.id, 1], [c.id, 2]]);
+ });
+
+ it('DAY-008: deleting a dated day with no day left to take the last date ends the trip a day earlier', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-03' });
+ const first = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number LIMIT 1').get(trip.id) as { id: number };
+
+ const res = await request(app)
+ .delete(`/api/trips/${trip.id}/days/${first.id}`)
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.trip).toMatchObject({ start_date: '2026-08-01', end_date: '2026-08-02' });
+ const reread = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
+ expect(reread.body.trip).toMatchObject({ start_date: '2026-08-01', end_date: '2026-08-02' });
+ const list = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
+ expect(list.body.days.map((d: { date: string }) => d.date)).toEqual(['2026-08-01', '2026-08-02']);
+ });
+
+ it('DAY-009: the last day of a trip is refused with 400 and stays', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Trip' });
+ const day = createDay(testDb, trip.id);
+
+ const res = await request(app)
+ .delete(`/api/trips/${trip.id}/days/${day.id}`)
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(400);
+ expect(res.body).toEqual({ error: 'A trip needs at least one day.' });
+ expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
+ });
+
+ it('DAY-010: a member without day_edit gets 403 and the day stays', async () => {
+ const { user: owner } = createUser(testDb);
+ const { user: member } = createUser(testDb);
+ const trip = createTrip(testDb, owner.id, { title: 'Trip' });
+ addTripMember(testDb, trip.id, member.id);
+ const day = createDay(testDb, trip.id);
+ createDay(testDb, trip.id);
+ testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_day_edit', 'trip_owner')").run();
+ const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache');
+ invalidatePermissionsCache();
+ try {
+ const res = await request(app)
+ .delete(`/api/trips/${trip.id}/days/${day.id}`)
+ .set('Cookie', authCookie(member.id));
+ expect(res.status).toBe(403);
+ expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
+ } finally {
+ testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run();
+ invalidatePermissionsCache();
+ }
+ });
});
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/server/tests/unit/days/days-itinerary.rpc.test.ts b/server/tests/unit/days/days-itinerary.rpc.test.ts
index 920696b1c..e72ef515a 100644
--- a/server/tests/unit/days/days-itinerary.rpc.test.ts
+++ b/server/tests/unit/days/days-itinerary.rpc.test.ts
@@ -15,6 +15,7 @@ import { DaysModule } from '../../../src/nest/days/days.module';
import { ItineraryRpc } from '../../../src/nest/assignments/itinerary.rpc';
import { AssignmentsModule } from '../../../src/nest/assignments/assignments.module';
import type { DaysService } from '../../../src/nest/days/days.service';
+import { DayDeleteError, type DayRemoval, type DayRemovalService } from '../../../src/nest/days/day-removal.service';
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
@@ -31,8 +32,20 @@ function build(opts: { canEdit?: boolean } = {}) {
create: vi.fn((tripId: number, date?: string) => ({ id: 20, trip_id: tripId, date })),
getDay: vi.fn((dayId: number, tripId: number) => (dayId === 3 && tripId === 1 ? { id: 3 } : undefined)),
update: vi.fn((id: number) => ({ id })),
- remove: vi.fn(),
} as unknown as DaysService & Record>;
+ const removed: DayRemoval = {
+ dayId: 3, orderedIds: [4], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
+ boundaries: null, endDate: null, trip: { id: 1 },
+ };
+ // announce() is the removal service's own fan-out (DAY-DEL-014); the stub sends
+ // one event through each sender so the case can see where they lead.
+ const removal = {
+ remove: vi.fn(() => removed),
+ announce: vi.fn((_tripId: number, r: DayRemoval, senders: Parameters[2]) => {
+ senders.others('day:deleted', { dayId: r.dayId });
+ senders.all('day:reordered', { orderedIds: r.orderedIds });
+ }),
+ } as unknown as DayRemovalService;
const assignments = {
dayExists: vi.fn((dayId: number, tripId: number) => dayId === 3 && tripId === 1),
placeExists: vi.fn((placeId: number, tripId: number) => placeId === 7 && tripId === 1),
@@ -50,9 +63,9 @@ function build(opts: { canEdit?: boolean } = {}) {
{ checkPermission: vi.fn(() => opts.canEdit ?? true) } as unknown as PermissionsService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
);
- const registry = createTestPluginRegistry([new DaysRpc(days, realtime, guards), new ItineraryRpc(assignments, realtime, guards)]);
+ const registry = createTestPluginRegistry([new DaysRpc(days, realtime, guards, removal), new ItineraryRpc(assignments, realtime, guards)]);
const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), registry);
- return { days, assignments, realtime, host };
+ return { days, assignments, realtime, host, removal, removed };
}
describe('DaysRpc', () => {
@@ -72,7 +85,7 @@ describe('DaysRpc', () => {
.toBe('no day 404 on trip 1');
expect(((await host.dispatch(req('days.delete', { tripId: 1, dayId: 404 }), 42)) as RpcError).error.message)
.toBe('no day 404 on trip 1');
- expect(f.days.remove).not.toHaveBeenCalled();
+ expect(f.removal.remove).not.toHaveBeenCalled();
});
it('DAYS-RPC-003 every write broadcasts its event', async () => {
@@ -81,12 +94,42 @@ describe('DaysRpc', () => {
await host.dispatch(req('days.create', { tripId: 1, input: {} }), 42);
await host.dispatch(req('days.update', { tripId: 1, dayId: 3, input: { notes: 'x' } }), 42);
await host.dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42);
- expect(f.realtime.broadcast.mock.calls.map((c) => c[1])).toEqual(['day:created', 'day:updated', 'day:deleted']);
+ expect(f.realtime.broadcast.mock.calls.map((c) => c[1])).toEqual(['day:created', 'day:updated', 'day:deleted', 'day:reordered']);
});
it('DAYS-RPC-004 the class is listed in its module providers', () => {
expectRegisteredProvider(DaysModule, DaysRpc);
});
+
+ it('DAYS-RPC-005 the last day of a trip is BAD_PARAMS with the sentence REST and MCP give, and nothing is announced', async () => {
+ const f = build();
+ vi.mocked(f.removal.remove).mockImplementation(() => { throw new DayDeleteError('A trip needs at least one day.'); });
+ const res = (await f.host('db:write:days').dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42)) as RpcError;
+ expect(res.error).toMatchObject({ code: 'BAD_PARAMS', message: 'A trip needs at least one day.' });
+ expect(f.removal.announce).not.toHaveBeenCalled();
+ expect(f.realtime.broadcast).not.toHaveBeenCalled();
+ });
+
+ it('DAYS-RPC-006 a delete runs as the bound user and fans out through the shared announce, to every socket', async () => {
+ const f = build();
+ const res = await f.host('db:write:days').dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42);
+ expect(res).toMatchObject({ ok: true, result: { deleted: true } });
+ expect(f.removal.remove).toHaveBeenCalledWith(1, 3, { userId: 42 });
+ const senders = vi.mocked(f.removal.announce).mock.calls[0][2];
+ // A plugin has no socket of its own, so nobody is left out.
+ expect(senders.all).toBe(senders.others);
+ expect(f.realtime.broadcast.mock.calls).toEqual([
+ [1, 'day:deleted', { dayId: 3 }],
+ [1, 'day:reordered', { orderedIds: [4] }],
+ ]);
+ });
+
+ it('DAYS-RPC-006b anything but the last-day refusal is not dressed up as bad params', async () => {
+ const f = build();
+ vi.mocked(f.removal.remove).mockImplementation(() => { throw new Error('db is down'); });
+ const res = (await f.host('db:write:days').dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42)) as RpcError;
+ expect(res.error.code).not.toBe('BAD_PARAMS');
+ });
});
describe('ItineraryRpc', () => {
diff --git a/server/tests/unit/mcp/tools-days-accommodations.test.ts b/server/tests/unit/mcp/tools-days-accommodations.test.ts
index 67534c877..1c41d60ae 100644
--- a/server/tests/unit/mcp/tools-days-accommodations.test.ts
+++ b/server/tests/unit/mcp/tools-days-accommodations.test.ts
@@ -51,7 +51,9 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
-import { createUser, createTrip, createDay, createPlace, createDayAccommodation } from '../../helpers/factories';
+import { createUser, createTrip, createDay, createPlace, createDayAccommodation, addTripMember } from '../../helpers/factories';
+import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
+import { DayRemovalService } from '../../../src/nest/days/day-removal.service';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
@@ -155,19 +157,94 @@ describe('Tool: create_day', () => {
// ---------------------------------------------------------------------------
describe('Tool: delete_day', () => {
- it('deletes a day and broadcasts', async () => {
+ const dayRows = (tripId: number) =>
+ testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number; day_number: number; date: string | null }[];
+
+ it('deletes a day the way REST does: the gap closes, the dates stay on their slots, and every screen hears it', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { start_date: '2026-01-01', end_date: '2026-01-03' });
+ const [d1, d2, d3] = dayRows(trip.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'delete_day',
+ arguments: { tripId: trip.id, dayId: d2.id },
+ });
+ const data = parseToolResult(result) as any;
+ expect(data.success).toBe(true);
+ expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(d2.id)).toBeUndefined();
+ expect(dayRows(trip.id)).toEqual([
+ { id: d1.id, day_number: 1, date: '2026-01-01' },
+ { id: d3.id, day_number: 2, date: '2026-01-02' },
+ ]);
+ expect(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ end_date: '2026-01-02' });
+ const events = broadcastMock.mock.calls.map(c => c[1]);
+ expect(events).toEqual(['day:deleted', 'day:reordered', 'trip:updated']);
+ expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', expect.objectContaining({ dayId: d2.id }));
+ expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:reordered', expect.objectContaining({ orderedIds: [d1.id, d3.id] }));
+ expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'trip:updated', expect.objectContaining({ trip: expect.objectContaining({ id: trip.id, end_date: '2026-01-02' }) }));
+ });
+ });
+
+ it('cancels a stay checking in on the day with its booking, and says so', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const d1 = createDay(testDb, trip.id);
+ const d2 = createDay(testDb, trip.id);
+ const place = createPlace(testDb, trip.id, { name: 'Harbour Hotel' });
+ const stay = createDayAccommodation(testDb, trip.id, place.id, d2.id, d2.id);
+ const reservationId = Number(testDb.prepare(
+ "INSERT INTO reservations (trip_id, day_id, title, type, accommodation_id) VALUES (?, ?, 'Harbour Hotel', 'hotel', ?)",
+ ).run(trip.id, d2.id, stay.id).lastInsertRowid);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: d2.id } });
+ expect(result.isError).toBeFalsy();
+ expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(stay.id)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservationId)).toBeUndefined();
+ expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:deleted', expect.objectContaining({ reservationId }));
+ expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', expect.objectContaining({ accommodationId: stay.id }));
+ expect(dayRows(trip.id).map(r => r.id)).toEqual([d1.id]);
+ });
+ });
+
+ it('refuses the last day of a trip with the REST sentence and leaves it in place', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
- const result = await h.client.callTool({
- name: 'delete_day',
- arguments: { tripId: trip.id, dayId: day.id },
+ const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
+ expect(result.isError).toBe(true);
+ expect(JSON.stringify(result.content)).toContain('A trip needs at least one day.');
+ expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
+ expect(broadcastMock).not.toHaveBeenCalled();
+ });
+ });
+
+ it('lets an unexpected failure surface as one, not as the last-day refusal', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id);
+ createDay(testDb, trip.id);
+ const boom = vi.spyOn(DayRemovalService.prototype, 'remove').mockImplementation(() => { throw new Error('disk full'); });
+ try {
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
+ expect(result.isError).toBe(true);
+ expect(JSON.stringify(result.content)).not.toContain('A trip needs at least one day.');
+ expect(broadcastMock).not.toHaveBeenCalled();
});
- const data = parseToolResult(result) as any;
- expect(data.success).toBe(true);
- expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', expect.objectContaining({ dayId: day.id }));
- expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeUndefined();
+ } finally {
+ boom.mockRestore();
+ }
+ });
+
+ it('reports a day that is not on the trip', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ createDay(testDb, trip.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: 999999 } });
+ expect(result.isError).toBe(true);
+ expect(JSON.stringify(result.content)).toContain('Day not found.');
});
});
@@ -181,6 +258,40 @@ describe('Tool: delete_day', () => {
expect(result.isError).toBe(true);
});
});
+
+ it('refuses a member without day_edit, the same right the REST route asks for', async () => {
+ const { user: owner } = createUser(testDb);
+ const { user: member } = createUser(testDb);
+ const trip = createTrip(testDb, owner.id);
+ addTripMember(testDb, trip.id, member.id);
+ const day = createDay(testDb, trip.id);
+ createDay(testDb, trip.id);
+ testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_day_edit', 'trip_owner')").run();
+ invalidatePermissionsCache();
+ try {
+ await withHarness(member.id, async (h) => {
+ const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
+ expect(result.isError).toBe(true);
+ expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
+ });
+ } finally {
+ testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run();
+ invalidatePermissionsCache();
+ }
+ });
+
+ it('blocks demo user', async () => {
+ process.env.DEMO_MODE = 'true';
+ const { user } = createUser(testDb, { email: 'demo@nomad.app' });
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id);
+ createDay(testDb, trip.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
+ expect(result.isError).toBe(true);
+ expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
+ });
+ });
});
// ---------------------------------------------------------------------------
diff --git a/server/tests/unit/nest/day-removal.service.test.ts b/server/tests/unit/nest/day-removal.service.test.ts
new file mode 100644
index 000000000..28815b58c
--- /dev/null
+++ b/server/tests/unit/nest/day-removal.service.test.ts
@@ -0,0 +1,427 @@
+/**
+ * DayRemovalService: deleting one day, the way the reorder dialog, the MCP tool
+ * and the plugin RPC all do it. DAY-DEL-001 to DAY-DEL-018.
+ *
+ * Real in-memory SQLite with the real accommodations and assignments services,
+ * so the foreign key cascades and the stay cancellation are the ones production runs.
+ */
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
+
+const { testDb, dbMock } = vi.hoisted(() => {
+ const Database = require('better-sqlite3');
+ const db = new Database(':memory:');
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec('PRAGMA foreign_keys = ON');
+ db.exec('PRAGMA busy_timeout = 5000');
+ const mock = {
+ db,
+ closeDb: () => {},
+ reinitialize: () => {},
+ getPlaceWithTags: () => null,
+ canAccessTrip: (tripId: number | string, userId: number) =>
+ db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
+ isOwner: (tripId: number | string, userId: number) =>
+ !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
+ };
+ return { testDb: db, dbMock: mock };
+});
+
+vi.mock('../../../src/db/database', () => dbMock);
+vi.mock('../../../src/config', () => ({
+ JWT_SECRET: 'test-secret',
+ ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
+ updateJwtSecret: () => {},
+}));
+vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
+
+import { createTables } from '../../../src/db/schema';
+import { runMigrations } from '../../../src/db/migrations';
+import { resetTestDb } from '../../helpers/test-db';
+import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
+import { DatabaseService } from '../../../src/nest/database/database.service';
+import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
+import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
+import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
+import { DaysService } from '../../../src/nest/days/days.service';
+import { DayRemovalService, DayDeleteError, LAST_DAY_MESSAGE, type DayRemoval } from '../../../src/nest/days/day-removal.service';
+import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
+import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
+import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
+import { AccommodationsService, type MirrorSender } from '../../../src/nest/accommodations/accommodations.service';
+
+const dbs = new DatabaseService(testDb);
+const permissions = new PermissionsService(dbs);
+const realtime = new RealtimeService();
+const queryHelpers = new QueryHelpersService(dbs);
+const days = new DaysService(dbs, permissions, realtime, queryHelpers);
+const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs));
+const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey);
+const accommodations = new AccommodationsService(dbs, permissions, realtime, assignments);
+const removal = new DayRemovalService(dbs, days, accommodations, assignments);
+
+beforeAll(() => {
+ createTables(testDb);
+ runMigrations(testDb);
+});
+
+beforeEach(() => {
+ resetTestDb(testDb);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+afterAll(() => {
+ testDb.close();
+});
+
+type Row = { id: number; day_number: number; date: string | null };
+
+const dayRows = (tripId: number): Row[] =>
+ testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as Row[];
+
+const range = (tripId: number) =>
+ testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(tripId) as { start_date: string | null; end_date: string | null };
+
+const reservationRow = (id: number) =>
+ testDb.prepare('SELECT day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE id = ?').get(id) as
+ { day_id: number | null; end_day_id: number | null; reservation_time: string | null; reservation_end_time: string | null } | undefined;
+
+function booking(tripId: number, dayId: number | null, time: string | null, extra: { endDayId?: number; endTime?: string } = {}): number {
+ return Number(testDb.prepare(
+ 'INSERT INTO reservations (trip_id, day_id, end_day_id, title, type, reservation_time, reservation_end_time) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ ).run(tripId, dayId, extra.endDayId ?? null, 'Booking', 'restaurant', time, extra.endTime ?? null).lastInsertRowid);
+}
+
+/** A dated trip whose days the factory generated, one per date. */
+function datedTrip(start: string, end: string) {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { start_date: start, end_date: end });
+ return { user, trip, rows: dayRows(trip.id) };
+}
+
+/** A booked night: the stay, its hotel booking, the stop on its check-in day and an expense. */
+function bookedNight(tripId: number, startDayId: number, endDayId: number) {
+ const place = createPlace(testDb, tripId, { name: 'Harbour Hotel' });
+ const { accommodation } = accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id: startDayId, end_day_id: endDayId });
+ const stayId = (accommodation as { id: number }).id;
+ const reservationId = (testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(stayId) as { id: number }).id;
+ const budgetId = Number(testDb.prepare(
+ 'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)',
+ ).run(tripId, 'Harbour Hotel', 'Accommodation', 240, reservationId).lastInsertRowid);
+ const stop = testDb.prepare('SELECT id, day_id FROM day_assignments WHERE accommodation_id = ?').get(stayId) as { id: number; day_id: number };
+ return { place, stayId, reservationId, budgetId, stop };
+}
+
+function boundary(tripId: number, dayNumber: number, fromAssignmentId: number) {
+ testDb.prepare(
+ 'INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, ?, ?, NULL, 1)',
+ ).run(tripId, dayNumber, fromAssignmentId);
+}
+
+describe('DayRemovalService.remove', () => {
+ it('DAY-DEL-001 an undated day only closes the gap in the numbering', () => {
+ const { user } = createUser(testDb);
+ const dateless = createTrip(testDb, user.id);
+ const [a, b, c] = [createDay(testDb, dateless.id), createDay(testDb, dateless.id), createDay(testDb, dateless.id)];
+
+ const result = removal.remove(dateless.id, b.id, { userId: user.id });
+
+ expect(dayRows(dateless.id)).toEqual([
+ { id: a.id, day_number: 1, date: null },
+ { id: c.id, day_number: 2, date: null },
+ ]);
+ expect(result).toMatchObject({ dayId: b.id, orderedIds: [a.id, c.id], endDate: null, boundaries: null, stayIds: [] });
+
+ // The extra day of a dated trip, the case the dialog was asked for: no date moves.
+ const dated = datedTrip('2026-03-01', '2026-03-02');
+ const spare = createDay(testDb, dated.trip.id);
+ const dinner = booking(dated.trip.id, dated.rows[1].id, '2026-03-02T19:00');
+ removal.remove(dated.trip.id, spare.id, { userId: dated.user.id });
+ expect(dayRows(dated.trip.id)).toEqual(dated.rows);
+ expect(range(dated.trip.id)).toEqual({ start_date: '2026-03-01', end_date: '2026-03-02' });
+ expect(reservationRow(dinner)?.reservation_time).toBe('2026-03-02T19:00');
+ });
+
+ it('DAY-DEL-002 a dated day with a spare day after it: the dates stay on their slots and the bookings move along', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const [d1, d2, d3] = rows;
+ const spare = createDay(testDb, trip.id);
+ const flight = booking(trip.id, d3.id, '2026-01-03T10:00', { endDayId: d3.id, endTime: '2026-01-03T12:00' });
+ const leg = Number(testDb.prepare(
+ 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, local_date) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ ).run(flight, 'from', 0, 'HEL', 60.31, 24.96, '2026-01-03').lastInsertRowid);
+
+ const result = removal.remove(trip.id, d2.id, { userId: user.id });
+
+ expect(dayRows(trip.id)).toEqual([
+ { id: d1.id, day_number: 1, date: '2026-01-01' },
+ { id: d3.id, day_number: 2, date: '2026-01-02' },
+ { id: spare.id, day_number: 3, date: '2026-01-03' },
+ ]);
+ expect(reservationRow(flight)).toMatchObject({ reservation_time: '2026-01-02T10:00', reservation_end_time: '2026-01-02T12:00' });
+ expect(testDb.prepare('SELECT local_date FROM reservation_endpoints WHERE id = ?').get(leg)).toEqual({ local_date: '2026-01-02' });
+ // The spare day took the last date, so the trip keeps its range.
+ expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' });
+ expect(result.endDate).toBeNull();
+ });
+
+ it('DAY-DEL-003 a dated day with no spare day left: the last date goes and the trip ends a day earlier', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const [d1, d2, d3] = rows;
+
+ const result = removal.remove(trip.id, d1.id, { userId: user.id });
+
+ expect(dayRows(trip.id)).toEqual([
+ { id: d2.id, day_number: 1, date: '2026-01-01' },
+ { id: d3.id, day_number: 2, date: '2026-01-02' },
+ ]);
+ expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-02' });
+ expect(result.endDate).toBe('2026-01-02');
+ // The trip in list shape for the trip header, without its feed credential.
+ expect(result.trip).toMatchObject({ id: trip.id, end_date: '2026-01-02', day_count: 2, is_owner: 1, feed_token: null });
+ });
+
+ it('DAY-DEL-004 the last day of a trip stays, and nothing is written', () => {
+ const { user, trip, rows } = datedTrip('2026-05-01', '2026-05-01');
+ const night = bookedNight(trip.id, rows[0].id, rows[0].id);
+
+ expect(() => removal.remove(trip.id, rows[0].id, { userId: user.id })).toThrow(new DayDeleteError(LAST_DAY_MESSAGE));
+ expect(dayRows(trip.id)).toEqual(rows);
+ expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeDefined();
+ expect(reservationRow(night.reservationId)).toBeDefined();
+ });
+
+ it('DAY-DEL-005 a stay checking in on the day is cancelled with its booking and that booking\'s expense', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const night = bookedNight(trip.id, rows[1].id, rows[2].id);
+ expect(night.stop.day_id).toBe(rows[1].id);
+
+ const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
+
+ expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeUndefined();
+ expect(reservationRow(night.reservationId)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(night.budgetId)).toBeUndefined();
+ // The hotel itself is a place and stays on the list.
+ expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(night.place.id)).toBeDefined();
+ expect(result).toMatchObject({ stayIds: [night.stayId], reservationIds: [night.reservationId], budgetItemIds: [night.budgetId] });
+ // Its stop sat on the deleted day, which day:deleted already covers.
+ expect(result.mirrors[0].removed).toEqual([]);
+ });
+
+ it('DAY-DEL-006 a stay checking out on the day is cancelled too, and its stop on the check-in day goes with it', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const night = bookedNight(trip.id, rows[0].id, rows[1].id);
+
+ const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
+
+ expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeUndefined();
+ expect(reservationRow(night.reservationId)).toBeUndefined();
+ expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(night.stop.id)).toBeUndefined();
+ expect(result.mirrors[0].removed).toEqual([{ id: night.stop.id, dayId: rows[0].id }]);
+ });
+
+ it('DAY-DEL-007 a stay that only runs across the day keeps standing', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const night = bookedNight(trip.id, rows[0].id, rows[2].id);
+
+ const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
+
+ expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(night.stayId))
+ .toEqual({ start_day_id: rows[0].id, end_day_id: rows[2].id });
+ expect(reservationRow(night.reservationId)).toBeDefined();
+ expect(result.stayIds).toEqual([]);
+ });
+
+ it('DAY-DEL-008 bookings on the day are let go of, with their dates untouched', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const [d1, d2] = rows;
+ const dinner = booking(trip.id, d2.id, '2026-01-02T19:00');
+ const car = booking(trip.id, d1.id, '2026-01-01T09:00', { endDayId: d2.id, endTime: '2026-01-02T10:00' });
+
+ removal.remove(trip.id, d2.id, { userId: user.id });
+
+ expect(reservationRow(dinner)).toEqual({ day_id: null, end_day_id: null, reservation_time: '2026-01-02T19:00', reservation_end_time: null });
+ expect(reservationRow(car)).toEqual({ day_id: d1.id, end_day_id: null, reservation_time: '2026-01-01T09:00', reservation_end_time: '2026-01-02T10:00' });
+ });
+
+ it('DAY-DEL-009 the road trip boundaries after the day move up with their days', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-04');
+ const place = createPlace(testDb, trip.id);
+ const stops = rows.map(r => createDayAssignment(testDb, r.id, place.id));
+ rows.forEach((_, i) => boundary(trip.id, i + 1, stops[i].id));
+
+ const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
+
+ const expected = [
+ { day_number: 1, from_assignment_id: stops[0].id, to_assignment_id: null, fraction: 1 },
+ { day_number: 2, from_assignment_id: stops[2].id, to_assignment_id: null, fraction: 1 },
+ { day_number: 3, from_assignment_id: stops[3].id, to_assignment_id: null, fraction: 1 },
+ ];
+ expect(testDb.prepare('SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number').all(trip.id))
+ .toEqual(expected);
+ expect(result.boundaries).toEqual(expected);
+ });
+
+ it('DAY-DEL-010 deleting the day an insert slotted in gives back the trip as it was', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const train = booking(trip.id, rows[2].id, '2026-01-03T08:00');
+ const before = { rows: dayRows(trip.id), range: range(trip.id), train: reservationRow(train) };
+
+ const inserted = days.insert(trip.id, 2);
+ expect(range(trip.id).end_date).toBe('2026-01-04');
+ removal.remove(trip.id, inserted.id, { userId: user.id });
+
+ expect({ rows: dayRows(trip.id), range: range(trip.id), train: reservationRow(train) }).toEqual(before);
+ });
+
+ it('DAY-DEL-011 appending a day, moving it into a slot and deleting the day it pushed out keeps every date', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const [d1, d2, d3] = rows;
+ const lunch = booking(trip.id, d2.id, '2026-01-02T12:00');
+ const tour = booking(trip.id, d3.id, '2026-01-03T09:00');
+
+ const appended = days.create(trip.id);
+ days.reorder(trip.id, [d1.id, d2.id, appended.id, d3.id]);
+ // The pushed-out day lost its date to the new one and sits at the end without one.
+ expect(dayRows(trip.id).at(-1)).toEqual({ id: d3.id, day_number: 4, date: null });
+ removal.remove(trip.id, d3.id, { userId: user.id });
+
+ expect(dayRows(trip.id)).toEqual([
+ { id: d1.id, day_number: 1, date: '2026-01-01' },
+ { id: d2.id, day_number: 2, date: '2026-01-02' },
+ { id: appended.id, day_number: 3, date: '2026-01-03' },
+ ]);
+ expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' });
+ expect(reservationRow(lunch)?.reservation_time).toBe('2026-01-02T12:00');
+ expect(reservationRow(tour)?.reservation_time).toBe('2026-01-03T09:00');
+ });
+
+ it('DAY-DEL-012 a failure halfway rolls the whole delete back', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const night = bookedNight(trip.id, rows[1].id, rows[2].id);
+ vi.spyOn(days, 'restampReservationDates').mockImplementation(() => { throw new Error('disk full'); });
+
+ expect(() => removal.remove(trip.id, rows[1].id, { userId: user.id })).toThrow('disk full');
+
+ expect(dayRows(trip.id)).toEqual(rows);
+ expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeDefined();
+ expect(reservationRow(night.reservationId)).toBeDefined();
+ expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(night.budgetId)).toBeDefined();
+ });
+
+ it('DAY-DEL-013 the journey catches up after the commit, and a failure there does not undo the delete', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-02');
+ const reconcile = vi.spyOn(journey, 'reconcileTripSkeletons').mockImplementation(() => { throw new Error('journey down'); });
+
+ const result = removal.remove(trip.id, rows[0].id, { userId: user.id, socketId: 'sock-1' });
+
+ expect(reconcile).toHaveBeenCalledWith(trip.id, 'sock-1');
+ expect(result.orderedIds).toEqual([rows[1].id]);
+ expect(dayRows(trip.id)).toHaveLength(1);
+ });
+
+ it('DAY-DEL-015 a day of another trip is refused, and nothing is written', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-02');
+ const other = datedTrip('2026-02-01', '2026-02-02');
+
+ expect(() => removal.remove(trip.id, other.rows[0].id, { userId: user.id })).toThrow(new DayDeleteError('Day not found'));
+ expect(dayRows(trip.id)).toEqual(rows);
+ expect(dayRows(other.trip.id)).toEqual(other.rows);
+ });
+
+ it('DAY-DEL-016 dated days on a trip without a range move their dates but leave the trip alone', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const a = createDay(testDb, trip.id, { date: '2026-04-01' });
+ const b = createDay(testDb, trip.id, { date: '2026-04-02' });
+
+ const result = removal.remove(trip.id, a.id, { userId: user.id });
+
+ expect(dayRows(trip.id)).toEqual([{ id: b.id, day_number: 1, date: '2026-04-01' }]);
+ expect(range(trip.id)).toEqual({ start_date: null, end_date: null });
+ expect(result.endDate).toBeNull();
+ });
+
+ it('DAY-DEL-018 a cancelled stay does not announce the deleted day: neither its stop there nor its roads', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const place = createPlace(testDb, trip.id);
+ createDayAccommodation(testDb, trip.id, place.id, rows[1].id, rows[1].id);
+ const stub = {
+ deleteAccommodation: vi.fn(() => ({
+ linkedReservationId: null, deletedBudgetItemId: null, linkedReservationIds: [], deletedBudgetItemIds: [],
+ mirror: {
+ created: null, moved: null, updated: [], stamped: null,
+ removed: [{ id: 1, dayId: rows[1].id }, { id: 2, dayId: rows[0].id }],
+ vias: [{ dayId: rows[1].id, vias: [] }, { dayId: rows[0].id, vias: [] }],
+ },
+ })),
+ } as unknown as AccommodationsService;
+
+ const result = new DayRemovalService(dbs, days, stub, assignments).remove(trip.id, rows[1].id, { userId: user.id });
+
+ expect(result.mirrors).toEqual([{
+ created: null, moved: null, updated: [], stamped: null,
+ removed: [{ id: 2, dayId: rows[0].id }],
+ vias: [{ dayId: rows[0].id, vias: [] }],
+ }]);
+ });
+});
+
+describe('DayRemovalService.announce', () => {
+ function recorder() {
+ const sent: [string, string, unknown][] = [];
+ const all: MirrorSender = (event, payload) => { sent.push(['all', event, payload]); };
+ const others: MirrorSender = (event, payload) => { sent.push(['others', event, payload]); };
+ return { sent, all, others };
+ }
+
+ it('DAY-DEL-014 fans out in one order: the day, the new order, the stays, their rows, the boundaries, the trip', () => {
+ const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
+ const night = bookedNight(trip.id, rows[0].id, rows[1].id);
+ const place = createPlace(testDb, trip.id);
+ boundary(trip.id, 3, createDayAssignment(testDb, rows[2].id, place.id).id);
+ const result = removal.remove(trip.id, rows[1].id, { userId: user.id, socketId: 'sock' });
+ const mirror = vi.spyOn(accommodations, 'announceMirror');
+ const { sent, all, others } = recorder();
+
+ removal.announce(trip.id, result, { all, others, socketId: 'sock' });
+
+ expect(sent.map(([to, event]) => `${to} ${event}`)).toEqual([
+ 'others day:deleted',
+ 'others day:reordered',
+ 'all assignment:deleted',
+ 'all assignment:reordered',
+ 'all reservation:deleted',
+ 'all budget:deleted',
+ 'others accommodation:deleted',
+ 'all roadtripBoundary:changed',
+ 'others trip:updated',
+ ]);
+ expect(sent[0][2]).toEqual({ dayId: rows[1].id });
+ expect(sent[1][2]).toEqual({ orderedIds: [rows[0].id, rows[2].id] });
+ expect(sent[2][2]).toEqual({ assignmentId: night.stop.id, dayId: rows[0].id });
+ expect(sent[4][2]).toEqual({ reservationId: night.reservationId });
+ expect(sent[5][2]).toEqual({ itemId: night.budgetId });
+ expect(sent[6][2]).toEqual({ accommodationId: night.stayId });
+ expect(sent[7][2]).toEqual({ boundaries: [expect.objectContaining({ day_number: 2 })] });
+ expect(sent[8][2]).toEqual({ trip: expect.objectContaining({ id: trip.id, end_date: '2026-01-02' }) });
+ expect(mirror).toHaveBeenCalledWith(trip.id, result.mirrors[0], all, 'sock');
+ });
+
+ it('DAY-DEL-017 a plain delete announces the day and the new order, nothing else', () => {
+ const removed: DayRemoval = {
+ dayId: 4, orderedIds: [3, 5], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
+ boundaries: null, endDate: null, trip: null,
+ };
+ const { sent, all, others } = recorder();
+
+ removal.announce(9, removed, { all, others });
+
+ expect(sent).toEqual([
+ ['others', 'day:deleted', { dayId: 4 }],
+ ['others', 'day:reordered', { orderedIds: [3, 5] }],
+ ]);
+ });
+});
diff --git a/server/tests/unit/nest/days.controller.test.ts b/server/tests/unit/nest/days.controller.test.ts
index 50008a8c9..99f4deac9 100644
--- a/server/tests/unit/nest/days.controller.test.ts
+++ b/server/tests/unit/nest/days.controller.test.ts
@@ -4,6 +4,7 @@ import { DaysController } from '../../../src/nest/days/days.controller';
import { DayNotesController } from '../../../src/nest/day-notes/day-notes.controller';
import { DayNoteCreateDto, DayNoteUpdateDto } from '../../../src/nest/day-notes/day-notes.dto';
import { DayReorderError } from '../../../src/nest/days/days.service';
+import { DayDeleteError, type DayRemoval, type DayRemovalService } from '../../../src/nest/days/day-removal.service';
import type { DayReorderDto } from '../../../src/nest/days/days.dto';
import type { DaysService } from '../../../src/nest/days/days.service';
import type { DayNotesService } from '../../../src/nest/day-notes/day-notes.service';
@@ -24,6 +25,9 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
function daysSvc(o: Partial = {}): DaysService {
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DaysService;
}
+function removalSvc(o: Partial = {}): DayRemovalService {
+ return { remove: vi.fn(), announce: vi.fn(), ...o } as unknown as DayRemovalService;
+}
function notesSvc(o: Partial = {}): DayNotesService {
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DayNotesService;
}
@@ -44,12 +48,12 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
it('GET / returns the list service result verbatim (the { days } envelope)', () => {
const svc = daysSvc({ list: vi.fn().mockReturnValue({ days: [{ id: 1 }] }) } as Partial);
- expect(new DaysController(svc).list(user, '5')).toEqual({ days: [{ id: 1 }] });
+ expect(new DaysController(svc, removalSvc()).list(user, '5')).toEqual({ days: [{ id: 1 }] });
});
it('POST / creates + broadcasts', () => {
const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn();
- expect(new DaysController(daysSvc({ create, broadcast } as Partial)).create(user, '5', { date: '2026-07-01' }, 'sock')).toEqual({ day: { id: 9 } });
+ expect(new DaysController(daysSvc({ create, broadcast } as Partial), removalSvc()).create(user, '5', { date: '2026-07-01' }, 'sock')).toEqual({ day: { id: 9 } });
expect(create).toHaveBeenCalledWith('5', '2026-07-01', undefined);
expect(broadcast).toHaveBeenCalledWith('5', 'day:created', { day: { id: 9 } }, 'sock');
});
@@ -58,7 +62,7 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
it('POST / with a position inserts + broadcasts day:reordered', () => {
const insert = vi.fn().mockReturnValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn();
const svc = daysSvc({ insert, create, broadcast } as Partial);
- expect(new DaysController(svc).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } });
+ expect(new DaysController(svc, removalSvc()).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } });
expect(insert).toHaveBeenCalledWith('5', 0);
expect(create).not.toHaveBeenCalled();
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { day: { id: 12 } }, 'sock');
@@ -68,17 +72,17 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
it('400 when orderedIds is missing', () => {
- expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', rawReorderBody({})))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
+ expect(thrown(() => new DaysController(daysSvc(), removalSvc()).reorder(user, '5', rawReorderBody({})))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
});
it('400 when orderedIds is not an array', () => {
- expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', rawReorderBody({ orderedIds: 'nope' })))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
+ expect(thrown(() => new DaysController(daysSvc(), removalSvc()).reorder(user, '5', rawReorderBody({ orderedIds: 'nope' })))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
});
it('maps a DayReorderError to 400 with its message', () => {
const reorder = vi.fn(() => { throw new DayReorderError('orderedIds must be a permutation of the trip day ids.'); });
const svc = daysSvc({ reorder } as Partial);
- expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [9] }))).toEqual({
+ expect(thrown(() => new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [9] }))).toEqual({
status: 400, body: { error: 'orderedIds must be a permutation of the trip day ids.' },
});
});
@@ -87,29 +91,71 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
const boom = new Error('db is down');
const reorder = vi.fn(() => { throw boom; });
const svc = daysSvc({ reorder } as Partial);
- expect(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom);
+ expect(() => new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom);
});
it('reorders and broadcasts day:reordered', () => {
const reorder = vi.fn(); const broadcast = vi.fn();
const svc = daysSvc({ reorder, broadcast } as Partial);
- expect(new DaysController(svc).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true });
+ expect(new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true });
expect(reorder).toHaveBeenCalledWith('5', [2, 1]);
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { orderedIds: [2, 1] }, 'sock');
});
});
it('PUT /:id 404 when the day is missing, else updates', () => {
- expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } });
+ expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial), removalSvc()).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } });
const update = vi.fn().mockReturnValue({ id: 9, title: 'T' });
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), update } as Partial);
- expect(new DaysController(svc).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } });
+ expect(new DaysController(svc, removalSvc()).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } });
});
- it('DELETE /:id 404 when missing, else success', () => {
- expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Day not found' } });
- const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), remove: vi.fn() } as Partial);
- expect(new DaysController(svc).remove(user, '5', '9')).toEqual({ success: true });
+ describe('DELETE /:id', () => {
+ const removed: DayRemoval = {
+ dayId: 9, orderedIds: [8, 10], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
+ boundaries: null, endDate: '2026-07-02', trip: { id: 5, end_date: '2026-07-02' },
+ };
+
+ it('404 when the day is not on the trip, before anything is removed', () => {
+ const removal = removalSvc();
+ expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial), removal).remove(user, '5', '9')))
+ .toEqual({ status: 404, body: { error: 'Day not found' } });
+ expect(removal.remove).not.toHaveBeenCalled();
+ });
+
+ it('400 with the service sentence for the last day, and nothing is announced', () => {
+ const removal = removalSvc({ remove: vi.fn(() => { throw new DayDeleteError('A trip needs at least one day.'); }) });
+ const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }) } as Partial);
+ expect(thrown(() => new DaysController(svc, removal).remove(user, '5', '9', 'sock')))
+ .toEqual({ status: 400, body: { error: 'A trip needs at least one day.' } });
+ expect(removal.announce).not.toHaveBeenCalled();
+ });
+
+ it('rethrows anything else unchanged', () => {
+ const boom = new Error('db is down');
+ const removal = removalSvc({ remove: vi.fn(() => { throw boom; }) });
+ const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }) } as Partial);
+ expect(() => new DaysController(svc, removal).remove(user, '5', '9')).toThrow(boom);
+ });
+
+ it('removes as the caller, announces with and without the socket, and answers with the trip', () => {
+ const broadcast = vi.fn();
+ const removal = removalSvc({ remove: vi.fn().mockReturnValue(removed) });
+ const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), broadcast } as Partial);
+
+ expect(new DaysController(svc, removal).remove(user, '5', '9', 'sock'))
+ .toEqual({ success: true, trip: { id: 5, end_date: '2026-07-02' } });
+ expect(removal.remove).toHaveBeenCalledWith('5', '9', { userId: 1, socketId: 'sock' });
+
+ const [tripId, sentRemoval, senders] = vi.mocked(removal.announce).mock.calls[0];
+ expect([tripId, sentRemoval, senders.socketId]).toEqual(['5', removed, 'sock']);
+ senders.all('budget:deleted', { itemId: 3 });
+ senders.others('day:deleted', { dayId: 9 });
+ expect(broadcast.mock.calls).toEqual([
+ ['5', 'budget:deleted', { itemId: 3 }, undefined],
+ ['5', 'day:deleted', { dayId: 9 }, 'sock'],
+ ]);
+ });
});
});
diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts
index cc77f56b2..76f1044e1 100644
--- a/server/tests/unit/nest/days.service.test.ts
+++ b/server/tests/unit/nest/days.service.test.ts
@@ -247,13 +247,22 @@ describe('update', () => {
});
});
-describe('remove', () => {
- it('DAY-SVC-015 — deletes the day', () => {
+// DAY-SVC-015 pinned the bare delete this class used to own. Deleting a day moved
+// to DayRemovalService with the renumbering and the stay cancellation around it;
+// its cases are DAY-DEL-001 to DAY-DEL-018 in day-removal.service.test.ts.
+
+describe('getTripForViewer', () => {
+ it('DAY-SVC-098: reads the trip in list shape, ownership from the viewer, without the feed token', () => {
const { user } = createUser(testDb);
- const trip = createTrip(testDb, user.id);
- const day = createDay(testDb, trip.id) as any;
- svc.remove(day.id);
- expect(svc.getDay(day.id, trip.id)).toBeUndefined();
+ const { user: member } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { start_date: '2026-06-01', end_date: '2026-06-02' });
+ testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run('secret-feed', trip.id);
+
+ expect(svc.getTripForViewer(trip.id, user.id)).toMatchObject({
+ id: trip.id, end_date: '2026-06-02', day_count: 2, place_count: 0, is_owner: 1, feed_token: null,
+ });
+ expect(svc.getTripForViewer(trip.id, member.id)).toMatchObject({ id: trip.id, is_owner: 0 });
+ expect(svc.getTripForViewer(99999, user.id)).toBeUndefined();
});
});
diff --git a/server/tests/unit/plugins/rpc-fallbacks.test.ts b/server/tests/unit/plugins/rpc-fallbacks.test.ts
index ae7a0a1e2..d3bb06b3a 100644
--- a/server/tests/unit/plugins/rpc-fallbacks.test.ts
+++ b/server/tests/unit/plugins/rpc-fallbacks.test.ts
@@ -249,7 +249,7 @@ describe('every schema-validated method rejects a payload its schema refuses', (
const anything = new Proxy({}, { get: () => vi.fn(() => ({ id: 1, trip: { id: 1 }, updatedTrip: { id: 1 }, reservation: { id: 1 } })) }) as never;
const registry = createTestPluginRegistry([
new PlacesRpc(anything, anything, realtime(), guards),
- new DaysRpc(anything, realtime(), guards),
+ new DaysRpc(anything, realtime(), guards, anything),
new PackingRpc(anything, realtime(), guards),
new TripsRpc(anything, anything, anything, anything, db, realtime(), guards, anything, anything),
new AccommodationsRpc(anything, realtime(), guards),
diff --git a/shared/src/i18n/ar/dayplan.ts b/shared/src/i18n/ar/dayplan.ts
index 1581fd758..ea727cf39 100644
--- a/shared/src/i18n/ar/dayplan.ts
+++ b/shared/src/i18n/ar/dayplan.ts
@@ -88,5 +88,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'إعادة ترتيب الأيام',
'dayplan.reorderError': 'تعذّر إعادة ترتيب الأيام',
'dayplan.addDayError': 'تعذّر إضافة يوم',
+ 'dayplan.deleteDay': 'حذف اليوم',
+ 'dayplan.deleteDayTitle': 'حذف {day}؟',
+ 'dayplan.deleteDayBody': 'سيُزال اليوم من الرحلة. لا يمكن التراجع عن ذلك.',
+ 'dayplan.deleteDayEmpty': 'لا يوجد شيء مخطط لهذا اليوم.',
+ 'dayplan.impactPlaces': 'الأماكن المخططة: {count}',
+ 'dayplan.impactPlacesHint': 'تبقى في قائمة الأماكن.',
+ 'dayplan.impactNotes': 'الملاحظات: {count}',
+ 'dayplan.impactTexts': 'عناوين الأيام وأوصافها: {count}',
+ 'dayplan.impactDeletedHint': 'تُحذف أيضًا.',
+ 'dayplan.impactBookings': 'الحجوزات: {count}',
+ 'dayplan.impactStay': 'الإقامة في {name}',
+ 'dayplan.deleteDayBookingsHint': 'تبقى ضمن الحجوزات، دون يوم.',
+ 'dayplan.deleteDayStayHint': 'يقع تسجيل الوصول أو المغادرة في هذا اليوم، لذا تُلغى الإقامة.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'يقع تسجيل الوصول أو المغادرة في هذا اليوم، لذا تُلغى الإقامة مع الحجز "{booking}" ومصروفه.',
+ 'dayplan.deleteDayShift': 'الأيام اللاحقة: {count}',
+ 'dayplan.deleteDayShiftHint': 'ينتقل كل منها إلى التاريخ السابق.',
+ 'dayplan.deleteDayShiftBookingsHint': 'ينتقل كل منها إلى التاريخ السابق. الحجوزات التي تنتقل معها: {count}',
+ 'dayplan.deleteDayShrink': 'تنتهي الرحلة الآن في {date}',
+ 'dayplan.deleteDayShrinkHint': 'لا يوجد يوم بلا تاريخ ليأخذ التاريخ الأخير.',
+ 'dayplan.deleteDayLast': 'تحتاج الرحلة إلى يوم واحد على الأقل',
+ 'dayplan.daysOffline': 'تغيير الأيام يتطلب اتصالًا',
+ 'dayplan.deleteDaySuccess': 'تم حذف اليوم',
+ 'dayplan.deleteDayError': 'تعذّر حذف اليوم',
};
export default dayplan;
diff --git a/shared/src/i18n/br/dayplan.ts b/shared/src/i18n/br/dayplan.ts
index 4abfb977d..1db7581cc 100644
--- a/shared/src/i18n/br/dayplan.ts
+++ b/shared/src/i18n/br/dayplan.ts
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Reordenar dias',
'dayplan.reorderError': 'Falha ao reordenar os dias',
'dayplan.addDayError': 'Falha ao adicionar o dia',
+ 'dayplan.deleteDay': 'Excluir dia',
+ 'dayplan.deleteDayTitle': 'Excluir {day}?',
+ 'dayplan.deleteDayBody': 'O dia é removido da viagem. Isso não pode ser desfeito.',
+ 'dayplan.deleteDayEmpty': 'Nada está planejado para este dia.',
+ 'dayplan.impactPlaces': 'Lugares planejados: {count}',
+ 'dayplan.impactPlacesHint': 'Eles continuam na lista de lugares.',
+ 'dayplan.impactNotes': 'Notas: {count}',
+ 'dayplan.impactTexts': 'Títulos e descrições do dia: {count}',
+ 'dayplan.impactDeletedHint': 'Também são excluídos.',
+ 'dayplan.impactBookings': 'Reservas: {count}',
+ 'dayplan.impactStay': 'Hospedagem em {name}',
+ 'dayplan.deleteDayBookingsHint': 'Elas continuam em Reservas, sem dia.',
+ 'dayplan.deleteDayStayHint': 'O check-in ou o check-out é neste dia, então a hospedagem é cancelada.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'O check-in ou o check-out é neste dia, então a hospedagem é cancelada, junto com a reserva “{booking}” e a despesa dela.',
+ 'dayplan.deleteDayShift': 'Dias seguintes: {count}',
+ 'dayplan.deleteDayShiftHint': 'Cada um passa para a data anterior.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Cada um passa para a data anterior. Reservas que vão junto: {count}',
+ 'dayplan.deleteDayShrink': 'A viagem agora termina em {date}',
+ 'dayplan.deleteDayShrinkHint': 'Não há nenhum dia sem data para assumir a última data.',
+ 'dayplan.deleteDayLast': 'Uma viagem precisa de pelo menos um dia',
+ 'dayplan.daysOffline': 'Alterar os dias exige conexão',
+ 'dayplan.deleteDaySuccess': 'Dia excluído',
+ 'dayplan.deleteDayError': 'Falha ao excluir o dia',
};
export default dayplan;
diff --git a/shared/src/i18n/ca/dayplan.ts b/shared/src/i18n/ca/dayplan.ts
index d213b370f..8837a4cbd 100644
--- a/shared/src/i18n/ca/dayplan.ts
+++ b/shared/src/i18n/ca/dayplan.ts
@@ -62,7 +62,7 @@ const dayplan: TranslationStrings = {
'dayplan.gpxPlaces': 'Només els llocs',
'dayplan.gpxDays': 'Dies com a rutes',
'dayplan.gpxEmpty': 'Encara no hi ha res per exportar',
- 'dayplan.gpxFailed': 'L\'exportació GPX ha fallat',
+ 'dayplan.gpxFailed': "L'exportació GPX ha fallat",
'dayplan.pdfError': "No s'ha pogut exportar el PDF",
'dayplan.cannotReorderTransport': 'Les reserves amb hora fixa no es poden reordenar',
'dayplan.confirmRemoveTimeTitle': "Vols eliminar l'hora?",
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Reordena els dies',
'dayplan.reorderError': "No s'han pogut reordenar els dies",
'dayplan.addDayError': "No s'ha pogut afegir el dia",
+ 'dayplan.deleteDay': 'Elimina el dia',
+ 'dayplan.deleteDayTitle': 'Vols eliminar {day}?',
+ 'dayplan.deleteDayBody': "El dia s'elimina del viatge. No es pot desfer.",
+ 'dayplan.deleteDayEmpty': 'No hi ha res planificat aquest dia.',
+ 'dayplan.impactPlaces': 'Llocs planificats: {count}',
+ 'dayplan.impactPlacesHint': 'Es queden a la llista de llocs.',
+ 'dayplan.impactNotes': 'Notes: {count}',
+ 'dayplan.impactTexts': 'Títols i descripcions del dia: {count}',
+ 'dayplan.impactDeletedHint': "També s'eliminen.",
+ 'dayplan.impactBookings': 'Reserves: {count}',
+ 'dayplan.impactStay': 'Estada a {name}',
+ 'dayplan.deleteDayBookingsHint': 'Es queden a Reserves, sense dia.',
+ 'dayplan.deleteDayStayHint': "L'entrada o la sortida és aquest dia, així que l'estada es cancel·la.",
+ 'dayplan.deleteDayStayBookedHint':
+ "L'entrada o la sortida és aquest dia, així que l'estada es cancel·la, juntament amb la reserva «{booking}» i la seva despesa.",
+ 'dayplan.deleteDayShift': 'Dies posteriors: {count}',
+ 'dayplan.deleteDayShiftHint': 'Cadascun passa a la data anterior.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Cadascun passa a la data anterior. Reserves que es mouen amb ells: {count}',
+ 'dayplan.deleteDayShrink': 'Ara el viatge acaba el {date}',
+ 'dayplan.deleteDayShrinkHint': "No queda cap dia sense data que pugui agafar l'última data.",
+ 'dayplan.deleteDayLast': 'Un viatge necessita com a mínim un dia',
+ 'dayplan.daysOffline': 'Per canviar els dies cal connexió',
+ 'dayplan.deleteDaySuccess': 'Dia eliminat',
+ 'dayplan.deleteDayError': "No s'ha pogut eliminar el dia",
};
export default dayplan;
diff --git a/shared/src/i18n/cs/dayplan.ts b/shared/src/i18n/cs/dayplan.ts
index 24fa46edd..cfb82d622 100644
--- a/shared/src/i18n/cs/dayplan.ts
+++ b/shared/src/i18n/cs/dayplan.ts
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Změnit pořadí dnů',
'dayplan.reorderError': 'Nepodařilo se změnit pořadí dnů',
'dayplan.addDayError': 'Nepodařilo se přidat den',
+ 'dayplan.deleteDay': 'Smazat den',
+ 'dayplan.deleteDayTitle': 'Smazat {day}?',
+ 'dayplan.deleteDayBody': 'Den bude z cesty odebrán. Tuto akci nelze vrátit zpět.',
+ 'dayplan.deleteDayEmpty': 'Na tento den není nic naplánováno.',
+ 'dayplan.impactPlaces': 'Naplánovaná místa: {count}',
+ 'dayplan.impactPlacesHint': 'Zůstanou v seznamu míst.',
+ 'dayplan.impactNotes': 'Poznámky: {count}',
+ 'dayplan.impactTexts': 'Názvy a popisy dne: {count}',
+ 'dayplan.impactDeletedHint': 'Budou také smazány.',
+ 'dayplan.impactBookings': 'Rezervace: {count}',
+ 'dayplan.impactStay': 'Ubytování v {name}',
+ 'dayplan.deleteDayBookingsHint': 'Zůstanou v Rezervacích, bez dne.',
+ 'dayplan.deleteDayStayHint': 'Příjezd nebo odjezd připadá na tento den, proto se ubytování zruší.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Příjezd nebo odjezd připadá na tento den, proto se ubytování zruší spolu s rezervací „{booking}“ a jejím výdajem.',
+ 'dayplan.deleteDayShift': 'Pozdější dny: {count}',
+ 'dayplan.deleteDayShiftHint': 'Každý se posune o jedno datum dříve.',
+ 'dayplan.deleteDayShiftBookingsHint':
+ 'Každý se posune o jedno datum dříve. Rezervace, které se posunou s nimi: {count}',
+ 'dayplan.deleteDayShrink': 'Cesta nyní končí {date}',
+ 'dayplan.deleteDayShrinkHint': 'Nezbývá žádný den bez data, který by převzal poslední datum.',
+ 'dayplan.deleteDayLast': 'Cesta musí mít alespoň jeden den',
+ 'dayplan.daysOffline': 'Změna dnů vyžaduje připojení',
+ 'dayplan.deleteDaySuccess': 'Den smazán',
+ 'dayplan.deleteDayError': 'Den se nepodařilo smazat',
};
export default dayplan;
diff --git a/shared/src/i18n/de/dayplan.ts b/shared/src/i18n/de/dayplan.ts
index 7f47853a4..6850d8772 100644
--- a/shared/src/i18n/de/dayplan.ts
+++ b/shared/src/i18n/de/dayplan.ts
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Tage neu anordnen',
'dayplan.reorderError': 'Tage konnten nicht neu angeordnet werden',
'dayplan.addDayError': 'Tag konnte nicht hinzugefügt werden',
+ 'dayplan.deleteDay': 'Tag löschen',
+ 'dayplan.deleteDayTitle': '{day} löschen?',
+ 'dayplan.deleteDayBody': 'Der Tag wird aus der Reise entfernt. Das lässt sich nicht rückgängig machen.',
+ 'dayplan.deleteDayEmpty': 'An diesem Tag ist nichts geplant.',
+ 'dayplan.impactPlaces': 'Geplante Orte: {count}',
+ 'dayplan.impactPlacesHint': 'Sie bleiben in der Ortsliste.',
+ 'dayplan.impactNotes': 'Notizen: {count}',
+ 'dayplan.impactTexts': 'Tagestitel und Beschreibungen: {count}',
+ 'dayplan.impactDeletedHint': 'Werden mitgelöscht.',
+ 'dayplan.impactBookings': 'Buchungen: {count}',
+ 'dayplan.impactStay': 'Übernachtung in {name}',
+ 'dayplan.deleteDayBookingsHint': 'Sie bleiben unter Buchungen, ohne Tag.',
+ 'dayplan.deleteDayStayHint': 'Check-in oder Check-out an diesem Tag, sie wird storniert.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Check-in oder Check-out an diesem Tag, sie wird storniert, zusammen mit der Buchung „{booking}“ und ihrer Ausgabe.',
+ 'dayplan.deleteDayShift': 'Spätere Tage: {count}',
+ 'dayplan.deleteDayShiftHint': 'Jeder rückt um einen Kalendertag nach vorn.',
+ 'dayplan.deleteDayShiftBookingsHint':
+ 'Jeder rückt um einen Kalendertag nach vorn. Buchungen, die mitwandern: {count}',
+ 'dayplan.deleteDayShrink': 'Die Reise endet jetzt am {date}',
+ 'dayplan.deleteDayShrinkHint': 'Es gibt keinen Tag ohne Datum, der das letzte Datum übernehmen könnte.',
+ 'dayplan.deleteDayLast': 'Eine Reise braucht mindestens einen Tag',
+ 'dayplan.daysOffline': 'Tage ändern geht nur mit Verbindung',
+ 'dayplan.deleteDaySuccess': 'Tag gelöscht',
+ 'dayplan.deleteDayError': 'Tag konnte nicht gelöscht werden',
};
export default dayplan;
diff --git a/shared/src/i18n/en/dayplan.ts b/shared/src/i18n/en/dayplan.ts
index 21f46d7bc..4c9d91be7 100644
--- a/shared/src/i18n/en/dayplan.ts
+++ b/shared/src/i18n/en/dayplan.ts
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Reorder days',
'dayplan.reorderError': 'Failed to reorder days',
'dayplan.addDayError': 'Failed to add day',
+ 'dayplan.deleteDay': 'Delete day',
+ 'dayplan.deleteDayTitle': 'Delete {day}?',
+ 'dayplan.deleteDayBody': 'The day is removed from the trip. This cannot be undone.',
+ 'dayplan.deleteDayEmpty': 'Nothing is planned on this day.',
+ 'dayplan.impactPlaces': 'Planned places: {count}',
+ 'dayplan.impactPlacesHint': 'They stay in the place list.',
+ 'dayplan.impactNotes': 'Notes: {count}',
+ 'dayplan.impactTexts': 'Day titles and descriptions: {count}',
+ 'dayplan.impactDeletedHint': 'Deleted as well.',
+ 'dayplan.impactBookings': 'Bookings: {count}',
+ 'dayplan.impactStay': 'Stay at {name}',
+ 'dayplan.deleteDayBookingsHint': 'They stay under Bookings, without a day.',
+ 'dayplan.deleteDayStayHint': 'Checks in or out on this day and is cancelled.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Checks in or out on this day and is cancelled, together with the booking “{booking}” and its expense.',
+ 'dayplan.deleteDayShift': 'Later days: {count}',
+ 'dayplan.deleteDayShiftHint': 'Each moves one date earlier.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Each moves one date earlier. Bookings moving along: {count}',
+ 'dayplan.deleteDayShrink': 'Trip now ends on {date}',
+ 'dayplan.deleteDayShrinkHint': 'There is no day without a date to take over the last date.',
+ 'dayplan.deleteDayLast': 'A trip needs at least one day',
+ 'dayplan.daysOffline': 'Changing days needs a connection',
+ 'dayplan.deleteDaySuccess': 'Day deleted',
+ 'dayplan.deleteDayError': 'Failed to delete day',
};
export default dayplan;
diff --git a/shared/src/i18n/es/dayplan.ts b/shared/src/i18n/es/dayplan.ts
index ed33e5c06..400072399 100644
--- a/shared/src/i18n/es/dayplan.ts
+++ b/shared/src/i18n/es/dayplan.ts
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Reordenar días',
'dayplan.reorderError': 'No se pudieron reordenar los días',
'dayplan.addDayError': 'No se pudo añadir el día',
+ 'dayplan.deleteDay': 'Eliminar día',
+ 'dayplan.deleteDayTitle': '¿Eliminar {day}?',
+ 'dayplan.deleteDayBody': 'El día se quita del viaje. No se puede deshacer.',
+ 'dayplan.deleteDayEmpty': 'No hay nada planificado este día.',
+ 'dayplan.impactPlaces': 'Lugares planificados: {count}',
+ 'dayplan.impactPlacesHint': 'Se quedan en la lista de lugares.',
+ 'dayplan.impactNotes': 'Notas: {count}',
+ 'dayplan.impactTexts': 'Títulos y descripciones del día: {count}',
+ 'dayplan.impactDeletedHint': 'También se eliminan.',
+ 'dayplan.impactBookings': 'Reservas: {count}',
+ 'dayplan.impactStay': 'Estancia en {name}',
+ 'dayplan.deleteDayBookingsHint': 'Se quedan en Reservas, sin día.',
+ 'dayplan.deleteDayStayHint': 'La entrada o la salida es este día, así que la estancia se cancela.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'La entrada o la salida es este día, así que la estancia se cancela, junto con la reserva «{booking}» y su gasto.',
+ 'dayplan.deleteDayShift': 'Días posteriores: {count}',
+ 'dayplan.deleteDayShiftHint': 'Cada uno pasa a la fecha anterior.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Cada uno pasa a la fecha anterior. Reservas que se mueven con ellos: {count}',
+ 'dayplan.deleteDayShrink': 'El viaje termina ahora el {date}',
+ 'dayplan.deleteDayShrinkHint': 'No queda ningún día sin fecha que tome la última fecha.',
+ 'dayplan.deleteDayLast': 'Un viaje necesita al menos un día',
+ 'dayplan.daysOffline': 'Cambiar los días requiere conexión',
+ 'dayplan.deleteDaySuccess': 'Día eliminado',
+ 'dayplan.deleteDayError': 'No se pudo eliminar el día',
};
export default dayplan;
diff --git a/shared/src/i18n/fr/dayplan.ts b/shared/src/i18n/fr/dayplan.ts
index d505ed416..dfac52d0b 100644
--- a/shared/src/i18n/fr/dayplan.ts
+++ b/shared/src/i18n/fr/dayplan.ts
@@ -61,8 +61,8 @@ const dayplan: TranslationStrings = {
'dayplan.gpxAll': 'Tout le voyage',
'dayplan.gpxPlaces': 'Lieux uniquement',
'dayplan.gpxDays': 'Journées en itinéraires',
- 'dayplan.gpxEmpty': 'Il n\'y a encore rien à exporter',
- 'dayplan.gpxFailed': 'Échec de l\'export GPX',
+ 'dayplan.gpxEmpty': "Il n'y a encore rien à exporter",
+ 'dayplan.gpxFailed': "Échec de l'export GPX",
'dayplan.pdfError': "Échec de l'export PDF",
'dayplan.cannotReorderTransport': 'Les réservations avec une heure fixe ne peuvent pas être réorganisées',
'dayplan.confirmRemoveTimeTitle': "Supprimer l'heure ?",
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Réorganiser les jours',
'dayplan.reorderError': 'Échec de la réorganisation des jours',
'dayplan.addDayError': "Échec de l'ajout du jour",
+ 'dayplan.deleteDay': 'Supprimer le jour',
+ 'dayplan.deleteDayTitle': 'Supprimer {day} ?',
+ 'dayplan.deleteDayBody': 'Le jour est retiré du voyage. Cette action est irréversible.',
+ 'dayplan.deleteDayEmpty': "Rien n'est prévu ce jour-là.",
+ 'dayplan.impactPlaces': 'Lieux prévus : {count}',
+ 'dayplan.impactPlacesHint': 'Ils restent dans la liste des lieux.',
+ 'dayplan.impactNotes': 'Notes : {count}',
+ 'dayplan.impactTexts': 'Titres et descriptions du jour : {count}',
+ 'dayplan.impactDeletedHint': 'Supprimés également.',
+ 'dayplan.impactBookings': 'Réservations : {count}',
+ 'dayplan.impactStay': 'Séjour à {name}',
+ 'dayplan.deleteDayBookingsHint': 'Elles restent dans Réservations, sans jour.',
+ 'dayplan.deleteDayStayHint': "L'arrivée ou le départ a lieu ce jour-là, le séjour est donc annulé.",
+ 'dayplan.deleteDayStayBookedHint':
+ "L'arrivée ou le départ a lieu ce jour-là, le séjour est donc annulé, ainsi que la réservation « {booking} » et sa dépense.",
+ 'dayplan.deleteDayShift': 'Jours suivants : {count}',
+ 'dayplan.deleteDayShiftHint': 'Chacun passe à la date précédente.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Chacun passe à la date précédente. Réservations déplacées avec eux : {count}',
+ 'dayplan.deleteDayShrink': 'Le voyage se termine désormais le {date}',
+ 'dayplan.deleteDayShrinkHint': 'Aucun jour sans date ne peut reprendre la dernière date.',
+ 'dayplan.deleteDayLast': 'Un voyage doit compter au moins un jour',
+ 'dayplan.daysOffline': 'Modifier les jours nécessite une connexion',
+ 'dayplan.deleteDaySuccess': 'Jour supprimé',
+ 'dayplan.deleteDayError': 'Échec de la suppression du jour',
};
export default dayplan;
diff --git a/shared/src/i18n/gr/dayplan.ts b/shared/src/i18n/gr/dayplan.ts
index 2cf982b70..7446defdc 100644
--- a/shared/src/i18n/gr/dayplan.ts
+++ b/shared/src/i18n/gr/dayplan.ts
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Αναδιάταξη ημερών',
'dayplan.reorderError': 'Η αναδιάταξη των ημερών απέτυχε',
'dayplan.addDayError': 'Η προσθήκη ημέρας απέτυχε',
+ 'dayplan.deleteDay': 'Διαγραφή ημέρας',
+ 'dayplan.deleteDayTitle': 'Διαγραφή {day};',
+ 'dayplan.deleteDayBody': 'Η ημέρα αφαιρείται από το ταξίδι. Αυτό δεν μπορεί να αναιρεθεί.',
+ 'dayplan.deleteDayEmpty': 'Δεν υπάρχει τίποτα προγραμματισμένο αυτή την ημέρα.',
+ 'dayplan.impactPlaces': 'Προγραμματισμένα μέρη: {count}',
+ 'dayplan.impactPlacesHint': 'Παραμένουν στη λίστα μερών.',
+ 'dayplan.impactNotes': 'Σημειώσεις: {count}',
+ 'dayplan.impactTexts': 'Τίτλοι και περιγραφές ημέρας: {count}',
+ 'dayplan.impactDeletedHint': 'Διαγράφονται επίσης.',
+ 'dayplan.impactBookings': 'Κρατήσεις: {count}',
+ 'dayplan.impactStay': 'Διαμονή στο {name}',
+ 'dayplan.deleteDayBookingsHint': 'Παραμένουν στις Κρατήσεις, χωρίς ημέρα.',
+ 'dayplan.deleteDayStayHint': 'Η άφιξη ή η αναχώρηση είναι αυτή την ημέρα, οπότε η διαμονή ακυρώνεται.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Η άφιξη ή η αναχώρηση είναι αυτή την ημέρα, οπότε η διαμονή ακυρώνεται μαζί με την κράτηση «{booking}» και το έξοδό της.',
+ 'dayplan.deleteDayShift': 'Επόμενες ημέρες: {count}',
+ 'dayplan.deleteDayShiftHint': 'Καθεμία μετακινείται μία ημερομηνία νωρίτερα.',
+ 'dayplan.deleteDayShiftBookingsHint':
+ 'Καθεμία μετακινείται μία ημερομηνία νωρίτερα. Κρατήσεις που μετακινούνται μαζί: {count}',
+ 'dayplan.deleteDayShrink': 'Το ταξίδι τελειώνει τώρα στις {date}',
+ 'dayplan.deleteDayShrinkHint': 'Δεν υπάρχει ημέρα χωρίς ημερομηνία για να πάρει την τελευταία ημερομηνία.',
+ 'dayplan.deleteDayLast': 'Ένα ταξίδι χρειάζεται τουλάχιστον μία ημέρα',
+ 'dayplan.daysOffline': 'Η αλλαγή ημερών απαιτεί σύνδεση',
+ 'dayplan.deleteDaySuccess': 'Η ημέρα διαγράφηκε',
+ 'dayplan.deleteDayError': 'Η διαγραφή της ημέρας απέτυχε',
};
export default dayplan;
diff --git a/shared/src/i18n/hu/dayplan.ts b/shared/src/i18n/hu/dayplan.ts
index 012f4fdfd..0d59b56b7 100644
--- a/shared/src/i18n/hu/dayplan.ts
+++ b/shared/src/i18n/hu/dayplan.ts
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Napok átrendezése',
'dayplan.reorderError': 'Nem sikerült átrendezni a napokat',
'dayplan.addDayError': 'Nem sikerült napot hozzáadni',
+ 'dayplan.deleteDay': 'Nap törlése',
+ 'dayplan.deleteDayTitle': '{day} törlése?',
+ 'dayplan.deleteDayBody': 'A nap kikerül az utazásból. Ez nem vonható vissza.',
+ 'dayplan.deleteDayEmpty': 'Erre a napra nincs semmi tervezve.',
+ 'dayplan.impactPlaces': 'Tervezett helyek: {count}',
+ 'dayplan.impactPlacesHint': 'A helyek listájában maradnak.',
+ 'dayplan.impactNotes': 'Jegyzetek: {count}',
+ 'dayplan.impactTexts': 'Napcímek és leírások: {count}',
+ 'dayplan.impactDeletedHint': 'Ezek is törlődnek.',
+ 'dayplan.impactBookings': 'Foglalások: {count}',
+ 'dayplan.impactStay': 'Szállás: {name}',
+ 'dayplan.deleteDayBookingsHint': 'A Foglalások között maradnak, nap nélkül.',
+ 'dayplan.deleteDayStayHint':
+ 'A bejelentkezés vagy a kijelentkezés erre a napra esik, ezért a szállás lemondásra kerül.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'A bejelentkezés vagy a kijelentkezés erre a napra esik, ezért a szállás lemondásra kerül a(z) „{booking}” foglalással és annak kiadásával együtt.',
+ 'dayplan.deleteDayShift': 'Későbbi napok: {count}',
+ 'dayplan.deleteDayShiftHint': 'Mindegyik egy dátummal előrébb kerül.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Mindegyik egy dátummal előrébb kerül. Velük együtt mozgó foglalások: {count}',
+ 'dayplan.deleteDayShrink': 'Az utazás mostantól ekkor ér véget: {date}',
+ 'dayplan.deleteDayShrinkHint': 'Nincs dátum nélküli nap, amely átvehetné az utolsó dátumot.',
+ 'dayplan.deleteDayLast': 'Egy utazáshoz legalább egy nap kell',
+ 'dayplan.daysOffline': 'A napok módosításához kapcsolat szükséges',
+ 'dayplan.deleteDaySuccess': 'Nap törölve',
+ 'dayplan.deleteDayError': 'Nem sikerült törölni a napot',
};
export default dayplan;
diff --git a/shared/src/i18n/id/dayplan.ts b/shared/src/i18n/id/dayplan.ts
index e986c05de..d01553ca9 100644
--- a/shared/src/i18n/id/dayplan.ts
+++ b/shared/src/i18n/id/dayplan.ts
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Atur ulang hari',
'dayplan.reorderError': 'Gagal mengatur ulang hari',
'dayplan.addDayError': 'Gagal menambah hari',
+ 'dayplan.deleteDay': 'Hapus hari',
+ 'dayplan.deleteDayTitle': 'Hapus {day}?',
+ 'dayplan.deleteDayBody': 'Hari tersebut dihapus dari perjalanan. Tindakan ini tidak dapat dibatalkan.',
+ 'dayplan.deleteDayEmpty': 'Tidak ada rencana pada hari tersebut.',
+ 'dayplan.impactPlaces': 'Tempat yang direncanakan: {count}',
+ 'dayplan.impactPlacesHint': 'Tempat tetap ada di daftar tempat.',
+ 'dayplan.impactNotes': 'Catatan: {count}',
+ 'dayplan.impactTexts': 'Judul dan deskripsi hari: {count}',
+ 'dayplan.impactDeletedHint': 'Ikut dihapus.',
+ 'dayplan.impactBookings': 'Pemesanan: {count}',
+ 'dayplan.impactStay': 'Menginap di {name}',
+ 'dayplan.deleteDayBookingsHint': 'Tetap ada di Pemesanan, tanpa hari.',
+ 'dayplan.deleteDayStayHint': 'Check-in atau check-out jatuh pada hari tersebut, jadi penginapan dibatalkan.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Check-in atau check-out jatuh pada hari tersebut, jadi penginapan dibatalkan bersama pemesanan “{booking}” dan pengeluarannya.',
+ 'dayplan.deleteDayShift': 'Hari berikutnya: {count}',
+ 'dayplan.deleteDayShiftHint': 'Masing-masing maju satu tanggal.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Masing-masing maju satu tanggal. Pemesanan yang ikut berpindah: {count}',
+ 'dayplan.deleteDayShrink': 'Perjalanan kini berakhir pada {date}',
+ 'dayplan.deleteDayShrinkHint': 'Tidak ada hari tanpa tanggal yang dapat mengambil tanggal terakhir.',
+ 'dayplan.deleteDayLast': 'Perjalanan memerlukan setidaknya satu hari',
+ 'dayplan.daysOffline': 'Mengubah hari memerlukan koneksi',
+ 'dayplan.deleteDaySuccess': 'Hari dihapus',
+ 'dayplan.deleteDayError': 'Gagal menghapus hari',
};
export default dayplan;
diff --git a/shared/src/i18n/it/dayplan.ts b/shared/src/i18n/it/dayplan.ts
index 75c381bae..b45174c54 100644
--- a/shared/src/i18n/it/dayplan.ts
+++ b/shared/src/i18n/it/dayplan.ts
@@ -61,7 +61,7 @@ const dayplan: TranslationStrings = {
'dayplan.gpxAll': 'Tutto il viaggio',
'dayplan.gpxPlaces': 'Solo i luoghi',
'dayplan.gpxDays': 'Giorni come itinerari',
- 'dayplan.gpxEmpty': 'Non c\'è ancora nulla da esportare',
+ 'dayplan.gpxEmpty': "Non c'è ancora nulla da esportare",
'dayplan.gpxFailed': 'Esportazione GPX non riuscita',
'dayplan.pdfError': 'Impossibile esportare il PDF',
'dayplan.cannotReorderTransport': 'Le prenotazioni con un orario fisso non possono essere riordinate',
@@ -90,5 +90,30 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Riordina i giorni',
'dayplan.reorderError': 'Riordino dei giorni non riuscito',
'dayplan.addDayError': 'Aggiunta del giorno non riuscita',
+ 'dayplan.deleteDay': 'Elimina giorno',
+ 'dayplan.deleteDayTitle': 'Eliminare {day}?',
+ 'dayplan.deleteDayBody': 'Il giorno viene rimosso dal viaggio. Non si può annullare.',
+ 'dayplan.deleteDayEmpty': "Non c'è nulla di pianificato in questo giorno.",
+ 'dayplan.impactPlaces': 'Luoghi pianificati: {count}',
+ 'dayplan.impactPlacesHint': "Restano nell'elenco dei luoghi.",
+ 'dayplan.impactNotes': 'Note: {count}',
+ 'dayplan.impactTexts': 'Titoli e descrizioni del giorno: {count}',
+ 'dayplan.impactDeletedHint': 'Vengono eliminati anche questi.',
+ 'dayplan.impactBookings': 'Prenotazioni: {count}',
+ 'dayplan.impactStay': 'Soggiorno presso {name}',
+ 'dayplan.deleteDayBookingsHint': 'Restano in Prenotazioni, senza giorno.',
+ 'dayplan.deleteDayStayHint': 'Il check-in o il check-out cade in questo giorno, quindi il soggiorno viene annullato.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Il check-in o il check-out cade in questo giorno, quindi il soggiorno viene annullato insieme alla prenotazione «{booking}» e alla sua spesa.',
+ 'dayplan.deleteDayShift': 'Giorni successivi: {count}',
+ 'dayplan.deleteDayShiftHint': 'Ognuno passa alla data precedente.',
+ 'dayplan.deleteDayShiftBookingsHint':
+ 'Ognuno passa alla data precedente. Prenotazioni che si spostano con loro: {count}',
+ 'dayplan.deleteDayShrink': 'Il viaggio ora termina il {date}',
+ 'dayplan.deleteDayShrinkHint': "Non c'è alcun giorno senza data che possa prendere l'ultima data.",
+ 'dayplan.deleteDayLast': 'Un viaggio ha bisogno di almeno un giorno',
+ 'dayplan.daysOffline': 'Per modificare i giorni serve una connessione',
+ 'dayplan.deleteDaySuccess': 'Giorno eliminato',
+ 'dayplan.deleteDayError': 'Eliminazione del giorno non riuscita',
};
export default dayplan;
diff --git a/shared/src/i18n/ja/dayplan.ts b/shared/src/i18n/ja/dayplan.ts
index e08a20ff5..0027cdf70 100644
--- a/shared/src/i18n/ja/dayplan.ts
+++ b/shared/src/i18n/ja/dayplan.ts
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': '日付を並べ替え',
'dayplan.reorderError': '日付の並べ替えに失敗しました',
'dayplan.addDayError': '日付の追加に失敗しました',
+ 'dayplan.deleteDay': '日を削除',
+ 'dayplan.deleteDayTitle': '{day}を削除しますか?',
+ 'dayplan.deleteDayBody': 'この日は旅行から削除されます。元に戻すことはできません。',
+ 'dayplan.deleteDayEmpty': 'この日には何も予定されていません。',
+ 'dayplan.impactPlaces': '予定しているスポット: {count}',
+ 'dayplan.impactPlacesHint': 'スポット一覧には残ります。',
+ 'dayplan.impactNotes': 'メモ: {count}',
+ 'dayplan.impactTexts': '日のタイトルと説明: {count}',
+ 'dayplan.impactDeletedHint': 'これらも削除されます。',
+ 'dayplan.impactBookings': '予約: {count}',
+ 'dayplan.impactStay': '{name}での宿泊',
+ 'dayplan.deleteDayBookingsHint': '日付なしで予約に残ります。',
+ 'dayplan.deleteDayStayHint': 'この日にチェックインまたはチェックアウトするため、宿泊はキャンセルされます。',
+ 'dayplan.deleteDayStayBookedHint':
+ 'この日にチェックインまたはチェックアウトするため、宿泊は予約「{booking}」とその支出と一緒にキャンセルされます。',
+ 'dayplan.deleteDayShift': '以降の日: {count}',
+ 'dayplan.deleteDayShiftHint': 'それぞれ1日前の日付になります。',
+ 'dayplan.deleteDayShiftBookingsHint': 'それぞれ1日前の日付になります。一緒に移動する予約: {count}',
+ 'dayplan.deleteDayShrink': '旅行の終了日は{date}になります',
+ 'dayplan.deleteDayShrinkHint': '最後の日付を引き継ぐ日付なしの日がありません。',
+ 'dayplan.deleteDayLast': '旅行には少なくとも1日が必要です',
+ 'dayplan.daysOffline': '日の変更には接続が必要です',
+ 'dayplan.deleteDaySuccess': '日を削除しました',
+ 'dayplan.deleteDayError': '日の削除に失敗しました',
};
export default dayplan;
diff --git a/shared/src/i18n/ko/dayplan.ts b/shared/src/i18n/ko/dayplan.ts
index 96e7b1f03..73820f852 100644
--- a/shared/src/i18n/ko/dayplan.ts
+++ b/shared/src/i18n/ko/dayplan.ts
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': '날짜 순서 변경',
'dayplan.reorderError': '날짜 순서를 변경하지 못했습니다',
'dayplan.addDayError': '날짜를 추가하지 못했습니다',
+ 'dayplan.deleteDay': '날짜 삭제',
+ 'dayplan.deleteDayTitle': '{day}을(를) 삭제할까요?',
+ 'dayplan.deleteDayBody': '이 날짜가 여행에서 제거됩니다. 되돌릴 수 없습니다.',
+ 'dayplan.deleteDayEmpty': '이 날짜에는 계획된 항목이 없습니다.',
+ 'dayplan.impactPlaces': '계획된 장소: {count}',
+ 'dayplan.impactPlacesHint': '장소 목록에는 그대로 남습니다.',
+ 'dayplan.impactNotes': '메모: {count}',
+ 'dayplan.impactTexts': '날짜 제목과 설명: {count}',
+ 'dayplan.impactDeletedHint': '함께 삭제됩니다.',
+ 'dayplan.impactBookings': '예약: {count}',
+ 'dayplan.impactStay': '{name} 숙박',
+ 'dayplan.deleteDayBookingsHint': '날짜 없이 예약에 남습니다.',
+ 'dayplan.deleteDayStayHint': '이 날짜에 체크인 또는 체크아웃하므로 숙박이 취소됩니다.',
+ 'dayplan.deleteDayStayBookedHint':
+ '이 날짜에 체크인 또는 체크아웃하므로 숙박이 예약 “{booking}” 및 해당 지출과 함께 취소됩니다.',
+ 'dayplan.deleteDayShift': '이후 날짜: {count}',
+ 'dayplan.deleteDayShiftHint': '각각 하루씩 앞당겨집니다.',
+ 'dayplan.deleteDayShiftBookingsHint': '각각 하루씩 앞당겨집니다. 함께 이동하는 예약: {count}',
+ 'dayplan.deleteDayShrink': '이제 여행이 {date}에 끝납니다',
+ 'dayplan.deleteDayShrinkHint': '마지막 날짜를 이어받을 날짜 없는 일정이 없습니다.',
+ 'dayplan.deleteDayLast': '여행에는 최소 하루가 필요합니다',
+ 'dayplan.daysOffline': '날짜를 변경하려면 연결이 필요합니다',
+ 'dayplan.deleteDaySuccess': '날짜가 삭제되었습니다',
+ 'dayplan.deleteDayError': '날짜를 삭제하지 못했습니다',
};
export default dayplan;
diff --git a/shared/src/i18n/nl/dayplan.ts b/shared/src/i18n/nl/dayplan.ts
index bfa721372..ad222a7c0 100644
--- a/shared/src/i18n/nl/dayplan.ts
+++ b/shared/src/i18n/nl/dayplan.ts
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Dagen herordenen',
'dayplan.reorderError': 'Dagen herordenen mislukt',
'dayplan.addDayError': 'Dag toevoegen mislukt',
+ 'dayplan.deleteDay': 'Dag verwijderen',
+ 'dayplan.deleteDayTitle': '{day} verwijderen?',
+ 'dayplan.deleteDayBody': 'De dag wordt uit de reis verwijderd. Dit kan niet ongedaan worden gemaakt.',
+ 'dayplan.deleteDayEmpty': 'Er staat niets gepland op deze dag.',
+ 'dayplan.impactPlaces': 'Geplande plekken: {count}',
+ 'dayplan.impactPlacesHint': 'Ze blijven in de lijst met plekken.',
+ 'dayplan.impactNotes': 'Notities: {count}',
+ 'dayplan.impactTexts': 'Dagtitels en beschrijvingen: {count}',
+ 'dayplan.impactDeletedHint': 'Worden ook verwijderd.',
+ 'dayplan.impactBookings': 'Boekingen: {count}',
+ 'dayplan.impactStay': 'Verblijf in {name}',
+ 'dayplan.deleteDayBookingsHint': 'Ze blijven onder Boekingen, zonder dag.',
+ 'dayplan.deleteDayStayHint': 'Inchecken of uitchecken valt op deze dag, dus het verblijf wordt geannuleerd.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Inchecken of uitchecken valt op deze dag, dus het verblijf wordt geannuleerd, samen met de boeking ‘{booking}’ en de bijbehorende uitgave.',
+ 'dayplan.deleteDayShift': 'Latere dagen: {count}',
+ 'dayplan.deleteDayShiftHint': 'Elke dag schuift één datum naar voren.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Elke dag schuift één datum naar voren. Boekingen die meeschuiven: {count}',
+ 'dayplan.deleteDayShrink': 'De reis eindigt nu op {date}',
+ 'dayplan.deleteDayShrinkHint': 'Er is geen dag zonder datum die de laatste datum kan overnemen.',
+ 'dayplan.deleteDayLast': 'Een reis heeft minstens één dag nodig',
+ 'dayplan.daysOffline': 'Dagen wijzigen vraagt een verbinding',
+ 'dayplan.deleteDaySuccess': 'Dag verwijderd',
+ 'dayplan.deleteDayError': 'Dag verwijderen mislukt',
};
export default dayplan;
diff --git a/shared/src/i18n/pl/dayplan.ts b/shared/src/i18n/pl/dayplan.ts
index 7ff120505..0da440ab0 100644
--- a/shared/src/i18n/pl/dayplan.ts
+++ b/shared/src/i18n/pl/dayplan.ts
@@ -90,5 +90,30 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Zmień kolejność dni',
'dayplan.reorderError': 'Nie udało się zmienić kolejności dni',
'dayplan.addDayError': 'Nie udało się dodać dnia',
+ 'dayplan.deleteDay': 'Usuń dzień',
+ 'dayplan.deleteDayTitle': 'Usunąć {day}?',
+ 'dayplan.deleteDayBody': 'Dzień zostanie usunięty z podróży. Tej operacji nie można cofnąć.',
+ 'dayplan.deleteDayEmpty': 'Na ten dzień nic nie zaplanowano.',
+ 'dayplan.impactPlaces': 'Zaplanowane miejsca: {count}',
+ 'dayplan.impactPlacesHint': 'Pozostają na liście miejsc.',
+ 'dayplan.impactNotes': 'Notatki: {count}',
+ 'dayplan.impactTexts': 'Tytuły i opisy dnia: {count}',
+ 'dayplan.impactDeletedHint': 'Również zostaną usunięte.',
+ 'dayplan.impactBookings': 'Rezerwacje: {count}',
+ 'dayplan.impactStay': 'Nocleg w {name}',
+ 'dayplan.deleteDayBookingsHint': 'Pozostają w Rezerwacjach, bez dnia.',
+ 'dayplan.deleteDayStayHint': 'Zameldowanie lub wymeldowanie przypada na ten dzień, więc nocleg zostanie anulowany.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Zameldowanie lub wymeldowanie przypada na ten dzień, więc nocleg zostanie anulowany razem z rezerwacją „{booking}” i jej wydatkiem.',
+ 'dayplan.deleteDayShift': 'Późniejsze dni: {count}',
+ 'dayplan.deleteDayShiftHint': 'Każdy przesuwa się o jedną datę wcześniej.',
+ 'dayplan.deleteDayShiftBookingsHint':
+ 'Każdy przesuwa się o jedną datę wcześniej. Rezerwacje przesuwane razem z nimi: {count}',
+ 'dayplan.deleteDayShrink': 'Podróż kończy się teraz {date}',
+ 'dayplan.deleteDayShrinkHint': 'Nie ma dnia bez daty, który mógłby przejąć ostatnią datę.',
+ 'dayplan.deleteDayLast': 'Podróż musi mieć co najmniej jeden dzień',
+ 'dayplan.daysOffline': 'Zmiana dni wymaga połączenia',
+ 'dayplan.deleteDaySuccess': 'Dzień usunięty',
+ 'dayplan.deleteDayError': 'Nie udało się usunąć dnia',
};
export default dayplan;
diff --git a/shared/src/i18n/ru/dayplan.ts b/shared/src/i18n/ru/dayplan.ts
index a83b66609..1279cc0fb 100644
--- a/shared/src/i18n/ru/dayplan.ts
+++ b/shared/src/i18n/ru/dayplan.ts
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Изменить порядок дней',
'dayplan.reorderError': 'Не удалось изменить порядок дней',
'dayplan.addDayError': 'Не удалось добавить день',
+ 'dayplan.deleteDay': 'Удалить день',
+ 'dayplan.deleteDayTitle': 'Удалить {day}?',
+ 'dayplan.deleteDayBody': 'День будет удалён из поездки. Это действие нельзя отменить.',
+ 'dayplan.deleteDayEmpty': 'На этот день ничего не запланировано.',
+ 'dayplan.impactPlaces': 'Запланированные места: {count}',
+ 'dayplan.impactPlacesHint': 'Они останутся в списке мест.',
+ 'dayplan.impactNotes': 'Заметки: {count}',
+ 'dayplan.impactTexts': 'Названия и описания дня: {count}',
+ 'dayplan.impactDeletedHint': 'Тоже будут удалены.',
+ 'dayplan.impactBookings': 'Бронирования: {count}',
+ 'dayplan.impactStay': 'Проживание в {name}',
+ 'dayplan.deleteDayBookingsHint': 'Они останутся в Бронированиях, без дня.',
+ 'dayplan.deleteDayStayHint': 'Заезд или выезд приходится на этот день, поэтому проживание отменяется.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Заезд или выезд приходится на этот день, поэтому проживание отменяется вместе с бронированием «{booking}» и его расходом.',
+ 'dayplan.deleteDayShift': 'Последующие дни: {count}',
+ 'dayplan.deleteDayShiftHint': 'Каждый сдвигается на одну дату раньше.',
+ 'dayplan.deleteDayShiftBookingsHint':
+ 'Каждый сдвигается на одну дату раньше. Бронирования, которые сдвигаются вместе с ними: {count}',
+ 'dayplan.deleteDayShrink': 'Теперь поездка заканчивается {date}',
+ 'dayplan.deleteDayShrinkHint': 'Нет дня без даты, который мог бы занять последнюю дату.',
+ 'dayplan.deleteDayLast': 'В поездке должен быть хотя бы один день',
+ 'dayplan.daysOffline': 'Для изменения дней нужно подключение',
+ 'dayplan.deleteDaySuccess': 'День удалён',
+ 'dayplan.deleteDayError': 'Не удалось удалить день',
};
export default dayplan;
diff --git a/shared/src/i18n/sv/dayplan.ts b/shared/src/i18n/sv/dayplan.ts
index 15659c27d..5dc61467b 100644
--- a/shared/src/i18n/sv/dayplan.ts
+++ b/shared/src/i18n/sv/dayplan.ts
@@ -90,5 +90,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Sortera om dagar',
'dayplan.reorderError': 'Kunde inte sortera om dagar',
'dayplan.addDayError': 'Kunde inte lägga till dag',
+ 'dayplan.deleteDay': 'Ta bort dag',
+ 'dayplan.deleteDayTitle': 'Ta bort {day}?',
+ 'dayplan.deleteDayBody': 'Dagen tas bort från resan. Det går inte att ångra.',
+ 'dayplan.deleteDayEmpty': 'Inget är planerat den här dagen.',
+ 'dayplan.impactPlaces': 'Planerade platser: {count}',
+ 'dayplan.impactPlacesHint': 'De ligger kvar i platslistan.',
+ 'dayplan.impactNotes': 'Anteckningar: {count}',
+ 'dayplan.impactTexts': 'Dagtitlar och beskrivningar: {count}',
+ 'dayplan.impactDeletedHint': 'Tas också bort.',
+ 'dayplan.impactBookings': 'Bokningar: {count}',
+ 'dayplan.impactStay': 'Boende på {name}',
+ 'dayplan.deleteDayBookingsHint': 'De ligger kvar under Bokningar, utan dag.',
+ 'dayplan.deleteDayStayHint': 'Incheckning eller utcheckning sker den här dagen, så boendet avbokas.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Incheckning eller utcheckning sker den här dagen, så boendet avbokas tillsammans med bokningen ”{booking}” och dess utgift.',
+ 'dayplan.deleteDayShift': 'Senare dagar: {count}',
+ 'dayplan.deleteDayShiftHint': 'Var och en flyttas ett datum tidigare.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Var och en flyttas ett datum tidigare. Bokningar som följer med: {count}',
+ 'dayplan.deleteDayShrink': 'Resan slutar nu {date}',
+ 'dayplan.deleteDayShrinkHint': 'Det finns ingen dag utan datum som kan ta över det sista datumet.',
+ 'dayplan.deleteDayLast': 'En resa behöver minst en dag',
+ 'dayplan.daysOffline': 'Att ändra dagar kräver en anslutning',
+ 'dayplan.deleteDaySuccess': 'Dagen togs bort',
+ 'dayplan.deleteDayError': 'Kunde inte ta bort dagen',
};
export default dayplan;
diff --git a/shared/src/i18n/tr/dayplan.ts b/shared/src/i18n/tr/dayplan.ts
index c27facfef..0caafc5f0 100644
--- a/shared/src/i18n/tr/dayplan.ts
+++ b/shared/src/i18n/tr/dayplan.ts
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Günleri yeniden sırala',
'dayplan.reorderError': 'Günler yeniden sıralanamadı',
'dayplan.addDayError': 'Gün eklenemedi',
+ 'dayplan.deleteDay': 'Günü sil',
+ 'dayplan.deleteDayTitle': '{day} silinsin mi?',
+ 'dayplan.deleteDayBody': 'Gün seyahatten kaldırılır. Bu işlem geri alınamaz.',
+ 'dayplan.deleteDayEmpty': 'Bu gün için planlanmış bir şey yok.',
+ 'dayplan.impactPlaces': 'Planlanan yerler: {count}',
+ 'dayplan.impactPlacesHint': 'Yer listesinde kalırlar.',
+ 'dayplan.impactNotes': 'Notlar: {count}',
+ 'dayplan.impactTexts': 'Gün başlıkları ve açıklamaları: {count}',
+ 'dayplan.impactDeletedHint': 'Bunlar da silinir.',
+ 'dayplan.impactBookings': 'Rezervasyonlar: {count}',
+ 'dayplan.impactStay': '{name} konaklaması',
+ 'dayplan.deleteDayBookingsHint': 'Rezervasyonlar altında, bir güne bağlı olmadan kalırlar.',
+ 'dayplan.deleteDayStayHint': 'Giriş veya çıkış bu güne denk geldiği için konaklama iptal edilir.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Giriş veya çıkış bu güne denk geldiği için konaklama, “{booking}” rezervasyonu ve harcamasıyla birlikte iptal edilir.',
+ 'dayplan.deleteDayShift': 'Sonraki günler: {count}',
+ 'dayplan.deleteDayShiftHint': 'Her biri bir tarih öne kayar.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Her biri bir tarih öne kayar. Onlarla birlikte kayan rezervasyonlar: {count}',
+ 'dayplan.deleteDayShrink': 'Seyahat artık {date} tarihinde bitiyor',
+ 'dayplan.deleteDayShrinkHint': 'Son tarihi devralacak tarihsiz bir gün yok.',
+ 'dayplan.deleteDayLast': 'Bir seyahatin en az bir günü olmalı',
+ 'dayplan.daysOffline': 'Günleri değiştirmek için bağlantı gerekir',
+ 'dayplan.deleteDaySuccess': 'Gün silindi',
+ 'dayplan.deleteDayError': 'Gün silinemedi',
};
export default dayplan;
diff --git a/shared/src/i18n/uk/dayplan.ts b/shared/src/i18n/uk/dayplan.ts
index 8ec828b01..b052cf5df 100644
--- a/shared/src/i18n/uk/dayplan.ts
+++ b/shared/src/i18n/uk/dayplan.ts
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Змінити порядок днів',
'dayplan.reorderError': 'Не вдалося змінити порядок днів',
'dayplan.addDayError': 'Не вдалося додати день',
+ 'dayplan.deleteDay': 'Видалити день',
+ 'dayplan.deleteDayTitle': 'Видалити {day}?',
+ 'dayplan.deleteDayBody': 'День буде видалено з подорожі. Цю дію не можна скасувати.',
+ 'dayplan.deleteDayEmpty': 'На цей день нічого не заплановано.',
+ 'dayplan.impactPlaces': 'Заплановані місця: {count}',
+ 'dayplan.impactPlacesHint': 'Вони залишаться у списку місць.',
+ 'dayplan.impactNotes': 'Нотатки: {count}',
+ 'dayplan.impactTexts': 'Назви й описи дня: {count}',
+ 'dayplan.impactDeletedHint': 'Теж будуть видалені.',
+ 'dayplan.impactBookings': 'Бронювання: {count}',
+ 'dayplan.impactStay': 'Проживання в {name}',
+ 'dayplan.deleteDayBookingsHint': 'Вони залишаться в Бронюваннях, без дня.',
+ 'dayplan.deleteDayStayHint': 'Заїзд або виїзд припадає на цей день, тому проживання скасовується.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Заїзд або виїзд припадає на цей день, тому проживання скасовується разом із бронюванням «{booking}» і його витратою.',
+ 'dayplan.deleteDayShift': 'Наступні дні: {count}',
+ 'dayplan.deleteDayShiftHint': 'Кожен зсувається на одну дату раніше.',
+ 'dayplan.deleteDayShiftBookingsHint':
+ 'Кожен зсувається на одну дату раніше. Бронювання, що зсуваються разом із ними: {count}',
+ 'dayplan.deleteDayShrink': 'Тепер подорож закінчується {date}',
+ 'dayplan.deleteDayShrinkHint': 'Немає дня без дати, який міг би зайняти останню дату.',
+ 'dayplan.deleteDayLast': 'Подорож має містити щонайменше один день',
+ 'dayplan.daysOffline': 'Для зміни днів потрібне з’єднання',
+ 'dayplan.deleteDaySuccess': 'День видалено',
+ 'dayplan.deleteDayError': 'Не вдалося видалити день',
};
export default dayplan;
diff --git a/shared/src/i18n/vi/dayplan.ts b/shared/src/i18n/vi/dayplan.ts
index 81e95c697..6b9eb1c14 100644
--- a/shared/src/i18n/vi/dayplan.ts
+++ b/shared/src/i18n/vi/dayplan.ts
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': 'Sắp xếp lại ngày',
'dayplan.reorderError': 'Không thể sắp xếp lại ngày',
'dayplan.addDayError': 'Không thể thêm ngày',
+ 'dayplan.deleteDay': 'Xóa ngày',
+ 'dayplan.deleteDayTitle': 'Xóa {day}?',
+ 'dayplan.deleteDayBody': 'Ngày này sẽ bị xóa khỏi chuyến đi. Không thể hoàn tác.',
+ 'dayplan.deleteDayEmpty': 'Không có gì được lên kế hoạch cho ngày này.',
+ 'dayplan.impactPlaces': 'Địa điểm đã lên kế hoạch: {count}',
+ 'dayplan.impactPlacesHint': 'Chúng vẫn nằm trong danh sách địa điểm.',
+ 'dayplan.impactNotes': 'Ghi chú: {count}',
+ 'dayplan.impactTexts': 'Tiêu đề và mô tả ngày: {count}',
+ 'dayplan.impactDeletedHint': 'Cũng bị xóa.',
+ 'dayplan.impactBookings': 'Đặt chỗ: {count}',
+ 'dayplan.impactStay': 'Lưu trú tại {name}',
+ 'dayplan.deleteDayBookingsHint': 'Chúng vẫn nằm trong Đặt chỗ, không gắn với ngày nào.',
+ 'dayplan.deleteDayStayHint': 'Nhận phòng hoặc trả phòng rơi vào ngày này, nên chỗ lưu trú bị hủy.',
+ 'dayplan.deleteDayStayBookedHint':
+ 'Nhận phòng hoặc trả phòng rơi vào ngày này, nên chỗ lưu trú bị hủy cùng với đặt chỗ “{booking}” và chi phí của nó.',
+ 'dayplan.deleteDayShift': 'Các ngày sau: {count}',
+ 'dayplan.deleteDayShiftHint': 'Mỗi ngày được chuyển sớm hơn một ngày.',
+ 'dayplan.deleteDayShiftBookingsHint': 'Mỗi ngày được chuyển sớm hơn một ngày. Đặt chỗ di chuyển theo: {count}',
+ 'dayplan.deleteDayShrink': 'Chuyến đi giờ kết thúc vào {date}',
+ 'dayplan.deleteDayShrinkHint': 'Không còn ngày nào chưa có ngày tháng để nhận ngày cuối cùng.',
+ 'dayplan.deleteDayLast': 'Một chuyến đi cần ít nhất một ngày',
+ 'dayplan.daysOffline': 'Cần có kết nối để thay đổi ngày',
+ 'dayplan.deleteDaySuccess': 'Đã xóa ngày',
+ 'dayplan.deleteDayError': 'Không thể xóa ngày',
};
export default dayplan;
diff --git a/shared/src/i18n/zh-TW/dayplan.ts b/shared/src/i18n/zh-TW/dayplan.ts
index e597d166b..7198a7e5a 100644
--- a/shared/src/i18n/zh-TW/dayplan.ts
+++ b/shared/src/i18n/zh-TW/dayplan.ts
@@ -88,5 +88,28 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': '重新排序日期',
'dayplan.reorderError': '重新排序日期失敗',
'dayplan.addDayError': '新增日期失敗',
+ 'dayplan.deleteDay': '刪除這一天',
+ 'dayplan.deleteDayTitle': '刪除 {day}?',
+ 'dayplan.deleteDayBody': '這一天會從行程中移除。此操作無法復原。',
+ 'dayplan.deleteDayEmpty': '這一天沒有任何安排。',
+ 'dayplan.impactPlaces': '已安排的地點:{count}',
+ 'dayplan.impactPlacesHint': '它們會保留在地點清單中。',
+ 'dayplan.impactNotes': '筆記:{count}',
+ 'dayplan.impactTexts': '日期標題和說明:{count}',
+ 'dayplan.impactDeletedHint': '也會一併刪除。',
+ 'dayplan.impactBookings': '預訂:{count}',
+ 'dayplan.impactStay': '住宿:{name}',
+ 'dayplan.deleteDayBookingsHint': '它們會保留在「預訂」中,但不再屬於任何一天。',
+ 'dayplan.deleteDayStayHint': '入住或退房在這一天,因此住宿會被取消。',
+ 'dayplan.deleteDayStayBookedHint': '入住或退房在這一天,因此住宿會被取消,同時取消預訂「{booking}」及其費用。',
+ 'dayplan.deleteDayShift': '之後的日期:{count}',
+ 'dayplan.deleteDayShiftHint': '每一天都會提前一個日期。',
+ 'dayplan.deleteDayShiftBookingsHint': '每一天都會提前一個日期。隨之移動的預訂:{count}',
+ 'dayplan.deleteDayShrink': '行程現在結束於 {date}',
+ 'dayplan.deleteDayShrinkHint': '沒有未設定日期的一天可以接替最後一個日期。',
+ 'dayplan.deleteDayLast': '行程至少需要一天',
+ 'dayplan.daysOffline': '變更日期需要網路連線',
+ 'dayplan.deleteDaySuccess': '已刪除這一天',
+ 'dayplan.deleteDayError': '刪除這一天失敗',
};
export default dayplan;
diff --git a/shared/src/i18n/zh/dayplan.ts b/shared/src/i18n/zh/dayplan.ts
index 7b7860ec7..8b6610458 100644
--- a/shared/src/i18n/zh/dayplan.ts
+++ b/shared/src/i18n/zh/dayplan.ts
@@ -88,5 +88,28 @@ const dayplan: TranslationStrings = {
'dayplan.reorderUndo': '调整日期顺序',
'dayplan.reorderError': '调整日期顺序失败',
'dayplan.addDayError': '添加一天失败',
+ 'dayplan.deleteDay': '删除这一天',
+ 'dayplan.deleteDayTitle': '删除 {day}?',
+ 'dayplan.deleteDayBody': '这一天会从行程中移除。此操作无法撤销。',
+ 'dayplan.deleteDayEmpty': '这一天没有任何安排。',
+ 'dayplan.impactPlaces': '已安排的地点:{count}',
+ 'dayplan.impactPlacesHint': '它们会保留在地点列表中。',
+ 'dayplan.impactNotes': '备注:{count}',
+ 'dayplan.impactTexts': '日期标题和描述:{count}',
+ 'dayplan.impactDeletedHint': '也会一并删除。',
+ 'dayplan.impactBookings': '预订:{count}',
+ 'dayplan.impactStay': '住宿:{name}',
+ 'dayplan.deleteDayBookingsHint': '它们会保留在“预订”中,但不再属于任何一天。',
+ 'dayplan.deleteDayStayHint': '入住或退房在这一天,因此住宿会被取消。',
+ 'dayplan.deleteDayStayBookedHint': '入住或退房在这一天,因此住宿会被取消,同时取消预订“{booking}”及其费用。',
+ 'dayplan.deleteDayShift': '之后的日期:{count}',
+ 'dayplan.deleteDayShiftHint': '每一天都会提前一个日期。',
+ 'dayplan.deleteDayShiftBookingsHint': '每一天都会提前一个日期。随之移动的预订:{count}',
+ 'dayplan.deleteDayShrink': '行程现在结束于 {date}',
+ 'dayplan.deleteDayShrinkHint': '没有未设日期的一天可以接替最后一个日期。',
+ 'dayplan.deleteDayLast': '行程至少需要一天',
+ 'dayplan.daysOffline': '更改日期需要网络连接',
+ 'dayplan.deleteDaySuccess': '已删除这一天',
+ 'dayplan.deleteDayError': '删除这一天失败',
};
export default dayplan;
diff --git a/wiki/Day-Plans-and-Notes.md b/wiki/Day-Plans-and-Notes.md
index decd1ced1..d2598a229 100644
--- a/wiki/Day-Plans-and-Notes.md
+++ b/wiki/Day-Plans-and-Notes.md
@@ -77,7 +77,7 @@ At the top of the Day Plan sidebar:
- **Export** — opens the export dialog, which holds every format in three groups: **Document** (a PDF of the full trip plan, see [PDF-Export](PDF-Export)), **Calendar** (a `.ics` file for import into calendar apps, plus a subscribable calendar feed for members who can manage share links) and **Maps & GPS** (GPX — whole trip, places only, or days as routes).
- **Expand / Collapse all** — toggles all day sections open or closed at once.
- **Undo** — reverses the last drag, reorder, or assign action.
-- **Reorder days** — reorder the days of the trip or insert a new one. A day's places, notes and bookings move with it. Shown to members who can edit days.
+- **Reorder days**: reorder the days of the trip, add a day, or delete one. A day's places, notes and bookings move with it. Shown to members who can edit days. See [Deleting a day](#deleting-a-day).
- **Show all booking routes** — draws the connection of every routable booking on the map at once. Shown once the trip has at least one routable booking.
Route controls appear at the bottom of a day section, after the place list, and only for the day you have selected — click a day header to select it. The row only appears on a day that can actually be routed: two or more places on the day, a single located place that accommodation optimization can bookend with a hotel, or a transfer day where you check out of one hotel and into another. On a phone the same controls sit in the **Daily Overview** sheet instead, opened from the pill above the plan timeline, and appear there when the day has two located places, or one located place plus a hotel to start from.
@@ -88,4 +88,16 @@ Route controls appear at the bottom of a day section, after the place list, and
- **Optimize** — reorders the day's places into the shortest route. See [Route-Optimization](Route-Optimization).
- **Travel mode** — Driving or Walking for that day, plus any travel mode a plugin adds.
+## Deleting a day
+
+Each row of the **Reorder days** dialog has a delete button; on a phone it sits next to the arrows in the day sheet. Before anything is deleted, TREK asks and lists what goes with the day:
+
+- **Planned places** stay in the place list of the trip. Only their spot on this day goes.
+- **Notes**, the day title and the day description are deleted.
+- **Bookings** on the day stay under Bookings, without a day, and keep their date.
+- **A stay that checks in or out on the day** is cancelled, together with its booking and the expense of that booking. The list shows it in red. A stay that only runs across the day is kept.
+- **The days after it** move up one place. On a trip with dates the dates stay where they are, so every later day, and the bookings on it, takes the date one slot earlier. A day without a date at the end takes over the last date; when there is none, the trip ends one day earlier.
+
+A trip always keeps at least one day, and deleting needs a connection. There is no undo, which is why the dialog spells out the consequences first.
+
**See also:** [Places-and-Search](Places-and-Search) · [Map-Features](Map-Features) · [Route-Optimization](Route-Optimization) · [Weather-Forecasts](Weather-Forecasts) · [Reservations-and-Bookings](Reservations-and-Bookings)
\ No newline at end of file
diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md
index ba61adf49..bd3869aef 100644
--- a/wiki/MCP-Tools-and-Resources.md
+++ b/wiki/MCP-Tools-and-Resources.md
@@ -79,7 +79,7 @@ Requires `trips:read` or `trips:write` scope.
|---|---|
| `update_day` | Set or clear a day's title. |
| `create_day` | Add a new day to a trip with optional date and notes. |
-| `delete_day` | Delete a day from a trip. |
+| `delete_day` | Delete a day from a trip. Its places stay in the place list, its notes and texts go, and bookings on it stay without a day. A stay that checks in or out on the day is cancelled with its booking and expense. Later days move up one place; on a dated trip they and their bookings take the date one slot earlier, and the trip ends a day earlier when no day without a date is left to take the last date. The last day of a trip cannot be deleted. |
| `reorder_days` | Reorder whole days by listing every day ID of the trip in the desired order. Each day keeps its places, notes, stays and bookings; on a dated trip the dates stay pinned to their slots, so the content moves across them. For places inside one day use `reorder_day_assignments`. Requires `trips:write`. |
| `set_day_default_transport_mode` | Set the whole-day default travel mode. Per-leg modes still override it. Pass `null` to clear. |
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. Requires `places:write`. |