fix(calendar): mark today and past days from the Europe/Paris date #312
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()"
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user