Files
vac-optimizer/src/app/components/calendar-year/calendar-year.html
T
nessar 2edf2004f5 feat(planner): implement cancellation for planning calculations
- Refactor leave-planner.worker.ts to support cancellation of ongoing calculations.
- Introduce `calculatePlanningAsyncInternal` for asynchronous planning calculations.
- Add cancellation handling in the worker message event.
- Update planning-date.ts with new utility functions for date manipulation.
- Extend translation-service.ts and translation files (en.json, de.json, es.json, fr.json) to include new strings related to planning horizon and cancellation.
- Create documentation for the cancellation feature in planner-cancellation.md.
- Add unit tests for cancellation functionality in leave-planner-cancellation.spec.ts and leave-planner.worker.spec.ts.

#57 #58
2026-10-05 15:34:07 +02:00

1377 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div class="calendar-year">
<p class="sr-only" role="status" aria-live="polite" aria-atomic="true">
{{ liveMessage() }}
</p>
@if (isLoading()) {
<div class="loading-overlay">
<div class="loading-spinner">
<div class="spinner-container">
<mat-spinner
[diameter]="72"
[mode]="'determinate'"
[value]="loadingProgress()"
></mat-spinner>
<div class="spinner-text">{{ loadingProgress() }}%</div>
</div>
<p role="status">{{ isCancelling() ? tr().planning.cancelling : tr().loading }}</p>
<button
mat-stroked-button
type="button"
class="cancel-calculation"
[disabled]="isCancelling()"
(click)="cancelCalculation()"
>
{{ tr().planning.cancel }}
</button>
</div>
</div>
}
<header class="results-header">
<div class="results-heading">
<h2 #resultsTitle id="planning-results-title" tabindex="-1">
{{ tr().planning.title }}
</h2>
@if (planningOutcome()?.kind === 'InvalidInput') {
<p class="planning-status error" role="alert">
<mat-icon aria-hidden="true">error</mat-icon>
{{ tr().planning.invalidInput }}
</p>
} @else if (planningOutcome()?.kind === 'PlanningConflict') {
<p class="planning-status error" role="alert">
<mat-icon aria-hidden="true">error</mat-icon>
{{ tr().planning.conflict }}
</p>
} @else if (planningOutcome()?.kind === 'PlanningIncomplete') {
<p
class="planning-status"
[class.error]="hasPlannerError()"
[class.warning]="!hasPlannerError()"
[attr.role]="hasPlannerError() ? 'alert' : 'status'"
>
<mat-icon aria-hidden="true">{{
hasPlannerError() ? 'error' : 'warning'
}}</mat-icon>
{{ outcomeLabel(planningOutcome()!) }}
</p>
} @else if (planningOutcome()?.kind === 'Plan') {
<p class="planning-status success" role="status">
<mat-icon aria-hidden="true">check_circle</mat-icon>
{{ tr().planning.complete }}
</p>
}
</div>
</header>
@if (planningOutcome(); as outcome) {
@if (outcome.kind === 'InvalidInput') {
<ul class="reason-list" role="alert">
@for (reason of outcome.reasons; track reason.code + reason.message) {
<li>
<strong>{{ formatIdentifier(reason.code) }}:</strong> {{ reason.message }}
</li>
}
</ul>
}
}
@if (planningExplanation(); as outcomeExplanation) {
@if (outcomeExplanation.kind === 'ConflictExplanation') {
<section
class="outcome-explanation conflict-explanation"
aria-labelledby="conflict-title"
>
<h3 id="conflict-title">{{ tr().planning.conflictDetails }}</h3>
<p>
<strong>{{ tr().planning.conflictingRules }}:</strong>
</p>
<ul>
@for (rule of outcomeExplanation.conflictingRules; track rule.id) {
<li>
{{ rule.label }} <code>{{ rule.id }}</code>
</li>
}
</ul>
<p>
<strong>{{ tr().planning.bottleneck }}:</strong>
{{ outcomeExplanation.bottleneck.message }}
</p>
</section>
} @else if (outcomeExplanation.kind === 'IncompleteExplanation') {
<section
class="outcome-explanation incomplete-explanation"
aria-labelledby="incomplete-title"
>
<h3 id="incomplete-title">{{ tr().planning.incompleteDetails }}</h3>
<p>{{ outcomeExplanation.reason.message }}</p>
<dl class="compact-facts">
<div>
<dt>{{ tr().planning.completedStages }}</dt>
<dd>
{{
outcomeExplanation.completedStages.length
? outcomeExplanation.completedStages.join(', ')
: tr().planning.none
}}
</dd>
</div>
<div>
<dt>{{ tr().planning.uncompletedStages }}</dt>
<dd>
{{
outcomeExplanation.uncompletedStages.length
? outcomeExplanation.uncompletedStages.join(', ')
: tr().planning.none
}}
</dd>
</div>
</dl>
</section>
}
}
@if (displayedPlan(); as plan) {
<dl class="plan-summary">
<div>
<dt>{{ tr().planning.daysPlaced }}</dt>
<dd>{{ plan.placements.length }}</dd>
</div>
<div>
<dt>{{ tr().planning.daysGained }}</dt>
<dd>{{ plan.metrics.nonWorkingDayGain }}</dd>
</div>
<div>
<dt>{{ tr().planning.longestWorkSequence }}</dt>
<dd>{{ plan.metrics.longestWorkSequence }}</dd>
</div>
</dl>
}
<div class="plan-insights">
@if (selectedExplanation(); as selected) {
<details
#planDetails
class="plan-details"
[open]="planDetailsOpen()"
(toggle)="togglePlanDetails($event)"
>
<summary>
<mat-icon aria-hidden="true">lightbulb</mat-icon>
<span
>{{ tr().planning.whyPlacement
}}<small>{{ tr().planning.explanationHelp }}</small></span
>
<mat-icon class="disclosure-chevron" aria-hidden="true">expand_more</mat-icon>
</summary>
<section
class="placement-explanation"
aria-labelledby="placement-explanation-title"
>
<header class="explanation-heading">
<div>
<h3 id="placement-explanation-title">
{{ displayDate(selected.placement.date) }} ·
{{ balanceLabel(selected) }}
</h3>
</div>
<nav
class="placement-stepper"
[attr.aria-label]="tr().planning.placementNavigation"
>
<button
type="button"
data-step="first"
[attr.aria-disabled]="selectedPlacementIndex() === 0"
[attr.aria-label]="tr().planning.first"
(click)="stepPlacement('first')"
>
<mat-icon aria-hidden="true">first_page</mat-icon>
</button>
<button
type="button"
data-step="previous"
[attr.aria-disabled]="selectedPlacementIndex() === 0"
[attr.aria-label]="tr().planning.previous"
(click)="stepPlacement('previous')"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<output aria-current="step" [attr.aria-label]="placementAnnouncement()">
{{ tr().planning.placement }} {{ selectedPlacementIndex() + 1 }}
{{ tr().planning.of }} {{ planExplanation()!.placements.length }}
</output>
<button
type="button"
data-step="next"
[attr.aria-disabled]="
selectedPlacementIndex() ===
planExplanation()!.placements.length - 1
"
[attr.aria-label]="tr().planning.next"
(click)="stepPlacement('next')"
>
<mat-icon aria-hidden="true">chevron_right</mat-icon>
</button>
<button
type="button"
data-step="last"
[attr.aria-disabled]="
selectedPlacementIndex() ===
planExplanation()!.placements.length - 1
"
[attr.aria-label]="tr().planning.last"
(click)="stepPlacement('last')"
>
<mat-icon aria-hidden="true">last_page</mat-icon>
</button>
</nav>
</header>
@if (planExplanation()!.incomplete; as interrupted) {
<div class="incomplete-incumbent" role="status">
<strong>{{ tr().planning.incumbentExplanation }}</strong>
<p>{{ interrupted.reason.message }}</p>
<p>
{{ tr().planning.completedStages }}:
{{
interrupted.completedStages.length
? interrupted.completedStages.join(', ')
: tr().planning.none
}}
</p>
<p>
{{ tr().planning.uncompletedStages }}:
{{
interrupted.uncompletedStages.length
? interrupted.uncompletedStages.join(', ')
: tr().planning.none
}}
</p>
</div>
}
@if (selected.timeOffPeriod; as period) {
<section class="break-story" aria-labelledby="break-title">
<h4 id="break-title">
{{ tr().planning.explanation.breakTitle }} ·
{{ displayDate(period.start) }} – {{ displayDate(period.end) }}
</h4>
<div class="break-equation">
<span
><strong>{{ period.placementDates.length }}</strong
>{{ tr().planning.explanation.leaveDays }}</span
>
<span>+</span>
<span
><strong>{{ period.nonWorkingDates.length }}</strong
>{{ tr().planning.explanation.freeDays }}</span
>
<span>=</span>
<span
><strong>{{ period.duration }}</strong
>{{ tr().planning.explanation.daysOff }}</span
>
</div>
<p>{{ tr().planning.explanation.wholeBreak }}</p>
</section>
}
<div class="leave-story">
<p>
{{ leaveUsed(selected) }}
@if (selected.balance) {
{{ tr().planning.explanation.expires }}
<strong>{{
displayDate(planningDate(selected.balance.expiryDate))
}}</strong
>.
}
</p>
@if (selected.ruleIds.length) {
<p>
{{ tr().planning.explanation.linkedRules }}
@for (ruleId of selected.ruleIds; track ruleId; let last = $last) {
<strong>{{ ruleLabel(ruleId) }}</strong
>{{ last ? '.' : ', ' }}
}
</p>
}
@if (hasAtRiskContribution(selected)) {
<p>{{ tr().planning.explanation.atRiskContribution }}</p>
}
@if (selected.supporting) {
<p>{{ tr().planning.details.supporting }}</p>
}
<p>
<strong>{{ tr().planning.remaining }}:</strong>
{{ selected.remainingQuantity }} {{ tr().userInput.daysSuffix }}
@if (selected.remainingBalance; as remaining) {
·
{{
tr().planning.details.remainingReasons[remaining.classification]
}}
}
</p>
</div>
@let decisiveObjective =
selected.firstDecisiveObjective ??
selected.comparison.firstDecisiveObjective;
@if (decisiveObjective) {
<p class="decisive-summary">
<strong>{{ tr().planning.details.firstDifference }}:</strong>
{{ objectiveCopy(decisiveObjective).label }}
</p>
}
<section class="day-impact" aria-labelledby="impact-title">
<h4 id="impact-title">{{ tr().planning.explanation.impact }}</h4>
<p>{{ tr().planning.explanation.impactHelp }}</p>
@if (placementChanges().length) {
<table class="impact-table">
<thead>
<tr>
<th scope="col">
{{ tr().planning.explanation.criterion }}
</th>
<th scope="col">
{{ tr().planning.explanation.withoutDay }}
</th>
<th scope="col">{{ tr().planning.explanation.withDay }}</th>
</tr>
</thead>
<tbody>
@for (change of placementChanges(); track change.id) {
<tr [class.decisive-row]="change.id === decisiveObjective">
<th scope="row">
{{ change.label }}<small>{{ change.help }}</small>
@if (change.id === decisiveObjective) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
}
</th>
<td>{{ change.before }}</td>
<td>{{ change.after }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p>{{ tr().planning.explanation.noChange }}</p>
}
</section>
<details class="technical-details">
<summary>{{ tr().planning.technicalDetails }}</summary>
<div class="calculation-content">
<p class="calculation-intro">{{ tr().planning.details.intro }}</p>
<section class="balance-detail" aria-labelledby="detail-balance-title">
<h4 id="detail-balance-title">
{{ tr().planning.details.balance }} ·
{{ balanceLabel(selected) }}
</h4>
<dl class="balance-ledger">
@if (selected.balance?.availableFrom; as available) {
<div>
<dt>{{ tr().planning.details.available }}</dt>
<dd>{{ displayDate(planningDate(available)) }}</dd>
</div>
}
@if (selected.balance; as balance) {
<div>
<dt>{{ tr().planning.details.expires }}</dt>
<dd>
{{ displayDate(planningDate(balance.expiryDate)) }}
</dd>
</div>
}
<div>
<dt>{{ tr().planning.remaining }}</dt>
<dd>
{{ selected.remainingQuantity }}
{{ tr().userInput.daysSuffix }}
</dd>
</div>
</dl>
@if (selected.remainingBalance; as remaining) {
<p>
{{
tr().planning.details.remainingReasons[
remaining.classification
]
}}
</p>
}
</section>
<ng-container
[ngTemplateOutlet]="calculationTable"
[ngTemplateOutletContext]="{
comparison: selected.comparison,
beforeLabel: tr().planning.explanation.withoutDay,
afterLabel: tr().planning.explanation.withDay,
audit: selected,
}"
/>
</div>
</details>
<button type="button" class="compare-dates-button" (click)="compareDates()">
<mat-icon aria-hidden="true">compare_arrows</mat-icon
>{{
counterfactualError()
? tr().planning.retryCounterfactual
: tr().planning.compareDates
}}
</button>
</section>
</details>
} @else if (explanationError()) {
<p class="inline-error" role="alert">
{{ tr().planning.explanationError }} {{ explanationError()!.message }}
</p>
}
@if (planningOutcome() && planningOutcome()?.kind !== 'InvalidInput') {
<details class="trace-explorer" [open]="traceOpen()" (toggle)="toggleTrace($event)">
<summary>
<mat-icon aria-hidden="true">account_tree</mat-icon>
<span
>{{ tr().planning.traceTitle
}}<small>{{ tr().planning.traceHelp }}</small></span
>
<mat-icon class="disclosure-chevron" aria-hidden="true">expand_more</mat-icon>
</summary>
@if (traceLoading()) {
<p role="status">{{ tr().planning.traceLoading }}</p>
} @else if (planningTrace(); as trace) {
@if (trace.error) {
<p class="inline-error" role="alert">{{ trace.error.message }}</p>
}
<div class="trace-layout">
<nav class="trace-phases" [attr.aria-label]="tr().planning.phases">
<div class="trace-phase-stepper">
<button
type="button"
data-phase-step="first"
[attr.aria-disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.firstPhase"
(click)="stepTracePhase('first')"
>
<mat-icon aria-hidden="true">first_page</mat-icon>
</button>
<button
type="button"
data-phase-step="previous"
[attr.aria-disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.previousPhase"
(click)="stepTracePhase('previous')"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<output
aria-current="step"
[attr.aria-label]="tracePhaseAnnouncement()"
>{{ tracePhaseIndex() + 1 }} {{ tr().planning.of }}
{{ trace.phases.length }}</output
>
<button
type="button"
data-phase-step="next"
[attr.aria-disabled]="
tracePhaseIndex() === trace.phases.length - 1
"
[attr.aria-label]="tr().planning.nextPhase"
(click)="stepTracePhase('next')"
>
<mat-icon aria-hidden="true">chevron_right</mat-icon>
</button>
<button
type="button"
data-phase-step="last"
[attr.aria-disabled]="
tracePhaseIndex() === trace.phases.length - 1
"
[attr.aria-label]="tr().planning.lastPhase"
(click)="stepTracePhase('last')"
>
<mat-icon aria-hidden="true">last_page</mat-icon>
</button>
</div>
@for (phase of trace.phases; track phase.id; let index = $index) {
<button
type="button"
[class.active]="index === tracePhaseIndex()"
[attr.aria-pressed]="index === tracePhaseIndex()"
(click)="selectTracePhase(index)"
>
<span>{{ phaseLabel(phase.name) }}</span>
<small>{{ tracePhaseStatusLabel(phase.status) }}</small>
</button>
}
</nav>
@if (selectedTracePhase(); as phase) {
<section class="trace-decisions" aria-labelledby="trace-phase-title">
<h3 id="trace-phase-title">{{ phaseLabel(phase.name) }}</h3>
@if (phase.decisions.length) {
<nav [attr.aria-label]="tr().planning.decisions">
<button
type="button"
[attr.aria-disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.firstDecision"
(click)="stepTraceDecision('first')"
>
<mat-icon aria-hidden="true">first_page</mat-icon>
</button>
<button
type="button"
[attr.aria-disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.previousDecision"
(click)="stepTraceDecision('previous')"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<output
aria-current="step"
[attr.aria-label]="traceDecisionAnnouncement()"
>{{ tr().planning.decision }}
{{ traceDecisionIndex() + 1 }} {{ tr().planning.of }}
{{ phase.decisions.length }}</output
>
<button
type="button"
[attr.aria-disabled]="
traceDecisionIndex() === phase.decisions.length - 1
"
[attr.aria-label]="tr().planning.nextDecision"
(click)="stepTraceDecision('next')"
>
<mat-icon aria-hidden="true">chevron_right</mat-icon>
</button>
<button
type="button"
[attr.aria-disabled]="
traceDecisionIndex() === phase.decisions.length - 1
"
[attr.aria-label]="tr().planning.lastDecision"
(click)="stepTraceDecision('last')"
>
<mat-icon aria-hidden="true">last_page</mat-icon>
</button>
</nav>
@if (selectedTraceDecision(); as decision) {
<div
class="trace-toolbar"
role="group"
[attr.aria-label]="tr().planning.replay"
>
<button
type="button"
[attr.aria-pressed]="
traceReplay() && traceMoment() === 'before'
"
(click)="showTraceMoment('before')"
>
{{ tr().planning.before }}
</button>
<button
type="button"
[attr.aria-pressed]="
traceReplay() && traceMoment() === 'after'
"
(click)="showTraceMoment('after')"
>
{{ tr().planning.after }}
</button>
<button
type="button"
[attr.aria-pressed]="!traceReplay()"
(click)="showFinalPlan()"
>
{{ tr().planning.finalPlan }}
</button>
</div>
@if (historicalEligibilityLoading()) {
<p role="status">
{{ tr().planning.historicalEligibilityLoading }}
</p>
}
@if (historicalEligibilityError(); as error) {
<p role="alert">
{{ tr().planning.historicalEligibilityError }}
{{ error }}
<button
type="button"
(click)="retryHistoricalEligibility()"
>
{{ tr().planning.retryHistoricalEligibility }}
</button>
</p>
}
<p>
<strong>{{ tr().planning.dates }}:</strong>
{{
decision.dates.length
? decision.dates.join(', ')
: tr().planning.none
}}
</p>
@if (decision.reason) {
<p>
<strong>{{ tr().planning.reason }}:</strong>
{{ decision.reason.message }}
</p>
}
<h4>{{ tr().planning.candidates }}</h4>
@if (decision.candidates.length > 50) {
<nav
class="candidate-pagination"
[attr.aria-label]="tr().planning.candidates"
>
<button
type="button"
[attr.aria-disabled]="
traceCandidatePage() === 0
"
[attr.aria-label]="
tr().planning.previousCandidates
"
(click)="stepTraceCandidatePage(-1)"
>
<mat-icon aria-hidden="true"
>chevron_left</mat-icon
>
</button>
<output
>{{ tr().planning.page }}
{{ traceCandidatePage() + 1 }}
{{ tr().planning.of }}
{{ traceCandidatePages() }}</output
>
<button
type="button"
[attr.aria-disabled]="
traceCandidatePage() ===
traceCandidatePages() - 1
"
[attr.aria-label]="tr().planning.nextCandidates"
(click)="stepTraceCandidatePage(1)"
>
<mat-icon aria-hidden="true"
>chevron_right</mat-icon
>
</button>
</nav>
}
<div class="candidate-list">
@for (
candidate of visibleTraceCandidates();
track $index
) {
<details>
<summary>
<span
class="status-symbol"
aria-hidden="true"
>{{
candidate.status === 'chosen'
? '✓'
: candidate.status ===
'ineligible'
? '×'
: '•'
}}</span
>
{{ traceStatusLabel(candidate.status) }} ·
@if (
candidate.sourceDate &&
candidate.sourceDate !== candidate.date
) {
{{ candidate.sourceDate }} →
}
{{
candidate.dates?.join(', ') ||
candidate.date ||
tr().planning.none
}}
</summary>
@if (candidate.reason) {
<p>{{ candidate.reason.message }}</p>
}
@if (candidate.technicalComparison?.length) {
<section class="technical-comparison">
<h5>
{{
tr().planning.details
.traceStageTitle
}}
</h5>
<p class="calculation-intro">
{{
tr().planning.details
.traceStageHelp
}}
</p>
<table class="impact-table">
<thead>
<tr>
<th scope="col">
{{
tr().planning
.explanation
.criterion
}}
</th>
<th scope="col">
{{
tr().planning.before
}}
</th>
<th scope="col">
{{
tr().planning.after
}}
</th>
</tr>
</thead>
<tbody>
@for (
comparator of candidate.technicalComparison;
track $index
) {
<tr
[class.decisive-row]="
comparator.name ===
candidate.firstDecisiveComparator
"
>
<th scope="row">
<code>{{
comparator.name
}}</code>
<small>{{
comparator.direction ===
'maximize'
? tr()
.planning
.details
.higher
: tr()
.planning
.details
.lower
}}</small>
@if (
comparator.name ===
candidate.firstDecisiveComparator
) {
<span
class="decisive-label"
>{{
tr()
.planning
.details
.firstDifference
}}</span
>
}
</th>
<td>
{{
comparator.before
}}
</td>
<td>
{{
comparator.after
}}
</td>
</tr>
}
</tbody>
</table>
</section>
}
@if (candidate.comparison; as comparison) {
<div class="calculation-content">
<ng-container
[ngTemplateOutlet]="
calculationTable
"
[ngTemplateOutletContext]="{
comparison,
beforeLabel:
tr().planning.before,
afterLabel: tr().planning.after,
audit: comparison,
traceEffect:
!!candidate
.technicalComparison
?.length,
}"
/>
</div>
}
@if (candidate.placement) {
<button
type="button"
(click)="
analyzeTraceCandidate(candidate)
"
>
{{ tr().planning.compareAlternatives }}
</button>
}
</details>
}
</div>
<h4>{{ tr().planning.deltas }}</h4>
<ul>
@for (delta of decision.deltas; track $index) {
<li>
<strong>{{
formatIdentifier(delta.kind)
}}</strong>
@if (delta.fromDate || delta.date) {
· {{ delta.fromDate || delta.date }}
}
@if (delta.toDate) {
→ {{ delta.toDate }}
}
@if (delta.balanceId) {
· {{ delta.balanceId }}
}
@if (delta.remainingAfter !== undefined) {
· {{ delta.remainingBefore }} →
{{ delta.remainingAfter }}
}
</li>
}
</ul>
@if (traceState(); as state) {
<p class="trace-state-summary" role="status">
{{
traceMoment() === 'before'
? tr().planning.before
: tr().planning.after
}}: {{ state.placements.length }}
{{ tr().planning.placements }},
{{ state.earnedActivations.length }}
{{ tr().planning.activations }}
@for (balance of state.balances; track balance.id) {
<span class="trace-state-balance"
>{{ balance.id }}: {{ balance.remaining }}
{{ tr().planning.balanceRemaining }}
<small
>{{ tr().planning.details.available }}
{{ displayDate(balance.availableFrom) }}
·
{{ tr().planning.details.expires }}
{{
displayDate(balance.expiryDate)
}}</small
></span
>
}
</p>
}
}
} @else {
<p>{{ tr().planning.noDecisions }}</p>
}
</section>
}
</div>
} @else if (traceResolved()) {
<p role="status">{{ tr().planning.traceUnavailable }}</p>
}
</details>
}
</div>
@if (traceReplay()) {
<div class="replay-notice" role="status">
<span>{{ tr().planning.replayNotice }}</span>
<button type="button" class="quiet-button" (click)="showFinalPlan()">
{{ tr().planning.finalPlan }}
</button>
</div>
}
<p class="calendar-help">
<mat-icon aria-hidden="true">touch_app</mat-icon>{{ tr().planning.calendarHelp }}
</p>
<div class="calendar-legend" role="group" [attr.aria-label]="tr().planning.legend">
<strong>{{ tr().planning.legend }}</strong>
<ul>
<li>
<span class="legend-swatch weekend" aria-hidden="true"></span
>{{ tr().dayTypes.weekend }}
</li>
<li>
<span class="legend-swatch closed-day" aria-hidden="true"></span
>{{ tr().dayTypes.closedDay }}
</li>
<li><span class="legend-swatch cp" aria-hidden="true"></span>{{ tr().dayTypes.cp }}</li>
<li>
<span class="legend-swatch rtt" aria-hidden="true"></span>{{ tr().dayTypes.rtt }}
</li>
<li>
<span class="legend-swatch other" aria-hidden="true"></span
>{{ tr().dayTypes.other }}
</li>
<li>
<span class="legend-swatch today" aria-hidden="true"></span
>{{ tr().dayTypes.today }}
</li>
@if (planDetailsOpen() || traceReplay()) {
<li>
<span class="legend-swatch current" aria-hidden="true"></span
>{{ tr().planning.currentPlacement }}
</li>
}
@if (explainedPeriod()) {
<li>
<span class="legend-swatch explained-period" aria-hidden="true"></span
>{{ tr().planning.explainedPeriod }}
</li>
}
@if (traceReplay() && counterfactual()) {
<li>
<span class="legend-symbol hypothetical-symbol" aria-hidden="true">~✓</span
>{{ tr().planning.eligible }}
</li>
<li>
<span class="legend-symbol hypothetical-symbol" aria-hidden="true">~×</span
>{{ tr().planning.ineligible }}
</li>
}
@if (traceReplay()) {
<li>
<span class="legend-symbol" aria-hidden="true">✓</span
>{{ traceStatusLabel('chosen') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">−</span
>{{ traceStatusLabel('evaluated-rejected') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">⌁</span
>{{ traceStatusLabel('pruned') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">?</span
>{{ traceStatusLabel('not-examined') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">×</span
>{{ traceStatusLabel('ineligible') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">+</span
>{{ traceChangeLabel('added') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">←</span
>{{ traceChangeLabel('moved-from') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">→</span
>{{ traceChangeLabel('moved-to') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">−</span
>{{ traceChangeLabel('removed') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">≈</span
>{{ traceChangeLabel('shared') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">=</span
>{{ traceChangeLabel('unchanged') }}
</li>
}
</ul>
</div>
<div class="month-grid">
@for (month of months(); track month.year + '-' + month.monthIndex) {
<mat-card class="month-card">
<mat-card-content>
<app-calendar-month
[monthIndex]="month.monthIndex"
[year]="month.year"
[selectedDates]="calendarSelectedDates()"
[horizonStart]="planningHorizon()?.start"
[horizonEnd]="planningHorizon()?.end"
[currentDate]="currentPlacementDate()"
[explainedPeriod]="explainedPeriod()"
[dateStatuses]="traceReplay() ? dateStatuses() : {}"
(dateSelected)="selectCalendarDate($event)"
></app-calendar-month>
</mat-card-content>
</mat-card>
}
</div>
</div>
<ng-template
#calculationTable
let-comparison="comparison"
let-beforeLabel="beforeLabel"
let-afterLabel="afterLabel"
let-audit="audit"
let-traceEffect="traceEffect"
>
<section class="criteria-detail">
<h4>
{{
traceEffect
? tr().planning.details.traceImpactTitle
: tr().planning.details.criteria
}}
</h4>
<p class="calculation-intro">
{{
traceEffect
? tr().planning.details.traceImpactHelp
: tr().planning.details.criteriaHelp
}}
</p>
<table class="impact-table criteria-table">
<thead>
<tr>
<th scope="col">{{ tr().planning.explanation.criterion }}</th>
<th scope="col">{{ beforeLabel }}</th>
<th scope="col">{{ afterLabel }}</th>
</tr>
</thead>
<tbody>
@for (
objective of objectiveRows(comparison.before, comparison.after);
track objective.id;
let index = $index
) {
@if (objective.numeric) {
<tr
[class.decisive-row]="
!traceEffect && objective.id === comparison.firstDecisiveObjective
"
>
<th scope="row">
<details class="criterion-help">
<summary>{{ index + 1 }}. {{ objective.label }}</summary>
<p>{{ objective.help }}</p>
</details>
<small>{{
objective.direction === 'maximize'
? tr().planning.details.higher
: tr().planning.details.lower
}}</small>
@if (
!traceEffect &&
objective.id === comparison.firstDecisiveObjective
) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
}
</th>
<td>{{ objective.before ?? tr().planning.none }}</td>
<td>{{ objective.value }}</td>
</tr>
}
}
</tbody>
</table>
@if (hasTieBreaks(comparison.after)) {
<div class="tie-breaks">
<h5>{{ tr().planning.details.tieBreaks }}</h5>
<ol>
@for (
objective of objectiveRows(comparison.before, comparison.after);
track objective.id
) {
@if (!objective.numeric) {
<li>
{{ objective.label }}
@if (
!traceEffect &&
objective.id === comparison.firstDecisiveObjective
) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
}
</li>
}
}
</ol>
</div>
}
</section>
<ng-container [ngTemplateOutlet]="auditData" [ngTemplateOutletContext]="{ $implicit: audit }" />
</ng-template>
<ng-template #auditData let-data>
<details class="audit-data">
<summary>{{ tr().planning.details.audit }}</summary>
<p class="calculation-intro">{{ tr().planning.details.auditHelp }}</p>
<pre tabindex="0" [attr.aria-label]="tr().planning.details.audit">{{ data | json }}</pre>
</details>
</ng-template>
<ng-template #dateComparisonDialog>
<section class="counterfactual-panel">
<header class="comparison-heading">
<div>
<h2 id="counterfactual-title" tabindex="-1">
{{ tr().planning.comparison.title }}
</h2>
<p id="comparison-intro">
{{
traceReplay()
? tr().planning.explanation.traceHelp
: tr().planning.explanation.moveHelp
}}
</p>
</div>
<button
type="button"
class="comparison-close"
[attr.aria-label]="tr().planning.stopComparing"
(click)="clearCounterfactual()"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
</header>
<mat-dialog-content class="comparison-content">
@if (counterfactualLoading()) {
<div class="comparison-loading" role="status">
<mat-spinner [diameter]="28" />
<p>{{ tr().planning.counterfactualLoading }}</p>
</div>
} @else if (counterfactualError()) {
<div class="counterfactual-inline-error" role="alert">
<p>{{ tr().planning.counterfactualError }} {{ counterfactualError() }}</p>
</div>
} @else if (counterfactual(); as analysis) {
<div class="comparison-date-pair">
<div class="original-date">
<span>{{
traceReplay()
? tr().planning.comparison.candidate
: tr().planning.explanation.plannedDate
}}</span>
<strong>{{ displayDate(analysis.placement.date) }}</strong>
</div>
<mat-icon aria-hidden="true">arrow_forward</mat-icon>
<mat-form-field
class="compare-date-field"
appearance="outline"
subscriptSizing="dynamic"
>
<mat-label>{{ tr().planning.explanation.tryDate }}</mat-label>
<input
id="comparison-date"
matInput
[matDatepicker]="comparisonPicker"
placeholder="DD/MM/YYYY"
[value]="comparisonDateInput()"
[min]="planningHorizon()?.start + 'T00:00:00'"
[max]="analysis.results.at(-1)?.date + 'T00:00:00'"
[attr.aria-invalid]="invalidComparisonDate()"
[aria-describedby]="
invalidComparisonDate()
? 'comparison-range comparison-date-error'
: 'comparison-range'
"
(dateInput)="changeComparisonDate($event)"
/>
<mat-datepicker-toggle matIconSuffix [for]="comparisonPicker" />
<mat-datepicker #comparisonPicker />
</mat-form-field>
</div>
@if (analysis.results[0]; as firstDate) {
<p id="comparison-range" class="comparison-range">
{{ tr().planning.comparison.dateRange }} {{ displayDate(firstDate.date) }} –
{{ displayDate(analysis.results.at(-1)!.date) }}
</p>
}
@if (invalidComparisonDate()) {
<p id="comparison-date-error" class="inline-error" role="alert">
{{ tr().planning.comparison.invalidDate }}
</p>
}
@if (nearbyComparisonDates().length) {
<div
class="nearby-dates"
role="group"
[attr.aria-label]="tr().planning.comparison.nearby"
>
<span>{{ tr().planning.comparison.nearby }}</span>
@for (alternative of nearbyComparisonDates(); track alternative.date) {
<button
type="button"
[attr.aria-pressed]="
selectedCounterfactualDate() === alternative.date
"
(click)="selectCalendarDate(alternative.date)"
>
{{ displayDate(alternative.date) }}
</button>
}
</div>
} @else {
<p class="comparison-range">{{ tr().planning.comparison.noAlternatives }}</p>
}
@if (selectedCounterfactualResult(); as result) {
<div class="counterfactual-result">
<p class="sr-only" role="status">
{{ displayDate(result.date) }} ·
{{
result.status === 'eligible'
? tr().planning.comparison.available
: tr().planning.comparison.unavailable
}}
</p>
@if (result.status === 'ineligible') {
<div class="unavailable-date" role="status">
<mat-icon aria-hidden="true">event_busy</mat-icon>
<div>
<h3>{{ tr().planning.comparison.unavailable }}</h3>
@if (result.reason) {
<p>
{{
tr().planning.explanation.unavailable[
result.reason.code
] ?? result.reason.message
}}
</p>
}
</div>
</div>
} @else if (!traceReplay() && result.date === analysis.placement.date) {
<p class="comparison-empty">{{ tr().planning.explanation.sameDate }}</p>
} @else {
<div class="comparison-result-heading">
<h3>{{ tr().planning.comparison.changes }}</h3>
<span class="available-date"
><mat-icon aria-hidden="true">check_circle</mat-icon
>{{ tr().planning.comparison.available }}</span
>
</div>
@if (dateComparison()) {
@if (dateChanges().length) {
<table class="impact-table">
<thead>
<tr>
<th scope="col">
{{ tr().planning.explanation.criterion }}
</th>
<th scope="col">
{{
traceReplay()
? tr().planning.explanation
.beforeDecision
: tr().planning.explanation.currentPlan
}}
</th>
<th scope="col">
{{
traceReplay()
? tr().planning.explanation
.withCandidate
: tr().planning.explanation.alternative
}}
</th>
</tr>
</thead>
<tbody>
@for (change of dateChanges(); track change.id) {
<tr>
<th scope="row">{{ change.label }}</th>
<td>{{ change.before }}</td>
<td>{{ change.after }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="comparison-empty">
{{ tr().planning.explanation.noChange }}
</p>
}
} @else {
<p class="comparison-empty">
{{ tr().planning.explanation.comparisonUnavailable }}
</p>
}
}
<details class="technical-details date-calculation-details">
<summary>{{ tr().planning.technicalDetails }}</summary>
<div class="calculation-content">
<p>
<strong>{{ tr().planning.historical }}:</strong>
{{
dateStatuses()[result.date]?.label ??
traceStatusLabel(
result.historicalStatus ?? 'not-examined'
)
}}
</p>
@if (dateStatuses()[result.date]?.reason; as historicalReason) {
<p>
<strong>{{ tr().planning.reason }}:</strong>
{{ historicalReason }}
</p>
}
@if (dateComparison(); as comparison) {
<ng-container
[ngTemplateOutlet]="calculationTable"
[ngTemplateOutletContext]="{
comparison,
beforeLabel: traceReplay()
? tr().planning.explanation.beforeDecision
: tr().planning.explanation.currentPlan,
afterLabel: traceReplay()
? tr().planning.explanation.withCandidate
: tr().planning.explanation.alternative,
audit: result,
traceEffect: traceReplay(),
}"
/>
} @else {
<ng-container
[ngTemplateOutlet]="auditData"
[ngTemplateOutletContext]="{ $implicit: result }"
/>
}
</div>
</details>
</div>
} @else if (!invalidComparisonDate()) {
<p class="comparison-empty">{{ tr().planning.comparison.chooseDate }}</p>
}
}
</mat-dialog-content>
<footer class="comparison-footer">
<span>{{ tr().planning.comparison.preview }}</span>
<button
type="button"
class="comparison-done"
(click)="counterfactualError() ? retryCounterfactual() : clearCounterfactual()"
>
{{
counterfactualError()
? tr().planning.retryCounterfactual
: traceReplay()
? tr().planning.comparison.backToTrace
: tr().planning.comparison.backToPlan
}}
</button>
</footer>
</section>
</ng-template>