# Design Document: Compliance Metric Grouping ## Overview The Compliance Metric Grouping feature consolidates the AEO Compliance page's metric health cards from one-per-summary-entry to one-per-metric-family. A metric family is the set of summary entries that share the same base `metric_id` (e.g., `5.2.5`). The same base ID can appear multiple times in the summary data because the backend parser produces one entry per team/variant row in the xlsx Summary sheet. The feature adds three new capabilities on top of the grouping: 1. **Variant pills** inside each grouped card showing per-variant compliance percentages and status indicators 2. **Hover tooltip** (300ms delay) displaying metric title, business justification, and data sources from a static definitions file 3. **Info panel** opened via an info icon on each card, showing the full metric definition (scope, filters, exclusions, notes) A static JSON file (`metricDefinitions.json`) ships with the frontend containing structured metric definition data for all tracked metrics. No new backend endpoints are needed. ### Design Decisions - **Grouping is frontend-only.** The backend summary endpoint returns flat entries. The frontend groups them by `metric_id` at render time. This avoids backend changes and keeps the grouping logic testable as a pure function. - **`metricFilter` changes from a single string to an array.** Currently `metricFilter` is a single `metric_id` string or `null`. With grouping, clicking a card sets the filter to the array of all `metric_id` values in that family. For single-entry families this is a one-element array. The device table filter checks `metricFilter.includes(m.metric_id)` instead of `m.metric_id === metricFilter`. - **Worst-status drives card color.** Each grouped card computes the most severe status across its variants using a defined severity ordering. This gives engineers an at-a-glance signal when any variant is failing. - **Definitions file is static, not an API.** The metric definitions table has ~130 rows that change infrequently. A static JSON import avoids API round-trips and keeps the tooltip/panel responsive. The file can be regenerated from the source xlsx definitions table when metrics change. - **MetricInfoPanel is a new component.** The detail panel is complex enough (12+ fields, dark-themed sections) to warrant its own component file rather than inlining it in CompliancePage.js. The hover tooltip, being lightweight, stays inline. - **Info icon click uses `stopPropagation`.** The info icon sits inside the card button. Clicking it opens the detail panel without triggering the card's metric filter toggle. ## Architecture ```mermaid graph TD A[CompliancePage.js] -->|imports| B[metricDefinitions.json] A -->|renders| C[MetricHealthCard - grouped] A -->|renders| D[MetricInfoPanel] A -->|renders| E[HoverTooltip - inline] A -->|fetches| F[GET /api/compliance/summary?team=X] A -->|fetches| G[GET /api/compliance/items?team=X&status=Y] F -->|returns| H[summary.entries array] H -->|groupByMetricFamily| I[Map of metricId → entries array] I -->|one card per group| C C -->|click outside info icon| J[setMetricFilter - array of IDs] C -->|click info icon| K[setInfoMetric - opens MetricInfoPanel] C -->|hover 300ms| E J -->|filters| G2[filteredDevices] B -->|lookup by metric_id| E B -->|lookup by metric_id| D ``` ### Component Hierarchy ``` CompliancePage ├── PageHeader (unchanged) ├── TeamTabs (unchanged) ├── MetricHealthSection │ ├── SectionHeader ("Metric Health — click to filter" + clear button) │ └── MetricHealthCard (one per metric family) │ ├── CardTitle (base metric_id + category) │ ├── VariantPill[] (one per summary entry in family) │ ├── WorstStatusPill (computed from all variants) │ ├── TargetDisplay (shared target %) │ ├── InfoIcon (lucide-react Info, top-right) │ └── HoverTooltip (inline, 300ms delay) ├── MetricInfoPanel (slide-out/overlay, opened by info icon click) ├── ComplianceChartsPanel (unchanged) ├── DeviceTable (unchanged, filter logic updated) ├── ComplianceDetailPanel (unchanged) ├── ComplianceUploadModal (unchanged) └── RollbackModal (unchanged) ``` ### Data Flow 1. `CompliancePage` fetches summary entries from `/api/compliance/summary?team=X` on mount and team change. 2. The `groupByMetricFamily(entries)` helper groups the flat entries array into a `Map` keyed by base `metric_id`. 3. One `MetricHealthCard` renders per map entry. Each card receives the full array of entries for that family. 4. The card computes `worstStatus` from the entries' status fields and uses it for border/pill coloring. 5. On card click (outside info icon), `metricFilter` is set to the array of `metric_id` values in that family (or cleared if already active). 6. The device table filter changes from `d.failing_metrics.some(m => m.metric_id === metricFilter)` to `d.failing_metrics.some(m => metricFilter.includes(m.metric_id))`. 7. On hover (300ms), a tooltip renders using data from the `metricDefinitions.json` lookup map, falling back to the summary entry description. 8. On info icon click, `infoMetric` state is set, opening `MetricInfoPanel` with the full definition. ## Components and Interfaces ### groupByMetricFamily (pure helper function) ```javascript // In CompliancePage.js — replaces the current teamMetrics() helper // Input: entries (array of SummaryEntry from the summary endpoint), team (string) // Output: array of { metricId, entries, category, target, worstStatus } function groupByMetricFamily(allEntries, team) { const teamEntries = allEntries.filter(e => e.team === team); const familyMap = {}; for (const entry of teamEntries) { const baseId = entry.metric_id; // already the base ID from the parser if (!familyMap[baseId]) { familyMap[baseId] = []; } familyMap[baseId].push(entry); } return Object.entries(familyMap).map(([metricId, entries]) => ({ metricId, entries, category: entries[0].category, target: entries[0].target, worstStatus: computeWorstStatus(entries.map(e => e.status)), })); } ``` ### computeWorstStatus (pure helper function) ```javascript // Input: array of status strings // Output: the most severe status string // Severity order: "Below 15% of Target" > "Within 15% of Target" > "Meets/Exceeds Target" const STATUS_SEVERITY = { 'Below 15% of Target': 0, 'Within 15% of Target': 1, 'Meets/Exceeds Target': 2, }; function computeWorstStatus(statuses) { let worst = 'Meets/Exceeds Target'; let worstSev = 2; for (const s of statuses) { const sev = STATUS_SEVERITY[s] ?? 0; if (sev < worstSev) { worstSev = sev; worst = s; } } return worst; } ``` ### MetricHealthCard (redesigned) ```javascript // Props: // family: { metricId, entries, category, target, worstStatus } // active: boolean (is this family's filter currently active) // onClick: () => void (toggle metric filter) // onInfoClick: (metricId) => void (open detail panel) // definitionLookup: Map (for tooltip) // // Renders: // - Base metric ID as title // - Category label // - One VariantPill per entry in family.entries // - Shared target percentage // - Worst-status pill with border color // - Info icon (top-right, stopPropagation on click) // - HoverTooltip (300ms delay, positioned near card) ``` ### VariantPill (new inline sub-component) ```javascript // Props: // entry: SummaryEntry (single variant) // // Renders: // - Label: entry.team or entry.description (distinguishing text) // - Compliance percentage in monospace // - Background tint from entry's status color at ~12% opacity // - Glow dot if status !== "Meets/Exceeds Target" // // Layout: inline-flex, wraps via parent flexWrap ``` ### HoverTooltip (inline in CompliancePage.js) ```javascript // State managed in CompliancePage: // hoveredMetric: string | null // hoverTimeout: ref (setTimeout ID) // tooltipPosition: { top, left } (computed from card bounding rect) // // On mouseEnter on MetricHealthCard: // Set timeout for 300ms → set hoveredMetric to family.metricId // On mouseLeave: // Clear timeout, set hoveredMetric to null // // Renders (when hoveredMetric matches): // - Fixed-position div near the card // - Metric title (from definitionLookup or entry.description) // - Business justification (from definitionLookup) // - Data sources required (from definitionLookup) // - Dark card background, subtle border, shadow per DESIGN_SYSTEM.md // - Falls back to summary entry description if no definition found ``` ### MetricInfoPanel (new component file) ```javascript // frontend/src/components/pages/MetricInfoPanel.js // Props: // metricId: string (base metric ID) // definition: MetricDefinition | null (from lookup) // summaryEntries: SummaryEntry[] (the family's entries, for fallback) // onClose: () => void // // Renders: // - Overlay/slide-out panel with dark theme // - Close button (X icon, top-right) // - Metric title (h3, monospace) // - Sections with monospace uppercase labels: // - Asset Types / Asset Types In Scope // - Application Types In Scope // - Environment In Scope // - Status In Scope // - Instance Types In Scope // - Criticality Levels In Scope // - Exclusions // - Special Conditions // - Data Sources Required // - Business Justification // - Notes // - If definition is null: "No detailed definition available" + summary description fallback // - Click outside or close button → onClose() ``` ### Integration with CompliancePage.js ```javascript // New imports: import { Info } from 'lucide-react'; import MetricInfoPanel from './MetricInfoPanel'; import metricDefinitionsRaw from '../../data/metricDefinitions.json'; // Build lookup map once at module level: const METRIC_DEFINITIONS = {}; for (const def of metricDefinitionsRaw) { METRIC_DEFINITIONS[def.metric_id] = def; } // State changes in CompliancePage: // - metricFilter: null → null | string[] (array of metric IDs) // - New state: infoMetric (string | null) — which metric's info panel is open // - New state: hoveredMetric (string | null) — which metric is being hovered // - New ref: hoverTimeoutRef — for 300ms delay // Filter logic change: // Old: .filter(d => !metricFilter || d.failing_metrics.some(m => m.metric_id === metricFilter)) // New: .filter(d => !metricFilter || d.failing_metrics.some(m => metricFilter.includes(m.metric_id))) // Card rendering change: // Old: metrics.map(entry => ) // New: families.map(family => ) ``` ## Data Models ### SummaryEntry (from backend, unchanged) ```javascript { metric_id: string, // e.g. "5.2.5" — base ID, no suffix team: string, // e.g. "STEAM", "ACCESS-ENG" priority: string, non_compliant: number, compliant: number, total: number, compliance_pct: number, // 0.0–1.0 target: number, // 0.0–1.0 status: string, // "Meets/Exceeds Target" | "Within 15% of Target" | "Below 15% of Target" description: string, category: string // from compliance_config.json metric_categories } ``` ### MetricFamily (computed client-side) ```javascript { metricId: string, // base metric ID (e.g. "5.2.5") entries: SummaryEntry[], // all summary entries for this base ID category: string, // from first entry target: number, // from first entry (shared across variants) worstStatus: string // computed worst status across all entries } ``` ### MetricDefinition (from metricDefinitions.json) ```javascript { metric_id: string, // e.g. "5.2.5" metric_title: string, // e.g. "MFA for Privileged Access" asset_types: string, // e.g. "Servers, Network Devices" asset_types_in_scope: string, application_types_in_scope: string, environment_in_scope: string, status_in_scope: string, instance_types_in_scope: string, criticality_levels_in_scope: string, exclusions: string, // empty string if none special_conditions: string, // empty string if none data_sources_required: string, business_justification: string, notes: string // empty string if none } ``` ### metricDefinitions.json (file structure) ```json [ { "metric_id": "1.1.1", "metric_title": "...", "asset_types": "...", "asset_types_in_scope": "...", "application_types_in_scope": "...", "environment_in_scope": "...", "status_in_scope": "...", "instance_types_in_scope": "...", "criticality_levels_in_scope": "...", "exclusions": "", "special_conditions": "", "data_sources_required": "...", "business_justification": "...", "notes": "" } ] ``` All entries use the same set of keys. Optional fields with no value use empty strings, never `null` or omitted keys. ### Status Severity Map ```javascript const STATUS_SEVERITY = { 'Below 15% of Target': 0, // worst 'Within 15% of Target': 1, 'Meets/Exceeds Target': 2, // best }; ``` ### Updated metricFilter State ```javascript // Old: metricFilter: string | null // New: metricFilter: string[] | null // // null = no filter (show all devices) // string[] = show devices with failing_metrics matching any ID in the array ``` ## Correctness Properties *A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* ### Property 1: Grouping invariant — no entries lost or misplaced *For any* array of summary entries and any team string, grouping by metric family SHALL produce groups where (a) every entry appears in exactly one group, (b) all entries within a group share the same `metric_id`, and (c) the total number of entries across all groups equals the number of entries for that team in the input. **Validates: Requirements 1.1, 1.2** ### Property 2: Worst-status computation follows severity ordering *For any* non-empty array of status strings drawn from `{"Below 15% of Target", "Within 15% of Target", "Meets/Exceeds Target"}`, the computed worst status SHALL be the status with the lowest severity rank present in the array. If the array contains "Below 15% of Target", the result SHALL be "Below 15% of Target" regardless of other values. **Validates: Requirements 1.6, 3.1** ### Property 3: Device filtering with metric family includes all matching devices *For any* array of device objects (each with a `failing_metrics` array of `{metric_id}` objects) and *for any* non-empty array of filter metric IDs, the filtered result SHALL contain exactly those devices that have at least one `failing_metrics` entry whose `metric_id` is included in the filter array. No matching device is excluded and no non-matching device is included. **Validates: Requirements 1.8, 7.1, 7.2** ### Property 4: Definition lookup returns correct entry or null *For any* array of metric definition objects with unique `metric_id` values, building a lookup map and querying it with a `metric_id` that exists in the array SHALL return the corresponding definition object. Querying with a `metric_id` not in the array SHALL return `undefined`. **Validates: Requirements 4.2, 4.6** ### Property 5: Detail panel renders all required definition fields *For any* valid metric definition object (with all 14 fields present), the set of field keys rendered by the detail panel SHALL include: `metric_title`, `asset_types`, `asset_types_in_scope`, `application_types_in_scope`, `environment_in_scope`, `status_in_scope`, `instance_types_in_scope`, `criticality_levels_in_scope`, `exclusions`, `special_conditions`, `data_sources_required`, `business_justification`, and `notes`. **Validates: Requirements 5.3** ### Property 6: Definitions schema validation — all entries have required fields *For any* entry in the metric definitions array, the entry SHALL have all 14 required keys present, and the `metric_id` field SHALL be a non-empty string. Optional fields (`exclusions`, `special_conditions`, `notes`) SHALL be present as strings (empty string if no value), never omitted or null. **Validates: Requirements 6.2, 8.3, 8.4** ### Property 7: Lookup map construction preserves all definitions *For any* array of metric definition objects with unique `metric_id` values, building a lookup map keyed by `metric_id` SHALL produce a map with exactly as many entries as the input array, and every input definition SHALL be retrievable by its `metric_id`. **Validates: Requirements 6.4** ### Property 8: JSON round-trip preserves metric definition data *For any* valid metric definition object, `JSON.parse(JSON.stringify(definition))` SHALL produce an object deeply equal to the original. **Validates: Requirements 8.1, 8.2** ## Error Handling ### Metric Definitions File | Error Scenario | Handling | |---|---| | `metricDefinitions.json` fails to import (malformed JSON) | Build-time error caught by Create React App. The file is validated at development time. | | Metric ID not found in definitions lookup | Tooltip falls back to `entry.description` from summary data. Info panel shows "No detailed definition available" with summary description. | | Definition entry has empty optional fields | Rendered sections show "—" placeholder for empty strings. No error thrown. | ### Grouping Logic | Error Scenario | Handling | |---|---| | Summary entries array is empty | `groupByMetricFamily` returns empty array. No cards rendered. Existing "No compliance data" empty state shown. | | Summary entry has missing or empty `metric_id` | Entry is skipped during grouping (filtered out). | | All entries for a team have the same `metric_id` | Single family group with multiple variant pills. Works correctly. | ### Hover Tooltip | Error Scenario | Handling | |---|---| | User moves mouse away before 300ms | Timeout cleared, tooltip never shown. No side effects. | | Tooltip would render off-screen | Position clamped to viewport bounds using `getBoundingClientRect()`. | | Rapid hover/unhover across multiple cards | Previous timeout cleared on each `mouseLeave`. Only the currently hovered card's tooltip can appear. | ### Info Panel | Error Scenario | Handling | |---|---| | Info icon click while tooltip is visible | Tooltip dismissed (mouseLeave fires). Panel opens. No conflict. | | Multiple rapid info icon clicks | `infoMetric` state is set to the latest clicked metric. Only one panel open at a time. | | Click outside panel while scrolled | Overlay backdrop captures click, closes panel. Scroll position preserved. | ### Device Table Filtering | Error Scenario | Handling | |---|---| | `metricFilter` is set to an array but no devices match | Empty state message shown: "No non-compliant devices". | | Device has `failing_metrics` with IDs not in any family | Device only shown when no filter is active or when its metric IDs match the active filter. | ## Testing Strategy ### Unit Tests (Example-Based) Unit tests cover specific rendering, interaction, and integration scenarios: **Grouping and display:** - Groups entries with the same `metric_id` into one card - Single-entry families render one variant pill - Multi-entry families render one pill per entry - Card title shows base metric ID and category from first entry - Card shows shared target percentage **Worst-status computation:** - All "Meets/Exceeds Target" → card shows "OK" with success color - Mix of statuses → card uses the worst status color - Single "Below 15% of Target" among passing variants → card shows danger color **Variant pills:** - Each pill shows the entry's team label and compliance percentage - Pill background tint matches the entry's individual status color - Non-passing variants show a glow dot **Hover tooltip:** - Tooltip appears after 300ms hover delay - Tooltip shows metric title, business justification, data sources from definitions - Tooltip disappears on mouse leave - Tooltip falls back to summary description when no definition exists - Tooltip does not interfere with card click **Info panel:** - Info icon click opens MetricInfoPanel with correct metric definition - Info icon click does not trigger card's metric filter toggle (stopPropagation) - Panel displays all 12+ definition fields with section labels - Panel shows fallback message when no definition exists - Panel closes on outside click or close button **Device table filtering:** - Clicking a grouped card sets filter to all metric IDs in that family - Filtered device table shows devices matching any ID in the family - Clicking the same card again clears the filter - Clear filter button resets to show all devices - Active card shows highlighted styling **Definitions file:** - File imports without error - Lookup map contains all metric IDs from the file - All entries have the required 14 fields ### Property-Based Tests Property-based tests use [fast-check](https://github.com/dubzzz/fast-check) to verify universal properties across generated inputs. Each test runs a minimum of 100 iterations. | Property | Test Description | Tag | |---|---|---| | Property 1 | Generate random arrays of summary entries with varying metric_id values and team strings. Group them and verify: all entries accounted for, entries within each group share the same metric_id, group count equals unique metric_id count. | Feature: compliance-metric-grouping, Property 1: Grouping invariant — no entries lost or misplaced | | Property 2 | Generate random non-empty arrays of status strings from the valid set. Compute worst status and verify it matches the minimum severity rank in the array. | Feature: compliance-metric-grouping, Property 2: Worst-status computation follows severity ordering | | Property 3 | Generate random device arrays (each with random failing_metrics) and random filter ID arrays. Filter and verify the result contains exactly the devices with at least one matching metric. | Feature: compliance-metric-grouping, Property 3: Device filtering with metric family includes all matching devices | | Property 4 | Generate random arrays of metric definitions with unique IDs. Build lookup map, query with IDs from the array (expect hit) and IDs not in the array (expect miss). | Feature: compliance-metric-grouping, Property 4: Definition lookup returns correct entry or null | | Property 5 | Generate random metric definition objects with all 14 fields. Extract the rendered field keys and verify all required keys are present. | Feature: compliance-metric-grouping, Property 5: Detail panel renders all required definition fields | | Property 6 | Generate random arrays of metric definition objects. Verify every entry has all 14 keys present, metric_id is a non-empty string, and optional fields are strings (not null/undefined). | Feature: compliance-metric-grouping, Property 6: Definitions schema validation — all entries have required fields | | Property 7 | Generate random definition arrays with unique IDs. Build lookup map and verify map size equals array length, and every definition is retrievable by its metric_id. | Feature: compliance-metric-grouping, Property 7: Lookup map construction preserves all definitions | | Property 8 | Generate random metric definition objects with string values. Round-trip through JSON.stringify then JSON.parse and verify deep equality. | Feature: compliance-metric-grouping, Property 8: JSON round-trip preserves metric definition data | ### Test Configuration - **Library:** fast-check (JavaScript property-based testing) - **Runner:** Jest (via react-scripts test) - **Iterations:** Minimum 100 per property test (`fc.assert(property, { numRuns: 100 })`) - **Tag format:** Comment at top of each property test referencing the design property