diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json
index a23119f7c..8a01b5206 100644
--- a/server/scripts/size-baseline.json
+++ b/server/scripts/size-baseline.json
@@ -18,5 +18,5 @@
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
"src/nest/reservations/reservations.service.ts": 1239,
"src/nest/trips/trips.service.ts": 1009,
- "src/nest/vacay/vacay.service.ts": 1433
+ "src/nest/vacay/vacay.service.ts": 1420
}
diff --git a/server/src/db/repositories/VacayEntries.repository.ts b/server/src/db/repositories/VacayEntries.repository.ts
index ccc9574d8..acfb60ed6 100644
--- a/server/src/db/repositories/VacayEntries.repository.ts
+++ b/server/src/db/repositories/VacayEntries.repository.ts
@@ -1,4 +1,3 @@
-import { dateAdd } from '../dialect/sql-functions';
import type { VacayEntries } from '../entities/VacayEntries.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
@@ -18,13 +17,6 @@ export interface VacayEntryWithPersonRow {
person_color: string;
}
-/** SQLite's `date(
, '+N days')` — the same calendar-day arithmetic `dateAdd` spells in SQL (§13), computed in JS for {@link VacayEntriesRepository.shiftForOwnerWindow}'s collision pre-check. Verified against SQLite's own `date()` modifier: both walk the proleptic Gregorian calendar by whole days, so they agree on every input (the same reasoning R9's VC4 date-diff verification gives). */
-function shiftIsoDate(isoDate: string, days: number): string {
- const d = new Date(`${isoDate}T00:00:00Z`);
- d.setUTCDate(d.getUTCDate() + days);
- return d.toISOString().slice(0, 10);
-}
-
export class VacayEntriesRepository extends TrekRepository {
/**
* VC1 — `SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction
@@ -79,40 +71,28 @@ export class VacayEntriesRepository extends TrekRepository {
}
/**
- * VC5 (`shiftOwnerEntriesForTripWindow`) — `UPDATE OR IGNORE vacay_entries
- * SET date = date(date, ? || ' days') WHERE plan_id = ? AND user_id = ? AND
- * date BETWEEN ? AND ?`. SQLite's per-statement `OR IGNORE`
- * conflict-resolution clause applies to UPDATE as well as INSERT, but
- * neither Kysely's `UpdateQueryBuilder` (only `.modifyEnd()`, which appends
- * AFTER the whole statement, not after the `UPDATE` keyword) nor MikroORM's
- * `nativeUpdate` exposes it — a genuine builder gap, not a helper this
- * plan's R9 anticipated (R9's own VC5 note only covers the date
- * EXPRESSION, via the existing `dateAdd`, not this conflict-resolution
- * clause). Reproduced as a read-then-conditionally-write loop instead:
- * read every candidate row, and for each one whose shifted date would
- * collide with an existing `(user, plan, date)` row, skip it — `OR
- * IGNORE`'s own per-row behavior, exactly — otherwise write it with
- * `dateAdd` (still the SQL-computed value, per R9). Low-volume,
- * request-scoped (a trip's own leave entries on a date-window move, never
- * a hot path), so the per-row round trip is not a concern.
+ * VC5 (`shiftOwnerEntriesForTripWindow`): `SELECT id, date, fraction FROM
+ * vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN ? AND ?
+ * ORDER BY date`: the leave days a trip window move carries along, and the
+ * ones already standing on its targets. The service decides each row's fate
+ * and writes it with {@link moveEntry} or {@link deleteById} (#2604).
*/
- async shiftForOwnerWindow(planId: number, userId: number, start: string, end: string, offset: number): Promise {
- const platform = this.getEntityManager().getPlatform();
- // M1 (task-7-review.md): legacy's single `UPDATE OR IGNORE` walks rows in
- // rowid order; without an explicit orderBy here, SQLite returns rows in
- // (user, plan, date) index order instead, so the per-row collision check
- // above skips different rows than legacy did on consecutive days inserted
- // out of id order.
+ async listForOwnerWindow(
+ planId: number,
+ userId: number,
+ start: string,
+ end: string,
+ ): Promise<{ id: number; date: string; fraction: number }[]> {
const rows = await this.find(
{ plan: planId, user: userId, date: { $gte: start, $lte: end } },
- { fields: ['id', 'date'], orderBy: { id: 'asc' } },
+ { fields: ['id', 'date', 'fraction'], orderBy: { date: 'asc' } },
);
- for (const row of rows) {
- const shiftedDate = shiftIsoDate(row.date, offset);
- const collision = await this.findOne({ plan: planId, user: userId, date: shiftedDate }, { fields: ['id'] });
- if (collision) continue;
- await this.nativeUpdate({ id: row.id }, { date: dateAdd(platform, 'date', offset) });
- }
+ return rows.map((row) => ({ id: row.id, date: row.date, fraction: row.fraction }));
+ }
+
+ /** VC5: `UPDATE vacay_entries SET date = ?, fraction = ? WHERE id = ?`, one moved day of `shiftOwnerEntriesForTripWindow`. */
+ async moveEntry(id: number, date: string, fraction: number): Promise {
+ await this.nativeUpdate({ id }, { date, fraction });
}
/** VC24/VC30/VC121 — `DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?` (`applyHolidayCalendars`'s auto-clear, and `makeRoomForCompanyHoliday`'s whole-holiday clear, reached from `updatePlan`'s company-holiday enable and `toggleCompanyHoliday`). */
diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts
index 6f5299a3b..1cc21c896 100644
--- a/server/src/nest/trips/trips.service.ts
+++ b/server/src/nest/trips/trips.service.ts
@@ -522,6 +522,7 @@ export class TripsService {
// The row, the leave entries that follow its dates and the rebuilt day grid are
// one write: a failure halfway used to leave a trip whose days missed its dates.
let removedDays: DayGridRemoval[] = [];
+ let vacayId: number | undefined; // the vacay plan whose leave days moved along (#2604)
await this.uow.transactional(async () => {
// Before the row: the rebase reads the outgoing currency off it (#1543).
if (rebase) await this.budget.applyCurrencyRebase(tripId, rebase);
@@ -542,7 +543,7 @@ export class TripsService {
});
if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date)
- await this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
+ vacayId = await this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
if (regenerate) {
// Accommodations have no absolute date columns, so their pre-change dates must be
@@ -565,6 +566,7 @@ export class TripsService {
}
}
});
+ if (vacayId !== undefined) await this.vacay.notifyPlanUsers(vacayId, undefined);
const changes: Record = {};
if (title && title !== trip.title) changes.title = title;
diff --git a/server/src/nest/vacay/vacay.helpers.ts b/server/src/nest/vacay/vacay.helpers.ts
new file mode 100644
index 000000000..7396ba80d
--- /dev/null
+++ b/server/src/nest/vacay/vacay.helpers.ts
@@ -0,0 +1,71 @@
+/**
+ * Pure half of the vacay domain (no DB access), moved out of vacay.service.ts
+ * so the service keeps to its size budget.
+ */
+
+/** Coerce an arbitrary input to a supported entry fraction: half (0.5) or full (1). */
+export function normalizeFraction(value: unknown): number {
+ return Number(value) === 0.5 ? 0.5 : 1;
+}
+
+/** Coerce an arbitrary input to a supported leave type: comp/flex or vacation (#1074). */
+export function normalizeKind(value: unknown): 'vacation' | 'comp' {
+ return value === 'comp' ? 'comp' : 'vacation';
+}
+
+/**
+ * Same semantics as the client's isWeekend guard (holidays.ts): UTC weekday,
+ * weekend_days falls back to Sat/Sun when the column is NULL or empty.
+ */
+export function isBlockedWeekend(
+ plan: { block_weekends: number | null; weekend_days: string | null },
+ date: string,
+): boolean {
+ if (!plan.block_weekends) return false;
+ const days = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6];
+ return days.includes(new Date(date + 'T00:00:00Z').getUTCDay());
+}
+
+export const pad2 = (n: number) => String(n).padStart(2, '0');
+
+/** Days a month has in every year — February caps at 28 so no boundary lands on a date a common year lacks. */
+export function daysAlwaysInMonth(month: number): number {
+ if (month === 2) return 28;
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
+}
+
+/** Coerce an arbitrary input to a supported leave-year type (#737). */
+export function normalizeYearType(value: unknown): 'calendar' | 'fiscal' | 'anniversary' {
+ return value === 'fiscal' || value === 'anniversary' ? value : 'calendar';
+}
+
+/**
+ * The calendar year a window's last day falls in. `end` is exclusive, so a window
+ * ending on Jan 1 (the calendar default) stops inside the previous year.
+ */
+export function windowEndYear(end: string): number {
+ const y = Number.parseInt(end.slice(0, 4), 10);
+ return end.endsWith('-01-01') ? y - 1 : y;
+}
+
+/**
+ * Parse a date or datetime string the way SQLite's `julianday()` does — the
+ * numeric components taken at face value as UTC, never the host's local
+ * timezone. `Date.parse` only does this for a bare `YYYY-MM-DD`; a datetime
+ * string with no zone suffix (`2025-06-12T23:30`) is local time per the ES
+ * spec, which drifted from `julianday`'s always-UTC reading by whatever the
+ * server's offset is (M2, task-7-review.md). Unparseable input returns `NaN`.
+ */
+export function parseAsUtcMillis(value: string): number {
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
+ if (!m) return Number.NaN;
+ const [, y, mo, d, h, mi, s] = m;
+ return Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h ?? 0), Number(mi ?? 0), Number(s ?? 0));
+}
+
+/** A `YYYY-MM-DD` date moved by whole calendar days, in UTC. */
+export function shiftIsoDate(isoDate: string, days: number): string {
+ const d = new Date(`${isoDate}T00:00:00Z`);
+ d.setUTCDate(d.getUTCDate() + days);
+ return d.toISOString().slice(0, 10);
+}
diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts
index 89b13e12c..dc0da9c1c 100644
--- a/server/src/nest/vacay/vacay.service.ts
+++ b/server/src/nest/vacay/vacay.service.ts
@@ -28,6 +28,10 @@ import { VacayUserSettings as VacayUserSettingsEntity } from '../../db/entities/
import type { VacayUserSettingsRepository } from '../../db/repositories/VacayUserSettings.repository';
import { SchoolHolidayRegions } from '../../db/entities/SchoolHolidayRegions.entity';
import type { SchoolHolidayRegionsRepository } from '../../db/repositories/SchoolHolidayRegions.repository';
+import {
+ daysAlwaysInMonth, isBlockedWeekend, normalizeFraction, normalizeKind, normalizeYearType, pad2, parseAsUtcMillis,
+ shiftIsoDate, windowEndYear,
+} from './vacay.helpers';
// ---------------------------------------------------------------------------
// Types
@@ -141,67 +145,6 @@ const COLORS = [
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
];
-// ---------------------------------------------------------------------------
-// Pure helpers (no DB access)
-// ---------------------------------------------------------------------------
-
-/** Coerce an arbitrary input to a supported entry fraction: half (0.5) or full (1). */
-function normalizeFraction(value: unknown): number {
- return Number(value) === 0.5 ? 0.5 : 1;
-}
-
-/** Coerce an arbitrary input to a supported leave type: comp/flex or vacation (#1074). */
-function normalizeKind(value: unknown): 'vacation' | 'comp' {
- return value === 'comp' ? 'comp' : 'vacation';
-}
-
-/**
- * Same semantics as the client's isWeekend guard (holidays.ts): UTC weekday,
- * weekend_days falls back to Sat/Sun when the column is NULL or empty.
- */
-function isBlockedWeekend(plan: Pick, date: string): boolean {
- if (!plan.block_weekends) return false;
- const days = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6];
- return days.includes(new Date(date + 'T00:00:00Z').getUTCDay());
-}
-
-const pad2 = (n: number) => String(n).padStart(2, '0');
-
-/** Days a month has in every year — February caps at 28 so no boundary lands on a date a common year lacks. */
-function daysAlwaysInMonth(month: number): number {
- if (month === 2) return 28;
- return [4, 6, 9, 11].includes(month) ? 30 : 31;
-}
-
-/** Coerce an arbitrary input to a supported leave-year type (#737). */
-function normalizeYearType(value: unknown): 'calendar' | 'fiscal' | 'anniversary' {
- return value === 'fiscal' || value === 'anniversary' ? value : 'calendar';
-}
-
-/**
- * The calendar year a window's last day falls in. `end` is exclusive, so a window
- * ending on Jan 1 (the calendar default) stops inside the previous year.
- */
-function windowEndYear(end: string): number {
- const y = Number.parseInt(end.slice(0, 4), 10);
- return end.endsWith('-01-01') ? y - 1 : y;
-}
-
-/**
- * Parse a date or datetime string the way SQLite's `julianday()` does — the
- * numeric components taken at face value as UTC, never the host's local
- * timezone. `Date.parse` only does this for a bare `YYYY-MM-DD`; a datetime
- * string with no zone suffix (`2025-06-12T23:30`) is local time per the ES
- * spec, which drifted from `julianday`'s always-UTC reading by whatever the
- * server's offset is (M2, task-7-review.md). Unparseable input returns `NaN`.
- */
-function parseAsUtcMillis(value: string): number {
- const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
- if (!m) return Number.NaN;
- const [, y, mo, d, h, mi, s] = m;
- return Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h ?? 0), Number(mi ?? 0), Number(s ?? 0));
-}
-
/**
* Vacay domain service — owns the vacay SQL, now through the ten vacay
* repositories (+ `SchoolHolidayRegionsRepository`'s cross-domain read,
@@ -405,12 +348,22 @@ export class VacayService {
return (await this.getActivePlan(userId)).id;
}
+ /**
+ * A trip's new start carries its owner's leave days along by the same offset
+ * (#983), in whatever plan the owner is active in, a fused one included. Each
+ * day either moves or is dropped: a target on a blocked weekend or a whole
+ * company holiday is no leave day, and a target already holding leave that
+ * stays put is leave already. A half company holiday leaves half a day, as in
+ * toggleEntry. Public holidays come from an outside API and are not checked
+ * inside the trip's transaction. Returns the plan whose entries changed, so
+ * the caller can notify its users once the transaction is committed (#2604).
+ */
async shiftOwnerEntriesForTripWindow(
ownerId: number,
oldStart: string,
oldEnd: string,
newStart: string
- ): Promise {
+ ): Promise {
// VC4 (R9's verified restructured shape): `CAST(julianday(?) - julianday(?)
// AS INTEGER)` on two BOUND VALUES is plain JS date-diff arithmetic —
// verified against the SQL on 5 date pairs incl. a leap-year boundary and a
@@ -422,11 +375,45 @@ export class VacayService {
// same as legacy, instead of writing a NaN date and 500ing after the trip
// row is already committed.
const offset = Math.trunc((parseAsUtcMillis(newStart) - parseAsUtcMillis(oldStart)) / 86400000);
- if (!Number.isFinite(offset) || offset === 0) return;
+ if (!Number.isFinite(offset) || offset === 0) return undefined;
- const plan = await this.getOwnPlan(ownerId);
+ // A trip edit never creates a plan: without one there is no leave to move.
+ const plan = await this.peekActivePlan(ownerId);
+ if (!plan) return undefined;
- await this.entries.shiftForOwnerWindow(plan.id, ownerId, oldStart, oldEnd, offset);
+ return await this.uow.transactional(async () => {
+ const moving = await this.entries.listForOwnerWindow(plan.id, ownerId, oldStart, oldEnd);
+ if (moving.length === 0) return undefined;
+ const movingIds = new Set(moving.map(row => row.id));
+ const firstTarget = shiftIsoDate(moving[0].date, offset);
+ const lastTarget = shiftIsoDate(moving[moving.length - 1].date, offset);
+ const standing = new Set(
+ (await this.entries.listForOwnerWindow(plan.id, ownerId, firstTarget, lastTarget))
+ .filter(row => !movingIds.has(row.id))
+ .map(row => row.date),
+ );
+ const companyHolidays = plan.company_holidays_enabled
+ ? await this.companyHolidays.listDatesForRange(plan.id, firstTarget, shiftIsoDate(lastTarget, 1))
+ : [];
+ const companyFractions = new Map(companyHolidays.map(h => [h.date, h.fraction] as const));
+
+ const moves: { id: number; date: string; fraction: number }[] = [];
+ for (const row of moving) {
+ const date = shiftIsoDate(row.date, offset);
+ const company = companyFractions.get(date);
+ if (standing.has(date) || isBlockedWeekend(plan, date) || (company !== undefined && company >= 1)) {
+ await this.entries.deleteById(row.id);
+ } else {
+ moves.push({ id: row.id, date, fraction: company !== undefined && row.fraction > 0.5 ? 0.5 : row.fraction });
+ }
+ }
+ // Rows move in the direction of travel, so each target is already free of
+ // the moving row that held it when the next one arrives: (user, plan, date)
+ // is unique.
+ if (offset > 0) moves.reverse();
+ for (const move of moves) await this.entries.moveEntry(move.id, move.date, move.fraction);
+ return plan.id;
+ });
}
async getPlanUsers(planId: number): Promise {
diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts
index e16999abc..4ee4c6992 100644
--- a/server/tests/e2e/trips.e2e.test.ts
+++ b/server/tests/e2e/trips.e2e.test.ts
@@ -28,6 +28,8 @@ import { Journeys } from '../../src/db/entities/Journeys.entity';
import { Settings } from '../../src/db/entities/Settings.entity';
import { TripMembers } from '../../src/db/entities/TripMembers.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
+import { VacayEntries } from '../../src/db/entities/VacayEntries.entity';
+import { VacayPlans } from '../../src/db/entities/VacayPlans.entity';
// TripsService itself is real since the trip fold — no tripService mock; the
// real migrated trips/trip_members/days tables serve its SQL.
// bundle()'s days + accommodations now run DaysService's real SQL (DI-injected,
@@ -40,10 +42,11 @@ import { BudgetService } from '../../src/nest/budget/budget.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
+import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { TripsModule } from '../../src/nest/trips/trips.module';
import { makeDayAssignment } from '../helpers/factories/itinerary';
import { makePlace } from '../helpers/factories/places';
-import { countRows, deleteRows, findRow, findRows, insertRow } from '../helpers/factories/rows';
+import { countRows, deleteRows, findRow, findRows, insertRow, insertRows } from '../helpers/factories/rows';
import { setUserSetting } from '../helpers/factories/settings';
import { makeDay } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
@@ -235,6 +238,41 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
expect(await dayIds(week.body.trip.id)).toEqual(kept);
});
+ it('200 update with a later start moves the leave days of the owner along, off the blocked weekend, and tells the plan (#2604)', async () => {
+ // 2030-11-25 is a Monday; the plan blocks Saturday and Sunday by default.
+ const trip = await request(server)
+ .post('/api/trips')
+ .set('Cookie', sessionCookie(1))
+ .send({ title: 'Leave', start_date: '2030-11-25', end_date: '2030-11-30' });
+ const shift = (start_date: string, end_date: string) =>
+ request(server)
+ .put(`/api/trips/${trip.body.trip.id}`)
+ .set('Cookie', sessionCookie(1))
+ .send({ start_date, end_date });
+
+ // No vacay plan yet: moving the trip creates none.
+ expect((await shift('2030-11-26', '2030-12-01')).status).toBe(200);
+ expect(await countRows(orm, VacayPlans, { owner: 1 })).toBe(0);
+ expect((await shift('2030-11-25', '2030-11-30')).status).toBe(200);
+
+ const planId = await insertRow(orm, VacayPlans, { owner: 1 });
+ const week = ['2030-11-25', '2030-11-26', '2030-11-27', '2030-11-28', '2030-11-29'];
+ await insertRows(
+ orm,
+ VacayEntries,
+ week.map((date) => ({ plan: planId, user: 1, date, note: '' })),
+ );
+ const broadcast = vi.spyOn(app.get(RealtimeService), 'broadcastToUser');
+
+ expect((await shift('2030-11-27', '2030-12-02')).status).toBe(200);
+
+ const dates = (await findRows(orm, VacayEntries, { plan: planId }, { date: 'asc' })).map((e) => e.date);
+ expect(dates).toEqual(['2030-11-27', '2030-11-28', '2030-11-29']);
+ expect(broadcast).toHaveBeenCalledWith(1, { type: 'vacay:update' }, undefined);
+ broadcast.mockRestore();
+ await deleteRows(orm, VacayPlans, { id: planId });
+ });
+
it('404 on a missing trip', async () => {
const res = await request(server).get('/api/trips/77').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
diff --git a/server/tests/unit/nest/vacay-trip-window.test.ts b/server/tests/unit/nest/vacay-trip-window.test.ts
new file mode 100644
index 000000000..3155999cb
--- /dev/null
+++ b/server/tests/unit/nest/vacay-trip-window.test.ts
@@ -0,0 +1,219 @@
+/**
+ * VacayService.shiftOwnerEntriesForTripWindow: a trip's new start carries its
+ * owner's leave days along (#983). Moved out of vacay.service.test.ts with the
+ * #2604 cases (a block moves whole, blocked and taken targets drop the day,
+ * fused plans, no lazy plan).
+ */
+import { VacayCompanyHolidays } from '../../../src/db/entities/VacayCompanyHolidays.entity';
+import { VacayEntries } from '../../../src/db/entities/VacayEntries.entity';
+import { VacayPlanMembers } from '../../../src/db/entities/VacayPlanMembers.entity';
+import { VacayPlans } from '../../../src/db/entities/VacayPlans.entity';
+import { VacayModule } from '../../../src/nest/vacay/vacay.module';
+import { VacayService } from '../../../src/nest/vacay/vacay.service';
+import { createSnapshotTestDb } from '../../helpers/db-mock';
+import { countRows, deleteRows, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
+import { makeUser } from '../../helpers/factories/users';
+import { createTestModule, type TestModule } from '../../helpers/test-module';
+import { MikroORM } from '@mikro-orm/core';
+
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+const db = createSnapshotTestDb();
+let t: TestModule;
+let svc: VacayService;
+const orm = () => t.get(MikroORM);
+
+beforeAll(async () => {
+ t = await createTestModule({ db, imports: [VacayModule] });
+ svc = t.get(VacayService);
+});
+
+afterAll(async () => {
+ await t.close();
+ db.close();
+});
+
+/** A user with their own plan, weekends blocked as a new plan has them. */
+async function setupUserWithPlan() {
+ const { user } = await makeUser(orm());
+ const plan = await svc.getOwnPlan(user.id);
+ return { user, plan };
+}
+
+/** The user's entry dates in the plan, in date order. */
+async function datesOf(planId: number, userId: number): Promise {
+ return (await findRows(orm(), VacayEntries, { plan: planId, user: userId }, { date: 'asc' })).map((r) => r.date);
+}
+
+/** Replace the plan's entries with one per date for the user, inserted in the order given. */
+async function seedEntries(planId: number, userId: number, dates: string[]): Promise {
+ await deleteRows(orm(), VacayEntries, { plan: planId });
+ await insertRows(
+ orm(),
+ VacayEntries,
+ dates.map((date) => ({ plan: planId, user: userId, date, note: '' })),
+ );
+}
+
+describe('shiftOwnerEntriesForTripWindow', () => {
+ it('VACAY-SVC-030b (VC4 parity): shifts an entry by the exact calendar-day offset between the old and new trip start, across several date-pair shapes incl. a leap-year boundary', async () => {
+ // R9's verified restructured shape (task-0-report.md): plain JS date-diff
+ // arithmetic in place of the legacy CAST(julianday(?) - julianday(?) AS
+ // INTEGER), verified there against the SQL on 5 date pairs incl. a
+ // leap-year boundary and a negative offset. Every target is a weekday.
+ const { user, plan } = await setupUserWithPlan();
+ const shiftOne = async (oldStart: string, oldEnd: string, newStart: string, entryDate: string) => {
+ await seedEntries(plan.id, user.id, [entryDate]);
+ await svc.shiftOwnerEntriesForTripWindow(user.id, oldStart, oldEnd, newStart);
+ return datesOf(plan.id, user.id);
+ };
+
+ expect(await shiftOne('2026-03-01', '2026-03-10', '2026-03-06', '2026-03-05')).toEqual(['2026-03-10']); // +5 days
+ expect(await shiftOne('2024-02-25', '2024-03-05', '2024-02-28', '2024-03-01')).toEqual(['2024-03-04']); // leap year, +3
+ expect(await shiftOne('2026-06-10', '2026-06-20', '2026-06-03', '2026-06-15')).toEqual(['2026-06-08']); // -7 days
+ });
+
+ it('VACAY-SVC-030c: a zero offset is a no-op (no write at all)', async () => {
+ const { user, plan } = await setupUserWithPlan();
+ await seedEntries(plan.id, user.id, ['2026-05-05']);
+
+ await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', '2026-05-01');
+
+ expect(await datesOf(plan.id, user.id)).toEqual(['2026-05-05']);
+ });
+
+ it("VACAY-SVC-030d (M2 parity): an unparseable start (garbage) resolves as a no-op, same as legacy's NULL-julianday offset of 0, instead of writing a NaN date", async () => {
+ const { user, plan } = await setupUserWithPlan();
+ await seedEntries(plan.id, user.id, ['2026-05-05']);
+
+ await expect(
+ svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', 'soon'),
+ ).resolves.toBeUndefined();
+
+ expect(await datesOf(plan.id, user.id)).toEqual(['2026-05-05']);
+ });
+
+ it("VACAY-SVC-030e (M2 parity): a datetime start with no zone suffix is parsed as UTC and truncated, matching base's CAST(julianday(...) AS INTEGER) rather than a local-time round", async () => {
+ // base: CAST(julianday('2025-06-12T23:30') - julianday('2025-06-10') AS INTEGER) = 2
+ // (2 days 23.5 hours, truncated toward zero); a local-time Date.parse +
+ // Math.round of the same pair computed 3 instead (task-7-review.md M2).
+ const { user, plan } = await setupUserWithPlan();
+ await seedEntries(plan.id, user.id, ['2025-06-15']);
+
+ await svc.shiftOwnerEntriesForTripWindow(user.id, '2025-06-10', '2025-06-20', '2025-06-12T23:30');
+
+ expect(await datesOf(plan.id, user.id)).toEqual(['2025-06-17']);
+ });
+
+ it('VACAY-SVC-030f (#2604): a block of consecutive days moves whole, in both directions and whatever order the rows were inserted in', async () => {
+ // The old per-row `UPDATE OR IGNORE` checked each target against rows that
+ // were about to move themselves, so a block shifted by less than its own
+ // length left its first days behind. 2026-07-14 is a Tuesday.
+ const { user, plan } = await setupUserWithPlan();
+ const shiftBlock = async (dates: string[], newStart: string) => {
+ await seedEntries(plan.id, user.id, dates);
+ await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-01', '2026-07-20', newStart);
+ return datesOf(plan.id, user.id);
+ };
+ const ascending = ['2026-07-14', '2026-07-15', '2026-07-16'];
+ const descending = [...ascending].reverse();
+
+ expect(await shiftBlock(descending, '2026-07-02')).toEqual(['2026-07-15', '2026-07-16', '2026-07-17']);
+ expect(await shiftBlock(ascending, '2026-07-02')).toEqual(['2026-07-15', '2026-07-16', '2026-07-17']);
+ expect(await shiftBlock(descending, '2026-06-30')).toEqual(['2026-07-13', '2026-07-14', '2026-07-15']);
+ expect(await shiftBlock(ascending, '2026-06-30')).toEqual(['2026-07-13', '2026-07-14', '2026-07-15']);
+ });
+
+ it('VACAY-SVC-030g (#2604): a Monday to Friday block moved by two days keeps its length without weekend blocking and drops the days that land on a blocked weekend', async () => {
+ // 2030-11-25 is a Monday.
+ const { user, plan } = await setupUserWithPlan();
+ const week = ['2030-11-25', '2030-11-26', '2030-11-27', '2030-11-28', '2030-11-29'];
+ const shiftWeek = async (newStart: string) => {
+ await seedEntries(plan.id, user.id, week);
+ await svc.shiftOwnerEntriesForTripWindow(user.id, '2030-11-25', '2030-11-30', newStart);
+ return datesOf(plan.id, user.id);
+ };
+
+ expect(await shiftWeek('2030-11-27')).toEqual(['2030-11-27', '2030-11-28', '2030-11-29']);
+ expect(await shiftWeek('2030-11-23')).toEqual(['2030-11-25', '2030-11-26', '2030-11-27']);
+
+ await updateRows(orm(), VacayPlans, { id: plan.id }, { block_weekends: 0 });
+ expect(await shiftWeek('2030-11-27')).toEqual([
+ '2030-11-27',
+ '2030-11-28',
+ '2030-11-29',
+ '2030-11-30',
+ '2030-12-01',
+ ]);
+ expect(await shiftWeek('2030-11-23')).toEqual([
+ '2030-11-23',
+ '2030-11-24',
+ '2030-11-25',
+ '2030-11-26',
+ '2030-11-27',
+ ]);
+ });
+
+ it('VACAY-SVC-030h (#2604): a day whose target already holds leave that is not moving is dropped, the standing entry stays as it was', async () => {
+ const { user, plan } = await setupUserWithPlan();
+ await insertRows(orm(), VacayEntries, [
+ { plan: plan.id, user: user.id, date: '2026-07-14', note: '' },
+ { plan: plan.id, user: user.id, date: '2026-07-15', note: '' },
+ { plan: plan.id, user: user.id, date: '2026-07-17', note: 'standing', fraction: 0.5, kind: 'comp' },
+ ]);
+
+ await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-14', '2026-07-16', '2026-07-16');
+
+ const rows = await findRows(orm(), VacayEntries, { plan: plan.id, user: user.id }, { date: 'asc' });
+ expect(rows.map(({ date, note, fraction, kind }) => ({ date, note, fraction, kind }))).toEqual([
+ { date: '2026-07-16', note: '', fraction: 1, kind: 'vacation' },
+ { date: '2026-07-17', note: 'standing', fraction: 0.5, kind: 'comp' },
+ ]);
+ });
+
+ it('VACAY-SVC-030i (#2604): a target on a whole company holiday drops the day, one on a half company holiday keeps half of it', async () => {
+ const { user, plan } = await setupUserWithPlan();
+ await updateRows(orm(), VacayPlans, { id: plan.id }, { company_holidays_enabled: 1 });
+ await insertRows(orm(), VacayCompanyHolidays, [
+ { plan: plan.id, date: '2026-07-15', note: '', fraction: 1 },
+ { plan: plan.id, date: '2026-07-16', note: '', fraction: 0.5 },
+ ]);
+ await seedEntries(plan.id, user.id, ['2026-07-13', '2026-07-14']);
+
+ await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-13', '2026-07-14', '2026-07-15');
+
+ const rows = await findRows(orm(), VacayEntries, { plan: plan.id, user: user.id }, { date: 'asc' });
+ expect(rows.map(({ date, fraction }) => ({ date, fraction }))).toEqual([{ date: '2026-07-16', fraction: 0.5 }]);
+ });
+
+ it("VACAY-SVC-030j (#2604): a member of a fused plan has their own days moved in the shared plan, nobody else's, and gets no plan of their own", async () => {
+ const { user: owner, plan } = await setupUserWithPlan();
+ const { user: member } = await makeUser(orm());
+ await insertRow(orm(), VacayPlanMembers, { plan: plan.id, user: member.id, status: 'accepted' });
+ await insertRows(
+ orm(),
+ VacayEntries,
+ [member.id, owner.id].flatMap((userId) => [
+ { plan: plan.id, user: userId, date: '2026-07-14', note: '' },
+ { plan: plan.id, user: userId, date: '2026-07-15', note: '' },
+ ]),
+ );
+
+ const changed = await svc.shiftOwnerEntriesForTripWindow(member.id, '2026-07-14', '2026-07-15', '2026-07-21');
+
+ expect(changed).toBe(plan.id);
+ expect(await datesOf(plan.id, member.id)).toEqual(['2026-07-21', '2026-07-22']);
+ expect(await datesOf(plan.id, owner.id)).toEqual(['2026-07-14', '2026-07-15']);
+ expect(await countRows(orm(), VacayPlans, { owner: member.id })).toBe(0);
+ });
+
+ it('VACAY-SVC-030k (#2604): a trip owner without a vacay plan gets none from moving the trip', async () => {
+ const { user } = await makeUser(orm());
+
+ await expect(
+ svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-14', '2026-07-15', '2026-07-21'),
+ ).resolves.toBeUndefined();
+
+ expect(await countRows(orm(), VacayPlans, { owner: user.id })).toBe(0);
+ });
+});
diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts
index 7103940ec..26aa941ae 100644
--- a/server/tests/unit/nest/vacay.service.test.ts
+++ b/server/tests/unit/nest/vacay.service.test.ts
@@ -549,97 +549,7 @@ describe('deleteYear', () => {
});
});
-// ── shiftOwnerEntriesForTripWindow (VC4 restructured date-diff shape) ─────────
-
-describe('shiftOwnerEntriesForTripWindow', () => {
- it('VACAY-SVC-030b (VC4 parity): shifts an entry by the exact calendar-day offset between the old and new trip start, across several date-pair shapes incl. a leap-year boundary', async () => {
- // R9's verified restructured shape (task-0-report.md): plain JS
- // Math.round((Date.parse(newStart) - Date.parse(oldStart)) / 86400000)
- // in place of the legacy CAST(julianday(?) - julianday(?) AS INTEGER) —
- // verified there against the SQL on 5 date pairs incl. a leap-year
- // boundary and a negative offset, all matched exactly. This proves the
- // SAME arithmetic end-to-end through the converted service method.
- const { user, plan } = await setupUserWithPlan();
- const cases: [oldStart: string, oldEnd: string, newStart: string, entryDate: string, expectedShifted: string][] = [
- ['2026-03-01', '2026-03-10', '2026-03-06', '2026-03-05', '2026-03-10'], // +5 days
- ['2024-02-25', '2024-03-05', '2024-02-28', '2024-03-01', '2024-03-04'], // leap-year boundary, +3 days
- ['2026-06-10', '2026-06-20', '2026-06-03', '2026-06-15', '2026-06-08'], // -7 days
- ];
- for (const [oldStart, oldEnd, newStart, entryDate, expectedShifted] of cases) {
- await deleteRows(await orm(), VacayEntries, { plan: plan.id });
- await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: entryDate, note: '' });
-
- await svc.shiftOwnerEntriesForTripWindow(user.id, oldStart, oldEnd, newStart);
-
- const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string };
- expect(row.date).toBe(expectedShifted);
- }
- });
-
- it('VACAY-SVC-030c: a zero offset is a no-op (no write at all)', async () => {
- const { user, plan } = await setupUserWithPlan();
- await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2026-05-05', note: '' });
-
- await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', '2026-05-01');
-
- const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string };
- expect(row.date).toBe('2026-05-05');
- });
-
- it('VACAY-SVC-030d (M2 parity): an unparseable start (garbage) resolves as a no-op, same as legacy\'s NULL-julianday offset of 0, instead of writing a NaN date', async () => {
- const { user, plan } = await setupUserWithPlan();
- await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2026-05-05', note: '' });
-
- await expect(svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', 'soon')).resolves.toBeUndefined();
-
- const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string };
- expect(row.date).toBe('2026-05-05');
- });
-
- it('VACAY-SVC-030e (M2 parity): a datetime start with no zone suffix is parsed as UTC and truncated, matching base\'s CAST(julianday(...) AS INTEGER) rather than a local-time round', async () => {
- // base: CAST(julianday('2025-06-12T23:30') - julianday('2025-06-10') AS INTEGER) = 2
- // (2 days 23.5 hours, truncated toward zero) — a local-time Date.parse +
- // Math.round of the same pair computed 3 instead (task-7-review.md M2).
- const { user, plan } = await setupUserWithPlan();
- await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2025-06-15', note: '' });
-
- await svc.shiftOwnerEntriesForTripWindow(user.id, '2025-06-10', '2025-06-20', '2025-06-12T23:30');
-
- const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string };
- expect(row.date).toBe('2025-06-17');
- });
-
- it('VACAY-SVC-030f (M1 parity): consecutive-day entries inserted in descending-id order shift to the rowid-ordered (legacy) outcome, not the date-index-ordered one', async () => {
- // Legacy's single `UPDATE OR IGNORE` walks rows in rowid order.
- // VacayEntriesRepository.shiftForOwnerWindow's `find` had no `orderBy`, so
- // SQLite returned candidates in (user, plan, date) index order instead —
- // a different per-row collision check skips different rows
- // (task-7-review.md M1). Inserting the latest date first makes the id
- // order run opposite the date order, so the two orderings disagree.
- const { user, plan } = await setupUserWithPlan();
- const insertEntry = async (date: string) =>
- await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date, note: '' });
- const readDates = async () =>
- (await storedRows(VacayEntries, { plan: plan.id, user: user.id }, { date: 'asc' }, ['date']) as { date: string }[]).map(
- (r) => r.date,
- );
-
- await insertEntry('2026-07-12');
- await insertEntry('2026-07-11');
- await insertEntry('2026-07-10');
-
- await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-01', '2026-07-20', '2026-07-02'); // offset +1
- expect((await readDates())).toEqual(['2026-07-11', '2026-07-12', '2026-07-13']);
-
- await deleteRows(await orm(), VacayEntries, { plan: plan.id });
- await insertEntry('2026-07-12');
- await insertEntry('2026-07-11');
- await insertEntry('2026-07-10');
-
- await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-02', '2026-07-21', '2026-07-01'); // offset -1
- expect((await readDates())).toEqual(['2026-07-09', '2026-07-11', '2026-07-12']);
- });
-});
+// shiftOwnerEntriesForTripWindow (VACAY-SVC-030b to 030k) lives in vacay-trip-window.test.ts.
// ── getEntries / toggleEntry ──────────────────────────────────────────────────