Compare commits

...
3 Commits
Author SHA1 Message Date
nessarandClaude Opus 5.5 581c637029 ci: run push workflows only on develop, main and release tags
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 28s
CI / build (pull_request) Successful in 25s
CI / benchmark (pull_request) Successful in 1m30s
CI / security (pull_request) Successful in 1m5s
CI / test (pull_request) Successful in 2m31s
CI / images (pull_request) Successful in 1m21s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 11s
CI / validate (push) Successful in 28s
CI / build (push) Successful in 22s
CI / security (push) Successful in 1m6s
CI / benchmark (push) Successful in 1m47s
CI / test (push) Successful in 2m7s
CI / images (push) Successful in 1m17s
CI / required (push) Successful in 1s
Every commit on a branch with an open PR ran the whole pipeline twice, once
for the push and once for the pull_request event, in separate concurrency
groups. Filter the push trigger to develop, main and v* tags so other
branches run CI and publish their images only through their PR. Tags are
listed explicitly because a branch filter alone stops tag pushes.

Update the trigger, benchmark and image prose in the README and the Gitea
Actions guide to match.

Refs #309

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:17:04 +02:00
nessarandClaude Opus 5.5 ff6c4d8122 fix(calendar): mark today and past days from the Europe/Paris date
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 31s
CI / build (pull_request) Successful in 29s
CI / benchmark (pull_request) Successful in 1m34s
CI / security (pull_request) Successful in 1m3s
CI / test (pull_request) Successful in 2m9s
CI / images (pull_request) Successful in 1m10s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 8s
CI / validate (push) Successful in 36s
CI / build (push) Successful in 34s
CI / benchmark (push) Successful in 1m39s
CI / security (push) Successful in 1m8s
CI / test (push) Successful in 2m20s
CI / images (push) Successful in 1m1s
CI / required (push) Successful in 1s
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 <noreply@anthropic.com>
2026-10-19 14:06:52 +02:00
nessarandClaude Opus 5.5 d089c4670b 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 <noreply@anthropic.com>
2026-10-19 14:06:52 +02:00
12 changed files with 94 additions and 240 deletions

No files matched your search

