Compare commits

..

3 Commits

Author SHA1 Message Date
Maurice 280e55bbc9 test(vacay): a day in a year the plan has not opened also announces the new year
The broadcast parity cases toggle a day in 2027, which the seeded plan does
not hold, so both MCP and REST now send vacay:settings for the opened year
before vacay:update.
2026-10-24 17:31:47 +02:00
Maurice e84060cdf7 refactor(vacay): the pure constants live in the helpers, so the service keeps its size budget
The holiday feeds' limits and patterns and the colour palette move to
vacay.helpers.ts, which holds the vacay domain's pure half already. That
keeps the service under its line budget after the missing-year fix.
2026-10-24 17:31:47 +02:00
Maurice fbc85b05a1 fix(vacay): a day set for a year the plan has not opened adds that year
The calendar offers only the plan's years, but a vacation day or company
holiday could be written for any date. A day set months ahead, as the
assistant did for 6 January 2027, was stored and counted yet never shown,
because 2027 was not a year of the plan. Writing a day now adds its year,
with every member's allowance, the way opening it by hand does; the year
is the writer's leave period that holds the date. This holds for the app,
MCP, plugins and the assistant alike, since they all write through here.
2026-10-24 17:31:47 +02:00
5 changed files with 84 additions and 26 deletions
+1 -1
View File
@@ -17,5 +17,5 @@
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
"src/nest/reservations/reservations.service.ts": 1237,
"src/nest/trips/trips.service.ts": 1006,
"src/nest/vacay/vacay.service.ts": 1416
"src/nest/vacay/vacay.service.ts": 1412
}
+20
View File
@@ -69,3 +69,23 @@ export function shiftIsoDate(isoDate: string, days: number): string {
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export const CACHE_TTL = 24 * 60 * 60 * 1000;
export const FETCH_TIMEOUT_MS = 10_000;
// A country's holidays for one year are a few kilobytes; two megabytes means the
// provider is misbehaving and we would rather report the usual error than buffer it.
export const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
// else has no business reaching the URL path.
export const YEAR_RE = /^\d{4}$/;
export const COUNTRY_RE = /^[A-Za-z]{2}$/;
// ---------------------------------------------------------------------------
// Color palette for auto-assign
// ---------------------------------------------------------------------------
export const COLORS = [
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
];
+18 -23
View File
@@ -30,7 +30,7 @@ import { SchoolHolidayRegions } from '../../db/entities/SchoolHolidayRegions.ent
import type { SchoolHolidayRegionsRepository } from '../../db/repositories/SchoolHolidayRegions.repository';
import {
daysAlwaysInMonth, isBlockedWeekend, normalizeFraction, normalizeKind, normalizeYearType, pad2, parseAsUtcMillis,
shiftIsoDate, windowEndYear,
shiftIsoDate, windowEndYear, CACHE_TTL, COLORS, COUNTRY_RE, FETCH_TIMEOUT_MS, MAX_HOLIDAY_BYTES, YEAR_RE,
} from './vacay.helpers';
// ---------------------------------------------------------------------------
@@ -125,26 +125,6 @@ export interface VacayShare {
created_at?: string;
}
const CACHE_TTL = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 10_000;
// A country's holidays for one year are a few kilobytes; two megabytes means the
// provider is misbehaving and we would rather report the usual error than buffer it.
const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
// else has no business reaching the URL path.
const YEAR_RE = /^\d{4}$/;
const COUNTRY_RE = /^[A-Za-z]{2}$/;
// ---------------------------------------------------------------------------
// Color palette for auto-assign
// ---------------------------------------------------------------------------
const COLORS = [
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
];
/**
* Vacay domain service — owns the vacay SQL, now through the ten vacay
* repositories (+ `SchoolHolidayRegionsRepository`'s cross-domain read,
@@ -1150,16 +1130,29 @@ export class VacayService {
// Removing a stray entry on a blocked day stays possible; keeping one
// there (converted in place) does not.
if (weekendBlocked) return { error: 'weekend_blocked' };
await this.entries.updateFractionKind(existing.id, frac, knd);
await this.uow.transactional(async () => {
await this.entries.updateFractionKind(existing.id, frac, knd);
await this.ensureYearOf(planId, userId, date);
});
await this.notifyPlanUsers(planId, socketId);
return { action: 'updated', fraction: frac, kind: knd };
}
if (weekendBlocked) return { error: 'weekend_blocked' };
await this.entries.insertEntry(planId, userId, date, '', frac, knd);
await this.uow.transactional(async () => {
await this.entries.insertEntry(planId, userId, date, '', frac, knd);
await this.ensureYearOf(planId, userId, date);
});
await this.notifyPlanUsers(planId, socketId);
return { action: 'added', fraction: frac, kind: knd };
}
/** A day written for a year the plan has not opened adds that year, or the calendar never shows it (#737 picks the period). */
private async ensureYearOf(planId: number, userId: number, date: string): Promise<void> {
const [y, m, d] = date.split('-').map(Number);
const year = await this.currentPeriodYear(userId, new Date(y, m - 1, d));
if (!(await this.years.exists(planId, year))) await this.addYear(planId, year, undefined);
}
/**
* A company holiday on or off, whole or half (#2439). The same click again clears
* it; the other size converts it in place, the way a vacation entry toggles. A
@@ -1179,6 +1172,8 @@ export class VacayService {
await this.companyHolidays.insertHoliday(planId, date, note || '', frac); // VC120
}
await this.makeRoomForCompanyHoliday(planId, date, frac);
const owner = await this.plans.findOwnerId(planId);
if (owner) await this.ensureYearOf(planId, owner.owner_id, date);
return { action: existing ? 'updated' : 'added', fraction: frac };
});
await this.notifyPlanUsers(planId, socketId);
@@ -602,14 +602,22 @@ const vacayCases: ParityCase[] = [
needs: ['vacay'],
args: () => ({ date: '2027-06-01' }),
rest: { method: 'post', path: () => `${VACAY}/entries/toggle`, body: () => ({ date: '2027-06-01' }) },
expect: [{ event: 'vacay:update', user: 'actor' }],
// 2027 is not a year of the plan yet, so the day opens it too.
expect: [
{ event: 'vacay:settings', user: 'actor' },
{ event: 'vacay:update', user: 'actor' },
],
},
{
tool: 'toggle_company_holiday',
needs: ['vacay'],
args: () => ({ date: '2027-06-02' }),
rest: { method: 'post', path: () => `${VACAY}/entries/company-holiday`, body: () => ({ date: '2027-06-02' }) },
expect: [{ event: 'vacay:update', user: 'actor' }],
// 2027 is not a year of the plan yet, so the day opens it too.
expect: [
{ event: 'vacay:settings', user: 'actor' },
{ event: 'vacay:update', user: 'actor' },
],
},
{
tool: 'update_vacay_stats',
@@ -1875,3 +1875,38 @@ describe('quirk fixes', () => {
expect(await storedFields(VacayYears, { plan: plan.id, year: year + 1 }, ['id'])).toBeUndefined();
});
});
describe('a day written for a year the plan has not opened', () => {
it('VACAY-SVC-079: adds that year with the member allowance, so the day shows in the calendar', async () => {
const { user, plan } = await setupUserWithPlan();
await allowWeekends(plan.id);
expect(await svc.listYears(plan.id)).not.toContain(2031);
const result = await svc.toggleEntry(user.id, plan.id, '2031-01-06', 1, 'vacation');
expect(result.action).toBe('added');
expect(await svc.listYears(plan.id)).toContain(2031);
expect(await storedRow(VacayUserYears, { user: user.id, plan: plan.id, year: 2031 })).toBeDefined();
});
it('VACAY-SVC-080: taking the day away again keeps the year it opened', async () => {
const { user, plan } = await setupUserWithPlan();
await allowWeekends(plan.id);
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
await svc.deleteYear(plan.id, 2032, undefined);
const before = await svc.listYears(plan.id);
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
expect(await svc.listYears(plan.id)).toEqual([...before, 2032].sort((a, b) => a - b));
});
it('VACAY-SVC-081: a company holiday in a year not opened yet adds that year too', async () => {
const { plan } = await setupUserWithPlan();
await svc.toggleCompanyHoliday(plan.id, '2033-12-25', 'Christmas', undefined);
expect(await svc.listYears(plan.id)).toContain(2033);
});
});