Files
vac-optimizer/src/app/services/user-input-service.ts
T
nessarandClaude Opus 5.5 5cb0b93339 refactor(planning): give planning calculations one state owner
PlanningCalculationService now builds the submitted request, owns the
pending and accepted calculation state, and delivers explanations for
the accepted calculation only. The duration estimator reads the same
request. The calendar and leave-balance summary consume the accepted
outcome instead of coordinating writable state on the input service.

Refs #275

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

184 lines
5.8 KiB
TypeScript

import { Injectable, signal } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
import dayjs from 'dayjs';
import {
DeclaredNonWorkingDay,
isWorkingPlanningDate,
parseDeclaredDays,
} from './declared-non-working-days';
import type { LeaveType } from './leave-planner.service';
@Injectable({
providedIn: 'root',
})
export class UserInputService {
private readonly declaredDays = signal(this.restoreDeclaredDays());
readonly declaredDaysSignal = this.declaredDays.asReadonly();
readonly declarationEntrySignal = signal(false);
readonly declaredTypeSignal = signal<LeaveType | null>('cp');
private restoreDeclaredDays(): DeclaredNonWorkingDay[] {
try {
const saved =
typeof localStorage === 'undefined'
? null
: localStorage.getItem('declaredNonWorkingDays');
return saved === null ? [] : parseDeclaredDays(JSON.parse(saved));
} catch (error) {
console.warn('Failed to restore declared non-working days from localStorage.', error);
try {
localStorage.removeItem('declaredNonWorkingDays');
} catch {
// Browser storage can be unavailable; keep calendar entry usable.
}
return [];
}
}
toggleDeclaredDay(date: string, type: LeaveType): void {
this.toggleDeclaredDays([date], type);
}
toggleDeclaredDays(dates: readonly string[], type: LeaveType | null): void {
const workingDates = new Set(dates.filter(isWorkingPlanningDate));
if (!workingDates.size) return;
this.declaredDays.update((days) => {
const existing = new Map(days.map((day) => [day.date, day.type]));
const others = days.filter((day) => !workingDates.has(day.date));
return [
...others,
...(type
? [...workingDates]
.filter((date) => existing.get(date) !== type)
.map((date) => ({ date, type }))
: []),
].sort((a, b) => a.date.localeCompare(b.date));
});
try {
if (typeof localStorage !== 'undefined')
localStorage.setItem('declaredNonWorkingDays', JSON.stringify(this.declaredDays()));
} catch (error) {
console.warn('Failed to save declared non-working days to localStorage.', error);
}
}
private restoreDate(value: unknown): Date {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
const date = new Date(`${value}T00:00:00`);
if (dayjs(date).format('YYYY-MM-DD') === value) return date;
}
throw new Error('Unsupported saved balance date.');
}
private getSavedValues(): VacDay[] | undefined {
let stored: string | null = null;
try {
if (typeof localStorage === 'undefined') return;
stored = localStorage.getItem('leaveBalances');
if (stored === null) return;
const saved: unknown = JSON.parse(stored);
if (!Array.isArray(saved)) throw new Error('Saved leave balances must be a list.');
return saved.map((value: unknown) => {
if (typeof value !== 'object' || value === null)
throw new Error('Unsupported saved balance record.');
const { id, type, numberOfDays, availableDate, expiryDate } = value as Record<
string,
unknown
>;
if (
typeof id !== 'string' ||
typeof numberOfDays !== 'number' ||
!Number.isFinite(numberOfDays) ||
(type !== SelectableDayType.CP &&
type !== SelectableDayType.RTT &&
type !== SelectableDayType.OTHER)
)
throw new Error('Unsupported saved balance record.');
return {
id,
type,
numberOfDays,
availableDate:
availableDate === undefined ? undefined : this.restoreDate(availableDate),
expiryDate: this.restoreDate(expiryDate),
};
});
} catch (error) {
console.warn('Failed to restore leave balances from localStorage.', error);
if (stored !== null) {
try {
localStorage.removeItem('leaveBalances');
} catch {
// Keep the random fallback usable when browser storage is unavailable.
}
}
}
return undefined;
}
private getRandomNumber(): number {
return Math.floor(Math.random() * 11) + 10;
}
private getInitialValues(): VacDay[] {
const availableDate = new Date();
return [
{
id: crypto.randomUUID(),
type: SelectableDayType.CP,
numberOfDays: this.getRandomNumber(),
availableDate,
// today + 365 days
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
{
id: crypto.randomUUID(),
type: SelectableDayType.RTT,
numberOfDays: this.getRandomNumber(),
availableDate: new Date(availableDate),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
{
id: crypto.randomUUID(),
type: SelectableDayType.OTHER,
numberOfDays: this.getRandomNumber(),
availableDate: new Date(availableDate),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
];
}
public createVacationDay(): VacDay {
return {
id: crypto.randomUUID(),
type: SelectableDayType.CP,
numberOfDays: 1,
availableDate: new Date(),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
};
}
readonly vacationNumberSignal = signal<VacDay[]>(
this.getSavedValues() ?? this.getInitialValues(),
);
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
this.vacationNumberSignal.update(updater);
try {
if (typeof localStorage === 'undefined') return;
// Store calendar dates, so restoring them does not depend on a UTC offset.
const balances = this.vacationNumberSignal().map((balance) => ({
id: balance.id,
type: balance.type,
numberOfDays: balance.numberOfDays,
availableDate: balance.availableDate
? dayjs(balance.availableDate).format('YYYY-MM-DD')
: undefined,
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
}));
localStorage.setItem('leaveBalances', JSON.stringify(balances));
} catch (error) {
console.warn('Failed to save leave balances to localStorage.', error);
}
}
}