From d089c4670bb6e839ddc50d092593e8f327f80da7 Mon Sep 17 00:00:00 2001
From: Nessar
Date: Mon, 19 Oct 2026 13:35:21 +0200
Subject: [PATCH 1/2] docs(context): define today as the Europe/Paris calendar
date
The today marker and past styling follow the same Paris date that
starts the default planning horizon (#301).
Co-Authored-By: Claude Opus 5.5
---
CONTEXT.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CONTEXT.md b/CONTEXT.md
index b5e30df..0538c61 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -24,6 +24,10 @@ The unspent portion of a leave balance that expires after the planning horizon.
The category of leave in a balance or on a declared non-working day: CP (paid leave), RTT (working-time reduction leave), or Other (a user-managed catch-all, including any other reason for not working).
_Avoid_: ANY as a leave type; `ANY` is only a rule selector.
+**Today**:
+The current Europe/Paris calendar date, whatever the device's time zone. The calendar marks it as today and shows earlier dates as past.
+_Avoid_: local date
+
**Planning horizon**:
The user-selected half-open date interval within which leave may be placed; its start may precede today's Europe/Paris calendar date and its duration may exceed twelve months. It defaults to today through the same date twelve months later, and explicitly selected boundaries remain unchanged as time passes, with past dates still eligible for leave placements.
--
2.54.0
From ff6c4d81220ca6eddfa5a343f8492359d715a8a4 Mon Sep 17 00:00:00 2001
From: Nessar
Date: Mon, 19 Oct 2026 14:06:49 +0200
Subject: [PATCH 2/2] fix(calendar): mark today and past days from the
Europe/Paris date
The today marker and past styling used the device's local date while the
default planning horizon starts on the Paris date, so outside Paris time
near midnight the horizon's first day was not marked as today (CI in UTC
failed nightly). project() now takes an injectable now, derives today once
with planningDateInParis and returns it; calendar-month styles a day as
past when its date key is before that today.
Delete DateCacheService, monthsInAYearFromNow() and the default year of
getHolidays(), whose only remaining callers were their own specs.
Refs #301
Co-Authored-By: Claude Opus 5.5
---
.../calendar-month/calendar-month.html | 2 +-
.../calendar-month/calendar-month.spec.ts | 17 +++
.../calendar-month/calendar-month.ts | 10 +-
.../calendar-year/calendar-year.html | 1 +
src/app/services/calendar-service.spec.ts | 43 +++++--
src/app/services/calendar-service.ts | 37 ++----
src/app/services/date-cache.service.spec.ts | 63 -----------
src/app/services/date-cache.service.ts | 105 ------------------
8 files changed, 61 insertions(+), 217 deletions(-)
delete mode 100644 src/app/services/date-cache.service.spec.ts
delete mode 100644 src/app/services/date-cache.service.ts
diff --git a/src/app/components/calendar-month/calendar-month.html b/src/app/components/calendar-month/calendar-month.html
index d057ded..54cd033 100644
--- a/src/app/components/calendar-month/calendar-month.html
+++ b/src/app/components/calendar-month/calendar-month.html
@@ -34,7 +34,7 @@
{
@@ -20,4 +21,20 @@ describe('CalendarMonth', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('styles only the dates before the given today as past', () => {
+ const monthFixture = TestBed.createComponent(CalendarMonth);
+ monthFixture.componentRef.setInput('monthIndex', 9);
+ monthFixture.componentRef.setInput('year', 2026);
+ monthFixture.componentRef.setInput('selectedDates', new SelectedDates());
+ monthFixture.componentRef.setInput('today', '2026-10-18');
+ monthFixture.detectChanges();
+ const button = (date: string) =>
+ (monthFixture.nativeElement as HTMLElement).querySelector(
+ `button[data-date="${date}"]`,
+ )!;
+ expect(button('2026-10-17').classList).toContain('past');
+ expect(button('2026-10-18').classList).not.toContain('past');
+ expect(button('2026-10-19').classList).not.toContain('past');
+ });
});
diff --git a/src/app/components/calendar-month/calendar-month.ts b/src/app/components/calendar-month/calendar-month.ts
index 3043aad..8ee6f3a 100644
--- a/src/app/components/calendar-month/calendar-month.ts
+++ b/src/app/components/calendar-month/calendar-month.ts
@@ -10,7 +10,6 @@ import {
import { CalendarService, Month, SelectedDates } from '../../services/calendar-service';
import { MatTooltip } from '@angular/material/tooltip';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
-import { DateCacheService } from '../../services/date-cache.service';
import { TranslationService } from '../../services/translation-service';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import type { LeaveType } from '../../services/leave-planner.service';
@@ -29,7 +28,6 @@ export interface DeclarationPreview {
})
export class CalendarMonth implements OnInit {
protected calendarSettingsService = inject(CalendarSettingsService);
- private dateCache = inject(DateCacheService);
protected translationService = inject(TranslationService);
@Input({ required: true })
@@ -42,11 +40,6 @@ export class CalendarMonth implements OnInit {
return this.translationService.tr;
}
- // Use DateCacheService instead of creating Date objects
- getNow(): Date {
- return this.dateCache.getNow();
- }
-
currentMonth: Date = new Date();
weeksInAMonth: Month = [];
@@ -56,6 +49,9 @@ export class CalendarMonth implements OnInit {
@Input({ required: true })
selectedDates!: SelectedDates;
+ @Input({ required: true })
+ today!: string;
+
@Input()
horizonStart?: string;
diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html
index 0605a45..29e5e20 100644
--- a/src/app/components/calendar-year/calendar-year.html
+++ b/src/app/components/calendar-year/calendar-year.html
@@ -608,6 +608,7 @@
[monthIndex]="month.monthIndex"
[year]="month.year"
[selectedDates]="calendarSelectedDates()"
+ [today]="calendarProjection()?.today ?? ''"
[horizonStart]="calendarHorizon()?.start"
[horizonEnd]="calendarHorizon()?.end"
[declarationEntry]="declarationEntry()"
diff --git a/src/app/services/calendar-service.spec.ts b/src/app/services/calendar-service.spec.ts
index ec749a9..e3276cb 100644
--- a/src/app/services/calendar-service.spec.ts
+++ b/src/app/services/calendar-service.spec.ts
@@ -150,14 +150,14 @@ describe('CalendarService', () => {
describe('getHolidays', () => {
it('should return an array of holidays', () => {
- const holidays = service.getHolidays();
+ const holidays = service.getHolidays(2026);
expect(Array.isArray(holidays)).toBe(true);
expect(holidays.length).toBeGreaterThan(0);
});
it('should cache holidays for the same year', () => {
- const holidays1 = service.getHolidays();
- const holidays2 = service.getHolidays();
+ const holidays1 = service.getHolidays(2026);
+ const holidays2 = service.getHolidays(2026);
expect(holidays1).toEqual(holidays2);
});
});
@@ -179,11 +179,6 @@ describe('CalendarService', () => {
expect(months.length).toBe(12);
});
- it('should calculate months from now', () => {
- const months = service.monthsInAYearFromNow();
- expect(months.length).toBe(12);
- });
-
it('should return week number for a date', () => {
const date = new Date(2024, 0, 1);
const weekNumber = CalendarService.getWeekNumber(date);
@@ -193,20 +188,44 @@ describe('CalendarService', () => {
});
describe('project', () => {
- const project = (horizon: { start: string; end: string }) =>
- service.project({ placements: [], horizon });
+ const project = (
+ horizon: { start: string; end: string },
+ options?: Parameters[1],
+ ) => service.project({ placements: [], horizon }, options);
const day = (key: string) => new Date(`${key}T00:00:00`);
it('projects weekends, holidays and today for the default planning horizon', () => {
const { selections } = project({ start: '2026-01-01', end: '2027-01-01' });
expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND);
expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY);
- const horizon = service.getDefaultPlanningHorizon();
- expect(project(horizon).selections.getClasses(day(horizon.start))).toContain(
+ const now = new Date();
+ const horizon = service.getDefaultPlanningHorizon(now);
+ expect(project(horizon, { now }).selections.getClasses(day(horizon.start))).toContain(
DayType.TODAY,
);
});
+ it('marks the Europe/Paris date as today when the device is in UTC', () => {
+ const previousTimeZone = process.env['TZ'];
+ process.env['TZ'] = 'UTC';
+ try {
+ const now = new Date('2026-10-17T22:16:00Z');
+ const horizon = service.getDefaultPlanningHorizon(now);
+ expect(horizon.start).toBe('2026-10-18');
+ const projection = project(horizon, { now });
+ expect(projection.today).toBe('2026-10-18');
+ expect(projection.selections.getClasses(day('2026-10-18'))).toContain(
+ DayType.TODAY,
+ );
+ expect(projection.selections.getClasses(day('2026-10-17'))).not.toContain(
+ DayType.TODAY,
+ );
+ } finally {
+ if (previousTimeZone === undefined) delete process.env['TZ'];
+ else process.env['TZ'] = previousTimeZone;
+ }
+ });
+
it('lists the covered months, including partial boundary months and year changes', () => {
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
{ monthIndex: 10, year: 2024 },
diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts
index bedccdc..66f3af0 100644
--- a/src/app/services/calendar-service.ts
+++ b/src/app/services/calendar-service.ts
@@ -1,10 +1,9 @@
-import { Injectable, inject } from '@angular/core';
+import { Injectable } from '@angular/core';
import { DateRange } from '@angular/material/datepicker';
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek';
-import { DateCacheService } from './date-cache.service';
import {
createPlanningCalculation as createPlanningCalculationOperation,
planLeave as planLeaveOperation,
@@ -55,14 +54,13 @@ export function calendarProjectionHorizon(plan: CalendarPlan) {
export interface CalendarProjection {
months: { monthIndex: number; year: number }[];
selections: SelectedDates;
+ today: string;
}
@Injectable({
providedIn: 'root',
})
export class CalendarService {
- private dateCache = inject(DateCacheService);
-
readonly planLeave = planLeaveOperation;
readonly createPlanningCalculation = createPlanningCalculationOperation;
@@ -74,7 +72,7 @@ export class CalendarService {
/** `fixedMonths` lists only the plan horizon's months, so editing declarations never adds a month. */
project(
plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] },
- { fixedMonths = false } = {},
+ { fixedMonths = false, now = new Date() } = {},
): CalendarProjection {
const horizon = calendarProjectionHorizon(plan)!;
const firstMonth = plan.horizon.start.slice(0, 7);
@@ -85,11 +83,8 @@ export class CalendarService {
const holidays = new Set(
this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()),
);
- const {
- date: todayDate,
- month: todayMonth,
- year: todayYear,
- } = this.dateCache.getTodayComponents();
+ const today = planningDateInParis(now);
+ const todayTime = new Date(`${today}T00:00:00`).getTime();
const months: CalendarProjection['months'] = [];
const ranges: SelectedDate[] = [];
const cursor = new Date(first);
@@ -122,11 +117,7 @@ export class CalendarService {
ranges.push(
new SelectedDate(DayType.CLOSED_DAY, new DateRange(day, day)),
);
- if (
- day.getDate() === todayDate &&
- day.getMonth() === todayMonth &&
- day.getFullYear() === todayYear
- )
+ if (day.getTime() === todayTime)
ranges.push(new SelectedDate(DayType.TODAY, new DateRange(day, day)));
}
}
@@ -146,10 +137,10 @@ export class CalendarService {
),
);
}
- return { months, selections: new SelectedDates(ranges) };
+ return { months, selections: new SelectedDates(ranges), today };
}
- getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
+ getHolidays(startYear: number, lastYear = startYear + 1): Date[] {
const dates: Date[] = [];
for (let year = startYear; year <= lastYear; year++) {
for (const date of frenchPublicHolidaysForYear(year))
@@ -204,18 +195,6 @@ export class CalendarService {
return months;
}
- monthsInAYearFromNow(): Year {
- const months: Year = [];
- const currentMonthIndex = this.dateCache.getCurrentMonth();
- const currentYear = this.dateCache.getCurrentYear();
- for (let i = 0; i < 12; i++) {
- const monthIndex = (currentMonthIndex + i) % 12;
- const year = currentYear + Math.floor((currentMonthIndex + i) / 12);
- months.push(CalendarService.weeksInMonth(monthIndex, year));
- }
- return months;
- }
-
static getWeekNumber(date: Date): number {
return dayjs(date).isoWeek();
}
diff --git a/src/app/services/date-cache.service.spec.ts b/src/app/services/date-cache.service.spec.ts
deleted file mode 100644
index f7a199c..0000000
--- a/src/app/services/date-cache.service.spec.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { TestBed } from '@angular/core/testing';
-import { provideZonelessChangeDetection } from '@angular/core';
-import { DateCacheService } from './date-cache.service';
-
-describe('DateCacheService', () => {
- let service: DateCacheService;
-
- beforeEach(() => {
- TestBed.configureTestingModule({
- providers: [provideZonelessChangeDetection()],
- });
- service = TestBed.inject(DateCacheService);
- });
-
- it('should be created', () => {
- expect(service).toBeTruthy();
- });
-
- it('should cache today date', () => {
- const today1 = service.getToday();
- const today2 = service.getToday();
- expect(today1).toBe(today2); // Same reference
- });
-
- it('should cache today components', () => {
- const components1 = service.getTodayComponents();
- const components2 = service.getTodayComponents();
- expect(components1).toBe(components2); // Same reference
- });
-
- it('should cache current month', () => {
- const month1 = service.getCurrentMonth();
- const month2 = service.getCurrentMonth();
- expect(month1).toBe(month2);
- });
-
- it('should cache current year', () => {
- const year1 = service.getCurrentYear();
- const year2 = service.getCurrentYear();
- expect(year1).toBe(year2);
- });
-
- it('should clear cache', () => {
- const year1 = service.getCurrentYear();
- service.clearCache();
- const year2 = service.getCurrentYear();
- expect(year1).toBe(year2); // Values should be the same even after clearing
- });
-
- it('should return today with time normalized to 00:00:00.000', () => {
- const today = service.getToday();
- expect(today.getHours()).toBe(0);
- expect(today.getMinutes()).toBe(0);
- expect(today.getSeconds()).toBe(0);
- expect(today.getMilliseconds()).toBe(0);
- });
-
- it('should return getNow() same as getToday()', () => {
- const now = service.getNow();
- const today = service.getToday();
- expect(now).toBe(today);
- });
-});
diff --git a/src/app/services/date-cache.service.ts b/src/app/services/date-cache.service.ts
deleted file mode 100644
index 8edf3f2..0000000
--- a/src/app/services/date-cache.service.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { Injectable } from '@angular/core';
-
-/**
- * Service to cache and provide commonly used date values.
- * This reduces redundant Date object creation across the application.
- */
-@Injectable({
- providedIn: 'root',
-})
-export class DateCacheService {
- private _today: Date | null = null;
- private _todayComponents: { date: number; month: number; year: number } | null = null;
- private _currentMonth: number | null = null;
- private _currentYear: number | null = null;
-
- /**
- * Get today's date (time set to 00:00:00.000)
- */
- getToday(): Date {
- if (!this._today || this.isNewDay(this._today)) {
- this._today = new Date();
- this._today.setHours(0, 0, 0, 0);
- this._todayComponents = null; // Invalidate components cache
- }
- return this._today;
- }
-
- /**
- * Get today's date components (date, month, year) for efficient comparison
- */
- getTodayComponents(): { date: number; month: number; year: number } {
- if (!this._todayComponents || (this._today && this.isNewDay(this._today))) {
- const today = this.getToday();
- this._todayComponents = {
- date: today.getDate(),
- month: today.getMonth(),
- year: today.getFullYear(),
- };
- }
- return this._todayComponents;
- }
-
- /**
- * Get current month (0-11)
- */
- getCurrentMonth(): number {
- if (this._currentMonth === null || this.isNewMonth()) {
- this._currentMonth = new Date().getMonth();
- }
- return this._currentMonth;
- }
-
- /**
- * Get current year
- */
- getCurrentYear(): number {
- if (this._currentYear === null || this.isNewYear()) {
- this._currentYear = new Date().getFullYear();
- }
- return this._currentYear;
- }
-
- /**
- * Get current date with time set to 00:00:00.000 (alias for getToday)
- */
- getNow(): Date {
- return this.getToday();
- }
-
- /**
- * Check if a cached date is from a previous day
- */
- private isNewDay(cachedDate: Date): boolean {
- const now = new Date();
- return (
- cachedDate.getDate() !== now.getDate() ||
- cachedDate.getMonth() !== now.getMonth() ||
- cachedDate.getFullYear() !== now.getFullYear()
- );
- }
-
- /**
- * Check if we're in a new month
- */
- private isNewMonth(): boolean {
- return this._currentMonth !== null && this._currentMonth !== new Date().getMonth();
- }
-
- /**
- * Check if we're in a new year
- */
- private isNewYear(): boolean {
- return this._currentYear !== null && this._currentYear !== new Date().getFullYear();
- }
-
- /**
- * Clear all caches (useful for testing)
- */
- clearCache(): void {
- this._today = null;
- this._todayComponents = null;
- this._currentMonth = null;
- this._currentYear = null;
- }
-}
--
2.54.0