+2
View File
@@ -2,6 +2,8 @@ name: CI
on:
push:
branches: [develop, main]
tags: ['v*']
pull_request:
types: [opened, reopened, synchronize, edited]
workflow_dispatch:
+4
View File
@@ -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.
+3 -3
View File
@@ -249,13 +249,13 @@ The server answers `GET` and `HEAD` only (other methods get 405). It serves file
[`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) defines Gitea Actions validation, functional coverage, production builds, security scans, and Docker image publication. [Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) tracks activation. Actions is enabled with owner approval; publisher credentials and live workflow validation are still pending. The [activation guide](docs/gitea-actions.md) records runner, credential, permission, and merge-gate requirements.
The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, `bun check` type checks, Bun coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
The workflow runs on pushes to `develop` and `main`, `v*` tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Other branches run CI only through their PR, so a branch without a PR gets no automatic CI or image. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, `bun check` type checks, Bun coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
Jobs that run project commands install Bun 1.4.3 with `scripts/install-bun.sh` (a checksum-verified download, no npm) and run their steps with a `PATH` that holds Bun but no Node. The job containers keep Node only for Gitea's checkout and artifact actions, which are JavaScript actions the runner executes itself; see the [activation guide](docs/gitea-actions.md#runners-and-permissions).
Functional tests run with `bun test --isolate`; coverage is line-level only (Bun reports no branch or function data). The separate required benchmark job runs without coverage in a container limited to 3 GiB (JavaScriptCore has no heap-size flag, so this replaces the former 2 GiB Node heap cap) and keeps its 240-second hang guard, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restored this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). After [#302](docs/benchmark/issue-302.md) the local protocol takes about 41 seconds. Benchmark success is required for image publication and the aggregate `required` job. The lowered hang guard and tightened multipliers still need confirmation on the CI runners.
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes and same-repository PRs. Registry credentials are confined to the publishing step.
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes to `develop`, `main` and release tags, and same-repository PRs. Registry credentials are confined to the publishing step.
Both protected branches still require `gitlab-to-gitea/ci-conversion-required` until the owner approves the first merge message and a successful Gitea PR run, then replaces the migration gate with the actual pull-request context for `required`. Local validation does not satisfy this status. Historical CI reports and artifacts remain in administrator rollback backups; the original configuration is retained in Git history.
@@ -271,7 +271,7 @@ The image publisher implements this tag contract in `gitea.nessar.fr/nessar/vac-
| Trigger | Published image tags |
| --- | --- |
| Successful push to any branch | `branch-<normalized branch slug>` and `sha-<full commit SHA>` |
| Successful push to `develop` or `main`, or same-repository PR | `branch-<normalized branch slug>` and `sha-<full commit SHA>` |
| Explicit release tag `vX.Y.Z` | `vX.Y.Z` and `latest`, using the existing SHA image |
For example, `feature/calendar` uses `branch-feature-calendar`; integration uses `branch-develop`. Preserve the previous slug normalization: lowercase the branch name, replace characters other than `a-z` and `0-9` with `-`, truncate to 63 bytes, and remove leading/trailing `-`. Choose branch names with distinct slugs. Only valid release tags can update `latest`. Automatic image publication and tag promotion begin after the owner provisions publisher credentials and validates publication permissions.
+24 -20
View File
@@ -11,10 +11,13 @@ message.
## Jobs and triggers
Every branch and tag push runs CI. Pull requests run on opening, reopening,
synchronization, and title/body edits. Manual dispatch runs validation without
publishing images. There is no schedule or deployment job in the preserved
configuration; the Compose files remain manual deployment options.
Pushes to `develop` and `main` and `v*` tag pushes run CI. Other branches
(feature, renovate, release, hotfix) run it only through their pull request; a
branch without a PR gets no automatic CI or image, so open a draft PR to get
one. Pull requests run on opening, reopening, synchronization, and title/body
edits. Manual dispatch runs validation without publishing images. There is no
schedule or deployment job in the preserved configuration; the Compose files
remain manual deployment options.
| Job | Behavior |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -27,18 +30,19 @@ configuration; the Compose files remain manual deployment options.
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag, running the publisher with Bun. Runs only for pushes and same-repository PRs. |
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. It is shell only and needs no Bun. |
The benchmark runs on both push and pull_request events: the push run gates image
publication (`images.needs` includes `benchmark`) and the `required` aggregate.
The relative gate absorbs a slower runner and load that lasts through the whole
job, because calibration runs in the same job. It calibrates before and after the
protocol and judges against the slower value, so it also absorbs load that starts
during the protocol, such as an overlapping push and pull_request run of the same
commit. It does not absorb load that ends before the second calibration; the
240-second hang guard still bounds those runs.
The benchmark gates image publication (`images.needs` includes `benchmark`) and
the `required` aggregate. The relative gate absorbs a slower runner and load
that lasts through the whole job, because calibration runs in the same job. It
calibrates before and after the protocol and judges against the slower value,
so it also absorbs load that starts during the protocol, such as another run
sharing the runner. It does not absorb load that ends before the second
calibration; the 240-second hang guard still bounds those runs.
Push and PR runs are separate so PR title and rebase checks run on the PR event.
New runs cancel older runs for the same event/ref. Image jobs serialize per
branch slug; stable promotions share one group and are not cancelled in progress.
Because branch pushes outside `develop` and `main` do not trigger CI, a commit
on a PR branch runs only on PR events, which carry the PR title and rebase
checks. New runs cancel older runs for the same event/ref. Image jobs serialize
per branch slug; stable promotions share one group and are not cancelled in
progress.
## Runners and permissions
@@ -112,10 +116,10 @@ only one place fails CI instead of drifting.
## Images and releases
Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
at `gitea.nessar.fr/nessar/vac-optimizer`. Branch pushes and same-repository PRs
publish `branch-<slug>` and `sha-<full commit SHA>`. Slugs retain the previous
lowercase, non-alphanumeric-to-hyphen, 63-byte normalization; choose distinct
branch slugs.
at `gitea.nessar.fr/nessar/vac-optimizer`. Pushes to `develop` and `main` and
same-repository PRs publish `branch-<slug>` and `sha-<full commit SHA>`. Slugs
retain the previous lowercase, non-alphanumeric-to-hyphen, 63-byte
normalization; choose distinct branch slugs.
Only stable `vX.Y.Z` tags matching the tagged commit's `package.json` version
and reachable from `main` may promote images. Promotion first pulls the existing
@@ -148,7 +152,7 @@ secrets. Complete the remaining activation checks in this order:
`git show 0d900fd72d1a405d257f5a566c55cc0cb3d2bc61:.gitlab-ci.yml`.
2. Publish the reviewed feature branch and open its PR to register the workflow.
Once registered, manual dispatch verifies validation and artifacts without
publishing images. Push and PR validation can also run now; their image and
publishing images. PR validation can also run now; its image and
aggregate checks remain blocked until publisher secrets are provisioned.
3. Verify runner isolation and Docker service support, then provision the two
publisher secrets. Review the package publisher's permissions and registry
@@ -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;
}
}