feat(web): derive timeline-list into canonical audit-grade event-stream timeline [SPRINT-029]

Rework the orphan TimelineListComponent into a canonical audit-grade
event-stream primitive for all mounted chronology surfaces.

Canonical event model (FE-TLD-001):
- TimelineEvent with id, timestamp (ISO-8601 UTC), title, description,
  actor, eventKind (info/success/warning/error/critical/neutral), icon,
  evidenceLink, metadata key-value pairs, and expandable detail payload
- Relative time for <24h, absolute UTC for >=24h, full ISO on tooltip
- Date grouping when events span multiple days

Derived primitive (FE-TLD-002):
- Vertical timeline with colored severity markers
- Deterministic UTC timestamp formatting
- Expandable detail sections with expand/collapse toggle
- Optional actor, metadata chips, and evidence links
- Loading skeleton and empty state
- Accessibility: role="feed", role="article", aria-labels, datetime attrs
- Content projection via ng-template for domain-specific rendering

Adopted on 3 surfaces (FE-TLD-003):
- incident-timeline: replaces bespoke inline timeline markers with shared
  component; preserves affected-services chips and correlated-events via
  expandable and content projection
- audit-timeline-search: replaces bespoke timeline rendering; preserves
  module/action badges via content projection
- releases-activity: replaces timeline view mode (was rendering duplicate
  table) with canonical timeline; preserves lane/env/outcome chips

Tests (FE-TLD-004): 32 focused tests covering event rendering, severity
markers, timestamp formatting, expandable toggle, loading/empty states,
date grouping, accessibility, and default fallbacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-08 23:22:26 +02:00
parent 12a6ef831b
commit d27d68d8c6
8 changed files with 1427 additions and 323 deletions

View File

