From fbc85b05a1d12db6d84249c5fef3dc08e0e18857 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 24 Oct 2026 16:52:31 +0200 Subject: [PATCH] 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. --- server/src/nest/vacay/vacay.service.ts | 25 ++++++++++++-- server/tests/unit/nest/vacay.service.test.ts | 35 ++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index f6bdcb8fa..f65beb380 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -1150,16 +1150,35 @@ 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 yet adds that year, with + * each member's allowance, the way opening it by hand does. Without it a day + * set months ahead (by the assistant, MCP, a plugin or an old client) was + * stored but never shown, since the calendar only offers the plan's years. + * The year is the writer's leave period that holds the date (#737). + */ + private async ensureYearOf(planId: number, userId: number, date: string): Promise { + 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 +1198,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); diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index 3f13963c9..f3ca60b32 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -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); + }); +});