feat(web): derive metric-card into canonical KPI card with semantic delta handling [SPRINT-028]
Rework MetricCardComponent from a basic label+value+delta card into the
canonical Stella Ops KPI card primitive with:
- deltaDirection input ('up-is-good' | 'up-is-bad' | 'neutral') to control
green/red semantics per metric context
- severity input ('healthy' | 'warning' | 'critical' | 'unknown') for
left-border health accents
- unit input for display units (ms, %, /hr, GB)
- loading, empty, and error states with skeleton/placeholder rendering
- ARIA accessibility (role="group", composite aria-label, delta labels)
- Responsive dense-grid support
Adopted on 3 representative dashboards (12 bespoke tiles replaced):
- signals-runtime-dashboard (3 cards)
- search-quality-dashboard (4 cards)
- delivery-analytics (5 cards)
40 focused tests covering delta direction semantics, all states, severity
accents, and accessibility.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-MCD-001 - Freeze KPI semantics and visual rules
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: UX, Product Manager
|
||||
Task description:
|
||||
@@ -28,12 +28,31 @@ Task description:
|
||||
- Decide when positive deltas are good vs bad, so the shared component does not encode misleading green/red assumptions.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] KPI card semantic fields are explicitly defined.
|
||||
- [ ] Delta direction rules are documented for operational contexts where “higher” can be either good or bad.
|
||||
- [ ] The visual contract includes empty/loading/error states where needed.
|
||||
- [x] KPI card semantic fields are explicitly defined.
|
||||
- [x] Delta direction rules are documented for operational contexts where "higher" can be either good or bad.
|
||||
- [x] The visual contract includes empty/loading/error states where needed.
|
||||
|
||||
**Frozen semantic model:**
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `label` | `string` | yes | Metric name, displayed uppercase |
|
||||
| `value` | `string \| number` | yes | Current metric value |
|
||||
| `unit` | `string` | no | Display unit (ms, %, /hr, GB, etc.) |
|
||||
| `delta` | `number` | no | Percentage change; sign determines arrow |
|
||||
| `deltaDirection` | `'up-is-good' \| 'up-is-bad' \| 'neutral'` | no (default: `up-is-good`) | Controls green/red semantics |
|
||||
| `severity` | `'healthy' \| 'warning' \| 'critical' \| 'unknown'` | no | Left-border accent color |
|
||||
| `subtitle` | `string` | no | Supporting context line below value |
|
||||
| `loading` | `boolean` | no | Skeleton placeholder state |
|
||||
| `empty` | `boolean` | no | No-data state (shows `--`) |
|
||||
| `error` | `string` | no | Error message state (shows `--` + message) |
|
||||
|
||||
**Delta direction rules:**
|
||||
- `up-is-good`: uptime, throughput, scan completion, healthy service count, feedback score
|
||||
- `up-is-bad`: error rate, latency, vulnerability count, failure count, zero-result rate
|
||||
- `neutral`: informational metrics without value judgment (total count, signal volume)
|
||||
|
||||
### FE-MCD-002 - Derive the shared KPI card primitive
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-MCD-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
@@ -41,12 +60,12 @@ Task description:
|
||||
- Keep the API reusable across quota, health, system, and admin overview surfaces without requiring ad hoc wrappers.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] The shared KPI card supports the agreed semantic model.
|
||||
- [ ] Directional styling does not assume all positive movement is good.
|
||||
- [ ] The component is accessible and responsive in dense dashboard grids.
|
||||
- [x] The shared KPI card supports the agreed semantic model.
|
||||
- [x] Directional styling does not assume all positive movement is good.
|
||||
- [x] The component is accessible and responsive in dense dashboard grids.
|
||||
|
||||
### FE-MCD-003 - Adopt the derived KPI card on representative dashboards
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-MCD-002
|
||||
Owners: Developer (FE), UX
|
||||
Task description:
|
||||
@@ -54,32 +73,44 @@ Task description:
|
||||
- Prioritize pages with repeated bespoke KPI tiles or weak visual consistency.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] A bounded set of mounted dashboard pages use the shared KPI card.
|
||||
- [ ] Repeated bespoke KPI tile markup is reduced on adopted surfaces.
|
||||
- [ ] The adopted dashboards present clearer health/trend information.
|
||||
- [x] A bounded set of mounted dashboard pages use the shared KPI card.
|
||||
- [x] Repeated bespoke KPI tile markup is reduced on adopted surfaces.
|
||||
- [x] The adopted dashboards present clearer health/trend information.
|
||||
|
||||
**Adopted surfaces (3):**
|
||||
1. `signals-runtime-dashboard.component.ts` - 3 bespoke metric articles replaced with `<app-metric-card>`
|
||||
2. `search-quality-dashboard.component.ts` - 4 bespoke metric divs replaced with `<app-metric-card>`
|
||||
3. `delivery-analytics.component.ts` - 5 of 6 bespoke metric divs replaced with `<app-metric-card>` (success-rate card kept bespoke due to specialized progress bar)
|
||||
|
||||
### FE-MCD-004 - Verify and document the derivation
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-MCD-003
|
||||
Owners: Test Automation, Documentation author
|
||||
Task description:
|
||||
- Add focused component or host tests for semantic delta handling and document the shared KPI-card contract in the UI docs.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Tests cover the critical semantic cases for delta and state rendering.
|
||||
- [ ] Docs record the adopted KPI-card contract and target surfaces.
|
||||
- [ ] Future audits can classify the old unused component as intentionally derived, not forgotten.
|
||||
- [x] Tests cover the critical semantic cases for delta and state rendering.
|
||||
- [x] Docs record the adopted KPI-card contract and target surfaces.
|
||||
- [x] Future audits can classify the old unused component as intentionally derived, not forgotten.
|
||||
|
||||
**Test evidence:** 40 tests pass covering normal rendering, delta direction semantics (up-is-good, up-is-bad, neutral), loading/empty/error states, severity accents, and ARIA accessibility.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created to derive the orphan metric-card into a canonical KPI card pattern for mounted dashboards and overview surfaces. | Codex |
|
||||
| 2026-03-08 | FE-MCD-001: Froze KPI semantic model with 10 fields including deltaDirection and severity. Delta direction rules codified for up-is-good, up-is-bad, and neutral scenarios. | Developer (FE) |
|
||||
| 2026-03-08 | FE-MCD-002: Rewrote MetricCardComponent with full semantic model, ARIA labels, loading/empty/error states, severity accents, and responsive dense-grid support. Exported DeltaDirection and MetricSeverity types from shared/ui/index.ts. | Developer (FE) |
|
||||
| 2026-03-08 | FE-MCD-003: Adopted canonical card on 3 representative dashboards: signals-runtime (3 cards), search-quality (4 cards), delivery-analytics (5 cards). 12 bespoke inline tiles replaced total. | Developer (FE) |
|
||||
| 2026-03-08 | FE-MCD-004: Added 40 focused tests covering all semantic cases. Build verified clean. Docs updated. | Developer (FE) |
|
||||
|
||||
## Decisions & Risks
|
||||
- Key risk: dashboard metrics have different “good/bad” semantics, so a naive green-for-up, red-for-down treatment would be wrong.
|
||||
- Key risk: dashboard metrics have different "good/bad" semantics, so a naive green-for-up, red-for-down treatment would be wrong.
|
||||
- Mitigation: freeze semantic rules before component API design and test both positive-is-good and positive-is-bad cases.
|
||||
- Decision: `deltaDirection` defaults to `'up-is-good'` for backward compatibility with existing callers.
|
||||
- Decision: success-rate card in delivery-analytics kept bespoke because its progress bar visualization goes beyond the KPI card contract scope.
|
||||
- Decision: existing `StatsCardComponent` and `StatCardComponent` are not merged in this sprint; they serve different visual patterns (trend+sparkline vs. KPI). Consolidation is a separate future sprint.
|
||||
|
||||
## Next Checkpoints
|
||||
- Freeze KPI semantics and state rules.
|
||||
- Build the canonical shared KPI card.
|
||||
- Adopt it on a bounded dashboard set.
|
||||
- All tasks DONE. Sprint ready for archive after review.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
- `docs/implplan/SPRINT_20260308_023_FE_unreachable_registry_admin_route.md`
|
||||
- `docs/implplan/SPRINT_20260308_026_FE_settings_information_architecture_rationalization.md`
|
||||
- `docs/implplan/SPRINT_20260308_027_FE_page_header_context_header_derivation.md`
|
||||
- `docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md`
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md` - Derived MetricCardComponent into canonical KPI card with semantic delta handling, severity accents, and loading/empty/error states. Adopted on 3 dashboards (12 bespoke tiles replaced). 40 tests pass.
|
||||
- `docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md`
|
||||
- `docs/implplan/SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation.md`
|
||||
- `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md`
|
||||
|
||||
@@ -13,6 +13,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
||||
- The next queued batch is `docs/modules/ui/orphan-revival-batch/README.md`, which stages independent review-ready sprints for orphan shared-component adoption and disconnected-route integration.
|
||||
- The queued orphan batch currently spans `SPRINT_20260308_013` through `SPRINT_20260308_023` and is intentionally not marked active until product review approves staffing.
|
||||
- Newly queued follow-on planning sprints cover Settings information architecture rationalization plus UX derivation tracks for the orphan `PageHeaderComponent`, `MetricCardComponent`, `TimelineListComponent`, `SplitPaneComponent`, and `WitnessViewerComponent` (`SPRINT_20260308_026` through `SPRINT_20260308_031`).
|
||||
- Sprint `028` (MetricCardComponent derivation into canonical KPI card) is DONE. The shared `MetricCardComponent` now supports semantic delta direction (`up-is-good` / `up-is-bad` / `neutral`), severity accents, loading/empty/error states, and ARIA accessibility. Adopted on signals-runtime, search-quality, and delivery-analytics dashboards. See `docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md`.
|
||||
- Sprint `014` (CopyToClipboard, InlineCode, TruncatePipe adoption) is DONE. See `docs/features/checked/web/orphan-copy-inline-truncate-adoption.md`.
|
||||
- Sprint `015` (FilterBarComponent adoption) shipped, then was partially rolled back on audit-family pages to restore lost filter semantics. See `docs/features/checked/web/filter-bar-unification.md` and `docs/features/checked/web/orphan-revival-regression-remediation-ui.md`.
|
||||
- Sprint `020` (FindingListComponent consolidation) shipped, then was rolled back on mounted findings and release-security hosts because the shared contract required fabricated data. See `docs/features/checked/web/orphan-finding-list-consolidation.md` and `docs/features/checked/web/orphan-revival-regression-remediation-ui.md`.
|
||||
|
||||
@@ -17,10 +17,11 @@ import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
|
||||
import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notifier.models';
|
||||
import { MetricCardComponent } from '../../../shared/ui/metric-card/metric-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-analytics',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, MetricCardComponent],
|
||||
template: `
|
||||
<div class="delivery-analytics">
|
||||
<header class="section-header">
|
||||
@@ -60,60 +61,41 @@ import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon sent-icon">S</span>
|
||||
<span class="metric-label">Total Sent</span>
|
||||
</div>
|
||||
<div class="metric-value">{{ formatNumber(stats()!.totalSent) }}</div>
|
||||
<div class="metric-trend">
|
||||
<span class="trend-text">Delivered successfully</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Total Sent"
|
||||
[value]="formatNumber(stats()!.totalSent)"
|
||||
severity="healthy"
|
||||
subtitle="Delivered successfully"
|
||||
/>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon failed-icon">F</span>
|
||||
<span class="metric-label">Failed</span>
|
||||
</div>
|
||||
<div class="metric-value failed">{{ formatNumber(stats()!.totalFailed) }}</div>
|
||||
<div class="metric-trend">
|
||||
<span class="trend-text">Require attention</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Failed"
|
||||
[value]="formatNumber(stats()!.totalFailed)"
|
||||
[severity]="stats()!.totalFailed > 0 ? 'critical' : 'healthy'"
|
||||
subtitle="Require attention"
|
||||
/>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon pending-icon">P</span>
|
||||
<span class="metric-label">Pending</span>
|
||||
</div>
|
||||
<div class="metric-value pending">{{ formatNumber(stats()!.totalPending) }}</div>
|
||||
<div class="metric-trend">
|
||||
<span class="trend-text">In queue</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Pending"
|
||||
[value]="formatNumber(stats()!.totalPending)"
|
||||
[severity]="stats()!.totalPending > 50 ? 'warning' : 'healthy'"
|
||||
subtitle="In queue"
|
||||
/>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon throttled-icon">T</span>
|
||||
<span class="metric-label">Throttled</span>
|
||||
</div>
|
||||
<div class="metric-value throttled">{{ formatNumber(stats()!.totalThrottled) }}</div>
|
||||
<div class="metric-trend">
|
||||
<span class="trend-text">Rate limited</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Throttled"
|
||||
[value]="formatNumber(stats()!.totalThrottled)"
|
||||
[severity]="stats()!.totalThrottled > 0 ? 'warning' : 'healthy'"
|
||||
subtitle="Rate limited"
|
||||
/>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon latency-icon">L</span>
|
||||
<span class="metric-label">Avg Latency</span>
|
||||
</div>
|
||||
<div class="metric-value">{{ stats()!.avgDeliveryTimeMs }}ms</div>
|
||||
<div class="metric-trend">
|
||||
<span class="trend-text">Average delivery time</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Avg Latency"
|
||||
[value]="stats()!.avgDeliveryTimeMs"
|
||||
unit="ms"
|
||||
deltaDirection="up-is-bad"
|
||||
subtitle="Average delivery time"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Channel Breakdown -->
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { UnifiedSearchClient } from '../../../core/api/unified-search.client';
|
||||
import { MetricCardComponent } from '../../../shared/ui/metric-card/metric-card.component';
|
||||
import type {
|
||||
SearchQualityAlert,
|
||||
SearchQualityTrendPoint,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
@Component({
|
||||
selector: 'app-search-quality-dashboard',
|
||||
standalone: true,
|
||||
imports: [MetricCardComponent],
|
||||
template: `
|
||||
<div class="sqd">
|
||||
<div class="sqd__header">
|
||||
@@ -42,26 +44,30 @@ import type {
|
||||
|
||||
<!-- Summary metrics cards -->
|
||||
<div class="sqd__metrics">
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value">{{ metrics()?.totalSearches ?? 0 }}</div>
|
||||
<div class="sqd__metric-label">Total Searches</div>
|
||||
</div>
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value" [class.sqd__metric-value--warn]="(metrics()?.zeroResultRate ?? 0) > 10">
|
||||
{{ metrics()?.zeroResultRate ?? 0 }}%
|
||||
</div>
|
||||
<div class="sqd__metric-label">Zero-Result Rate</div>
|
||||
</div>
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value">{{ metrics()?.avgResultCount ?? 0 }}</div>
|
||||
<div class="sqd__metric-label">Avg Results / Query</div>
|
||||
</div>
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value" [class.sqd__metric-value--good]="(metrics()?.feedbackScore ?? 0) > 70">
|
||||
{{ metrics()?.feedbackScore ?? 0 }}%
|
||||
</div>
|
||||
<div class="sqd__metric-label">Feedback Score (Helpful)</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Total Searches"
|
||||
[value]="metrics()?.totalSearches ?? 0"
|
||||
deltaDirection="up-is-good"
|
||||
/>
|
||||
<app-metric-card
|
||||
label="Zero-Result Rate"
|
||||
[value]="(metrics()?.zeroResultRate ?? 0)"
|
||||
unit="%"
|
||||
deltaDirection="up-is-bad"
|
||||
[severity]="(metrics()?.zeroResultRate ?? 0) > 10 ? 'warning' : undefined"
|
||||
/>
|
||||
<app-metric-card
|
||||
label="Avg Results / Query"
|
||||
[value]="metrics()?.avgResultCount ?? 0"
|
||||
deltaDirection="up-is-good"
|
||||
/>
|
||||
<app-metric-card
|
||||
label="Feedback Score (Helpful)"
|
||||
[value]="(metrics()?.feedbackScore ?? 0)"
|
||||
unit="%"
|
||||
deltaDirection="up-is-good"
|
||||
[severity]="(metrics()?.feedbackScore ?? 0) > 70 ? 'healthy' : undefined"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zero-result alerts table -->
|
||||
@@ -291,34 +297,7 @@ import type {
|
||||
}
|
||||
}
|
||||
|
||||
.sqd__metric-card {
|
||||
padding: 1rem 1.25rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sqd__metric-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sqd__metric-value--warn {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.sqd__metric-value--good {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.sqd__metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
/* metric-card styling handled by the canonical MetricCardComponent */
|
||||
|
||||
.sqd__section {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@@ -3,11 +3,12 @@ import { CommonModule } from '@angular/common';
|
||||
|
||||
import { HostProbeHealth, ProbeHealthState, SignalsRuntimeDashboardViewModel } from './models/signals-runtime-dashboard.models';
|
||||
import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashboard.service';
|
||||
import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-signals-runtime-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, MetricCardComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="signals-page">
|
||||
@@ -27,21 +28,26 @@ import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashb
|
||||
|
||||
@if (vm(); as dashboard) {
|
||||
<section class="metrics-grid" aria-label="Signal runtime metrics">
|
||||
<article class="metric-card">
|
||||
<h2>Signals / sec</h2>
|
||||
<p>{{ dashboard.metrics.signalsPerSecond | number:'1.0-2' }}</p>
|
||||
<small>Last hour events: {{ dashboard.metrics.lastHourCount }}</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Error rate</h2>
|
||||
<p>{{ dashboard.metrics.errorRatePercent | number:'1.0-2' }}%</p>
|
||||
<small>Total signals: {{ dashboard.metrics.totalSignals }}</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Avg latency</h2>
|
||||
<p>{{ dashboard.metrics.averageLatencyMs | number:'1.0-0' }} ms</p>
|
||||
<small>Gateway-backed when available</small>
|
||||
</article>
|
||||
<app-metric-card
|
||||
label="Signals / sec"
|
||||
[value]="(dashboard.metrics.signalsPerSecond | number:'1.0-2') ?? '--'"
|
||||
deltaDirection="up-is-good"
|
||||
[subtitle]="'Last hour events: ' + dashboard.metrics.lastHourCount"
|
||||
/>
|
||||
<app-metric-card
|
||||
label="Error rate"
|
||||
[value]="(dashboard.metrics.errorRatePercent | number:'1.0-2') ?? '--'"
|
||||
unit="%"
|
||||
deltaDirection="up-is-bad"
|
||||
[subtitle]="'Total signals: ' + dashboard.metrics.totalSignals"
|
||||
/>
|
||||
<app-metric-card
|
||||
label="Avg latency"
|
||||
[value]="(dashboard.metrics.averageLatencyMs | number:'1.0-0') ?? '--'"
|
||||
unit="ms"
|
||||
deltaDirection="up-is-bad"
|
||||
subtitle="Gateway-backed when available"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid">
|
||||
@@ -174,31 +180,7 @@ import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashb
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--color-surface-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.metric-card h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.metric-card p {
|
||||
margin: 0.4rem 0 0.2rem;
|
||||
font-size: 1.8rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.metric-card small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
/* metric-card styling handled by the canonical MetricCardComponent */
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -18,7 +18,7 @@ export * from './context-route-state/context-route-state';
|
||||
|
||||
// Data display
|
||||
export * from './status-badge/status-badge.component';
|
||||
export * from './metric-card/metric-card.component';
|
||||
export { MetricCardComponent, DeltaDirection, MetricSeverity } from './metric-card/metric-card.component';
|
||||
export * from './timeline-list/timeline-list.component';
|
||||
|
||||
// Utility
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Metric Card Component Tests
|
||||
* Sprint: SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation (FE-MCD-004)
|
||||
*
|
||||
* Covers:
|
||||
* - Normal rendering with all inputs
|
||||
* - Delta direction semantics (up-is-good vs up-is-bad vs neutral)
|
||||
* - Loading / empty / error states
|
||||
* - Severity accent rendering
|
||||
* - Accessibility (ARIA labels)
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MetricCardComponent, DeltaDirection, MetricSeverity } from './metric-card.component';
|
||||
|
||||
describe('MetricCardComponent', () => {
|
||||
let fixture: ComponentFixture<MetricCardComponent>;
|
||||
let component: MetricCardComponent;
|
||||
let el: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MetricCardComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MetricCardComponent);
|
||||
component = fixture.componentInstance;
|
||||
el = fixture.nativeElement;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Normal rendering
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('normal rendering', () => {
|
||||
it('should render label and value', () => {
|
||||
component.label = 'Total Scans';
|
||||
component.value = 1234;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__label')?.textContent?.trim()).toBe('Total Scans');
|
||||
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('1,234');
|
||||
});
|
||||
|
||||
it('should render string values as-is', () => {
|
||||
component.label = 'Status';
|
||||
component.value = 'Healthy';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('Healthy');
|
||||
});
|
||||
|
||||
it('should render unit when provided', () => {
|
||||
component.label = 'Latency';
|
||||
component.value = 42;
|
||||
component.unit = 'ms';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__unit')?.textContent?.trim()).toBe('ms');
|
||||
});
|
||||
|
||||
it('should not render unit when not provided', () => {
|
||||
component.label = 'Count';
|
||||
component.value = 10;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__unit')).toBeNull();
|
||||
});
|
||||
|
||||
it('should render subtitle when provided', () => {
|
||||
component.label = 'Error Rate';
|
||||
component.value = '0.5%';
|
||||
component.subtitle = 'Platform-wide';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__subtitle')?.textContent?.trim()).toBe('Platform-wide');
|
||||
});
|
||||
|
||||
it('should not render subtitle when not provided', () => {
|
||||
component.label = 'Count';
|
||||
component.value = 5;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__subtitle')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Delta display
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('delta display', () => {
|
||||
it('should show positive delta with + sign and up arrow', () => {
|
||||
component.label = 'Throughput';
|
||||
component.value = 200;
|
||||
component.delta = 12.5;
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.textContent?.trim()).toContain('+12.5%');
|
||||
|
||||
// Should have an up arrow SVG
|
||||
const svg = deltaEl?.querySelector('svg');
|
||||
expect(svg).toBeTruthy();
|
||||
expect(deltaEl?.querySelector('polyline')?.getAttribute('points')).toContain('17,11');
|
||||
});
|
||||
|
||||
it('should show negative delta with - sign and down arrow', () => {
|
||||
component.label = 'Errors';
|
||||
component.value = 3;
|
||||
component.delta = -8;
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.textContent?.trim()).toContain('-8%');
|
||||
|
||||
// Should have a down arrow SVG
|
||||
expect(deltaEl?.querySelector('polyline')?.getAttribute('points')).toContain('7,13');
|
||||
});
|
||||
|
||||
it('should show zero delta without arrow', () => {
|
||||
component.label = 'Stable';
|
||||
component.value = 100;
|
||||
component.delta = 0;
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.textContent?.trim()).toContain('0%');
|
||||
expect(deltaEl?.querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render delta when undefined', () => {
|
||||
component.label = 'Simple';
|
||||
component.value = 42;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__delta')).toBeNull();
|
||||
});
|
||||
|
||||
it('should format integer delta without decimal', () => {
|
||||
component.label = 'Test';
|
||||
component.value = 10;
|
||||
component.delta = 5;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('+5%');
|
||||
});
|
||||
|
||||
it('should format fractional delta with one decimal', () => {
|
||||
component.label = 'Test';
|
||||
component.value = 10;
|
||||
component.delta = 5.7;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('+5.7%');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Delta direction semantics
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('delta direction semantics', () => {
|
||||
it('up-is-good: positive delta should be green (--good class)', () => {
|
||||
component.label = 'Uptime';
|
||||
component.value = '99.9%';
|
||||
component.delta = 2;
|
||||
component.deltaDirection = 'up-is-good';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(true);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(false);
|
||||
});
|
||||
|
||||
it('up-is-good: negative delta should be red (--bad class)', () => {
|
||||
component.label = 'Uptime';
|
||||
component.value = '97%';
|
||||
component.delta = -3;
|
||||
component.deltaDirection = 'up-is-good';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(true);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(false);
|
||||
});
|
||||
|
||||
it('up-is-bad: positive delta should be red (--bad class)', () => {
|
||||
component.label = 'Error Rate';
|
||||
component.value = '5.2%';
|
||||
component.delta = 1.5;
|
||||
component.deltaDirection = 'up-is-bad';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(true);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(false);
|
||||
});
|
||||
|
||||
it('up-is-bad: negative delta should be green (--good class)', () => {
|
||||
component.label = 'Vulnerabilities';
|
||||
component.value = 12;
|
||||
component.delta = -20;
|
||||
component.deltaDirection = 'up-is-bad';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(true);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(false);
|
||||
});
|
||||
|
||||
it('neutral: positive delta should be gray (--neutral class)', () => {
|
||||
component.label = 'Signals';
|
||||
component.value = 450;
|
||||
component.delta = 10;
|
||||
component.deltaDirection = 'neutral';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--neutral')).toBe(true);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(false);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(false);
|
||||
});
|
||||
|
||||
it('neutral: negative delta should be gray (--neutral class)', () => {
|
||||
component.label = 'Signals';
|
||||
component.value = 400;
|
||||
component.delta = -5;
|
||||
component.deltaDirection = 'neutral';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--neutral')).toBe(true);
|
||||
});
|
||||
|
||||
it('zero delta should always be neutral', () => {
|
||||
component.label = 'Count';
|
||||
component.value = 10;
|
||||
component.delta = 0;
|
||||
component.deltaDirection = 'up-is-good';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--neutral')).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults to up-is-good when deltaDirection is not set', () => {
|
||||
component.label = 'Default';
|
||||
component.value = 50;
|
||||
component.delta = 5;
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Severity state
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('severity state', () => {
|
||||
const severities: MetricSeverity[] = ['healthy', 'warning', 'critical', 'unknown'];
|
||||
|
||||
for (const sev of severities) {
|
||||
it(`should apply --${sev} class when severity is '${sev}'`, () => {
|
||||
component.label = 'Health';
|
||||
component.value = sev;
|
||||
component.severity = sev;
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = el.querySelector('.metric-card');
|
||||
expect(card?.classList.contains(`metric-card--${sev}`)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it('should not apply severity class when severity is undefined', () => {
|
||||
component.label = 'No Sev';
|
||||
component.value = 42;
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = el.querySelector('.metric-card');
|
||||
expect(card?.classList.contains('metric-card--healthy')).toBe(false);
|
||||
expect(card?.classList.contains('metric-card--warning')).toBe(false);
|
||||
expect(card?.classList.contains('metric-card--critical')).toBe(false);
|
||||
expect(card?.classList.contains('metric-card--unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Loading state
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should render skeleton placeholders when loading', () => {
|
||||
component.label = 'Loading';
|
||||
component.value = 0;
|
||||
component.loading = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = el.querySelector('.metric-card');
|
||||
expect(card?.classList.contains('metric-card--loading')).toBe(true);
|
||||
|
||||
const skeletons = el.querySelectorAll('.metric-card__skeleton');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not render actual value when loading', () => {
|
||||
component.label = 'Loading';
|
||||
component.value = 999;
|
||||
component.loading = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
// Value should not appear in the rendered output
|
||||
expect(el.textContent).not.toContain('999');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should render -- value when empty', () => {
|
||||
component.label = 'Empty Metric';
|
||||
component.value = 0;
|
||||
component.empty = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card--empty')).toBeTruthy();
|
||||
expect(el.querySelector('.metric-card__value--empty')?.textContent?.trim()).toBe('--');
|
||||
});
|
||||
|
||||
it('should still render subtitle when empty with subtitle', () => {
|
||||
component.label = 'Empty';
|
||||
component.value = 0;
|
||||
component.empty = true;
|
||||
component.subtitle = 'No data available';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__subtitle')?.textContent?.trim()).toBe('No data available');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Error state
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('error state', () => {
|
||||
it('should render error state with message', () => {
|
||||
component.label = 'Broken Metric';
|
||||
component.value = 0;
|
||||
component.error = 'Service unavailable';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card--error')).toBeTruthy();
|
||||
expect(el.querySelector('.metric-card__value--error')?.textContent?.trim()).toBe('--');
|
||||
expect(el.querySelector('.metric-card__subtitle--error')?.textContent?.trim()).toBe('Service unavailable');
|
||||
});
|
||||
|
||||
it('should show label even in error state', () => {
|
||||
component.label = 'Error Label';
|
||||
component.value = 0;
|
||||
component.error = 'Timeout';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__label')?.textContent?.trim()).toBe('Error Label');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Accessibility
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have role="group" on the card container', () => {
|
||||
component.label = 'Test';
|
||||
component.value = 42;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card')?.getAttribute('role')).toBe('group');
|
||||
});
|
||||
|
||||
it('should have a composite aria-label with value and unit', () => {
|
||||
component.label = 'Latency';
|
||||
component.value = 150;
|
||||
component.unit = 'ms';
|
||||
fixture.detectChanges();
|
||||
|
||||
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
|
||||
expect(ariaLabel).toContain('Latency');
|
||||
expect(ariaLabel).toContain('150');
|
||||
expect(ariaLabel).toContain('ms');
|
||||
});
|
||||
|
||||
it('should include delta in aria-label when present', () => {
|
||||
component.label = 'Rate';
|
||||
component.value = 10;
|
||||
component.delta = 5;
|
||||
fixture.detectChanges();
|
||||
|
||||
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
|
||||
expect(ariaLabel).toContain('+5%');
|
||||
});
|
||||
|
||||
it('should include severity in aria-label when present', () => {
|
||||
component.label = 'Health';
|
||||
component.value = 'Good';
|
||||
component.severity = 'healthy';
|
||||
fixture.detectChanges();
|
||||
|
||||
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
|
||||
expect(ariaLabel).toContain('healthy');
|
||||
});
|
||||
|
||||
it('should indicate loading in aria-label', () => {
|
||||
component.label = 'Loading';
|
||||
component.value = 0;
|
||||
component.loading = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card')?.getAttribute('aria-label')).toContain('loading');
|
||||
});
|
||||
|
||||
it('should indicate error in aria-label', () => {
|
||||
component.label = 'Broken';
|
||||
component.value = 0;
|
||||
component.error = 'Connection lost';
|
||||
fixture.detectChanges();
|
||||
|
||||
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
|
||||
expect(ariaLabel).toContain('error');
|
||||
expect(ariaLabel).toContain('Connection lost');
|
||||
});
|
||||
|
||||
it('should indicate no data in aria-label when empty', () => {
|
||||
component.label = 'Empty';
|
||||
component.value = 0;
|
||||
component.empty = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card')?.getAttribute('aria-label')).toContain('no data');
|
||||
});
|
||||
|
||||
it('should have aria-label on delta badge with favorable/unfavorable', () => {
|
||||
component.label = 'Good';
|
||||
component.value = 99;
|
||||
component.delta = 5;
|
||||
component.deltaDirection = 'up-is-good';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaAriaLabel = el.querySelector('.metric-card__delta')?.getAttribute('aria-label');
|
||||
expect(deltaAriaLabel).toContain('favorable');
|
||||
});
|
||||
|
||||
it('should label unfavorable delta in up-is-bad mode', () => {
|
||||
component.label = 'Bad';
|
||||
component.value = 50;
|
||||
component.delta = 10;
|
||||
component.deltaDirection = 'up-is-bad';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaAriaLabel = el.querySelector('.metric-card__delta')?.getAttribute('aria-label');
|
||||
expect(deltaAriaLabel).toContain('unfavorable');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,31 @@
|
||||
/**
|
||||
* Metric Card Component
|
||||
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
|
||||
* Metric Card Component - Canonical KPI Card
|
||||
* Sprint: SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation (FE-MCD-002)
|
||||
*
|
||||
* Displays a metric with label, value, and optional delta.
|
||||
* Reusable KPI card for dashboard grids. Supports:
|
||||
* - Semantic delta with configurable direction (up-is-good / up-is-bad / neutral)
|
||||
* - Severity/health state coloring (healthy / warning / critical / unknown)
|
||||
* - Optional unit display
|
||||
* - Loading, empty, and error states
|
||||
* - Dense dashboard grid responsiveness
|
||||
* - ARIA accessibility
|
||||
*/
|
||||
|
||||
import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/core';
|
||||
import { Component, Input, ChangeDetectionStrategy, computed, input } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Controls color semantics for delta values.
|
||||
* - 'up-is-good': positive delta = green, negative = red (e.g., uptime, throughput)
|
||||
* - 'up-is-bad': positive delta = red, negative = green (e.g., error rate, latency, vulnerabilities)
|
||||
* - 'neutral': delta is always neutral gray, no good/bad implication
|
||||
*/
|
||||
export type DeltaDirection = 'up-is-good' | 'up-is-bad' | 'neutral';
|
||||
|
||||
/**
|
||||
* Health/severity state for the card.
|
||||
* Applied as a left-border accent and optional value color.
|
||||
*/
|
||||
export type MetricSeverity = 'healthy' | 'warning' | 'critical' | 'unknown';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metric-card',
|
||||
@@ -14,38 +33,116 @@ import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/co
|
||||
imports: [],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
@if (delta !== undefined && delta !== null) {
|
||||
<span
|
||||
class="metric-card__delta"
|
||||
[class.metric-card__delta--positive]="delta > 0"
|
||||
[class.metric-card__delta--negative]="delta < 0"
|
||||
>
|
||||
{{ deltaDisplay() }}
|
||||
</span>
|
||||
<div
|
||||
class="metric-card"
|
||||
[class.metric-card--loading]="loading"
|
||||
[class.metric-card--empty]="empty"
|
||||
[class.metric-card--error]="error"
|
||||
[class.metric-card--healthy]="severity === 'healthy'"
|
||||
[class.metric-card--warning]="severity === 'warning'"
|
||||
[class.metric-card--critical]="severity === 'critical'"
|
||||
[class.metric-card--unknown]="severity === 'unknown'"
|
||||
[attr.role]="'group'"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
>
|
||||
@if (loading) {
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label metric-card__skeleton"> </span>
|
||||
</div>
|
||||
<div class="metric-card__value metric-card__skeleton"> </div>
|
||||
<div class="metric-card__subtitle metric-card__skeleton"> </div>
|
||||
} @else if (error) {
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
</div>
|
||||
<div class="metric-card__value metric-card__value--error">--</div>
|
||||
<div class="metric-card__subtitle metric-card__subtitle--error">{{ error }}</div>
|
||||
} @else if (empty) {
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
</div>
|
||||
<div class="metric-card__value metric-card__value--empty">--</div>
|
||||
@if (subtitle) {
|
||||
<div class="metric-card__subtitle">{{ subtitle }}</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
@if (delta !== undefined && delta !== null) {
|
||||
<span
|
||||
class="metric-card__delta"
|
||||
[class]="deltaColorClass()"
|
||||
[attr.aria-label]="deltaAriaLabel()"
|
||||
>
|
||||
@if (deltaIcon() === 'up') {
|
||||
<svg class="metric-card__delta-icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="17,11 12,6 7,11"></polyline>
|
||||
<line x1="12" y1="6" x2="12" y2="18"></line>
|
||||
</svg>
|
||||
} @else if (deltaIcon() === 'down') {
|
||||
<svg class="metric-card__delta-icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="7,13 12,18 17,13"></polyline>
|
||||
<line x1="12" y1="18" x2="12" y2="6"></line>
|
||||
</svg>
|
||||
}
|
||||
{{ deltaDisplay() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="metric-card__value-row">
|
||||
<span class="metric-card__value">{{ formattedValue() }}</span>
|
||||
@if (unit) {
|
||||
<span class="metric-card__unit">{{ unit }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (subtitle) {
|
||||
<div class="metric-card__subtitle">{{ subtitle }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="metric-card__value">{{ value }}</div>
|
||||
@if (subtitle) {
|
||||
<div class="metric-card__subtitle">{{ subtitle }}</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
border-color: var(--color-border-secondary);
|
||||
}
|
||||
|
||||
/* Severity accents */
|
||||
.metric-card--healthy {
|
||||
border-left: 3px solid var(--color-status-success);
|
||||
}
|
||||
|
||||
.metric-card--warning {
|
||||
border-left: 3px solid var(--color-status-warning);
|
||||
}
|
||||
|
||||
.metric-card--critical {
|
||||
border-left: 3px solid var(--color-status-error);
|
||||
}
|
||||
|
||||
.metric-card--unknown {
|
||||
border-left: 3px solid var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.metric-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
@@ -54,23 +151,51 @@ import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/co
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Delta badge */
|
||||
.metric-card__delta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-card__delta--positive {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
.metric-card__delta-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-card__delta--negative {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-status-error-text);
|
||||
.metric-card__delta--good {
|
||||
background: var(--color-status-success-bg, rgba(34, 197, 94, 0.1));
|
||||
color: var(--color-status-success-text, #16a34a);
|
||||
}
|
||||
|
||||
.metric-card__delta--bad {
|
||||
background: var(--color-status-error-bg, rgba(239, 68, 68, 0.1));
|
||||
color: var(--color-status-error-text, #dc2626);
|
||||
}
|
||||
|
||||
.metric-card__delta--neutral {
|
||||
background: var(--color-surface-tertiary, rgba(107, 114, 128, 0.1));
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* Value row */
|
||||
.metric-card__value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
@@ -78,24 +203,182 @@ import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/co
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.metric-card__value--empty {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.metric-card__value--error {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.metric-card__unit {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Subtitle */
|
||||
.metric-card__subtitle {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.metric-card__subtitle--error {
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
.metric-card--loading .metric-card__skeleton {
|
||||
background: var(--color-skeleton-base, rgba(107, 114, 128, 0.15));
|
||||
border-radius: var(--radius-sm);
|
||||
animation: metric-card-pulse 1.5s ease-in-out infinite;
|
||||
color: transparent;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.metric-card--loading .metric-card__label.metric-card__skeleton {
|
||||
width: 60%;
|
||||
min-height: 0.875rem;
|
||||
}
|
||||
|
||||
.metric-card--loading .metric-card__value.metric-card__skeleton {
|
||||
width: 40%;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.metric-card--loading .metric-card__subtitle.metric-card__skeleton {
|
||||
width: 80%;
|
||||
min-height: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@keyframes metric-card-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Responsive dense grids */
|
||||
@media (max-width: 640px) {
|
||||
.metric-card__value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MetricCardComponent {
|
||||
@Input() label!: string;
|
||||
@Input() value!: string | number;
|
||||
/** Metric label / name */
|
||||
@Input({ required: true }) label!: string;
|
||||
|
||||
/** Current metric value */
|
||||
@Input({ required: true }) value!: string | number;
|
||||
|
||||
/** Optional display unit (e.g., 'ms', '%', '/hr', 'GB') */
|
||||
@Input() unit?: string;
|
||||
|
||||
/** Percentage delta change. Sign determines arrow direction. */
|
||||
@Input() delta?: number;
|
||||
|
||||
/**
|
||||
* Controls color semantics for delta values.
|
||||
* - 'up-is-good': positive delta = green (e.g., uptime, throughput, healthy count)
|
||||
* - 'up-is-bad': positive delta = red (e.g., error rate, latency, vulnerability count)
|
||||
* - 'neutral': delta always shown in gray
|
||||
*
|
||||
* Default: 'up-is-good'
|
||||
*/
|
||||
@Input() deltaDirection: DeltaDirection = 'up-is-good';
|
||||
|
||||
/** Optional health/severity state. Renders a colored left-border accent. */
|
||||
@Input() severity?: MetricSeverity;
|
||||
|
||||
/** Supporting context line below the value */
|
||||
@Input() subtitle?: string;
|
||||
|
||||
deltaDisplay = computed(() => {
|
||||
/** Show loading skeleton */
|
||||
@Input() loading = false;
|
||||
|
||||
/** Show empty/no-data state */
|
||||
@Input() empty = false;
|
||||
|
||||
/** Error message string. When truthy, renders error state. */
|
||||
@Input() error?: string;
|
||||
|
||||
/** Formatted display value with locale-aware number formatting */
|
||||
readonly formattedValue = computed(() => {
|
||||
const val = this.value;
|
||||
if (typeof val === 'string') return val;
|
||||
if (typeof val === 'number') {
|
||||
if (Number.isFinite(val)) {
|
||||
return val.toLocaleString();
|
||||
}
|
||||
return '--';
|
||||
}
|
||||
return String(val);
|
||||
});
|
||||
|
||||
/** Delta display text: "+12.3%" or "-5.1%" */
|
||||
readonly deltaDisplay = computed(() => {
|
||||
if (this.delta === undefined || this.delta === null) return '';
|
||||
const sign = this.delta > 0 ? '+' : '';
|
||||
return `${sign}${this.delta}%`;
|
||||
const abs = Math.abs(this.delta);
|
||||
const formatted = abs % 1 === 0 ? abs.toString() : abs.toFixed(1);
|
||||
const sign = this.delta > 0 ? '+' : this.delta < 0 ? '-' : '';
|
||||
return `${sign}${formatted}%`;
|
||||
});
|
||||
|
||||
/** Arrow direction based on delta sign */
|
||||
readonly deltaIcon = computed((): 'up' | 'down' | null => {
|
||||
if (this.delta === undefined || this.delta === null || this.delta === 0) return null;
|
||||
return this.delta > 0 ? 'up' : 'down';
|
||||
});
|
||||
|
||||
/** CSS class for delta badge based on direction semantics */
|
||||
readonly deltaColorClass = computed((): string => {
|
||||
const base = 'metric-card__delta';
|
||||
if (this.delta === undefined || this.delta === null || this.delta === 0) {
|
||||
return `${base} ${base}--neutral`;
|
||||
}
|
||||
|
||||
if (this.deltaDirection === 'neutral') {
|
||||
return `${base} ${base}--neutral`;
|
||||
}
|
||||
|
||||
const isPositive = this.delta > 0;
|
||||
const isGood =
|
||||
(this.deltaDirection === 'up-is-good' && isPositive) ||
|
||||
(this.deltaDirection === 'up-is-bad' && !isPositive);
|
||||
|
||||
return `${base} ${isGood ? `${base}--good` : `${base}--bad`}`;
|
||||
});
|
||||
|
||||
/** Composite ARIA label for the entire card */
|
||||
readonly ariaLabel = computed(() => {
|
||||
if (this.loading) return `${this.label}: loading`;
|
||||
if (this.error) return `${this.label}: error, ${this.error}`;
|
||||
if (this.empty) return `${this.label}: no data`;
|
||||
|
||||
const val = this.formattedValue();
|
||||
const unitStr = this.unit ? ` ${this.unit}` : '';
|
||||
const deltaStr = this.delta != null ? `, ${this.deltaDisplay()}` : '';
|
||||
const severityStr = this.severity ? `, ${this.severity}` : '';
|
||||
return `${this.label}: ${val}${unitStr}${deltaStr}${severityStr}`;
|
||||
});
|
||||
|
||||
/** ARIA label specifically for the delta badge */
|
||||
readonly deltaAriaLabel = computed(() => {
|
||||
if (this.delta === undefined || this.delta === null) return '';
|
||||
const display = this.deltaDisplay();
|
||||
if (this.deltaDirection === 'neutral') return `Change: ${display}`;
|
||||
|
||||
const isPositive = this.delta > 0;
|
||||
const isGood =
|
||||
(this.deltaDirection === 'up-is-good' && isPositive) ||
|
||||
(this.deltaDirection === 'up-is-bad' && !isPositive);
|
||||
|
||||
return `Change: ${display}, ${isGood ? 'favorable' : 'unfavorable'}`;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user