diff --git a/docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md b/docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md index 113fafa93..fed01498a 100644 --- a/docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md +++ b/docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md @@ -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 `` +2. `search-quality-dashboard.component.ts` - 4 bespoke metric divs replaced with `` +3. `delivery-analytics.component.ts` - 5 of 6 bespoke metric divs replaced with `` (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. diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 4296c75ba..6bc358f68 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -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` diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index a60c940da..3b578108b 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.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`. diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-analytics.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-analytics.component.ts index bd0ca18e3..d0ce6318c 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-analytics.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-analytics.component.ts @@ -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: `
@@ -60,60 +61,41 @@ import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notif
-
-
- S - Total Sent -
-
{{ formatNumber(stats()!.totalSent) }}
-
- Delivered successfully -
-
+ -
-
- F - Failed -
-
{{ formatNumber(stats()!.totalFailed) }}
-
- Require attention -
-
+ -
-
- P - Pending -
-
{{ formatNumber(stats()!.totalPending) }}
-
- In queue -
-
+ -
-
- T - Throttled -
-
{{ formatNumber(stats()!.totalThrottled) }}
-
- Rate limited -
-
+ -
-
- L - Avg Latency -
-
{{ stats()!.avgDeliveryTimeMs }}ms
-
- Average delivery time -
-
+ diff --git a/src/Web/StellaOps.Web/src/app/features/operations/search-quality/search-quality-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/operations/search-quality/search-quality-dashboard.component.ts index f743c07e3..6e85826ae 100644 --- a/src/Web/StellaOps.Web/src/app/features/operations/search-quality/search-quality-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/operations/search-quality/search-quality-dashboard.component.ts @@ -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: `
@@ -42,26 +44,30 @@ import type {
-
-
{{ metrics()?.totalSearches ?? 0 }}
-
Total Searches
-
-
-
- {{ metrics()?.zeroResultRate ?? 0 }}% -
-
Zero-Result Rate
-
-
-
{{ metrics()?.avgResultCount ?? 0 }}
-
Avg Results / Query
-
-
-
- {{ metrics()?.feedbackScore ?? 0 }}% -
-
Feedback Score (Helpful)
-
+ + + +
@@ -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; diff --git a/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts index 27e53fd91..a09ead005 100644 --- a/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts @@ -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: `
@@ -27,21 +28,26 @@ import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashb @if (vm(); as dashboard) {
-
-

Signals / sec

-

{{ dashboard.metrics.signalsPerSecond | number:'1.0-2' }}

- Last hour events: {{ dashboard.metrics.lastHourCount }} -
-
-

Error rate

-

{{ dashboard.metrics.errorRatePercent | number:'1.0-2' }}%

- Total signals: {{ dashboard.metrics.totalSignals }} -
-
-

Avg latency

-

{{ dashboard.metrics.averageLatencyMs | number:'1.0-0' }} ms

- Gateway-backed when available -
+ + +
@@ -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; diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/index.ts b/src/Web/StellaOps.Web/src/app/shared/ui/index.ts index 6ab5bf3ca..0d096bb86 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/index.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/index.ts @@ -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 diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.spec.ts new file mode 100644 index 000000000..9bf05659f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.spec.ts @@ -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; + 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'); + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.ts index 802bcb7d3..512b310df 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.ts @@ -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: ` -
-
- {{ label }} - @if (delta !== undefined && delta !== null) { - - {{ deltaDisplay() }} - +
+ @if (loading) { +
+   +
+
 
+
 
+ } @else if (error) { +
+ {{ label }} +
+
--
+
{{ error }}
+ } @else if (empty) { +
+ {{ label }} +
+
--
+ @if (subtitle) { +
{{ subtitle }}
+ } + } @else { +
+ {{ label }} + @if (delta !== undefined && delta !== null) { + + @if (deltaIcon() === 'up') { + + } @else if (deltaIcon() === 'down') { + + } + {{ deltaDisplay() }} + + } +
+
+ {{ formattedValue() }} + @if (unit) { + {{ unit }} + } +
+ @if (subtitle) { +
{{ subtitle }}
} -
-
{{ value }}
- @if (subtitle) { -
{{ subtitle }}
}
`, 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'}`; }); }