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>
|
||||
}
|
||||
@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 });
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user