Compare commits

...
21 changed files with 574 additions and 115 deletions

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
---
trigger: always_on
---
# Agent Rules and Verification Constraints
All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to the following rules before declaring a task complete:
1. **Test Coverage (>= 80%)**:
- The overall project code coverage (Statements, Lines, and Functions) must be maintained at **80% or higher**.
- Verify coverage by running:
```bash
bun run test --watch=false --coverage
```
- If your changes introduce new code or modify existing code in a way that drops the coverage below 80%, you must write appropriate unit tests in `.spec.ts` files to bring it back up.
2. **Code Formatting (Prettier)**:
- All code files must be correctly formatted according to the project's Prettier config before finishing.
- Run the formatter with:
```bash
bun run format
```
3. **Successful Build**:
- The project must always build successfully for production without compilation or bundling errors before ending.
- Verify the build with:
```bash
bun run build
```
4. **GitLab Pipeline Checks**:
- You must execute the local equivalent of the GitLab CI/CD pipeline steps before completing your task. All of the following commands must pass:
- **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
- **Formatting check**: `bun run format:check`
- **TypeScript Type check**: `bunx tsc --noEmit`
- **Unit Tests and Coverage**: `bun run test --watch=false --coverage`
- **Production Build**: `bun run build -- --configuration production`
+52
View File
@@ -0,0 +1,52 @@
import "@angular/compiler";
import { CalendarService, SelectedDates, DayType, SelectableDayType } from './src/app/services/calendar-service';
import { DateCacheService } from './src/app/services/date-cache.service';
import { RuleService } from './src/app/services/rule-service';
const cs = new CalendarService();
cs['dateCache'] = {
getTodayComponents: () => {
const d = new Date();
return { date: d.getDate(), month: d.getMonth(), year: d.getFullYear() };
}
} as any;
const sd = cs.getSelectedDatesForPeriod(new Date(), new Date(new Date().setFullYear(new Date().getFullYear() + 1)));
console.log("Selected dates length:", sd._datesSelected.length);
const rules = [
{
id: 'default-samedi-malin',
name: 'Samedi Malin',
type: 'if_then',
enabled: true,
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other'
},
{
id: 'default-christmas',
name: 'Vacances de Noël',
type: 'time_window',
enabled: true,
startDate: '2026-12-19T23:00:00.000Z',
endDate: '2027-01-02T23:00:00.000Z',
placeDays: 5,
placeType: 'any'
}
] as any;
const vacs = [
{ id: '1', type: 'cp', numberOfDays: 5, expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)) },
{ id: '2', type: 'rtt', numberOfDays: 5, expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)) },
];
sd.optimizeVacations(vacs, rules, cs, new Date(), new Date(new Date().setFullYear(new Date().getFullYear() + 1))).then(res => {
console.log("Remaining:", res);
const vacPlaced = sd._datesSelected.filter(d => ['cp', 'rtt', 'other'].includes(d.type as string));
console.log("Placed vacs:", vacPlaced.length);
});
@@ -10,12 +10,12 @@
</div>
</div>
}
@for (month of months; track $index) {
@for (item of monthsAndYears(); track $index) {
<mat-card class="card">
<mat-card-content>
<app-calendar-month
[monthIndex]="month"
[year]="getYear(month)"
[monthIndex]="item.month"
[year]="item.year"
[selectedDates]="selectedDates"
></app-calendar-month>
</mat-card-content>
@@ -1,4 +1,4 @@
import { Component, effect, signal, inject, untracked } from '@angular/core';
import { Component, effect, signal, inject, untracked, computed } from '@angular/core';
import { UserInputService } from '../../services/user-input-service';
import { MatCard, MatCardContent } from '@angular/material/card';
import { CalendarMonth } from '../calendar-month/calendar-month';
@@ -42,7 +42,10 @@ export class CalendarYear {
effect(() => {
this.userInput.computeTriggerSignal();
this.selectedDates = this.calendarService.getSelectedDatesForYear();
const startDate = this.userInput.startDate();
const endDate = this.userInput.endDate();
this.selectedDates = this.calendarService.getSelectedDatesForPeriod(startDate, endDate);
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
const currentRules = untracked(() => this.ruleService.rules);
@@ -61,6 +64,8 @@ export class CalendarYear {
vacationDaysCopy,
currentRules,
this.calendarService,
startDate,
endDate,
(progress: number) => {
this.loadingProgress.set(progress);
},
@@ -76,11 +81,22 @@ export class CalendarYear {
protected selectedDates: SelectedDates = new SelectedDates();
getYear(monthIndex: number): number {
const offset = this.currentMonth <= monthIndex ? 0 : 1;
return this.currentYear + offset;
}
protected monthsAndYears = computed(() => {
const start = this.userInput.startDate();
const end = this.userInput.endDate();
// months starting from actual date - cache this array
protected months: number[] = Array.from({ length: 12 }, (_, i) => (this.currentMonth + i) % 12);
const result: { month: number; year: number }[] = [];
const current = new Date(start.getFullYear(), start.getMonth(), 1);
const limit = new Date(end.getFullYear(), end.getMonth(), 1);
while (current <= limit) {
result.push({
month: current.getMonth(),
year: current.getFullYear(),
});
current.setMonth(current.getMonth() + 1);
}
return result;
});
}
@@ -1,5 +1,31 @@
<div class="user-input">
<p>{{ tr().app.description }}</p>
<mat-card class="period-selection-card mat-elevation-z2">
<mat-card-content>
<h3 class="period-title">
<mat-icon>date_range</mat-icon> {{ tr().userInput.choosePeriod }}
</h3>
<mat-form-field appearance="outline" class="period-field">
<mat-label>{{ tr().userInput.choosePeriod }}</mat-label>
<mat-date-range-input [rangePicker]="picker">
<input
matStartDate
[value]="startDate()"
(dateChange)="onStartDateChange($event.value)"
[placeholder]="tr().userInput.startDate"
/>
<input
matEndDate
[value]="endDate()"
(dateChange)="onEndDateChange($event.value)"
[placeholder]="tr().userInput.endDate"
/>
</mat-date-range-input>
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-date-range-picker #picker></mat-date-range-picker>
</mat-form-field>
</mat-card-content>
</mat-card>
<div class="listOfDays">
@for (day of daysOff; track day.id) {
<div class="day-input-wrapper" animate.enter="day-enter" animate.leave="day-leave">
@@ -23,6 +23,22 @@
align-items: center;
gap: 0.5rem;
}
.period-selection-card {
margin-bottom: 1rem;
.period-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0;
margin-bottom: 1rem;
}
.period-field {
width: 100%;
}
}
}
.listOfDays {
@@ -3,6 +3,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { UserInput } from './user-input';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { UserInputService } from '../../services/user-input-service';
import { provideNativeDateAdapter } from '@angular/material/core';
describe('UserInput', () => {
let component: UserInput;
@@ -12,7 +13,11 @@ describe('UserInput', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserInput],
providers: [provideZonelessChangeDetection(), provideAnimationsAsync()],
providers: [
provideZonelessChangeDetection(),
provideAnimationsAsync(),
provideNativeDateAdapter(),
],
}).compileComponents();
fixture = TestBed.createComponent(UserInput);
@@ -43,4 +48,24 @@ describe('UserInput', () => {
component.removeFromList(0);
expect(userInputService.vacationNumberSignal().length).toBe(initialLength - 1);
});
it('should trigger computation', () => {
const initialValue = userInputService.computeTriggerSignal();
component.triggerComputation();
expect(userInputService.computeTriggerSignal()).toBe(initialValue + 1);
});
it('should update start date', () => {
const newDate = new Date();
newDate.setFullYear(newDate.getFullYear() + 2);
component.onStartDateChange(newDate);
expect(userInputService.startDate().getFullYear()).toBe(newDate.getFullYear());
});
it('should update end date', () => {
const newDate = new Date();
newDate.setFullYear(newDate.getFullYear() + 3);
component.onEndDateChange(newDate);
expect(userInputService.endDate().getFullYear()).toBe(newDate.getFullYear());
});
});
@@ -1,5 +1,7 @@
import { Component, computed, inject, ChangeDetectionStrategy } from '@angular/core';
import { MatInputModule } from '@angular/material/input';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettings } from '../calendar-settings/calendar-settings';
import { RulesPanel } from '../rules-panel/rules-panel';
@@ -14,6 +16,8 @@ import { TranslationService } from '../../services/translation-service';
selector: 'app-user-input',
imports: [
MatInputModule,
MatDatepickerModule,
MatFormFieldModule,
CalendarSettings,
RulesPanel,
MatCard,
@@ -86,4 +90,20 @@ export class UserInput {
public triggerComputation() {
this.userInputService.requestComputation();
}
protected get startDate() {
return this.userInputService.startDate;
}
protected get endDate() {
return this.userInputService.endDate;
}
public onStartDateChange(date: Date | null) {
this.userInputService.setStartDate(date);
}
public onEndDateChange(date: Date | null) {
this.userInputService.setEndDate(date);
}
}
@@ -18,7 +18,7 @@
}
.numberInput {
width: 80px;
width: 120px;
}
::ng-deep .mat-mdc-form-field-subscript-wrapper {
@@ -1,5 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { MatDatepickerInputEvent } from '@angular/material/datepicker';
import { vi } from 'vitest';
import { DayInput } from './day-input';
import { SelectableDayType, VacDay } from '../../../services/calendar-service';
@@ -30,4 +32,23 @@ describe('DayInput', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should emit dayChange on input', () => {
const spy = vi.spyOn(component.dayChange, 'emit');
component.onInput({ target: { value: '3' } } as unknown as Event);
expect(spy).toHaveBeenCalledWith({ ...mockVacDay, numberOfDays: 3 });
});
it('should emit dayChange on type change', () => {
const spy = vi.spyOn(component.dayChange, 'emit');
component.onTypeChange(SelectableDayType.RTT);
expect(spy).toHaveBeenCalledWith({ ...mockVacDay, type: SelectableDayType.RTT });
});
it('should emit dayChange on date input', () => {
const spy = vi.spyOn(component.dayChange, 'emit');
const newDate = new Date('2026-01-01');
component.addEvent({ value: newDate } as unknown as MatDatepickerInputEvent<Date>);
expect(spy).toHaveBeenCalledWith({ ...mockVacDay, expiryDate: newDate });
});
});
+68 -25
View File
@@ -26,18 +26,15 @@ describe('CalendarService', () => {
expect(service).toBeTruthy();
});
describe('getHolidays', () => {
describe('getHolidaysForPeriod', () => {
it('should return an array of holidays', () => {
const holidays = service.getHolidays();
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const holidays = service.getHolidaysForPeriod(startDate, endDate);
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();
expect(holidays1).toEqual(holidays2);
});
});
describe('static date calculation methods', () => {
@@ -57,9 +54,12 @@ describe('CalendarService', () => {
expect(months.length).toBe(12);
});
it('should calculate months from now', () => {
const months = service.monthsInAYearFromNow();
expect(months.length).toBe(12);
it('should calculate months in period', () => {
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const months = service.monthsInPeriod(startDate, endDate);
expect(months.length).toBeGreaterThanOrEqual(12);
});
it('should return week number for a date', () => {
@@ -70,15 +70,21 @@ describe('CalendarService', () => {
});
});
describe('getSelectedDatesForYear', () => {
it('should return selected dates for the year', () => {
const selectedDates = service.getSelectedDatesForYear();
describe('getSelectedDatesForPeriod', () => {
it('should return selected dates for the period', () => {
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
expect(selectedDates).toBeDefined();
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
});
it('should include weekends', () => {
const selectedDates = service.getSelectedDatesForYear();
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
const hasWeekend = selectedDates._datesSelected.some(
(date) => date.type === DayType.WEEKEND,
);
@@ -86,7 +92,10 @@ describe('CalendarService', () => {
});
it('should include holidays', () => {
const selectedDates = service.getSelectedDatesForYear();
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
const hasHoliday = selectedDates._datesSelected.some(
(date) => date.type === DayType.CLOSED_DAY,
);
@@ -94,7 +103,10 @@ describe('CalendarService', () => {
});
it('should include today', () => {
const selectedDates = service.getSelectedDatesForYear();
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
const hasToday = selectedDates._datesSelected.some(
(date) => date.type === DayType.TODAY,
);
@@ -232,7 +244,16 @@ describe('SelectedDates', () => {
const applyTimeWindowRulesSpy = vi.spyOn(selectedDates, 'applyTimeWindowRules');
const applyIfThenRulesSpy = vi.spyOn(selectedDates, 'applyIfThenRules');
await selectedDates.optimizeVacations(vacationDays, rules, calendarService);
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
await selectedDates.optimizeVacations(
vacationDays,
rules,
calendarService,
startDate,
endDate,
);
expect(applyIfThenRulesSpy.mock.calls.length).toBe(2);
expect(applyTimeWindowRulesSpy.mock.calls.length).toBe(1);
@@ -268,8 +289,8 @@ describe('SelectedDates', () => {
},
];
// Mock monthsInAYearFromNow to return a controlled range of days
vi.spyOn(calendarService, 'monthsInAYearFromNow').mockReturnValue([
// Mock monthsInPeriod to return a controlled range of days
vi.spyOn(calendarService, 'monthsInPeriod').mockReturnValue([
[
[
new Date(futureYear, 11, 20),
@@ -281,7 +302,15 @@ describe('SelectedDates', () => {
],
]);
selectedDates.applyTimeWindowRules(vacationDays, rules, calendarService);
const startDate = new Date(futureYear, 11, 20);
const endDate = new Date(futureYear, 11, 25);
selectedDates.applyTimeWindowRules(
vacationDays,
rules,
calendarService,
startDate,
endDate,
);
// Should have placed 3 days
const placedCount = selectedDates._datesSelected.filter(
@@ -331,7 +360,10 @@ describe('SelectedDates', () => {
),
);
selectedDates.applyIfThenRules(vacationDays, rules);
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 3);
selectedDates.applyIfThenRules(vacationDays, rules, startDate, endDate);
// Should have placed 2 CP days (e.g. Thu/Fri, Mon/Tue, or Fri/Mon)
const placedCP = selectedDates._datesSelected.filter(
@@ -377,7 +409,7 @@ describe('SelectedDates', () => {
},
];
vi.spyOn(calendarService, 'monthsInAYearFromNow').mockReturnValue([
vi.spyOn(calendarService, 'monthsInPeriod').mockReturnValue([
[
[
new Date(futureYear, 6, 1),
@@ -389,7 +421,15 @@ describe('SelectedDates', () => {
],
]);
selectedDates.applyTimeWindowRules(vacationDays, rules, calendarService);
const startDate = new Date(futureYear, 6, 1);
const endDate = new Date(futureYear, 6, 15);
selectedDates.applyTimeWindowRules(
vacationDays,
rules,
calendarService,
startDate,
endDate,
);
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
const placedRTT = selectedDates._datesSelected.filter(
@@ -448,7 +488,10 @@ describe('SelectedDates', () => {
),
);
selectedDates.applyIfThenRules(vacationDays, rules);
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 3);
selectedDates.applyIfThenRules(vacationDays, rules, startDate, endDate);
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
const placedRTT = selectedDates._datesSelected.filter(
+66 -67
View File
@@ -22,21 +22,9 @@ const allTypesOptions: HolidaysTypes.Options = {
export class CalendarService {
private dateCache = inject(DateCacheService);
// Cache for holiday timestamps
private holidayTimestampsCache: Set<number> | null = null;
private holidayCacheYear: number | null = null;
// Get selected dates from current date to the next year
getSelectedDatesFromNow(): SelectedDates {
const _holidays = this.getHolidays();
const selectedDates: SelectedDates = new SelectedDates();
// Logic to populate selectedDates from now until the next year
return selectedDates;
}
getSelectedDatesForYear(): SelectedDates {
const holidays = this.getHolidays();
const monthInAYear = this.monthsInAYearFromNow();
getSelectedDatesForPeriod(startDate: Date, endDate: Date): SelectedDates {
const holidays = this.getHolidaysForPeriod(startDate, endDate);
const monthInPeriod = this.monthsInPeriod(startDate, endDate);
const selectedDates: SelectedDates = new SelectedDates();
// Use DateCacheService to get today's date components
@@ -49,7 +37,7 @@ export class CalendarService {
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
for (const weeksInAMonth of monthInAYear) {
for (const weeksInAMonth of monthInPeriod) {
for (const daysInAWeek of weeksInAMonth) {
for (const day of daysInAWeek) {
if (day) {
@@ -101,24 +89,16 @@ export class CalendarService {
}
}
getHolidays(): Date[] {
// Cache holidays to avoid recalculation
const currentYear = this.dateCache.getCurrentYear();
if (this.holidayCacheYear === currentYear && this.holidayTimestampsCache) {
// Return timestamps directly for efficient lookup
return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts));
}
// Logic to return holidays for the given year
getHolidaysForPeriod(startDate: Date, endDate: Date): Date[] {
const startYear = startDate.getFullYear();
const endYear = endDate.getFullYear();
const holidaysDate: Date[] = [];
const hd = new Holidays('FR', allTypesOptions);
const holidays = hd.getHolidays(currentYear, lang);
const holidaysPlusOne = hd.getHolidays(currentYear + 1, lang);
holidays.push(...holidaysPlusOne);
const holidaysDate: Date[] = holidays.map((holiday) => new Date(holiday.date));
// Update cache
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
this.holidayCacheYear = currentYear;
for (let y = startYear; y <= endYear; y++) {
const yearHolidays = hd.getHolidays(y, lang);
holidaysDate.push(...yearHolidays.map((holiday) => new Date(holiday.date)));
}
return holidaysDate;
}
@@ -167,14 +147,15 @@ export class CalendarService {
return months;
}
monthsInAYearFromNow(): Year {
monthsInPeriod(startDate: Date, endDate: Date): 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));
const start = dayjs(startDate).startOf('month');
const end = dayjs(endDate).startOf('month');
let current = start;
while (current.isBefore(end) || current.isSame(end, 'month')) {
months.push(CalendarService.weeksInMonth(current.month(), current.year()));
current = current.add(1, 'month');
}
return months;
}
@@ -307,7 +288,6 @@ export class SelectedDates implements SelectedDateInterface {
// Constant to indicate no valid strategy was found
private static readonly NO_VALID_STRATEGY_HEURISTIC = -1;
private static readonly MAX_DAYS_WITH_SAME_HEURISTIC = 9;
update(): void {
this.datesSelected = this._datesSelected.slice();
@@ -549,6 +529,8 @@ export class SelectedDates implements SelectedDateInterface {
vacationDays: VacDay[],
rules: VacationRule[],
calendarService: CalendarService,
startDate: Date,
endDate: Date,
progressCallback?: (progress: number) => void,
): Promise<VacDay[]> {
// Calculate total days to process
@@ -560,9 +542,15 @@ export class SelectedDates implements SelectedDateInterface {
if (!rule.enabled) continue;
if (rule.type === RuleType.TIME_WINDOW) {
this.applyTimeWindowRules(vacationDays, [rule as TimeWindowRule], calendarService);
this.applyTimeWindowRules(
vacationDays,
[rule as TimeWindowRule],
calendarService,
startDate,
endDate,
);
} else if (rule.type === RuleType.IF_THEN) {
this.applyIfThenRules(vacationDays, [rule as IfThenRule]);
this.applyIfThenRules(vacationDays, [rule as IfThenRule], startDate, endDate);
}
}
@@ -598,7 +586,7 @@ export class SelectedDates implements SelectedDateInterface {
}
const vacDayToUse = sortedVacDays[0];
const result = this.lookForVacation(vacDayToUse, calendarService);
const result = this.lookForVacation(vacDayToUse, calendarService, startDate, endDate);
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
// Mark this VacDay as exhausted and try other vacation days
@@ -632,6 +620,8 @@ export class SelectedDates implements SelectedDateInterface {
vacationDays: VacDay[],
rules: TimeWindowRule[],
calendarService: CalendarService,
startDate: Date,
endDate: Date,
): void {
for (const rule of rules) {
let daysToPlace = rule.placeDays;
@@ -688,16 +678,24 @@ export class SelectedDates implements SelectedDateInterface {
};
const candidates: Date[] = [];
const daysOfYear = calendarService.monthsInAYearFromNow();
const daysOfYear = calendarService.monthsInPeriod(startDate, endDate);
const start = new Date(rule.startDate).getTime();
const end = new Date(rule.endDate).getTime();
const startLimit = startDate.getTime();
const endLimit = endDate.getTime();
for (const month of daysOfYear) {
for (const week of month) {
for (const day of week) {
if (day) {
const dayTime = day.getTime();
if (dayTime >= start && dayTime <= end && !this.isSelected(day)) {
if (
dayTime >= start &&
dayTime <= end &&
dayTime >= startLimit &&
dayTime <= endLimit &&
!this.isSelected(day)
) {
candidates.push(day);
}
}
@@ -751,8 +749,15 @@ export class SelectedDates implements SelectedDateInterface {
}
}
applyIfThenRules(vacationDays: VacDay[], rules: IfThenRule[]): void {
applyIfThenRules(
vacationDays: VacDay[],
rules: IfThenRule[],
startDate: Date,
endDate: Date,
): void {
const nowTime = new Date().setHours(0, 0, 0, 0);
const startLimit = Math.max(nowTime, startDate.getTime());
const endLimit = endDate.getTime();
const consumeDays = (count: number, type: SelectableDayType, useByDate: Date): boolean => {
let daysToConsume = count;
@@ -861,7 +866,10 @@ export class SelectedDates implements SelectedDateInterface {
for (const dates of placementOptions) {
const allValid = dates.every(
(d) => d.getTime() >= nowTime && !this.isSelected(d),
(d) =>
d.getTime() >= startLimit &&
d.getTime() <= endLimit &&
!this.isSelected(d),
);
if (!allValid) continue;
@@ -935,11 +943,15 @@ export class SelectedDates implements SelectedDateInterface {
lookForVacation(
vacDay: VacDay,
calendarService: CalendarService,
startDate: Date,
endDate: Date,
): { apply: () => void; heuristic: number } {
const daysOfYear = calendarService.monthsInAYearFromNow();
const daysOfYear = calendarService.monthsInPeriod(startDate, endDate);
const now = new Date();
now.setHours(0, 0, 0, 0);
const nowTime = now.getTime();
const startLimit = Math.max(nowTime, startDate.getTime());
const endLimit = endDate.getTime();
const expiryTime = vacDay.expiryDate.getTime();
// Preprocess: filter out past days, already selected days, and days after expiry date
@@ -950,7 +962,12 @@ export class SelectedDates implements SelectedDateInterface {
if (day) {
day.setHours(0, 0, 0, 0);
const dayTime = day.getTime();
if (dayTime >= nowTime && dayTime <= expiryTime && !this.isSelected(day)) {
if (
dayTime >= startLimit &&
dayTime <= endLimit &&
dayTime <= expiryTime &&
!this.isSelected(day)
) {
candidateDays.push(day);
}
}
@@ -967,7 +984,6 @@ export class SelectedDates implements SelectedDateInterface {
const tempDatesBackup = [...this._datesSelected];
let bestStrategy: { apply: () => void; heuristic: number; value: Date } | null = null;
let bestHeuristic = -Infinity;
let daysWithBestHeuristic = 0;
// Evaluate each candidate day
for (const day of candidateDays) {
@@ -984,7 +1000,6 @@ export class SelectedDates implements SelectedDateInterface {
// Track only if better than current best
if (heuristic > bestHeuristic) {
bestHeuristic = heuristic;
daysWithBestHeuristic = 1;
const capturedDay = day;
bestStrategy = {
apply: () => {
@@ -999,25 +1014,9 @@ export class SelectedDates implements SelectedDateInterface {
heuristic: heuristic,
value: capturedDay,
};
} else if (heuristic === bestHeuristic) {
daysWithBestHeuristic++;
}
}
// Check if too many days have the same best heuristic after evaluating all days
if (daysWithBestHeuristic > SelectedDates.MAX_DAYS_WITH_SAME_HEURISTIC) {
// Restore backup before stopping
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
// Stop optimization - let user decide manually
return {
apply: () => {},
heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC,
};
}
// Restore backup before returning
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { TranslationService } from './translation-service';
describe('TranslationService', () => {
let service: TranslationService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
service = TestBed.inject(TranslationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should get correct locale', () => {
expect(service.locale()).toBeDefined();
});
it('should set language if available', () => {
service.setLanguage('en');
expect(service.language()).toBe('en');
service.setLanguage('invalid-lang');
expect(service.language()).toBe('en'); // Should not change
});
it('should get language name', () => {
const enName = service.getLanguageName('en');
expect(enName).toBe('English');
});
it('should get language name fallback', () => {
const origIntl = global.Intl;
Object.defineProperty(global, 'Intl', {
value: {
DisplayNames: class {
of() {
return undefined;
}
},
},
writable: true,
});
expect(service.getLanguageName('test')).toBe('TEST');
Object.defineProperty(global, 'Intl', {
value: origIntl,
writable: true,
});
});
it('should get day type label', () => {
expect(service.getDayTypeLabel('cp')).toBeDefined();
expect(service.getDayTypeLabel('rtt')).toBeDefined();
expect(service.getDayTypeLabel('weekend')).toBeDefined();
expect(service.getDayTypeLabel('closed-day')).toBeDefined();
expect(service.getDayTypeLabel('other')).toBeDefined();
expect(service.getDayTypeLabel('today')).toBeDefined();
expect(service.getDayTypeLabel('any')).toBeDefined();
expect(service.getDayTypeLabel('unknown-type')).toBe('unknown-type');
});
});
+3
View File
@@ -17,6 +17,9 @@ export interface AppTranslations {
daySuffix: string;
daysSuffix: string;
expires: string;
choosePeriod: string;
startDate: string;
endDate: string;
};
dayTypes: {
weekend: string;
@@ -54,4 +54,24 @@ describe('UserInputService', () => {
expect(newDay.numberOfDays).toBe(1);
expect(newDay.expiryDate).toBeInstanceOf(Date);
});
it('should set start date', () => {
const newDate = new Date();
newDate.setFullYear(newDate.getFullYear() + 2);
service.setStartDate(newDate);
expect(service.startDate().getFullYear()).toBe(newDate.getFullYear());
});
it('should set end date', () => {
const newDate = new Date();
newDate.setFullYear(newDate.getFullYear() + 3);
service.setEndDate(newDate);
expect(service.endDate().getFullYear()).toBe(newDate.getFullYear());
});
it('should request computation', () => {
const initialValue = service.computeTriggerSignal();
service.requestComputation();
expect(service.computeTriggerSignal()).toBe(initialValue + 1);
});
});
+50 -6
View File
@@ -1,5 +1,6 @@
import { Injectable, signal } from '@angular/core';
import { Injectable, signal, computed, inject, Injector } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
import { storedSignal } from '../shared/utils/stored-signal';
@Injectable({
providedIn: 'root',
@@ -15,20 +16,19 @@ export class UserInputService {
id: crypto.randomUUID(),
type: SelectableDayType.CP,
numberOfDays: this.getRandomNumber(),
// today + 365 days
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
expiryDate: UserInputService.getOneYearFromTodayMidnight(),
},
{
id: crypto.randomUUID(),
type: SelectableDayType.RTT,
numberOfDays: this.getRandomNumber(),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
expiryDate: UserInputService.getOneYearFromTodayMidnight(),
},
{
id: crypto.randomUUID(),
type: SelectableDayType.OTHER,
numberOfDays: this.getRandomNumber(),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
expiryDate: UserInputService.getOneYearFromTodayMidnight(),
},
];
}
@@ -38,7 +38,7 @@ export class UserInputService {
id: crypto.randomUUID(),
type: SelectableDayType.CP,
numberOfDays: 1,
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
expiryDate: this.endDate(),
};
}
@@ -46,6 +46,50 @@ export class UserInputService {
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
readonly computeTriggerSignal = signal(0);
private readonly injector = inject(Injector);
private static getTodayMidnight(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
private static getOneYearFromTodayMidnight(): Date {
const d = new Date();
d.setFullYear(d.getFullYear() + 1);
d.setHours(0, 0, 0, 0);
return d;
}
readonly startDateString = storedSignal<string>(
'timeframe.startDate',
UserInputService.getTodayMidnight().toISOString(),
this.injector,
);
readonly endDateString = storedSignal<string>(
'timeframe.endDate',
UserInputService.getOneYearFromTodayMidnight().toISOString(),
this.injector,
);
readonly startDate = computed(() => new Date(this.startDateString()));
readonly endDate = computed(() => new Date(this.endDateString()));
public setStartDate(date: Date | null): void {
if (!date) return;
const d = new Date(date);
d.setHours(0, 0, 0, 0);
this.startDateString.set(d.toISOString());
}
public setEndDate(date: Date | null): void {
if (!date) return;
const d = new Date(date);
d.setHours(0, 0, 0, 0);
this.endDateString.set(d.toISOString());
}
public requestComputation(): void {
this.computeTriggerSignal.update((count) => count + 1);
}
@@ -0,0 +1,58 @@
import { TestBed } from '@angular/core/testing';
import { Injector, provideZonelessChangeDetection } from '@angular/core';
import { storedSignal } from './stored-signal';
import { vi } from 'vitest';
describe('storedSignal', () => {
let injector: Injector;
const testKey = 'test.key';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
injector = TestBed.inject(Injector);
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
it('should return initial value when no local storage value exists', () => {
const signal = storedSignal(testKey, 'initial', injector);
expect(signal()).toBe('initial');
});
it('should read from local storage if available', () => {
localStorage.setItem(testKey, JSON.stringify('stored'));
const signal = storedSignal(testKey, 'initial', injector);
expect(signal()).toBe('stored');
});
it('should fallback to initial value and remove key on invalid JSON', () => {
localStorage.setItem(testKey, 'invalid-json');
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const signal = storedSignal(testKey, 'initial', injector);
expect(signal()).toBe('initial');
expect(consoleSpy).toHaveBeenCalled();
expect(localStorage.getItem(testKey)).toBeNull();
});
it('should handle missing local storage gracefully', () => {
const originalLocalStorage = global.localStorage;
Object.defineProperty(global, 'localStorage', { value: undefined, writable: true });
const signal = storedSignal(testKey, 'initial', injector);
expect(signal()).toBe('initial');
// Restore
Object.defineProperty(global, 'localStorage', {
value: originalLocalStorage,
writable: true,
});
});
});
+4 -1
View File
@@ -12,7 +12,10 @@
"chooseDate": "Datum auswählen",
"daySuffix": "Tag",
"daysSuffix": "Tage",
"expires": "abl."
"expires": "abl.",
"choosePeriod": "Optimierungszeitraum",
"startDate": "Startdatum",
"endDate": "Enddatum"
},
"dayTypes": {
"weekend": "Wochenende",
+4 -1
View File
@@ -12,7 +12,10 @@
"chooseDate": "Choose a date",
"daySuffix": "day",
"daysSuffix": "days",
"expires": "exp."
"expires": "exp.",
"choosePeriod": "Optimization Period",
"startDate": "Start date",
"endDate": "End date"
},
"dayTypes": {
"weekend": "Weekend",
+4 -1
View File
@@ -12,7 +12,10 @@
"chooseDate": "Elija una fecha",
"daySuffix": "día",
"daysSuffix": "días",
"expires": "exp."
"expires": "exp.",
"choosePeriod": "Período de optimización",
"startDate": "Fecha de inicio",
"endDate": "Fecha de fin"
},
"dayTypes": {
"weekend": "Fin de semana",
+4 -1
View File
@@ -12,7 +12,10 @@
"chooseDate": "Choisissez une date",
"daySuffix": "jour",
"daysSuffix": "jours",
"expires": "exp."
"expires": "exp.",
"choosePeriod": "Période d'optimisation",
"startDate": "Date de début",
"endDate": "Date de fin"
},
"dayTypes": {
"weekend": "Weekend",