fix(vacay): leave days follow a moved trip as a whole block and never land on blocked days

Moving a trip's start shifted the owner's vacay entries one row at a time,
checking each target against rows that were about to move themselves. A
block shifted by less than its own length kept its first days behind and
pushed the rest onto the weekend, even with weekends blocked.

The shift now decides every day before writing: a day whose target is a
blocked weekend or a whole company holiday is dropped, as is one whose
target already holds leave that is not moving; a half company holiday
leaves half a day. The rest move in the direction of travel inside one
transaction. The plan is the one the owner is active in, so a member of a
fused plan gets their days moved too, and a trip edit no longer creates a
vacay plan for an owner who has none. Open vacay views are notified after
the trip update commits.

The pure vacay helpers moved to vacay.helpers.ts and the trip window tests
to their own suite, so both files stay inside their size budgets.

Refs #2604
This commit is contained in:
Maurice
2026-10-12 14:12:36 +02:00
parent 676f6233ed
commit ef150439f9
8 changed files with 404 additions and 197 deletions
+1 -1
View File
@@ -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
}
@@ -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(<col>, '+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<VacayEntries> {
/**
* VC1 — `SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction
@@ -79,40 +71,28 @@ export class VacayEntriesRepository extends TrekRepository<VacayEntries> {
}
/**
* 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<void> {
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<void> {
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`). */
+3 -1
View File
@@ -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<string, unknown> = {};
if (title && title !== trip.title) changes.title = title;
+71
View File
@@ -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);
}
+52 -65
View File
@@ -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<VacayPlan, 'block_weekends' | 'weekend_days'>, 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<void> {
): Promise<number | undefined> {
// 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<VacayUser[]> {
+39 -1
View File
@@ -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);
@@ -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<string[]> {
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<void> {
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);
});
});
+1 -91
View File
@@ -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 ──────────────────────────────────────────────────