UI improvments

This commit is contained in:
nessar committed 2026-10-02 15:05:30 +02:00
1 parent c9dbe82b79
commit 0ce33cc9d6
106 files changed
+72934 -1675

No files matched your search

+243 -10
View File
@@ -1,7 +1,25 @@
import { Component, computed, effect, inject, signal, untracked } from '@angular/core';
import {
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
signal,
TemplateRef,
untracked,
viewChild,
} from '@angular/core';
import { JsonPipe, NgTemplateOutlet } from '@angular/common';
import { MatCard, MatCardContent } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { DateAdapter } from '@angular/material/core';
import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month';
import { CalendarSettings } from '../calendar-settings/calendar-settings';
import {
@@ -23,6 +41,7 @@ import {
PlanningCalculationContext,
PlanningExplanation,
PlanningObjective,
PlanningObjectiveVector,
PlanningOutcome,
PlanningReason,
PlanningTrace,
@@ -37,6 +56,12 @@ type TraceMoment = 'before' | 'after';
@Component({
selector: 'app-calendar-year',
imports: [
JsonPipe,
NgTemplateOutlet,
MatDialogContent,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
MatCard,
CalendarMonth,
MatCardContent,
@@ -46,11 +71,18 @@ type TraceMoment = 'before' | 'after';
],
templateUrl: './calendar-year.html',
styleUrl: './calendar-year.scss',
providers: DAY_FIRST_DATE_PROVIDERS,
})
export class CalendarYear {
private readonly userInput = inject(UserInputService);
private readonly calendarService = inject(CalendarService);
private readonly ruleService = inject(RuleService);
private readonly dialog = inject(MatDialog);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
private readonly destroyRef = inject(DestroyRef);
private readonly comparisonTemplate =
viewChild.required<TemplateRef<unknown>>('dateComparisonDialog');
private comparisonDialog: MatDialogRef<unknown> | null = null;
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = signal(true);
@@ -62,11 +94,15 @@ export class CalendarYear {
protected readonly traceLoading = signal(false);
protected readonly traceResolved = signal(false);
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(true);
protected readonly planDetailsOpen = signal(false);
protected readonly traceOpen = signal(false);
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null);
protected readonly selectedCounterfactualDate = signal<string | null>(null);
protected readonly counterfactualLoading = signal(false);
protected readonly counterfactualError = signal<string | null>(null);
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal<Date | null>(null);
protected readonly tracePhaseIndex = signal(0);
protected readonly traceDecisionIndex = signal(0);
protected readonly traceCandidatePage = signal(0);
@@ -126,6 +162,70 @@ export class CalendarYear {
),
);
protected readonly nearbyComparisonDates = computed(() => {
const analysis = this.counterfactual();
if (!analysis) return [];
const original = Date.parse(analysis.placement.date);
return analysis.results
.filter(
(result) => result.status === 'eligible' && result.date !== analysis.placement.date,
)
.sort(
(a, b) =>
Math.abs(Date.parse(a.date) - original) -
Math.abs(Date.parse(b.date) - original) || a.date.localeCompare(b.date),
)
.slice(0, 3);
});
protected changeComparisonDate(event: MatDatepickerInputEvent<Date>): void {
const selected = event.value;
// Material returns local midnight; converting to UTC can change the calendar day.
const date = selected
? [
selected.getFullYear(),
String(selected.getMonth() + 1).padStart(2, '0'),
String(selected.getDate()).padStart(2, '0'),
].join('-')
: null;
const valid = !!this.counterfactual()?.results.some((result) => result.date === date);
this.invalidComparisonDate.set(!!(event.targetElement as HTMLInputElement).value && !valid);
if (valid && date) this.selectCalendarDate(date);
else this.selectedCounterfactualDate.set(null);
}
protected readonly placementChanges = computed(() => {
const comparison = this.selectedExplanation()?.comparison;
return this.numericChanges(comparison?.before, comparison?.after);
});
protected readonly dateComparison = computed(() => {
const analysis = this.counterfactual();
const comparison = this.selectedCounterfactualResult()?.comparison;
if (!analysis || !comparison) return undefined;
// Final-plan alternatives share a removed-day baseline. Compare their restored
// plans; a trace candidate instead uses the exact state before its decision.
const before = this.traceReplay()
? comparison.before
: analysis.results.find((result) => result.date === analysis.placement.date)?.comparison
?.after;
return before
? {
before,
after: comparison.after,
firstDecisiveObjective: comparison.after.objectives.find(
(objective) =>
before.objectives.find((item) => item.id === objective.id)?.value !==
objective.value,
)?.id,
}
: undefined;
});
protected readonly dateChanges = computed(() =>
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
);
protected readonly traceState = computed(() => {
if (!this.traceReplay()) return undefined;
return this.replayTraceState(
@@ -294,6 +394,8 @@ export class CalendarYear {
});
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
this.destroyRef.onDestroy(() => this.clearCounterfactual());
effect(() => {
this.userInput.computeTriggerSignal();
this.resetCalculationState();
@@ -384,7 +486,8 @@ export class CalendarYear {
this.traceLoading.set(true);
this.traceResolved.set(false);
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(true);
this.planDetailsOpen.set(false);
this.traceOpen.set(false);
this.clearCounterfactual();
this.tracePhaseIndex.set(0);
this.traceDecisionIndex.set(0);
@@ -456,19 +559,34 @@ export class CalendarYear {
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
this.selectedPlacementIndex.set(selected);
this.traceReplay.set(false);
this.clearCounterfactual();
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
this.liveMessage.set(
`${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`,
);
void this.analyzeSelection(explanation.placements[selected].placement);
}
protected compareDates(): void {
const placement = this.selectedExplanation()?.placement;
if (placement) void this.analyzeSelection(placement);
}
protected togglePlanDetails(event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open;
if (open === this.planDetailsOpen()) return;
this.planDetailsOpen.set(open);
if (open) this.selectPlacement(this.selectedPlacementIndex());
else this.clearCounterfactual();
if (open) {
this.traceOpen.set(false);
this.selectPlacement(this.selectedPlacementIndex());
} else this.clearCounterfactual();
}
protected toggleTrace(event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open;
if (open === this.traceOpen()) return;
this.traceOpen.set(open);
if (open) this.planDetailsOpen.set(false);
this.showFinalPlan();
}
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
@@ -486,12 +604,22 @@ export class CalendarYear {
}
protected selectCalendarDate(date: string): void {
if (!this.traceReplay() && this.planDetailsOpen()) {
if (this.counterfactual()?.results.some((result) => result.date === date)) {
this.invalidComparisonDate.set(false);
this.comparisonDateInput.set(new Date(`${date}T00:00:00`));
}
if (!this.traceReplay() && !this.counterfactual() && !this.counterfactualLoading()) {
const index = this.planExplanation()?.placements.findIndex(
(explanation) => explanation.placement.date === date,
);
if (index !== undefined && index >= 0) {
this.planDetailsOpen.set(true);
this.traceOpen.set(false);
this.selectPlacement(index);
this.planDetails()?.nativeElement.scrollIntoView?.({ block: 'start' });
this.planDetails()
?.nativeElement.querySelector('summary')
?.focus({ preventScroll: true });
return;
}
}
@@ -629,13 +757,35 @@ export class CalendarYear {
this.counterfactual.set(null);
this.counterfactualError.set(null);
this.counterfactualLoading.set(true);
this.invalidComparisonDate.set(false);
if (!this.comparisonDialog) {
const dialog = this.dialog.open(this.comparisonTemplate(), {
width: '48rem',
maxWidth: 'calc(100vw - 2rem)',
maxHeight: 'calc(100dvh - 2rem)',
autoFocus: 'first-heading',
ariaLabelledBy: 'counterfactual-title',
ariaDescribedBy: 'comparison-intro',
});
this.comparisonDialog = dialog;
dialog.beforeClosed().subscribe(() => {
if (this.comparisonDialog === dialog) {
this.comparisonDialog = null;
this.clearCounterfactual();
}
});
}
try {
const result = await calculation.analyzeCounterfactual(selection);
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
if (result.kind === 'Counterfactual') {
this.counterfactual.set(result);
this.selectedCounterfactualDate.set(result.placement.date);
this.selectCalendarDate(
this.traceReplay()
? result.placement.date
: (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
);
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
@@ -647,12 +797,17 @@ export class CalendarYear {
}
}
private clearCounterfactual(): void {
protected clearCounterfactual(): void {
const dialog = this.comparisonDialog;
this.comparisonDialog = null;
dialog?.close();
this.counterfactualVersion++;
this.counterfactual.set(null);
this.selectedCounterfactualDate.set(null);
this.counterfactualLoading.set(false);
this.counterfactualError.set(null);
this.invalidComparisonDate.set(false);
this.comparisonDateInput.set(null);
}
private replayTraceState(
@@ -744,7 +899,85 @@ export class CalendarYear {
}
protected objectiveLabel(objective: PlanningObjective): string {
return objective.label ?? objective.id;
return this.objectiveCopy(objective.id)?.label ?? objective.label ?? objective.id;
}
protected objectiveRows(before: PlanningObjectiveVector, after: PlanningObjectiveVector) {
return after.objectives.map((objective) => ({
...objective,
label: this.objectiveLabel(objective),
help: this.objectiveCopy(objective.id)?.help,
before: before.objectives.find((item) => item.id === objective.id)?.value,
numeric: typeof objective.value === 'number',
}));
}
protected hasTieBreaks(vector: PlanningObjectiveVector): boolean {
return vector.objectives.some((objective) => typeof objective.value === 'string');
}
protected objectiveCopy(id: string) {
const key = id.startsWith('preference:') ? 'preference' : id;
const copy = this.tr().planning.explanation.objectives[key];
const rule =
key === 'preference'
? this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === id.slice('preference:'.length),
)
: undefined;
return (
copy && {
...copy,
label: copy.label.replace('{rule}', this.ruleLabel(id.slice('preference:'.length))),
help:
(rule &&
this.tr().planning.explanation.preferenceHelp[rule.type.toLowerCase()]) ??
copy.help,
}
);
}
protected phaseLabel(name: string): string {
return (
this.tr().planning.explanation.stages[name] ??
this.objectiveCopy(name)?.label ??
this.formatIdentifier(name)
);
}
protected leaveUsed(explanation: PlacementExplanation): string {
return this.tr().planning.explanation.leaveUsed.replace(
'{balance}',
this.balanceLabel(explanation),
);
}
private numericChanges(before?: PlanningObjectiveVector, after?: PlanningObjectiveVector) {
return (after?.objectives ?? []).flatMap((objective) => {
const previous = before?.objectives.find((item) => item.id === objective.id)?.value;
if (
typeof previous !== 'number' ||
typeof objective.value !== 'number' ||
previous === objective.value
)
return [];
return [
{
id: objective.id,
label: this.objectiveLabel(objective),
help: this.objectiveCopy(objective.id)?.help,
before: previous,
after: objective.value,
},
];
});
}
protected displayDate(value: string): string {
return new Intl.DateTimeFormat(this.translationService.locale(), {
dateStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
}
protected planningDate(value: string | Date | undefined): string {