UI improvments
This commit is contained in:
1 parent
c9dbe82b79
commit
0ce33cc9d6
106 files changed
+72934
-1675
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user