From e94cb175623a15674d4507762140552b37299f45 Mon Sep 17 00:00:00 2001 From: Taleef Date: Mon, 8 Jun 2026 11:44:48 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(studio):=20add=20SQL=20Analogy=20panel?= =?UTF-8?q?=20to=20CQL=20tab=20=E2=80=94=20spec-derived=20illustrative=20p?= =?UTF-8?q?review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SqlPreviewPanel component below the Monaco editor in the Studio CQL tab. Panel is collapsed by default; expands to show a SQL-shaped analogy derived entirely from spec_json fields (complianceWindow, eligibilityCriteria, exclusions, requiredDataElements, policyRef) with no backend calls and no CQL parsing. Key constraints honoured: - Amber 'Illustrative only — CQL is the compliance source of truth' banner always visible when expanded - DUE_SOON threshold computed as window - 30d with explicit approximation comment: 'DUE_SOON threshold approximate; see CQL for exact window' - Falls back gracefully when complianceWindow contains no numeric value Includes 7-test Vitest suite covering collapsed default, banner, SQL content (policy ref, window, role/site filters, approximation comment), toggle, and non-numeric-window fallback. Co-Authored-By: Claude Sonnet 4.6 --- .../features/studio/components/CqlTab.tsx | 3 + .../studio/components/SqlPreviewPanel.tsx | 117 ++++++++++++++++++ .../__tests__/SqlPreviewPanel.test.tsx | 83 +++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 frontend/features/studio/components/SqlPreviewPanel.tsx create mode 100644 frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx diff --git a/frontend/features/studio/components/CqlTab.tsx b/frontend/features/studio/components/CqlTab.tsx index 60b552e..1b8760e 100644 --- a/frontend/features/studio/components/CqlTab.tsx +++ b/frontend/features/studio/components/CqlTab.tsx @@ -8,6 +8,7 @@ import { formatStatusLabel } from "@/lib/status"; import type { ApiClient } from "@/lib/api/client"; import type { MeasureDetail } from "../types"; import { compileStatusClass, formatIssue, parseCompileIssue } from "../utils"; +import { SqlPreviewPanel } from "./SqlPreviewPanel"; const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false, @@ -300,6 +301,8 @@ export function CqlTab({ ) : null} + + {showDraftCqlDialog && (
diff --git a/frontend/features/studio/components/SqlPreviewPanel.tsx b/frontend/features/studio/components/SqlPreviewPanel.tsx new file mode 100644 index 0000000..1204f93 --- /dev/null +++ b/frontend/features/studio/components/SqlPreviewPanel.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useState } from "react"; +import type { MeasureDetail } from "../types"; + +type Props = { + measure: MeasureDetail; +}; + +function buildSql(measure: MeasureDetail): string { + const { + policyRef, + eligibilityCriteria, + exclusions, + complianceWindow, + requiredDataElements, + } = measure; + + // Parse numeric days from compliance window string, e.g. "365 days", "820 days biannual" + const windowMatch = complianceWindow?.match(/(\d+)/); + const windowDays = windowMatch ? parseInt(windowMatch[1], 10) : null; + const dueSoonDays = windowDays ? windowDays - 30 : null; + + const excl = exclusions?.[0]; + const exclSlug = excl?.criteriaText + ? excl.criteriaText.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, "") + : null; + const exclLine = exclSlug + ? ` WHEN ep.${exclSlug} = TRUE THEN 'EXCLUDED' -- ${excl!.label}` + : ` -- (no exclusion criteria defined in Spec)`; + + const dataComment = + (requiredDataElements ?? []).length > 0 + ? ` -- required: ${requiredDataElements.join(", ")}` + : ""; + + const enrollComment = eligibilityCriteria?.programEnrollmentText + ? `\n -- eligibility: ${eligibilityCriteria.programEnrollmentText}` + : ""; + + const roleClause = eligibilityCriteria?.roleFilter + ? ` AND e.role = '${eligibilityCriteria.roleFilter}'` + : ` -- role: unrestricted`; + + const siteClause = eligibilityCriteria?.siteFilter + ? ` AND e.site = '${eligibilityCriteria.siteFilter}'` + : ` -- site: all sites`; + + const overdueExpr = + windowDays !== null + ? `NOW() - MAX(exam.date) > INTERVAL '${windowDays} days'` + : `/* window: ${complianceWindow ?? "see CQL"} */`; + + const dueSoonExpr = + dueSoonDays !== null + ? `NOW() - MAX(exam.date) > INTERVAL '${dueSoonDays} days'` + : `/* DUE_SOON: see CQL */`; + + return `-- Illustrative analogy only — CQL is the compliance source of truth +-- Policy: ${policyRef ?? "see Spec tab"} +SELECT + e.id, + e.name, + e.role, + e.site, + MAX(exam.date) AS last_exam_date${dataComment ? ",\n" + dataComment : ""} + CASE +${exclLine} + WHEN MAX(exam.date) IS NULL THEN 'MISSING_DATA' + WHEN ${overdueExpr} THEN 'OVERDUE' + WHEN ${dueSoonExpr} THEN 'DUE_SOON' + -- DUE_SOON threshold approximate; see CQL for exact window + ELSE 'COMPLIANT' + END AS outcome_status +FROM employees e +JOIN employee_programs ep ON ep.employee_id = e.id${enrollComment} +LEFT JOIN exams exam ON exam.employee_id = e.id +WHERE e.active = TRUE +${roleClause} +${siteClause} +GROUP BY e.id, e.name, e.role, e.site, ep.exclusion_flag`; +} + +export function SqlPreviewPanel({ measure }: Props) { + const [open, setOpen] = useState(false); + const sql = buildSql(measure); + + return ( +
+ + + {open && ( +
+
+ Illustrative only + Not executed. CQL is the compliance source of truth. Column names and table structure are analogical. +
+
+            {sql}
+          
+
+ )} +
+ ); +} diff --git a/frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx b/frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx new file mode 100644 index 0000000..735063c --- /dev/null +++ b/frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { SqlPreviewPanel } from "../SqlPreviewPanel"; +import type { MeasureDetail } from "../../types"; + +const sampleMeasure: MeasureDetail = { + id: "m1", + name: "Annual Audiogram Completed", + policyRef: "OSHA 29 CFR 1910.95", + oshaReferenceId: null, + version: "1.0", + status: "Active", + owner: "Safety", + description: "Annual audiogram for employees in hearing conservation.", + eligibilityCriteria: { + roleFilter: "Safety Technician", + siteFilter: "Plant A", + programEnrollmentText: "In Hearing Conservation Program", + }, + exclusions: [{ label: "Active Waiver", criteriaText: "Has Active Waiver" }], + complianceWindow: "365 days", + requiredDataElements: ["Audiogram Date", "Waiver Status"], + cqlText: "", + compileStatus: "COMPILED", + valueSets: [], + testFixtures: [], +}; + +describe("SqlPreviewPanel", () => { + it("renders a collapsed toggle button by default", () => { + render(); + expect(screen.getByRole("button", { name: /SQL Analogy/i })).toBeInTheDocument(); + expect(screen.queryByTestId("sql-preview-block")).toBeNull(); + }); + + it("expands and shows the illustrative-only banner on click", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + // The amber banner span uses exact text "Illustrative only" + expect(screen.getAllByText(/Illustrative only/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(/Not executed\. CQL is the compliance source of truth/i)).toBeInTheDocument(); + }); + + it("shows policy ref and compliance window in the SQL block", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + const block = screen.getByTestId("sql-preview-block"); + expect(block.textContent).toContain("OSHA 29 CFR 1910.95"); + expect(block.textContent).toContain("365 days"); + expect(block.textContent).toContain("335 days"); + }); + + it("includes role and site filters from spec", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + const block = screen.getByTestId("sql-preview-block"); + expect(block.textContent).toContain("Safety Technician"); + expect(block.textContent).toContain("Plant A"); + }); + + it("includes the approximate DUE_SOON comment", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + const block = screen.getByTestId("sql-preview-block"); + expect(block.textContent).toContain("DUE_SOON threshold approximate; see CQL for exact window"); + }); + + it("collapses again on second click", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + expect(screen.queryByTestId("sql-preview-block")).toBeNull(); + }); + + it("renders fallback text when compliance window has no numeric value", () => { + const noWindow: MeasureDetail = { ...sampleMeasure, complianceWindow: "see policy" }; + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + const block = screen.getByTestId("sql-preview-block"); + expect(block.textContent).toContain("see policy"); + }); +}); From 8e5d61b587ccd92ba59ccb847ac73f799184de46 Mon Sep 17 00:00:00 2001 From: Taleef Date: Mon, 8 Jun 2026 12:58:47 -0400 Subject: [PATCH 2/2] fix(studio): only parse day-count windows in SQL analogy panel Tighten regex from /(\d+)/ to /(\d+)\s*days?\b/i so strings like "Series of 3 doses over 6 months" fall through to the raw-string fallback instead of rendering a misleading INTERVAL '3 days'. Add test case covering the multi-number non-day window pattern. Co-Authored-By: Claude Sonnet 4.6 --- .../features/studio/components/SqlPreviewPanel.tsx | 5 +++-- .../components/__tests__/SqlPreviewPanel.test.tsx | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/features/studio/components/SqlPreviewPanel.tsx b/frontend/features/studio/components/SqlPreviewPanel.tsx index 1204f93..d937e1c 100644 --- a/frontend/features/studio/components/SqlPreviewPanel.tsx +++ b/frontend/features/studio/components/SqlPreviewPanel.tsx @@ -16,8 +16,9 @@ function buildSql(measure: MeasureDetail): string { requiredDataElements, } = measure; - // Parse numeric days from compliance window string, e.g. "365 days", "820 days biannual" - const windowMatch = complianceWindow?.match(/(\d+)/); + // Only parse as days when the string explicitly says "N days" / "N day". + // Strings like "Series of 3 doses over 6 months" must not be treated as a day count. + const windowMatch = complianceWindow?.match(/(\d+)\s*days?\b/i); const windowDays = windowMatch ? parseInt(windowMatch[1], 10) : null; const dueSoonDays = windowDays ? windowDays - 30 : null; diff --git a/frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx b/frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx index 735063c..30045fe 100644 --- a/frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx +++ b/frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx @@ -80,4 +80,14 @@ describe("SqlPreviewPanel", () => { const block = screen.getByTestId("sql-preview-block"); expect(block.textContent).toContain("see policy"); }); + + it("uses raw-string fallback for multi-number non-day windows like 'Series of 3 doses over 6 months'", () => { + const doseWindow: MeasureDetail = { ...sampleMeasure, complianceWindow: "Series of 3 doses over 6 months" }; + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + const block = screen.getByTestId("sql-preview-block"); + // Should pass the window through as a comment, not parse '3' as a day count + expect(block.textContent).toContain("Series of 3 doses over 6 months"); + expect(block.textContent).not.toContain("INTERVAL '3 days'"); + }); });