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>
203 lines
5.8 KiB
TypeScript
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);
|
|
}
|
|
}
|