@@ -20,7 +20,7 @@
## Delivery Tracker
### FE-TLD-001 - Freeze the canonical event model
Status: TODO
Status: DONE
Dependency: none
Owners: UX, Product Manager
Task description:
@@ -28,12 +28,12 @@ Task description:
- Decide where relative time, absolute time, and grouping should appear so audit and ops surfaces remain truthful and scannable.
Completion criteria:
- [ ] A canonical event model exists for mounted timeline surfaces.
- [ ] Rules for relative vs absolute time display are documented.
- [ ] Grouping or expansion expectations are defined before implementation.
- [x] A canonical event model exists for mounted timeline surfaces.
- [x] Rules for relative vs absolute time display are documented.
- [x] Grouping or expansion expectations are defined before implementation.
### FE-TLD-002 - Derive the shared timeline primitive
Status: TODO
Status: DONE
Dependency: FE-TLD-001
Owners: Developer (FE)
Task description:
@@ -41,45 +41,57 @@ Task description:
- Avoid keeping a toy timeline component that cannot carry actual operator evidence.
Completion criteria:
- [ ] The shared timeline primitive supports the agreed event model.
- [ ] Timestamp rendering is deterministic and appropriate for audit-grade surfaces.
- [ ] The component supports richer detail than the current orphan implementation.
- [x] The shared timeline primitive supports the agreed event model.
- [x] Timestamp rendering is deterministic and appropriate for audit-grade surfaces.
- [x] The component supports richer detail than the current orphan implementation.
### FE-TLD-003 - Adopt the derived timeline on mounted chronology surfaces
Status: TODO
Status: DONE
Dependency: FE-TLD-002
Owners: Developer (FE), UX
Task description:
- Adopt the derived timeline on a small set of mounted chronology surfaces where it improves consistency without flattening domain-specific meaning.
- Use the adoption set to validate both compact event streams and denser evidence timelines.
Adoption surfaces:
1. **Incident Timeline** (`features/platform-health/incident-timeline.component.ts`) - replaced bespoke inline timeline with canonical component, preserving domain-specific affected-services chips and correlated-events expandable.
2. **Audit Timeline Search** (`features/audit-log/audit-timeline-search.component.ts`) - replaced bespoke inline timeline with canonical component, preserving module/action badge rendering via content projection.
3. **Releases Activity** (`features/releases/releases-activity.component.ts`) - replaced the timeline view mode (which was rendering a table identical to the table view) with the canonical timeline, preserving lane/environment/outcome chips via content projection.
Completion criteria:
- [ ] A bounded set of mounted chronology surfaces adopt the shared timeline.
- [ ] Timeline UX improves on scanability and event meaning.
- [ ] Domain-specific context is preserved, not lost to over-generalization.
- [x] A bounded set of mounted chronology surfaces adopt the shared timeline.
- [x] Timeline UX improves on scanability and event meaning.
- [x] Domain-specific context is preserved, not lost to over-generalization.
### FE-TLD-004 - Verify and document the derivation
Status: TODO
Status: DONE
Dependency: FE-TLD-003
Owners: Test Automation, Documentation author
Task description:
- Add focused regression coverage for timeline formatting and document the canonical timeline contract and adoption choices.
Completion criteria:
- [ ] Tests cover core timeline rendering and timestamp behavior.
- [ ] Docs explain where the shared timeline is appropriate and where bespoke views still make sense.
- [ ] The old orphan classification becomes intentional and documented.
- [x] Tests cover core timeline rendering and timestamp behavior.
- [x] Docs explain where the shared timeline is appropriate and where bespoke views still make sense.
- [x] The old orphan classification becomes intentional and documented.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-08 | Sprint created to derive the unused timeline-list into a canonical event-stream pattern for mounted audit and evidence chronologies. | Codex |
| 2026-03-08 | FE-TLD-001 DONE: Frozen canonical event model with TimelineEvent interface (id, timestamp, title, description, actor, eventKind, icon, evidenceLink, metadata, expandable). Time display rules: relative <24h, absolute UTC ISO-8601 >=24h, full ISO on tooltip. Date grouping supported. | Developer |
| 2026-03-08 | FE-TLD-002 DONE: Derived TimelineListComponent with vertical timeline, colored severity markers (info/success/warning/error/critical/neutral), deterministic UTC timestamps, expandable detail sections, actor/source metadata, date grouping, loading skeleton, empty state, accessibility (role="feed", aria-labels), and content projection. | Developer |
| 2026-03-08 | FE-TLD-003 DONE: Adopted on 3 surfaces: incident-timeline, audit-timeline-search, releases-activity (timeline view mode). Domain-specific context preserved via content projection. | Developer |
| 2026-03-08 | FE-TLD-004 DONE: 32 focused tests covering event rendering, severity markers, timestamp formatting (relative vs absolute), expandable toggle, loading/empty states, date grouping, accessibility, and default fallbacks. Build passes. | Developer |
## Decisions & Risks
- Risk: oversimplifying audit/evidence timelines could erase domain meaning or precision.
- Mitigation: freeze the event model first and adopt only on bounded surfaces where the shared primitive fits cleanly.
- Decision: Excluded witness/evidence hosts (sprint 031 territory), VEX timeline (domain-specific source-consensus visualization), pedigree timeline (horizontal ancestry lineage), observation timeline (SVG bar chart), and explainer timeline (process steps) from adoption because they are fundamentally different visualization patterns, not generic event streams.
- Decision: Used content projection (ng-template #eventContent) to allow adopting surfaces to render domain-specific chips, badges, and links without modifying the shared component.
- Decision: The `eventKind` field uses 'critical' as a distinct severity above 'error' (with visual emphasis via box-shadow ring).
## Next Checkpoints
- Freeze the event model and time-display rules.
- Build the richer shared timeline primitive.
- Adopt it on a bounded set of mounted chronology surfaces.
- Freeze the event model and time-display rules. -- DONE
- Build the richer shared timeline primitive. -- DONE
- Adopt it on a bounded set of mounted chronology surfaces. -- DONE

View File

@@ -24,7 +24,7 @@
- `docs/implplan/SPRINT_20260308_026_FE_settings_information_architecture_rationalization.md`
- [DONE] `docs/implplan/SPRINT_20260308_027_FE_page_header_context_header_derivation.md` - Derived `PageHeaderComponent` into canonical `ContextHeaderComponent` with unified header contract, adopted on 4 target pages, 15 focused tests.
- [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`
- [DONE] `docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md` - Derived canonical audit-grade timeline-list primitive. Adopted on incident-timeline, audit-timeline-search, and releases-activity.
- [DONE] `docs/implplan/SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation.md` - Consolidated SplitPaneComponent into ListDetailShellComponent as the canonical master-detail layout primitive. Added collapsible toggle, detail slide-in animation, and accessibility roles. Adopted on signing-key-dashboard. SplitPaneComponent deprecated.
- `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md`

View File

@@ -17,6 +17,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- 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 `029` (TimelineListComponent derivation) is DONE. Canonical audit-grade timeline primitive with 6 severity levels, UTC timestamps, expandable detail, date grouping, and content projection. Adopted on incident-timeline, audit-timeline-search, and releases-activity. See `docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md`.
- Sprint `030` (SplitPaneComponent consolidation into ListDetailShellComponent) is DONE. The canonical master-detail layout primitive is `ListDetailShellComponent` with collapsible toggle support. `SplitPaneComponent` is deprecated. Adopted on signing-key-dashboard (trust-admin). See sprint file for contract details.
- 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`.

View File

@@ -1,14 +1,23 @@
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core';
// Updated: SPRINT_20260308_029_FE - Adopt canonical timeline-list (FE-TLD-003)
import { Component, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuditLogClient } from '../../core/api/audit-log.client';
import { AuditTimelineEntry } from '../../core/api/audit-log.models';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
function mapActionToKind(action: string): TimelineEventKind {
const lower = action.toLowerCase();
if (lower.includes('create') || lower.includes('approve') || lower.includes('success')) return 'success';
if (lower.includes('delete') || lower.includes('revoke') || lower.includes('fail')) return 'error';
if (lower.includes('update') || lower.includes('modify')) return 'warning';
return 'info';
}
@Component({
selector: 'app-audit-timeline-search',
imports: [RouterModule, FormsModule],
imports: [RouterModule, FormsModule, TimelineListComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="timeline-page">
@@ -32,36 +41,25 @@ import { AuditTimelineEntry } from '../../core/api/audit-log.models';
</button>
</div>
@if (entries().length > 0) {
<div class="timeline">
@for (entry of entries(); track entry.timestamp) {
<div class="timeline-entry">
<div class="timeline-marker">
<div class="marker-dot"></div>
<div class="marker-line"></div>
</div>
<div class="entry-content">
<div class="entry-time">{{ formatTime(entry.timestamp) }}</div>
@if (entry.clusterSize && entry.clusterSize > 1) {
<div class="cluster-badge">{{ entry.clusterSize }} events</div>
}
<div class="entry-events">
@for (event of entry.events; track event.id) {
<div class="event-item" [routerLink]="['/evidence/audit-log/events', event.id]">
<span class="badge module" [class]="event.module">{{ event.module }}</span>
<span class="badge action" [class]="event.action">{{ event.action }}</span>
<span class="actor">{{ event.actor.name }}</span>
<span class="desc">{{ event.description }}</span>
</div>
}
</div>
</div>
<!-- Canonical Timeline -->
<app-timeline-list
[events]="timelineEvents()"
[loading]="searching()"
[groupByDate]="true"
[emptyMessage]="searched() && !searching() ? 'No events found matching your search.' : 'Enter a search query to find audit events.'"
ariaLabel="Audit timeline search results"
>
<ng-template #eventContent let-event>
@if (event.metadata && event.metadata['module']) {
<div class="event-badges">
<span class="badge badge--module" [attr.data-module]="event.metadata['module']">{{ event.metadata['module'] }}</span>
@if (event.metadata['action']) {
<span class="badge badge--action">{{ event.metadata['action'] }}</span>
}
</div>
}
</div>
} @else if (searched() && !searching()) {
<div class="no-results">No events found matching your search.</div>
}
</ng-template>
</app-timeline-list>
</div>
`,
styles: [`
@@ -69,36 +67,27 @@ import { AuditTimelineEntry } from '../../core/api/audit-log.models';
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); margin: 0; }
.search-bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; margin-bottom: 2rem; }
.search-bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; margin-bottom: 1.5rem; }
.search-bar input[type="text"] { flex: 1; min-width: 250px; padding: 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: 1rem; }
.date-filters { display: flex; align-items: center; gap: 0.5rem; }
.date-filters input { padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); }
.date-filters span { color: var(--color-text-secondary); }
.btn-primary { background: var(--color-brand-primary); color: var(--color-text-heading); border: none; padding: 0.75rem 1.5rem; border-radius: var(--radius-sm); cursor: pointer; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.timeline { position: relative; }
.timeline-entry { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
.timeline-marker { display: flex; flex-direction: column; align-items: center; width: 20px; }
.marker-dot { width: 12px; height: 12px; border-radius: var(--radius-full); background: var(--color-brand-primary); border: 2px solid var(--color-surface-primary); z-index: 1; }
.marker-line { width: 2px; flex: 1; background: var(--color-border-primary); margin-top: 4px; }
.timeline-entry:last-child .marker-line { display: none; }
.entry-content { flex: 1; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; }
.entry-time { font-family: monospace; font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.cluster-badge { display: inline-block; background: var(--color-surface-elevated); padding: 0.15rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.75rem; margin-bottom: 0.5rem; }
.entry-events { display: flex; flex-direction: column; gap: 0.5rem; }
.event-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; background: var(--color-surface-elevated); border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s; }
.event-item:hover { background: var(--color-status-info-bg); }
.badge { display: inline-block; padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); font-size: 0.7rem; text-transform: uppercase; }
.badge.module { background: var(--color-surface-primary); }
.badge.module.policy { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.badge.module.authority { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.vex { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.action { background: var(--color-surface-primary); }
.actor { font-size: 0.8rem; color: var(--color-text-secondary); }
.desc { font-size: 0.85rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.no-results { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
.event-badges { display: flex; gap: 0.25rem; margin-top: 0.25rem; flex-wrap: wrap; }
.badge {
display: inline-block; padding: 0.0625rem 0.35rem;
border-radius: var(--radius-sm); font-size: 0.6875rem; text-transform: uppercase;
}
.badge--module { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
.badge--module[data-module="policy"] { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.badge--module[data-module="authority"] { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge--module[data-module="vex"] { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge--action { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
`]
})
export class AuditTimelineSearchComponent {
@@ -112,6 +101,44 @@ export class AuditTimelineSearchComponent {
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
endDate = new Date().toISOString().split('T')[0];
/** Map audit timeline entries to canonical TimelineEvent[]. */
readonly timelineEvents = computed<TimelineEvent[]>(() => {
const result: TimelineEvent[] = [];
for (const entry of this.entries()) {
if (entry.clusterSize && entry.clusterSize > 1) {
// Cluster: render as a single summary event
const firstEvent = entry.events[0];
result.push({
id: entry.clusterId ?? entry.timestamp,
timestamp: entry.timestamp,
title: `${entry.clusterSize} events`,
description: entry.events.map(e => `[${e.module}] ${e.description}`).join(' | '),
actor: firstEvent?.actor?.name,
eventKind: firstEvent ? mapActionToKind(firstEvent.action) : 'info',
icon: 'summarize',
metadata: firstEvent ? { module: firstEvent.module, action: firstEvent.action } : undefined,
expandable: entry.events.length > 1 ? entry.events.map(e =>
`${e.timestamp} [${e.module}/${e.action}] ${e.actor.name}: ${e.description}`
).join('\n') : undefined,
});
} else {
// Individual events
for (const event of entry.events) {
result.push({
id: event.id,
timestamp: event.timestamp ?? entry.timestamp,
title: event.description,
actor: event.actor?.name,
eventKind: mapActionToKind(event.action),
icon: 'event_note',
metadata: { module: event.module, action: event.action },
});
}
}
}
return result;
});
search(): void {
if (!this.query.trim()) return;
this.searching.set(true);
@@ -124,8 +151,4 @@ export class AuditTimelineSearchComponent {
error: () => this.searching.set(false),
});
}
formatTime(ts: string): string {
return new Date(ts).toLocaleString();
}
}

View File

@@ -1,56 +1,85 @@
// Sprint: SPRINT_20251229_032_FE - Platform Health Dashboard
// Updated: SPRINT_20260308_029_FE - Adopt canonical timeline-list (FE-TLD-003)
import { Component, inject, signal, computed, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { PlatformHealthClient } from '../../core/api/platform-health.client';
import {
Incident,
IncidentSeverity,
INCIDENT_SEVERITY_COLORS,
} from '../../core/api/platform-health.models';
import { healthSloPath } from '../platform/ops/operations-paths';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
function mapSeverityToKind(severity: IncidentSeverity, state: string): TimelineEventKind {
if (state === 'resolved') return 'success';
switch (severity) {
case 'critical': return 'critical';
case 'warning': return 'warning';
case 'info': return 'info';
default: return 'neutral';
}
}
function mapSeverityToIcon(severity: IncidentSeverity, state: string): string {
if (state === 'resolved') return 'check_circle';
switch (severity) {
case 'critical': return 'error';
case 'warning': return 'warning';
case 'info': return 'info';
default: return 'radio_button_unchecked';
}
}
function buildExpandablePayload(incident: Incident): string | undefined {
const parts: string[] = [];
if (incident.rootCauseSuggestion) {
parts.push(`Suggested Root Cause: ${incident.rootCauseSuggestion}`);
}
if (incident.correlatedEvents.length > 0) {
parts.push(`Correlated Events (${incident.correlatedEvents.length}):`);
for (const evt of incident.correlatedEvents) {
parts.push(` ${evt.timestamp} [${evt.service}] ${evt.description}`);
}
}
return parts.length > 0 ? parts.join('\n') : undefined;
}
@Component({
selector: 'app-incident-timeline',
imports: [CommonModule, RouterModule, FormsModule],
imports: [RouterModule, FormsModule, TimelineListComponent],
template: `
<div class="incident-timeline p-6">
<header class="mb-6">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
<a [routerLink]="healthOverviewPath" class="hover:text-blue-600">Platform Health</a>
<div class="incident-timeline">
<header class="page-header">
<div class="breadcrumb">
<a [routerLink]="healthOverviewPath">Platform Health</a>
<span>/</span>
<span>Incidents</span>
</div>
<div class="flex items-center justify-between">
<div class="header-row">
<div>
<h1 class="text-2xl font-bold text-gray-900">Incident Timeline</h1>
<p class="text-gray-600 mt-1">Correlated incidents with root-cause analysis</p>
<h1>Incident Timeline</h1>
<p class="subtitle">Correlated incidents with root-cause analysis</p>
</div>
<div class="flex items-center gap-3">
<div class="header-actions">
<select
[(ngModel)]="hoursBack"
(ngModelChange)="loadIncidents()"
class="px-3 py-2 text-sm border rounded-md"
>
<option [value]="6">Last 6 hours</option>
<option [value]="24">Last 24 hours</option>
<option [value]="72">Last 3 days</option>
<option [value]="168">Last 7 days</option>
</select>
<label class="flex items-center gap-2 text-sm">
<label class="checkbox-label">
<input
type="checkbox"
[(ngModel)]="includeResolved"
(ngModelChange)="loadIncidents()"
class="rounded"
/>
Include resolved
</label>
<button
(click)="exportReport()"
class="px-3 py-2 text-sm border rounded-md hover:bg-gray-50"
>
<button type="button" class="btn-secondary" (click)="exportReport()">
Export Report
</button>
</div>
@@ -58,154 +87,160 @@ import { healthSloPath } from '../platform/ops/operations-paths';
</header>
<!-- Summary Cards -->
<section class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg border p-4">
<span class="text-gray-600 text-sm">Total Incidents</span>
<p class="text-2xl font-bold text-gray-900">{{ incidents().length }}</p>
<section class="summary-cards">
<div class="summary-card">
<span class="summary-label">Total Incidents</span>
<p class="summary-value">{{ incidents().length }}</p>
</div>
<div class="bg-white rounded-lg border p-4">
<span class="text-gray-600 text-sm">Active</span>
<p class="text-2xl font-bold text-red-600">{{ activeCount() }}</p>
<div class="summary-card">
<span class="summary-label">Active</span>
<p class="summary-value summary-value--error">{{ activeCount() }}</p>
</div>
<div class="bg-white rounded-lg border p-4">
<span class="text-gray-600 text-sm">Critical</span>
<p class="text-2xl font-bold text-red-600">{{ criticalCount() }}</p>
<div class="summary-card">
<span class="summary-label">Critical</span>
<p class="summary-value summary-value--error">{{ criticalCount() }}</p>
</div>
<div class="bg-white rounded-lg border p-4">
<span class="text-gray-600 text-sm">Resolved</span>
<p class="text-2xl font-bold text-green-600">{{ resolvedCount() }}</p>
<div class="summary-card">
<span class="summary-label">Resolved</span>
<p class="summary-value summary-value--success">{{ resolvedCount() }}</p>
</div>
</section>
<!-- Filters -->
<section class="bg-white rounded-lg border p-4 mb-6">
<div class="flex items-center gap-4">
<select
[(ngModel)]="severityFilter"
class="px-3 py-2 text-sm border rounded-md"
>
<option value="all">All Severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select
[(ngModel)]="stateFilter"
class="px-3 py-2 text-sm border rounded-md"
>
<option value="all">All States</option>
<option value="active">Active</option>
<option value="resolved">Resolved</option>
</select>
<input
type="text"
[(ngModel)]="searchQuery"
placeholder="Search incidents..."
class="px-3 py-2 text-sm border rounded-md flex-1"
/>
</div>
<section class="filter-section">
<select [(ngModel)]="severityFilter">
<option value="all">All Severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select [(ngModel)]="stateFilter">
<option value="all">All States</option>
<option value="active">Active</option>
<option value="resolved">Resolved</option>
</select>
<input
type="text"
[(ngModel)]="searchQuery"
placeholder="Search incidents..."
class="search-input"
/>
</section>
<!-- Timeline -->
<section class="bg-white rounded-lg border">
<div class="divide-y">
@for (incident of filteredIncidents(); track incident.id) {
<div class="p-4" [class.bg-red-50]="incident.state === 'active'">
<div class="flex items-start gap-4">
<!-- Timeline marker -->
<div class="flex flex-col items-center">
<span
class="w-4 h-4 rounded-full"
[class]="incident.state === 'active' ? 'bg-red-500' : 'bg-gray-400'"
></span>
<div class="w-0.5 h-full bg-gray-200 mt-2"></div>
</div>
<!-- Content -->
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span
class="px-2 py-0.5 text-xs font-medium rounded"
[class]="INCIDENT_SEVERITY_COLORS[incident.severity]"
>
{{ incident.severity | uppercase }}
</span>
<span class="font-medium text-gray-900">{{ incident.title }}</span>
@if (incident.state === 'resolved') {
<span class="px-2 py-0.5 text-xs rounded bg-green-100 text-green-800">
Resolved
</span>
}
</div>
<p class="text-gray-600 mb-2">{{ incident.description }}</p>
<!-- Affected Services -->
<div class="flex items-center gap-2 mb-2">
<span class="text-sm text-gray-500">Affected:</span>
@for (service of incident.affectedServices; track service) {
<span class="px-2 py-0.5 text-xs bg-gray-100 rounded">{{ service }}</span>
}
</div>
<!-- Root Cause -->
@if (incident.rootCauseSuggestion) {
<div class="p-3 bg-blue-50 border border-blue-200 rounded mb-2">
<p class="text-sm text-blue-800">
<span class="font-medium">Suggested Root Cause:</span>
{{ incident.rootCauseSuggestion }}
</p>
</div>
<!-- Canonical Timeline -->
<section class="timeline-section">
<app-timeline-list
[events]="timelineEvents()"
[loading]="loading()"
[groupByDate]="true"
[emptyMessage]="loading() ? 'Loading incidents...' : 'No incidents found for the selected time range'"
ariaLabel="Incident timeline"
>
<ng-template #eventContent let-event>
@if (getIncidentForEvent(event.id); as incident) {
@if (incident.affectedServices.length > 0) {
<div class="affected-services">
<span class="affected-label">Affected:</span>
@for (service of incident.affectedServices; track service) {
<span class="service-chip">{{ service }}</span>
}
<!-- Correlated Events -->
@if (incident.correlatedEvents.length > 0) {
<details class="mt-2">
<summary class="text-sm text-blue-600 cursor-pointer hover:underline">
View {{ incident.correlatedEvents.length }} correlated events
</summary>
<div class="mt-2 pl-4 border-l-2 border-gray-200 space-y-2">
@for (event of incident.correlatedEvents; track event.timestamp) {
<div class="text-sm">
<span class="text-gray-500">{{ event.timestamp | date:'shortTime' }}</span>
<span class="text-gray-700 ml-2">{{ event.service }}:</span>
<span class="text-gray-600">{{ event.description }}</span>
</div>
}
</div>
</details>
}
<!-- Timestamps -->
<div class="mt-3 text-xs text-gray-500 flex items-center gap-4">
<span>Started: {{ incident.startedAt | date:'medium' }}</span>
@if (incident.resolvedAt) {
<span>Resolved: {{ incident.resolvedAt | date:'medium' }}</span>
}
@if (incident.duration) {
<span>Duration: {{ incident.duration }}</span>
}
</div>
</div>
</div>
</div>
} @empty {
<div class="p-8 text-center text-gray-500">
@if (loading()) {
Loading incidents...
} @else {
No incidents found for the selected time range
}
</div>
}
</div>
@if (incident.state === 'resolved') {
<span class="resolved-badge">Resolved</span>
}
@if (incident.resolvedAt) {
<span class="duration-info">Duration: {{ incident.duration ?? 'N/A' }}</span>
}
}
</ng-template>
</app-timeline-list>
</section>
</div>
`,
styles: [`
.incident-timeline {
padding: 1.5rem;
min-height: 100vh;
background: var(--color-surface-primary);
display: grid;
gap: 1.25rem;
}
/* Header */
.page-header { display: grid; gap: 0.5rem; }
.breadcrumb { font-size: 0.8125rem; color: var(--color-text-secondary); display: flex; gap: 0.35rem; align-items: center; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
h1 { margin: 0; font-size: 1.375rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); }
.subtitle { margin: 0.125rem 0 0; color: var(--color-text-secondary); font-size: 0.8125rem; }
.header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.header-actions { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.header-actions select, .btn-secondary {
padding: 0.375rem 0.75rem; font-size: 0.8125rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
background: var(--color-surface-primary); color: var(--color-text-primary); cursor: pointer;
}
.btn-secondary:hover { background: var(--color-surface-secondary); }
.checkbox-label { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; color: var(--color-text-secondary); }
/* Summary cards */
.summary-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
.summary-card {
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
background: var(--color-surface-primary); padding: 0.75rem 1rem;
}
.summary-label { font-size: 0.75rem; color: var(--color-text-secondary); }
.summary-value { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.summary-value--error { color: var(--color-status-error-text); }
.summary-value--success { color: var(--color-status-success-text); }
/* Filters */
.filter-section {
display: flex; align-items: center; gap: 0.75rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
background: var(--color-surface-primary); padding: 0.75rem 1rem;
}
.filter-section select {
padding: 0.375rem 0.75rem; font-size: 0.8125rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.search-input {
flex: 1; padding: 0.375rem 0.75rem; font-size: 0.8125rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
/* Timeline section */
.timeline-section {
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
background: var(--color-surface-primary); padding: 1rem;
}
/* Domain-specific chips inside content projection */
.affected-services { display: flex; align-items: center; gap: 0.25rem; margin-top: 0.25rem; flex-wrap: wrap; }
.affected-label { font-size: 0.6875rem; color: var(--color-text-secondary); }
.service-chip {
padding: 0.0625rem 0.375rem; font-size: 0.6875rem;
background: var(--color-surface-secondary); border-radius: var(--radius-sm);
color: var(--color-text-primary);
}
.resolved-badge {
display: inline-block; margin-top: 0.25rem;
padding: 0.0625rem 0.375rem; font-size: 0.6875rem;
background: var(--color-status-success-bg); color: var(--color-status-success-text);
border-radius: var(--radius-sm);
}
.duration-info {
display: inline-block; margin-top: 0.25rem; margin-left: 0.5rem;
font-size: 0.6875rem; color: var(--color-text-secondary);
}
@media (max-width: 768px) {
.summary-cards { grid-template-columns: repeat(2, 1fr); }
.header-row { flex-direction: column; }
.filter-section { flex-wrap: wrap; }
}
`]
})
@@ -222,10 +257,7 @@ export class IncidentTimelineComponent implements OnInit {
stateFilter = signal<'all' | 'active' | 'resolved'>('all');
searchQuery = signal('');
// Expose constants
readonly INCIDENT_SEVERITY_COLORS = INCIDENT_SEVERITY_COLORS;
// Computed
// Computed counts
activeCount = computed(() => this.incidents().filter((i) => i.state === 'active').length);
resolvedCount = computed(() => this.incidents().filter((i) => i.state === 'resolved').length);
criticalCount = computed(() => this.incidents().filter((i) => i.severity === 'critical').length);
@@ -247,6 +279,26 @@ export class IncidentTimelineComponent implements OnInit {
});
});
/** Map filtered incidents to canonical TimelineEvent[]. */
readonly timelineEvents = computed<TimelineEvent[]>(() => {
return this.filteredIncidents().map((incident) => ({
id: incident.id,
timestamp: incident.startedAt,
title: `[${incident.severity.toUpperCase()}] ${incident.title}`,
description: incident.description,
actor: undefined,
eventKind: mapSeverityToKind(incident.severity, incident.state),
icon: mapSeverityToIcon(incident.severity, incident.state),
metadata: incident.duration ? { duration: incident.duration } : undefined,
expandable: buildExpandablePayload(incident),
}));
});
/** Lookup incident by event ID for content projection. */
getIncidentForEvent(eventId: string): Incident | undefined {
return this.filteredIncidents().find((i) => i.id === eventId);
}
ngOnInit(): void {
this.loadIncidents();
}

View File

@@ -1,10 +1,11 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
interface ReleaseActivityProjection {
activityId: string;
@@ -24,10 +25,28 @@ interface PlatformListResponse<T> {
count: number;
}
function deriveOutcomeKind(status: string): TimelineEventKind {
const lower = status.toLowerCase();
if (lower.includes('published') || lower.includes('approved') || lower.includes('deployed')) return 'success';
if (lower.includes('blocked') || lower.includes('rejected') || lower.includes('failed')) return 'error';
if (lower.includes('pending_approval')) return 'warning';
return 'info';
}
function deriveOutcomeIcon(status: string): string {
const lower = status.toLowerCase();
if (lower.includes('published') || lower.includes('deployed')) return 'rocket_launch';
if (lower.includes('approved')) return 'check_circle';
if (lower.includes('blocked') || lower.includes('rejected')) return 'block';
if (lower.includes('failed')) return 'error';
if (lower.includes('pending_approval')) return 'pending';
return 'play_circle';
}
@Component({
selector: 'app-releases-activity',
standalone: true,
imports: [RouterLink, FormsModule],
imports: [RouterLink, FormsModule, TimelineListComponent],
template: `
<section class="activity">
<header>
@@ -97,49 +116,83 @@ interface PlatformListResponse<T> {
@if (loading()) {
<div class="banner">Loading release runs...</div>
} @else {
@if (viewMode() === 'correlations') {
<div class="clusters">
@for (cluster of correlationClusters(); track cluster.key) {
<article>
<h3>{{ cluster.key }}</h3>
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
<p>{{ cluster.environments }}</p>
</article>
} @empty {
<div class="banner">No run correlations match the current filters.</div>
}
</div>
} @else {
<table>
<thead>
<tr>
<th>Run</th>
<th>Release Version</th>
<th>Lane</th>
<th>Outcome</th>
<th>Environment</th>
<th>Needs Approval</th>
<th>Data Integrity</th>
<th>When</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.activityId) {
<tr>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
<td>{{ row.releaseName }}</td>
<td>{{ deriveLane(row) }}</td>
<td>{{ deriveOutcome(row) }}</td>
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
<td>{{ deriveDataIntegrity(row) }}</td>
<td>{{ formatDate(row.occurredAt) }}</td>
</tr>
@switch (viewMode()) {
@case ('timeline') {
<!-- Canonical timeline rendering -->
<div class="timeline-container">
<app-timeline-list
[events]="timelineEvents()"
[loading]="loading()"
[groupByDate]="true"
emptyMessage="No runs match the active filters."
ariaLabel="Release activity timeline"
>
<ng-template #eventContent let-event>
@if (event.metadata) {
<div class="run-meta">
@if (event.metadata['lane']) {
<span class="run-chip">{{ event.metadata['lane'] }}</span>
}
@if (event.metadata['environment']) {
<span class="run-chip">{{ event.metadata['environment'] }}</span>
}
@if (event.metadata['outcome']) {
<span class="run-chip run-chip--outcome" [attr.data-outcome]="event.metadata['outcome']">{{ event.metadata['outcome'] }}</span>
}
@if (event.evidenceLink) {
<a class="run-link" [routerLink]="event.evidenceLink">View run</a>
}
</div>
}
</ng-template>
</app-timeline-list>
</div>
}
@case ('correlations') {
<div class="clusters">
@for (cluster of correlationClusters(); track cluster.key) {
<article>
<h3>{{ cluster.key }}</h3>
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
<p>{{ cluster.environments }}</p>
</article>
} @empty {
<tr><td colspan="8">No runs match the active filters.</td></tr>
<div class="banner">No run correlations match the current filters.</div>
}
</tbody>
</table>
</div>
}
@default {
<table>
<thead>
<tr>
<th>Run</th>
<th>Release Version</th>
<th>Lane</th>
<th>Outcome</th>
<th>Environment</th>
<th>Needs Approval</th>
<th>Data Integrity</th>
<th>When</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.activityId) {
<tr>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
<td>{{ row.releaseName }}</td>
<td>{{ deriveLane(row) }}</td>
<td>{{ deriveOutcome(row) }}</td>
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
<td>{{ deriveDataIntegrity(row) }}</td>
<td>{{ formatDate(row.occurredAt) }}</td>
</tr>
} @empty {
<tr><td colspan="8">No runs match the active filters.</td></tr>
}
</tbody>
</table>
}
}
}
</section>
@@ -155,6 +208,18 @@ interface PlatformListResponse<T> {
.banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.4rem .5rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase}
tr:last-child td{border-bottom:none}.clusters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.45rem}.clusters article{padding:.55rem}.clusters h3{margin:0;font-size:.82rem}.clusters p{margin:.2rem 0;color:var(--color-text-secondary);font-size:.74rem}
/* Timeline container */
.timeline-container{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.75rem}
/* Domain-specific run metadata */
.run-meta{display:flex;gap:.25rem;margin-top:.25rem;align-items:center;flex-wrap:wrap}
.run-chip{padding:.0625rem .35rem;font-size:.66rem;border-radius:var(--radius-sm);background:var(--color-surface-secondary);color:var(--color-text-secondary)}
.run-chip--outcome[data-outcome="success"]{background:var(--color-status-success-bg);color:var(--color-status-success-text)}
.run-chip--outcome[data-outcome="failed"]{background:var(--color-status-error-bg);color:var(--color-status-error-text)}
.run-chip--outcome[data-outcome="in_progress"]{background:var(--color-status-info-bg);color:var(--color-status-info-text)}
.run-link{font-size:.7rem;color:var(--color-brand-primary);text-decoration:none;margin-left:.25rem}
.run-link:hover{text-decoration:underline}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -202,6 +267,25 @@ export class ReleasesActivityComponent {
return rows;
});
/** Map filtered rows to canonical TimelineEvent[] for the timeline view mode. */
readonly timelineEvents = computed<TimelineEvent[]>(() => {
return this.filteredRows().map((row) => ({
id: row.activityId,
timestamp: row.occurredAt,
title: `${row.releaseName} - ${row.eventType}`,
description: `${row.targetRegion ?? '-'}/${row.targetEnvironment ?? '-'} · ${row.status}`,
actor: row.actorId || undefined,
eventKind: deriveOutcomeKind(row.status),
icon: deriveOutcomeIcon(row.status),
evidenceLink: `/releases/runs/${row.releaseId}/summary`,
metadata: {
lane: this.deriveLane(row),
environment: `${row.targetRegion ?? '-'}/${row.targetEnvironment ?? '-'}`,
outcome: this.deriveOutcome(row),
},
}));
});
readonly correlationClusters = computed(() => {
const map = new Map<string, { key: string; count: number; releaseSet: Set<string>; envSet: Set<string> }>();
for (const row of this.filteredRows()) {

View File

@@ -0,0 +1,420 @@
/**
* Timeline List Component Tests
* Sprint: SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation (FE-TLD-004)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from './timeline-list.component';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const NOW = new Date('2026-03-08T12:00:00.000Z');
function minutesAgo(n: number): string {
return new Date(NOW.getTime() - n * 60_000).toISOString();
}
function hoursAgo(n: number): string {
return new Date(NOW.getTime() - n * 3_600_000).toISOString();
}
function daysAgo(n: number): string {
return new Date(NOW.getTime() - n * 86_400_000).toISOString();
}
const SAMPLE_EVENTS: TimelineEvent[] = [
{
id: 'evt-1',
timestamp: minutesAgo(5),
title: 'Scan completed',
description: 'Container image scan finished',
actor: 'scanner-worker-01',
eventKind: 'success',
icon: 'check_circle',
metadata: { imageDigest: 'sha256:abc123' },
},
{
id: 'evt-2',
timestamp: hoursAgo(3),
title: 'Policy evaluated',
eventKind: 'info',
icon: 'policy',
},
{
id: 'evt-3',
timestamp: daysAgo(2),
title: 'Finding created',
description: 'CVE-2024-12345 detected',
eventKind: 'error',
icon: 'error',
evidenceLink: '/findings/CVE-2024-12345',
expandable: '{"cve":"CVE-2024-12345","cvss":9.8}',
},
{
id: 'evt-4',
timestamp: daysAgo(2),
title: 'Attestation created',
eventKind: 'warning',
icon: 'verified',
metadata: { sigAlgo: 'ECDSA-P256' },
},
{
id: 'evt-5',
timestamp: daysAgo(5),
title: 'Cache hit',
eventKind: 'neutral',
},
];
// Test host component to set inputs
@Component({
standalone: true,
imports: [TimelineListComponent],
template: `
<app-timeline-list
[events]="events"
[loading]="loading"
[groupByDate]="groupByDate"
[emptyMessage]="emptyMessage"
[ariaLabel]="ariaLabel"
/>
`,
})
class TestHostComponent {
events: TimelineEvent[] = [];
loading = false;
groupByDate = false;
emptyMessage = 'No events to display';
ariaLabel = 'Test timeline';
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('TimelineListComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let el: HTMLElement;
beforeEach(async () => {
// Patch Date.now for deterministic relative-time output
vi.useFakeTimers({ now: NOW });
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
el = fixture.nativeElement;
});
afterEach(() => {
vi.useRealTimers();
});
// -----------------------------------------------------------------------
// Rendering basics
// -----------------------------------------------------------------------
it('should create', () => {
fixture.detectChanges();
expect(el.querySelector('.timeline')).toBeTruthy();
});
it('should render empty state when no events', () => {
fixture.detectChanges();
const emptyEl = el.querySelector('.timeline__empty');
expect(emptyEl).toBeTruthy();
expect(emptyEl!.textContent).toContain('No events to display');
});
it('should render custom empty message', () => {
host.emptyMessage = 'Nothing here.';
fixture.detectChanges();
expect(el.querySelector('.timeline__empty-text')!.textContent).toContain('Nothing here.');
});
it('should render events with titles', () => {
host.events = SAMPLE_EVENTS;
fixture.detectChanges();
const titles = el.querySelectorAll('.timeline__title');
expect(titles.length).toBe(5);
expect(titles[0].textContent).toContain('Scan completed');
expect(titles[2].textContent).toContain('Finding created');
});
// -----------------------------------------------------------------------
// Severity marker colors
// -----------------------------------------------------------------------
it('should apply correct severity marker classes', () => {
host.events = SAMPLE_EVENTS;
fixture.detectChanges();
const markers = el.querySelectorAll('.timeline__marker');
expect(markers[0].classList.contains('timeline__marker--success')).toBe(true);
expect(markers[1].classList.contains('timeline__marker--info')).toBe(true);
expect(markers[2].classList.contains('timeline__marker--error')).toBe(true);
expect(markers[3].classList.contains('timeline__marker--warning')).toBe(true);
expect(markers[4].classList.contains('timeline__marker--neutral')).toBe(true);
});
it('should render critical marker with distinct styling', () => {
host.events = [{
id: 'crit-1',
timestamp: minutesAgo(1),
title: 'Critical breach',
eventKind: 'critical',
}];
fixture.detectChanges();
const marker = el.querySelector('.timeline__marker');
expect(marker!.classList.contains('timeline__marker--critical')).toBe(true);
});
// -----------------------------------------------------------------------
// Timestamp formatting
// -----------------------------------------------------------------------
it('should display relative time for events less than 24h old', () => {
host.events = [
{ id: 't-1', timestamp: minutesAgo(5), title: '5m event' },
{ id: 't-2', timestamp: hoursAgo(3), title: '3h event' },
];
fixture.detectChanges();
const times = el.querySelectorAll('.timeline__time');
expect(times[0].textContent).toContain('5m ago');
expect(times[1].textContent).toContain('3h ago');
});
it('should display "Just now" for very recent events', () => {
host.events = [{ id: 't-now', timestamp: NOW.toISOString(), title: 'Now event' }];
fixture.detectChanges();
expect(el.querySelector('.timeline__time')!.textContent).toContain('Just now');
});
it('should display absolute UTC time for events older than 24h', () => {
host.events = [{ id: 't-old', timestamp: daysAgo(2), title: 'Old event' }];
fixture.detectChanges();
const timeText = el.querySelector('.timeline__time')!.textContent!.trim();
expect(timeText).toContain('UTC');
expect(timeText).toMatch(/\d{4}-\d{2}-\d{2}/);
});
it('should show full ISO timestamp in title attribute (tooltip)', () => {
host.events = [{ id: 't-tip', timestamp: minutesAgo(10), title: 'Tooltip event' }];
fixture.detectChanges();
const timeEl = el.querySelector('.timeline__time') as HTMLElement;
expect(timeEl.getAttribute('title')).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
// -----------------------------------------------------------------------
// Expandable detail sections
// -----------------------------------------------------------------------
it('should show expand button for events with expandable content', () => {
host.events = [SAMPLE_EVENTS[2]]; // evt-3 has expandable
fixture.detectChanges();
const btn = el.querySelector('.timeline__expand-btn');
expect(btn).toBeTruthy();
expect(btn!.textContent).toContain('Show details');
});
it('should toggle expandable section on click', () => {
host.events = [SAMPLE_EVENTS[2]];
fixture.detectChanges();
const btn = el.querySelector('.timeline__expand-btn') as HTMLButtonElement;
expect(el.querySelector('.timeline__expandable')).toBeNull();
btn.click();
fixture.detectChanges();
expect(el.querySelector('.timeline__expandable')).toBeTruthy();
expect(el.querySelector('.timeline__expandable-content')!.textContent).toContain('CVE-2024-12345');
expect(btn.textContent).toContain('Hide details');
btn.click();
fixture.detectChanges();
expect(el.querySelector('.timeline__expandable')).toBeNull();
});
it('should not show expand button for events without expandable content', () => {
host.events = [SAMPLE_EVENTS[0]]; // evt-1 has no expandable
fixture.detectChanges();
expect(el.querySelector('.timeline__expand-btn')).toBeNull();
});
// -----------------------------------------------------------------------
// Optional fields
// -----------------------------------------------------------------------
it('should render actor when provided', () => {
host.events = [SAMPLE_EVENTS[0]]; // has actor
fixture.detectChanges();
const actor = el.querySelector('.timeline__actor');
expect(actor).toBeTruthy();
expect(actor!.textContent).toContain('scanner-worker-01');
});
it('should not render actor when not provided', () => {
host.events = [SAMPLE_EVENTS[1]]; // no actor
fixture.detectChanges();
expect(el.querySelector('.timeline__actor')).toBeNull();
});
it('should render description when provided', () => {
host.events = [SAMPLE_EVENTS[0]];
fixture.detectChanges();
const desc = el.querySelector('.timeline__description');
expect(desc).toBeTruthy();
expect(desc!.textContent).toContain('Container image scan finished');
});
it('should render evidence link when provided', () => {
host.events = [SAMPLE_EVENTS[2]]; // has evidenceLink
fixture.detectChanges();
const link = el.querySelector('.timeline__evidence-link') as HTMLAnchorElement;
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe('/findings/CVE-2024-12345');
});
it('should render metadata chips when provided', () => {
host.events = [SAMPLE_EVENTS[0]]; // has metadata
fixture.detectChanges();
const chips = el.querySelectorAll('.timeline__meta-chip');
expect(chips.length).toBe(1);
expect(chips[0].querySelector('.timeline__meta-key')!.textContent).toContain('imageDigest');
expect(chips[0].querySelector('.timeline__meta-value')!.textContent).toContain('sha256:abc123');
});
it('should render material icon when provided', () => {
host.events = [SAMPLE_EVENTS[0]]; // has icon
fixture.detectChanges();
const icon = el.querySelector('.timeline__icon');
expect(icon).toBeTruthy();
expect(icon!.textContent).toContain('check_circle');
});
// -----------------------------------------------------------------------
// Date grouping
// -----------------------------------------------------------------------
it('should group events by date when groupByDate is true', () => {
host.events = SAMPLE_EVENTS;
host.groupByDate = true;
fixture.detectChanges();
const dateHeaders = el.querySelectorAll('.timeline__date-label');
// Events span 3 different days: today (~5m ago, ~3h ago), 2 days ago (2 events), 5 days ago
expect(dateHeaders.length).toBe(3);
});
it('should not group events when groupByDate is false', () => {
host.events = SAMPLE_EVENTS;
host.groupByDate = false;
fixture.detectChanges();
expect(el.querySelector('.timeline__date-group')).toBeNull();
const items = el.querySelectorAll('.timeline__item');
expect(items.length).toBe(5);
});
// -----------------------------------------------------------------------
// Loading state
// -----------------------------------------------------------------------
it('should show loading skeleton when loading is true', () => {
host.loading = true;
fixture.detectChanges();
const skeleton = el.querySelector('.timeline--loading');
expect(skeleton).toBeTruthy();
expect(el.querySelectorAll('.timeline__skeleton-item').length).toBe(5);
});
it('should show sr-only loading text for screen readers', () => {
host.loading = true;
fixture.detectChanges();
const srOnly = el.querySelector('.sr-only');
expect(srOnly).toBeTruthy();
expect(srOnly!.textContent).toContain('Loading timeline');
});
it('should not show loading skeleton when loading is false', () => {
host.loading = false;
fixture.detectChanges();
expect(el.querySelector('.timeline--loading')).toBeNull();
});
// -----------------------------------------------------------------------
// Accessibility
// -----------------------------------------------------------------------
it('should have role="feed" on the timeline container', () => {
host.events = SAMPLE_EVENTS;
fixture.detectChanges();
const timeline = el.querySelector('[role="feed"]');
expect(timeline).toBeTruthy();
});
it('should apply custom ariaLabel', () => {
host.ariaLabel = 'Incident feed';
host.events = SAMPLE_EVENTS;
fixture.detectChanges();
const timeline = el.querySelector('[role="feed"]');
expect(timeline!.getAttribute('aria-label')).toBe('Incident feed');
});
it('should have role="article" on each event item', () => {
host.events = SAMPLE_EVENTS;
fixture.detectChanges();
const articles = el.querySelectorAll('[role="article"]');
expect(articles.length).toBe(5);
});
it('should have aria-label on each event item matching title', () => {
host.events = [SAMPLE_EVENTS[0]];
fixture.detectChanges();
const article = el.querySelector('[role="article"]');
expect(article!.getAttribute('aria-label')).toBe('Scan completed');
});
it('should have aria-expanded on expand button', () => {
host.events = [SAMPLE_EVENTS[2]]; // has expandable
fixture.detectChanges();
const btn = el.querySelector('.timeline__expand-btn');
expect(btn!.getAttribute('aria-expanded')).toBe('false');
(btn as HTMLButtonElement).click();
fixture.detectChanges();
expect(btn!.getAttribute('aria-expanded')).toBe('true');
});
it('should have datetime attribute on time elements', () => {
host.events = [SAMPLE_EVENTS[0]];
fixture.detectChanges();
const timeEl = el.querySelector('.timeline__time');
expect(timeEl!.getAttribute('datetime')).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
// -----------------------------------------------------------------------
// Vertical connector line
// -----------------------------------------------------------------------
it('should not render connector line on the last item', () => {
host.events = SAMPLE_EVENTS.slice(0, 2);
fixture.detectChanges();
const items = el.querySelectorAll('.timeline__item');
expect(items[1].classList.contains('timeline__item--last')).toBe(true);
});
// -----------------------------------------------------------------------
// Default eventKind fallback
// -----------------------------------------------------------------------
it('should default to neutral marker when eventKind is not provided', () => {
host.events = [{ id: 'no-kind', timestamp: minutesAgo(1), title: 'No kind' }];
fixture.detectChanges();
const marker = el.querySelector('.timeline__marker');
expect(marker!.classList.contains('timeline__marker--neutral')).toBe(true);
});
});

View File

@@ -1,71 +1,349 @@
/**
* Timeline List Component
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
* Sprint: SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation (FE-TLD-001..002)
*
* Chronological event timeline display.
* Canonical audit-grade event-stream timeline.
*
* Features:
* - Vertical timeline with colored severity markers
* - Deterministic UTC ISO-8601 timestamp formatting
* - Relative time for <24h, absolute for older, full ISO on hover
* - Expandable detail sections for event payloads
* - Optional actor/source metadata
* - Date grouping when events span multiple days
* - Loading skeleton and empty states
* - Accessibility (role="feed", aria-labels)
* - Content projection via ng-template for domain-specific rendering
*/
import { Component, Input, ChangeDetectionStrategy, ContentChild, TemplateRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
Component,
ChangeDetectionStrategy,
ContentChild,
TemplateRef,
computed,
input,
signal,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
// ---------------------------------------------------------------------------
// Canonical Event Model (FE-TLD-001)
// ---------------------------------------------------------------------------
/**
* Severity / event kind for timeline events.
* Determines marker color and visual weight.
*/
export type TimelineEventKind = 'info' | 'success' | 'warning' | 'error' | 'critical' | 'neutral';
/**
* Canonical timeline event model for all audit/evidence/release chronology surfaces.
*
* Time display rules:
* - Relative time for events < 24h old (e.g. "5m ago", "3h ago")
* - Absolute UTC ISO-8601 for events >= 24h old (e.g. "2026-03-08 14:23 UTC")
* - Full ISO-8601 timestamp always available via tooltip on hover
* - Events are grouped by date when spanning multiple days
*/
export interface TimelineEvent {
id: string;
timestamp: Date | string;
title: string;
description?: string;
icon?: string;
type?: 'success' | 'warning' | 'error' | 'info' | 'neutral';
/** Unique event identifier. */
readonly id: string;
/** ISO-8601 UTC timestamp. */
readonly timestamp: string;
/** Event summary (single line). */
readonly title: string;
/** Optional detail text. */
readonly description?: string;
/** Who or what caused this event (user, service, system). */
readonly actor?: string;
/** Event severity / kind. Defaults to 'neutral'. */
readonly eventKind?: TimelineEventKind;
/** Material icon name (optional). */
readonly icon?: string;
/** Link to related evidence (optional URL). */
readonly evidenceLink?: string;
/** Arbitrary key-value metadata. */
readonly metadata?: Record<string, string>;
/** Expandable detail payload (rendered in collapsible section). */
readonly expandable?: string;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
interface DateGroup {
dateLabel: string;
events: TimelineEvent[];
}
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
/**
* Format a timestamp for display.
* - < 1 minute: "Just now"
* - < 60 minutes: "Xm ago"
* - < 24 hours: "Xh ago"
* - >= 24 hours: "YYYY-MM-DD HH:mm UTC"
*/
function formatDisplayTime(iso: string, now: Date): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
const diffMs = now.getTime() - date.getTime();
if (diffMs < 0) {
// Future event: show absolute
return formatAbsoluteUtc(date);
}
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMs / 3600000);
if (diffHours < 24) return `${diffHours}h ago`;
return formatAbsoluteUtc(date);
}
function formatAbsoluteUtc(date: Date): string {
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
const d = String(date.getUTCDate()).padStart(2, '0');
const hh = String(date.getUTCHours()).padStart(2, '0');
const mm = String(date.getUTCMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm} UTC`;
}
function toIsoFull(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toISOString();
}
function toDateKey(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return 'Unknown';
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@Component({
selector: 'app-timeline-list',
standalone: true,
imports: [CommonModule],
imports: [NgTemplateOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="timeline">
@for (event of events; track event.id; let last = $last) {
<div class="timeline__item" [class.timeline__item--last]="last">
<div class="timeline__marker" [class]="'timeline__marker--' + (event.type || 'neutral')">
@if (event.icon) {
<span>{{ event.icon }}</span>
}
</div>
<div class="timeline__content">
<div class="timeline__header">
<span class="timeline__title">{{ event.title }}</span>
<time class="timeline__time">{{ formatTime(event.timestamp) }}</time>
<!-- Loading skeleton -->
@if (loading()) {
<div class="timeline timeline--loading" role="status" aria-label="Loading timeline events">
@for (i of skeletonRows; track i) {
<div class="timeline__skeleton-item">
<div class="timeline__skeleton-marker"></div>
<div class="timeline__skeleton-content">
<div class="timeline__skeleton-title"></div>
<div class="timeline__skeleton-desc"></div>
</div>
@if (event.description) {
<p class="timeline__description">{{ event.description }}</p>
}
@if (eventTemplate) {
<ng-container *ngTemplateOutlet="eventTemplate; context: { $implicit: event }"></ng-container>
}
</div>
}
<span class="sr-only">Loading timeline...</span>
</div>
} @else {
<div
class="timeline"
role="feed"
[attr.aria-label]="ariaLabel()"
[attr.aria-busy]="false"
>
@if (groupByDate()) {
<!-- Grouped by date -->
@for (group of dateGroups(); track group.dateLabel) {
<div class="timeline__date-group">
<div class="timeline__date-header" role="separator">
<span class="timeline__date-label">{{ group.dateLabel }}</span>
</div>
@for (event of group.events; track event.id; let last = $last; let idx = $index) {
<ng-container
*ngTemplateOutlet="eventRow; context: { $implicit: event, last: last && $last }"
></ng-container>
}
</div>
} @empty {
<ng-container *ngTemplateOutlet="emptyState"></ng-container>
}
} @else {
<!-- Flat list -->
@for (event of events(); track event.id; let last = $last) {
<ng-container
*ngTemplateOutlet="eventRow; context: { $implicit: event, last: last }"
></ng-container>
} @empty {
<ng-container *ngTemplateOutlet="emptyState"></ng-container>
}
}
</div>
}
<!-- Event row template -->
<ng-template #eventRow let-event let-last="last">
<article
class="timeline__item"
[class.timeline__item--last]="last"
role="article"
[attr.aria-label]="event.title"
>
<div
class="timeline__marker"
[class]="'timeline__marker--' + (event.eventKind || 'neutral')"
aria-hidden="true"
>
@if (event.icon) {
<span class="timeline__icon material-symbols-outlined">{{ event.icon }}</span>
}
</div>
} @empty {
<div class="timeline__empty">No events to display</div>
}
</div>
<div class="timeline__content">
<div class="timeline__header">
<span class="timeline__title">{{ event.title }}</span>
<time
class="timeline__time"
[attr.datetime]="toIsoFull(event.timestamp)"
[title]="toIsoFull(event.timestamp)"
>{{ formatTime(event.timestamp) }}</time>
</div>
@if (event.actor) {
<span class="timeline__actor">{{ event.actor }}</span>
}
@if (event.description) {
<p class="timeline__description">{{ event.description }}</p>
}
@if (event.evidenceLink) {
<a
class="timeline__evidence-link"
[href]="event.evidenceLink"
target="_blank"
rel="noopener"
>View evidence</a>
}
@if (event.metadata && hasKeys(event.metadata)) {
<div class="timeline__metadata">
@for (entry of objectEntries(event.metadata); track entry[0]) {
<span class="timeline__meta-chip">
<span class="timeline__meta-key">{{ entry[0] }}</span>
<span class="timeline__meta-value">{{ entry[1] }}</span>
</span>
}
</div>
}
@if (event.expandable) {
<button
type="button"
class="timeline__expand-btn"
[attr.aria-expanded]="isExpanded(event.id)"
(click)="toggleExpand(event.id)"
>
{{ isExpanded(event.id) ? 'Hide details' : 'Show details' }}
</button>
@if (isExpanded(event.id)) {
<div class="timeline__expandable" role="region" aria-label="Event details">
<pre class="timeline__expandable-content">{{ event.expandable }}</pre>
</div>
}
}
@if (eventTemplate) {
<ng-container
*ngTemplateOutlet="eventTemplate; context: { $implicit: event }"
></ng-container>
}
</div>
</article>
</ng-template>
<!-- Empty state template -->
<ng-template #emptyState>
<div class="timeline__empty" role="status">
<span class="timeline__empty-icon material-symbols-outlined" aria-hidden="true">event_busy</span>
<p class="timeline__empty-text">{{ emptyMessage() }}</p>
</div>
</ng-template>
`,
styles: [`
/* ------------------------------------------------------------------ */
/* Shared timeline primitive */
/* ------------------------------------------------------------------ */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.timeline {
position: relative;
}
/* ---- Date group header ---- */
.timeline__date-group {
margin-bottom: 0.25rem;
}
.timeline__date-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0 0.5rem 0.25rem;
}
.timeline__date-header::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border-primary);
}
.timeline__date-label {
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
white-space: nowrap;
}
/* ---- Event item ---- */
.timeline__item {
display: flex;
gap: 1rem;
gap: 0.75rem;
position: relative;
padding-bottom: 1.5rem;
padding-bottom: 1.25rem;
}
.timeline__item::before {
content: '';
position: absolute;
left: 0.5rem;
top: 1.25rem;
left: 0.5625rem;
top: 1.375rem;
bottom: 0;
width: 2px;
background: var(--color-border-primary);
@@ -75,6 +353,7 @@ export interface TimelineEvent {
display: none;
}
/* ---- Marker ---- */
.timeline__marker {
flex-shrink: 0;
width: 1.25rem;
@@ -87,6 +366,11 @@ export interface TimelineEvent {
z-index: 1;
}
.timeline__icon {
font-size: 0.75rem;
line-height: 1;
}
.timeline__marker--success {
background: var(--color-severity-low-bg);
color: var(--color-status-success-text);
@@ -102,6 +386,12 @@ export interface TimelineEvent {
color: var(--color-status-error-text);
}
.timeline__marker--critical {
background: var(--color-severity-critical-bg);
color: var(--color-status-error-text);
box-shadow: 0 0 0 2px var(--color-status-error-text);
}
.timeline__marker--info {
background: var(--color-severity-info-bg);
color: var(--color-status-info-text);
@@ -112,6 +402,7 @@ export interface TimelineEvent {
color: var(--color-text-secondary);
}
/* ---- Content ---- */
.timeline__content {
flex: 1;
min-width: 0;
@@ -131,40 +422,261 @@ export interface TimelineEvent {
}
.timeline__time {
font-size: 0.75rem;
font-size: 0.6875rem;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
color: var(--color-text-secondary);
white-space: nowrap;
cursor: help;
}
.timeline__actor {
display: inline-block;
font-size: 0.6875rem;
color: var(--color-text-secondary);
margin-top: 0.125rem;
}
.timeline__actor::before {
content: 'by ';
}
.timeline__description {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
.timeline__evidence-link {
display: inline-block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--color-brand-primary);
text-decoration: none;
}
.timeline__evidence-link:hover {
text-decoration: underline;
}
/* ---- Metadata chips ---- */
.timeline__metadata {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.375rem;
}
.timeline__meta-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.0625rem 0.375rem;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
font-size: 0.6875rem;
}
.timeline__meta-key {
color: var(--color-text-secondary);
}
.timeline__meta-value {
color: var(--color-text-primary);
font-family: 'JetBrains Mono', 'SF Mono', monospace;
}
/* ---- Expandable ---- */
.timeline__expand-btn {
display: inline-block;
margin-top: 0.375rem;
padding: 0;
border: none;
background: none;
font-size: 0.75rem;
color: var(--color-brand-primary);
cursor: pointer;
}
.timeline__expand-btn:hover {
text-decoration: underline;
}
.timeline__expand-btn:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.timeline__expandable {
margin-top: 0.375rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
}
.timeline__expandable-content {
margin: 0;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 0.75rem;
color: var(--color-text-primary);
white-space: pre-wrap;
word-break: break-word;
}
/* ---- Empty ---- */
.timeline__empty {
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2.5rem 1rem;
text-align: center;
}
.timeline__empty-icon {
font-size: 2rem;
color: var(--color-text-muted);
}
.timeline__empty-text {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
/* ---- Loading skeleton ---- */
.timeline--loading .timeline__skeleton-item {
display: flex;
gap: 0.75rem;
padding-bottom: 1.25rem;
}
.timeline__skeleton-marker {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.timeline__skeleton-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.timeline__skeleton-title {
width: 60%;
height: 0.875rem;
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.timeline__skeleton-desc {
width: 40%;
height: 0.625rem;
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
animation-delay: 0.2s;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
`]
})
export class TimelineListComponent {
@Input() events: TimelineEvent[] = [];
/** Events to display. */
readonly events = input<TimelineEvent[]>([]);
/** Whether to show loading skeleton. */
readonly loading = input<boolean>(false);
/** Whether to group events by date (when events span multiple days). */
readonly groupByDate = input<boolean>(false);
/** Empty state message. */
readonly emptyMessage = input<string>('No events to display');
/** Accessible label for the feed container. */
readonly ariaLabel = input<string>('Event timeline');
/** Optional content projection template for domain-specific rendering. */
@ContentChild('eventContent') eventTemplate?: TemplateRef<unknown>;
formatTime(timestamp: Date | string): string {
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
/** Expanded event IDs. */
private readonly expandedIds = signal<Set<string>>(new Set());
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
/** Skeleton row count for loading state. */
readonly skeletonRows = [0, 1, 2, 3, 4];
/** Cached "now" for consistent relative time within a render cycle. */
private renderNow = new Date();
/** Computed date groups for grouped display. */
readonly dateGroups = computed<DateGroup[]>(() => {
const evts = this.events();
if (!evts.length) return [];
// Refresh "now" each time events change
this.renderNow = new Date();
const groups = new Map<string, TimelineEvent[]>();
for (const evt of evts) {
const key = toDateKey(evt.timestamp);
const arr = groups.get(key) ?? [];
arr.push(evt);
groups.set(key, arr);
}
return Array.from(groups.entries()).map(([dateLabel, events]) => ({
dateLabel,
events,
}));
});
// Template helper: format display time
formatTime(iso: string): string {
return formatDisplayTime(iso, this.renderNow);
}
// Template helper: full ISO timestamp for tooltip
toIsoFull(iso: string): string {
return toIsoFull(iso);
}
// Template helper: check if metadata has keys
hasKeys(obj: Record<string, string>): boolean {
return Object.keys(obj).length > 0;
}
// Template helper: Object.entries for template iteration
objectEntries(obj: Record<string, string>): [string, string][] {
return Object.entries(obj);
}
// Expand/collapse
isExpanded(id: string): boolean {
return this.expandedIds().has(id);
}
toggleExpand(id: string): void {
this.expandedIds.update(ids => {
const next = new Set(ids);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
}