Files
vac-optimizer/src/app/components/calendar-month/calendar-month.ts
T
nessarandClaude Opus 5.5 502d561f6f refactor(calendar): encapsulate calendar projection and selection ranges
CalendarService.project returns the covered months together with the
projected selections, so month enumeration and selected-day rendering
share one projection horizon. SelectedDates is built once from the
complete range list and groups connected periods internally, exposing
read-only selection, boundary and day-type queries.

In declaration entry, months reached by adjacent declared or
non-working days are now shown, matching the projected selections.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:18:33 +02:00

203 lines
5.8 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output,
inject,
} from '@angular/core';
import { CalendarService, Month, SelectedDates } from '../../services/calendar-service';
import { MatTooltip } from '@angular/material/tooltip';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { DateCacheService } from '../../services/date-cache.service';
import { TranslationService } from '../../services/translation-service';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import type { LeaveType } from '../../services/leave-planner.service';
export interface DeclarationPreview {
dates: ReadonlySet<string>;
type: LeaveType | null;
}
@Component({
selector: 'app-calendar-month',
imports: [MatTooltip],
templateUrl: './calendar-month.html',
styleUrl: './calendar-month.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CalendarMonth implements OnInit {
protected calendarSettingsService = inject(CalendarSettingsService);
private dateCache = inject(DateCacheService);
protected translationService = inject(TranslationService);
@Input({ required: true })
monthIndex!: number;
@Input({ required: true })
year!: number;
get tr() {
return this.translationService.tr;
}
// Use DateCacheService instead of creating Date objects
getNow(): Date {
return this.dateCache.getNow();
}
currentMonth: Date = new Date();
weeksInAMonth: Month = [];
private dayFormatterLocale = '';
private dayFormatter?: Intl.DateTimeFormat;
@Input({ required: true })
selectedDates!: SelectedDates;
@Input()
horizonStart?: string;
@Input()
horizonEnd?: string;
@Input()
declarationEntry = false;
@Input()
declaredDays: Readonly<Record<string, LeaveType>> = {};
@Input()
declarationPreview: DeclarationPreview | null = null;
getDeclaredType(date: string): LeaveType | undefined {
const current = this.declaredDays[date];
if (!this.declarationPreview?.dates.has(date)) return current;
const selected = this.declarationPreview.type;
return current === selected ? undefined : (selected ?? undefined);
}
getDayClasses(day: Date): string {
const classes = this.selectedDates.getClasses(day);
return this.declarationPreview?.dates.has(this.getDateKey(day))
? `${classes.replace(/\b(cp|rtt|other)\b/g, '')} ${this.getDeclaredType(this.getDateKey(day)) ?? ''}`
: classes;
}
isDateDisabled(day: Date): boolean {
return (
!this.isInHorizon(day) ||
(this.declarationEntry && !isWorkingPlanningDate(this.getDateKey(day)))
);
}
declaredTypeLabel(type: LeaveType): string {
return type === 'cp' ? 'CP' : this.translationService.getDayTypeLabel(type);
}
@Input()
currentDate: string | null = null;
@Input()
explainedPeriod?: { start: string; end: string };
@Output()
readonly dateSelected = new EventEmitter<string>();
getDateKey(day: Date): string {
return [
String(day.getFullYear()).padStart(4, '0'),
String(day.getMonth() + 1).padStart(2, '0'),
String(day.getDate()).padStart(2, '0'),
].join('-');
}
isInHorizon(day: Date): boolean {
const date = this.getDateKey(day);
return (
(!this.horizonStart || date >= this.horizonStart) &&
(!this.horizonEnd || date < this.horizonEnd)
);
}
isInExplainedPeriod(day: Date): boolean {
const date = this.getDateKey(day);
return (
!!this.explainedPeriod &&
date >= this.explainedPeriod.start &&
date <= this.explainedPeriod.end
);
}
getDayAriaLabel(day: Date, tooltip: string | null = this.getDayTooltip(day)): string {
const locale = this.translationService.locale();
if (!this.dayFormatter || locale !== this.dayFormatterLocale) {
this.dayFormatterLocale = locale;
this.dayFormatter = new Intl.DateTimeFormat(locale, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
const parts = [this.dayFormatter.format(day)];
if (tooltip) parts.push(tooltip);
if (this.getDateKey(day) === this.currentDate)
parts.push(this.translationService.tr().planning.currentPlacement);
if (this.isInExplainedPeriod(day))
parts.push(this.translationService.tr().planning.explainedPeriod);
if (!this.isInHorizon(day))
parts.push(this.translationService.tr().planning.outsideHorizon);
return parts.join('. ');
}
getMonthName(): string {
const locale = this.translationService.locale();
const tempDate = new Date(this.year, this.monthIndex, 1);
const monthName = tempDate.toLocaleString(locale, { month: 'long' });
return monthName.charAt(0).toUpperCase() + monthName.slice(1);
}
getWeekdayLetters(): string[] {
// January 5, 2026 is a Monday
const baseDate = new Date(2026, 0, 5);
const locale = this.translationService.locale();
const letters: string[] = [];
for (let i = 0; i < 7; i++) {
const date = new Date(baseDate);
date.setDate(baseDate.getDate() + i);
const letter = date.toLocaleString(locale, { weekday: 'narrow' });
letters.push(letter.toUpperCase());
}
return letters;
}
getWeekNumber(week: (Date | null)[]): number | null {
for (const day of week) {
if (day) {
return CalendarService.getWeekNumber(day);
}
}
return null;
}
getDayTooltip(day: Date): string | null {
const previewed = this.declarationPreview?.dates.has(this.getDateKey(day));
const tooltips: string[] = this.selectedDates
.dayTypes(day)
.filter((type) => !previewed || !['cp', 'rtt', 'other'].includes(type))
.map((type) => this.translationService.getDayTypeLabel(type));
const declaredType = this.getDeclaredType(this.getDateKey(day));
if (declaredType)
tooltips.push(
`${this.tr().bookedDays.booked} (${this.declaredTypeLabel(declaredType)}). ${this.tr().bookedDays.fixed}`,
);
return tooltips.length > 0 ? tooltips.join(' + ') : null;
}
ngOnInit(): void {
this.weeksInAMonth = CalendarService.weeksInMonth(this.monthIndex, this.year);
}
}