fix(calendar): mark today and past days from the Europe/Paris date #312

Merged
nessar merged 2 commits from feature/301-paris-today into develop 2026-10-19 14:16:58 +02:00
8 changed files with 61 additions and 217 deletions
Showing only changes of commit ff6c4d8122 - Show all commits

No files matched your search

@@ -34,7 +34,7 @@
<button
type="button"
[attr.data-date]="dateKey"
[class.past]="day < getNow()"
[class.past]="dateKey < today"
[class.start]="selectedDates.isStart(day)"
[class.end]="selectedDates.isEnd(day)"
[class.selected]="selectedDates.isSelected(day)"
@@ -1,5 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { SelectedDates } from '../../services/calendar-service';
import { CalendarMonth } from './calendar-month';
describe('CalendarMonth', () => {
@@ -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');
});
});
@@ -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;
@@ -608,6 +608,7 @@
[monthIndex]="month.monthIndex"
[year]="month.year"
[selectedDates]="calendarSelectedDates()"
[today]="calendarProjection()?.today ?? ''"
[horizonStart]="calendarHorizon()?.start"
[horizonEnd]="calendarHorizon()?.end"
[declarationEntry]="declarationEntry()"
+31 -12
View File
@@ -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<typeof service.project>[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 },
+8 -29
View File
@@ -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<Date>(day, day)),
);
if (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
)
if (day.getTime() === todayTime)
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(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();
}
@@ -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);
});
});
-105
View File
@@ -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;
}
}