Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cf2833683 | ||
|
|
fb3f0ba800 |
No files matched your search
@@ -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`
|
||||||
@@ -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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@for (month of months; track $index) {
|
@for (item of monthsAndYears(); track $index) {
|
||||||
<mat-card class="card">
|
<mat-card class="card">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<app-calendar-month
|
<app-calendar-month
|
||||||
[monthIndex]="month"
|
[monthIndex]="item.month"
|
||||||
[year]="getYear(month)"
|
[year]="item.year"
|
||||||
[selectedDates]="selectedDates"
|
[selectedDates]="selectedDates"
|
||||||
></app-calendar-month>
|
></app-calendar-month>
|
||||||
</mat-card-content>
|
</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 { UserInputService } from '../../services/user-input-service';
|
||||||
import { MatCard, MatCardContent } from '@angular/material/card';
|
import { MatCard, MatCardContent } from '@angular/material/card';
|
||||||
import { CalendarMonth } from '../calendar-month/calendar-month';
|
import { CalendarMonth } from '../calendar-month/calendar-month';
|
||||||
@@ -42,7 +42,10 @@ export class CalendarYear {
|
|||||||
effect(() => {
|
effect(() => {
|
||||||
this.userInput.computeTriggerSignal();
|
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 vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
||||||
const currentRules = untracked(() => this.ruleService.rules);
|
const currentRules = untracked(() => this.ruleService.rules);
|
||||||
|
|
||||||
@@ -61,6 +64,8 @@ export class CalendarYear {
|
|||||||
vacationDaysCopy,
|
vacationDaysCopy,
|
||||||
currentRules,
|
currentRules,
|
||||||
this.calendarService,
|
this.calendarService,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
(progress: number) => {
|
(progress: number) => {
|
||||||
this.loadingProgress.set(progress);
|
this.loadingProgress.set(progress);
|
||||||
},
|
},
|
||||||
@@ -76,11 +81,22 @@ export class CalendarYear {
|
|||||||
|
|
||||||
protected selectedDates: SelectedDates = new SelectedDates();
|
protected selectedDates: SelectedDates = new SelectedDates();
|
||||||
|
|
||||||
getYear(monthIndex: number): number {
|
protected monthsAndYears = computed(() => {
|
||||||
const offset = this.currentMonth <= monthIndex ? 0 : 1;
|
const start = this.userInput.startDate();
|
||||||
return this.currentYear + offset;
|
const end = this.userInput.endDate();
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// months starting from actual date - cache this array
|
return result;
|
||||||
protected months: number[] = Array.from({ length: 12 }, (_, i) => (this.currentMonth + i) % 12);
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,31 @@
|
|||||||
<div class="user-input">
|
<div class="user-input">
|
||||||
<p>{{ tr().app.description }}</p>
|
<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">
|
<div class="listOfDays">
|
||||||
@for (day of daysOff; track day.id) {
|
@for (day of daysOff; track day.id) {
|
||||||
<div class="day-input-wrapper" animate.enter="day-enter" animate.leave="day-leave">
|
<div class="day-input-wrapper" animate.enter="day-enter" animate.leave="day-leave">
|
||||||
|
|||||||
@@ -23,6 +23,22 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
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 {
|
.listOfDays {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
|
|||||||
import { UserInput } from './user-input';
|
import { UserInput } from './user-input';
|
||||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
|
import { provideNativeDateAdapter } from '@angular/material/core';
|
||||||
|
|
||||||
describe('UserInput', () => {
|
describe('UserInput', () => {
|
||||||
let component: UserInput;
|
let component: UserInput;
|
||||||
@@ -12,7 +13,11 @@ describe('UserInput', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [UserInput],
|
imports: [UserInput],
|
||||||
providers: [provideZonelessChangeDetection(), provideAnimationsAsync()],
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideAnimationsAsync(),
|
||||||
|
provideNativeDateAdapter(),
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(UserInput);
|
fixture = TestBed.createComponent(UserInput);
|
||||||
@@ -43,4 +48,24 @@ describe('UserInput', () => {
|
|||||||
component.removeFromList(0);
|
component.removeFromList(0);
|
||||||
expect(userInputService.vacationNumberSignal().length).toBe(initialLength - 1);
|
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 { Component, computed, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
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 { UserInputService } from '../../services/user-input-service';
|
||||||
import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
||||||
import { RulesPanel } from '../rules-panel/rules-panel';
|
import { RulesPanel } from '../rules-panel/rules-panel';
|
||||||
@@ -14,6 +16,8 @@ import { TranslationService } from '../../services/translation-service';
|
|||||||
selector: 'app-user-input',
|
selector: 'app-user-input',
|
||||||
imports: [
|
imports: [
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatFormFieldModule,
|
||||||
CalendarSettings,
|
CalendarSettings,
|
||||||
RulesPanel,
|
RulesPanel,
|
||||||
MatCard,
|
MatCard,
|
||||||
@@ -86,4 +90,20 @@ export class UserInput {
|
|||||||
public triggerComputation() {
|
public triggerComputation() {
|
||||||
this.userInputService.requestComputation();
|
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 {
|
.numberInput {
|
||||||
width: 80px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
|
import { MatDatepickerInputEvent } from '@angular/material/datepicker';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
import { DayInput } from './day-input';
|
import { DayInput } from './day-input';
|
||||||
import { SelectableDayType, VacDay } from '../../../services/calendar-service';
|
import { SelectableDayType, VacDay } from '../../../services/calendar-service';
|
||||||
@@ -30,4 +32,23 @@ describe('DayInput', () => {
|
|||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -26,18 +26,15 @@ describe('CalendarService', () => {
|
|||||||
expect(service).toBeTruthy();
|
expect(service).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getHolidays', () => {
|
describe('getHolidaysForPeriod', () => {
|
||||||
it('should return an array of holidays', () => {
|
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(Array.isArray(holidays)).toBe(true);
|
||||||
expect(holidays.length).toBeGreaterThan(0);
|
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', () => {
|
describe('static date calculation methods', () => {
|
||||||
@@ -57,9 +54,12 @@ describe('CalendarService', () => {
|
|||||||
expect(months.length).toBe(12);
|
expect(months.length).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should calculate months from now', () => {
|
it('should calculate months in period', () => {
|
||||||
const months = service.monthsInAYearFromNow();
|
const startDate = new Date();
|
||||||
expect(months.length).toBe(12);
|
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', () => {
|
it('should return week number for a date', () => {
|
||||||
@@ -70,15 +70,21 @@ describe('CalendarService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getSelectedDatesForYear', () => {
|
describe('getSelectedDatesForPeriod', () => {
|
||||||
it('should return selected dates for the year', () => {
|
it('should return selected dates for the period', () => {
|
||||||
const selectedDates = service.getSelectedDatesForYear();
|
const startDate = new Date();
|
||||||
|
const endDate = new Date();
|
||||||
|
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||||
|
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
|
||||||
expect(selectedDates).toBeDefined();
|
expect(selectedDates).toBeDefined();
|
||||||
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
|
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include weekends', () => {
|
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(
|
const hasWeekend = selectedDates._datesSelected.some(
|
||||||
(date) => date.type === DayType.WEEKEND,
|
(date) => date.type === DayType.WEEKEND,
|
||||||
);
|
);
|
||||||
@@ -86,7 +92,10 @@ describe('CalendarService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should include holidays', () => {
|
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(
|
const hasHoliday = selectedDates._datesSelected.some(
|
||||||
(date) => date.type === DayType.CLOSED_DAY,
|
(date) => date.type === DayType.CLOSED_DAY,
|
||||||
);
|
);
|
||||||
@@ -94,7 +103,10 @@ describe('CalendarService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should include today', () => {
|
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(
|
const hasToday = selectedDates._datesSelected.some(
|
||||||
(date) => date.type === DayType.TODAY,
|
(date) => date.type === DayType.TODAY,
|
||||||
);
|
);
|
||||||
@@ -232,7 +244,16 @@ describe('SelectedDates', () => {
|
|||||||
const applyTimeWindowRulesSpy = vi.spyOn(selectedDates, 'applyTimeWindowRules');
|
const applyTimeWindowRulesSpy = vi.spyOn(selectedDates, 'applyTimeWindowRules');
|
||||||
const applyIfThenRulesSpy = vi.spyOn(selectedDates, 'applyIfThenRules');
|
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(applyIfThenRulesSpy.mock.calls.length).toBe(2);
|
||||||
expect(applyTimeWindowRulesSpy.mock.calls.length).toBe(1);
|
expect(applyTimeWindowRulesSpy.mock.calls.length).toBe(1);
|
||||||
@@ -268,8 +289,8 @@ describe('SelectedDates', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock monthsInAYearFromNow to return a controlled range of days
|
// Mock monthsInPeriod to return a controlled range of days
|
||||||
vi.spyOn(calendarService, 'monthsInAYearFromNow').mockReturnValue([
|
vi.spyOn(calendarService, 'monthsInPeriod').mockReturnValue([
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
new Date(futureYear, 11, 20),
|
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
|
// Should have placed 3 days
|
||||||
const placedCount = selectedDates._datesSelected.filter(
|
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)
|
// Should have placed 2 CP days (e.g. Thu/Fri, Mon/Tue, or Fri/Mon)
|
||||||
const placedCP = selectedDates._datesSelected.filter(
|
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),
|
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
|
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
|
||||||
const placedRTT = selectedDates._datesSelected.filter(
|
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
|
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
|
||||||
const placedRTT = selectedDates._datesSelected.filter(
|
const placedRTT = selectedDates._datesSelected.filter(
|
||||||
|
|||||||
@@ -22,21 +22,9 @@ const allTypesOptions: HolidaysTypes.Options = {
|
|||||||
export class CalendarService {
|
export class CalendarService {
|
||||||
private dateCache = inject(DateCacheService);
|
private dateCache = inject(DateCacheService);
|
||||||
|
|
||||||
// Cache for holiday timestamps
|
getSelectedDatesForPeriod(startDate: Date, endDate: Date): SelectedDates {
|
||||||
private holidayTimestampsCache: Set<number> | null = null;
|
const holidays = this.getHolidaysForPeriod(startDate, endDate);
|
||||||
private holidayCacheYear: number | null = null;
|
const monthInPeriod = this.monthsInPeriod(startDate, endDate);
|
||||||
|
|
||||||
// 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();
|
|
||||||
const selectedDates: SelectedDates = new SelectedDates();
|
const selectedDates: SelectedDates = new SelectedDates();
|
||||||
|
|
||||||
// Use DateCacheService to get today's date components
|
// 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)
|
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
|
||||||
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
|
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 daysInAWeek of weeksInAMonth) {
|
||||||
for (const day of daysInAWeek) {
|
for (const day of daysInAWeek) {
|
||||||
if (day) {
|
if (day) {
|
||||||
@@ -101,24 +89,16 @@ export class CalendarService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getHolidays(): Date[] {
|
getHolidaysForPeriod(startDate: Date, endDate: Date): Date[] {
|
||||||
// Cache holidays to avoid recalculation
|
const startYear = startDate.getFullYear();
|
||||||
const currentYear = this.dateCache.getCurrentYear();
|
const endYear = endDate.getFullYear();
|
||||||
if (this.holidayCacheYear === currentYear && this.holidayTimestampsCache) {
|
const holidaysDate: Date[] = [];
|
||||||
// Return timestamps directly for efficient lookup
|
|
||||||
return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logic to return holidays for the given year
|
|
||||||
const hd = new Holidays('FR', allTypesOptions);
|
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
|
for (let y = startYear; y <= endYear; y++) {
|
||||||
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
|
const yearHolidays = hd.getHolidays(y, lang);
|
||||||
this.holidayCacheYear = currentYear;
|
holidaysDate.push(...yearHolidays.map((holiday) => new Date(holiday.date)));
|
||||||
|
}
|
||||||
|
|
||||||
return holidaysDate;
|
return holidaysDate;
|
||||||
}
|
}
|
||||||
@@ -167,14 +147,15 @@ export class CalendarService {
|
|||||||
return months;
|
return months;
|
||||||
}
|
}
|
||||||
|
|
||||||
monthsInAYearFromNow(): Year {
|
monthsInPeriod(startDate: Date, endDate: Date): Year {
|
||||||
const months: Year = [];
|
const months: Year = [];
|
||||||
const currentMonthIndex = this.dateCache.getCurrentMonth();
|
const start = dayjs(startDate).startOf('month');
|
||||||
const currentYear = this.dateCache.getCurrentYear();
|
const end = dayjs(endDate).startOf('month');
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
const monthIndex = (currentMonthIndex + i) % 12;
|
let current = start;
|
||||||
const year = currentYear + Math.floor((currentMonthIndex + i) / 12);
|
while (current.isBefore(end) || current.isSame(end, 'month')) {
|
||||||
months.push(CalendarService.weeksInMonth(monthIndex, year));
|
months.push(CalendarService.weeksInMonth(current.month(), current.year()));
|
||||||
|
current = current.add(1, 'month');
|
||||||
}
|
}
|
||||||
return months;
|
return months;
|
||||||
}
|
}
|
||||||
@@ -307,7 +288,6 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
|
|
||||||
// Constant to indicate no valid strategy was found
|
// Constant to indicate no valid strategy was found
|
||||||
private static readonly NO_VALID_STRATEGY_HEURISTIC = -1;
|
private static readonly NO_VALID_STRATEGY_HEURISTIC = -1;
|
||||||
private static readonly MAX_DAYS_WITH_SAME_HEURISTIC = 9;
|
|
||||||
|
|
||||||
update(): void {
|
update(): void {
|
||||||
this.datesSelected = this._datesSelected.slice();
|
this.datesSelected = this._datesSelected.slice();
|
||||||
@@ -549,6 +529,8 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
vacationDays: VacDay[],
|
vacationDays: VacDay[],
|
||||||
rules: VacationRule[],
|
rules: VacationRule[],
|
||||||
calendarService: CalendarService,
|
calendarService: CalendarService,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
progressCallback?: (progress: number) => void,
|
progressCallback?: (progress: number) => void,
|
||||||
): Promise<VacDay[]> {
|
): Promise<VacDay[]> {
|
||||||
// Calculate total days to process
|
// Calculate total days to process
|
||||||
@@ -560,9 +542,15 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
if (!rule.enabled) continue;
|
if (!rule.enabled) continue;
|
||||||
|
|
||||||
if (rule.type === RuleType.TIME_WINDOW) {
|
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) {
|
} 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 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) {
|
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
|
||||||
// Mark this VacDay as exhausted and try other vacation days
|
// Mark this VacDay as exhausted and try other vacation days
|
||||||
@@ -632,6 +620,8 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
vacationDays: VacDay[],
|
vacationDays: VacDay[],
|
||||||
rules: TimeWindowRule[],
|
rules: TimeWindowRule[],
|
||||||
calendarService: CalendarService,
|
calendarService: CalendarService,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
): void {
|
): void {
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
let daysToPlace = rule.placeDays;
|
let daysToPlace = rule.placeDays;
|
||||||
@@ -688,16 +678,24 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const candidates: Date[] = [];
|
const candidates: Date[] = [];
|
||||||
const daysOfYear = calendarService.monthsInAYearFromNow();
|
const daysOfYear = calendarService.monthsInPeriod(startDate, endDate);
|
||||||
const start = new Date(rule.startDate).getTime();
|
const start = new Date(rule.startDate).getTime();
|
||||||
const end = new Date(rule.endDate).getTime();
|
const end = new Date(rule.endDate).getTime();
|
||||||
|
const startLimit = startDate.getTime();
|
||||||
|
const endLimit = endDate.getTime();
|
||||||
|
|
||||||
for (const month of daysOfYear) {
|
for (const month of daysOfYear) {
|
||||||
for (const week of month) {
|
for (const week of month) {
|
||||||
for (const day of week) {
|
for (const day of week) {
|
||||||
if (day) {
|
if (day) {
|
||||||
const dayTime = day.getTime();
|
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);
|
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 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 => {
|
const consumeDays = (count: number, type: SelectableDayType, useByDate: Date): boolean => {
|
||||||
let daysToConsume = count;
|
let daysToConsume = count;
|
||||||
@@ -861,7 +866,10 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
|
|
||||||
for (const dates of placementOptions) {
|
for (const dates of placementOptions) {
|
||||||
const allValid = dates.every(
|
const allValid = dates.every(
|
||||||
(d) => d.getTime() >= nowTime && !this.isSelected(d),
|
(d) =>
|
||||||
|
d.getTime() >= startLimit &&
|
||||||
|
d.getTime() <= endLimit &&
|
||||||
|
!this.isSelected(d),
|
||||||
);
|
);
|
||||||
if (!allValid) continue;
|
if (!allValid) continue;
|
||||||
|
|
||||||
@@ -935,11 +943,15 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
lookForVacation(
|
lookForVacation(
|
||||||
vacDay: VacDay,
|
vacDay: VacDay,
|
||||||
calendarService: CalendarService,
|
calendarService: CalendarService,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
): { apply: () => void; heuristic: number } {
|
): { apply: () => void; heuristic: number } {
|
||||||
const daysOfYear = calendarService.monthsInAYearFromNow();
|
const daysOfYear = calendarService.monthsInPeriod(startDate, endDate);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setHours(0, 0, 0, 0);
|
now.setHours(0, 0, 0, 0);
|
||||||
const nowTime = now.getTime();
|
const nowTime = now.getTime();
|
||||||
|
const startLimit = Math.max(nowTime, startDate.getTime());
|
||||||
|
const endLimit = endDate.getTime();
|
||||||
const expiryTime = vacDay.expiryDate.getTime();
|
const expiryTime = vacDay.expiryDate.getTime();
|
||||||
|
|
||||||
// Preprocess: filter out past days, already selected days, and days after expiry date
|
// Preprocess: filter out past days, already selected days, and days after expiry date
|
||||||
@@ -950,7 +962,12 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
if (day) {
|
if (day) {
|
||||||
day.setHours(0, 0, 0, 0);
|
day.setHours(0, 0, 0, 0);
|
||||||
const dayTime = day.getTime();
|
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);
|
candidateDays.push(day);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -967,7 +984,6 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
const tempDatesBackup = [...this._datesSelected];
|
const tempDatesBackup = [...this._datesSelected];
|
||||||
let bestStrategy: { apply: () => void; heuristic: number; value: Date } | null = null;
|
let bestStrategy: { apply: () => void; heuristic: number; value: Date } | null = null;
|
||||||
let bestHeuristic = -Infinity;
|
let bestHeuristic = -Infinity;
|
||||||
let daysWithBestHeuristic = 0;
|
|
||||||
|
|
||||||
// Evaluate each candidate day
|
// Evaluate each candidate day
|
||||||
for (const day of candidateDays) {
|
for (const day of candidateDays) {
|
||||||
@@ -984,7 +1000,6 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
// Track only if better than current best
|
// Track only if better than current best
|
||||||
if (heuristic > bestHeuristic) {
|
if (heuristic > bestHeuristic) {
|
||||||
bestHeuristic = heuristic;
|
bestHeuristic = heuristic;
|
||||||
daysWithBestHeuristic = 1;
|
|
||||||
const capturedDay = day;
|
const capturedDay = day;
|
||||||
bestStrategy = {
|
bestStrategy = {
|
||||||
apply: () => {
|
apply: () => {
|
||||||
@@ -999,25 +1014,9 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
heuristic: heuristic,
|
heuristic: heuristic,
|
||||||
value: capturedDay,
|
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
|
// Restore backup before returning
|
||||||
this._datesSelected = [...tempDatesBackup];
|
this._datesSelected = [...tempDatesBackup];
|
||||||
this.datesSelected = this._datesSelected.slice();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,9 @@ export interface AppTranslations {
|
|||||||
daySuffix: string;
|
daySuffix: string;
|
||||||
daysSuffix: string;
|
daysSuffix: string;
|
||||||
expires: string;
|
expires: string;
|
||||||
|
choosePeriod: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
};
|
};
|
||||||
dayTypes: {
|
dayTypes: {
|
||||||
weekend: string;
|
weekend: string;
|
||||||
|
|||||||
@@ -54,4 +54,24 @@ describe('UserInputService', () => {
|
|||||||
expect(newDay.numberOfDays).toBe(1);
|
expect(newDay.numberOfDays).toBe(1);
|
||||||
expect(newDay.expiryDate).toBeInstanceOf(Date);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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 { SelectableDayType, VacDay } from './calendar-service';
|
||||||
|
import { storedSignal } from '../shared/utils/stored-signal';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -15,20 +16,19 @@ export class UserInputService {
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: SelectableDayType.CP,
|
type: SelectableDayType.CP,
|
||||||
numberOfDays: this.getRandomNumber(),
|
numberOfDays: this.getRandomNumber(),
|
||||||
// today + 365 days
|
expiryDate: UserInputService.getOneYearFromTodayMidnight(),
|
||||||
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: SelectableDayType.RTT,
|
type: SelectableDayType.RTT,
|
||||||
numberOfDays: this.getRandomNumber(),
|
numberOfDays: this.getRandomNumber(),
|
||||||
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
expiryDate: UserInputService.getOneYearFromTodayMidnight(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: SelectableDayType.OTHER,
|
type: SelectableDayType.OTHER,
|
||||||
numberOfDays: this.getRandomNumber(),
|
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(),
|
id: crypto.randomUUID(),
|
||||||
type: SelectableDayType.CP,
|
type: SelectableDayType.CP,
|
||||||
numberOfDays: 1,
|
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 remainingVacationDaysSignal = signal<VacDay[]>([]);
|
||||||
readonly computeTriggerSignal = signal(0);
|
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 {
|
public requestComputation(): void {
|
||||||
this.computeTriggerSignal.update((count) => count + 1);
|
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
@@ -12,7 +12,10 @@
|
|||||||
"chooseDate": "Datum auswählen",
|
"chooseDate": "Datum auswählen",
|
||||||
"daySuffix": "Tag",
|
"daySuffix": "Tag",
|
||||||
"daysSuffix": "Tage",
|
"daysSuffix": "Tage",
|
||||||
"expires": "abl."
|
"expires": "abl.",
|
||||||
|
"choosePeriod": "Optimierungszeitraum",
|
||||||
|
"startDate": "Startdatum",
|
||||||
|
"endDate": "Enddatum"
|
||||||
},
|
},
|
||||||
"dayTypes": {
|
"dayTypes": {
|
||||||
"weekend": "Wochenende",
|
"weekend": "Wochenende",
|
||||||
|
|||||||
+4
-1
@@ -12,7 +12,10 @@
|
|||||||
"chooseDate": "Choose a date",
|
"chooseDate": "Choose a date",
|
||||||
"daySuffix": "day",
|
"daySuffix": "day",
|
||||||
"daysSuffix": "days",
|
"daysSuffix": "days",
|
||||||
"expires": "exp."
|
"expires": "exp.",
|
||||||
|
"choosePeriod": "Optimization Period",
|
||||||
|
"startDate": "Start date",
|
||||||
|
"endDate": "End date"
|
||||||
},
|
},
|
||||||
"dayTypes": {
|
"dayTypes": {
|
||||||
"weekend": "Weekend",
|
"weekend": "Weekend",
|
||||||
|
|||||||
+4
-1
@@ -12,7 +12,10 @@
|
|||||||
"chooseDate": "Elija una fecha",
|
"chooseDate": "Elija una fecha",
|
||||||
"daySuffix": "día",
|
"daySuffix": "día",
|
||||||
"daysSuffix": "días",
|
"daysSuffix": "días",
|
||||||
"expires": "exp."
|
"expires": "exp.",
|
||||||
|
"choosePeriod": "Período de optimización",
|
||||||
|
"startDate": "Fecha de inicio",
|
||||||
|
"endDate": "Fecha de fin"
|
||||||
},
|
},
|
||||||
"dayTypes": {
|
"dayTypes": {
|
||||||
"weekend": "Fin de semana",
|
"weekend": "Fin de semana",
|
||||||
|
|||||||
+4
-1
@@ -12,7 +12,10 @@
|
|||||||
"chooseDate": "Choisissez une date",
|
"chooseDate": "Choisissez une date",
|
||||||
"daySuffix": "jour",
|
"daySuffix": "jour",
|
||||||
"daysSuffix": "jours",
|
"daysSuffix": "jours",
|
||||||
"expires": "exp."
|
"expires": "exp.",
|
||||||
|
"choosePeriod": "Période d'optimisation",
|
||||||
|
"startDate": "Date de début",
|
||||||
|
"endDate": "Date de fin"
|
||||||
},
|
},
|
||||||
"dayTypes": {
|
"dayTypes": {
|
||||||
"weekend": "Weekend",
|
"weekend": "Weekend",
|
||||||
|
|||||||
Reference in new issue
Block a user