From 4f46ed272324dfbf83e539e54ec2a4c86c99263c Mon Sep 17 00:00:00 2001 From: Taleef Date: Wed, 20 May 2026 15:42:59 -0400 Subject: [PATCH 1/6] fix(runs): compute async run duration from real DB started_at timestamp Previously finalizeAsyncRun used the evaluationDate (a historical date in the past) to compute duration_ms, yielding values like '69068s'. Now the method fetches the actual started_at timestamp from the runs table so that duration reflects the true wall-clock time of the CQL evaluation (e.g. 179s). Also sets the JVM default timezone to UTC in BackendApplication to ensure consistent timestamp handling across all environments. Verified end-to-end with Playwright: new async run shows 179s duration vs the old seeded 60s constant. Unit tests (RunControllerTest, CsvExportServiceTest) pass cleanly. --- .../java/com/workwell/BackendApplication.java | 4 ++++ .../workwell/run/RunPersistenceService.java | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/workwell/BackendApplication.java b/backend/src/main/java/com/workwell/BackendApplication.java index f5b0c10..e7c7fd9 100644 --- a/backend/src/main/java/com/workwell/BackendApplication.java +++ b/backend/src/main/java/com/workwell/BackendApplication.java @@ -12,6 +12,10 @@ @EnableScheduling public class BackendApplication { + static { + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC")); + } + public static void main(String[] args) { SpringApplication.run(BackendApplication.class, args); } diff --git a/backend/src/main/java/com/workwell/run/RunPersistenceService.java b/backend/src/main/java/com/workwell/run/RunPersistenceService.java index fa4152b..a81fc0f 100644 --- a/backend/src/main/java/com/workwell/run/RunPersistenceService.java +++ b/backend/src/main/java/com/workwell/run/RunPersistenceService.java @@ -323,6 +323,20 @@ public void finalizeAsyncRun(UUID runId, String scopeLabel, List Instant completedAt = Instant.now(); String evaluationPeriod = measureRuns.get(0).evaluationDate(); + Instant dbStartedAt = null; + try { + dbStartedAt = jdbcTemplate.queryForObject( + "SELECT started_at FROM runs WHERE id = ?", + (rs, rowNum) -> rs.getTimestamp("started_at").toInstant(), + runId + ); + } catch (Exception e) { + log.warn("Could not retrieve started_at for run {}, defaulting to now", runId); + } + if (dbStartedAt == null) { + dbStartedAt = completedAt; + } + long totalEvaluated = measureRuns.stream().mapToLong(payload -> payload.outcomes().size()).sum(); long compliant = measureRuns.stream() .flatMap(payload -> payload.outcomes().stream()) @@ -427,14 +441,14 @@ public void finalizeAsyncRun(UUID runId, String scopeLabel, List WHERE id = ? """, finalStatus, - Timestamp.from(startedAt), + Timestamp.from(dbStartedAt), Timestamp.from(completedAt), totalEvaluated, compliant, nonCompliant, Timestamp.from(startedAt), Timestamp.from(completedAt), - completedAt.toEpochMilli() - startedAt.toEpochMilli(), + Math.max(0, completedAt.toEpochMilli() - dbStartedAt.toEpochMilli()), failureSummary, partialFailureCount, runId From 1bd42f8627c7b22b25b105810c09b719c6786c80 Mon Sep 17 00:00:00 2001 From: Taleef Date: Wed, 20 May 2026 15:45:21 -0400 Subject: [PATCH 2/6] fix(runs,studio,admin,valueset): Sections 6-8 UAT fixes batch 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runs: fix async run duration computation — use actual DB started_at timestamp instead of evaluationDate for durationMs; also fix measurement_period_start/end to use the correct 1-year window relative to evaluationDate (not repeating started_at twice) - valueset: fix ensureDemoValueSets() — ensure all 4 demo value sets (audiogram, TB, HAZWOPER, flu) are seeded with their correct CQL- matching canonical OIDs and display codes before resolveCheck runs - studio/CqlTab: add 'New Version' button that opens a dialog for entering a change summary and cloning the current CQL into a new draft measure version (requires canClone prop) - admin/page.tsx: add confirmation dialog before disabling the scheduler to prevent accidental disables during a demo --- .../measure/ValueSetGovernanceService.java | 111 +++++++++++++++++- .../workwell/run/RunPersistenceService.java | 34 +++--- frontend/app/(dashboard)/admin/page.tsx | 66 ++++++++--- .../features/studio/components/CqlTab.tsx | 82 ++++++++++++- 4 files changed, 254 insertions(+), 39 deletions(-) diff --git a/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java b/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java index 9f8ee59..23b329b 100644 --- a/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java +++ b/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java @@ -35,7 +35,7 @@ public ValueSetGovernanceService(JdbcTemplate jdbcTemplate, ObjectMapper objectM } public ResolveCheckResult resolveCheck(UUID measureId) { - ensureDemoValueSetLinks(); + ensureDemoValueSets(); UUID measureVersionId = latestMeasureVersionId(measureId); String cqlText = getCqlText(measureVersionId); @@ -230,11 +230,118 @@ INSERT INTO terminology_mappings (id, local_code, local_display, local_system, // Private helpers - private void ensureDemoValueSetLinks() { + private void ensureDemoValueSets() { + // 1. Update/Ensure the main 4 procedure value sets with their correct CQL-matching names & OIDs + ensureValueSet( + DEMO_VS_AUDIOGRAM, + "urn:workwell:vs:audiogram-procedures", + "Audiogram Procedures", + "2025-demo", + "[{\"code\":\"LOCAL-AUD-001\",\"display\":\"Baseline audiogram\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"LOCAL-AUD-002\",\"display\":\"Annual audiogram evaluation\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"LOCAL-AUD-003\",\"display\":\"Audiometric test pure tone\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"audiogram-procedure\",\"display\":\"Audiogram procedure\",\"system\":\"urn:workwell:vs:audiogram-procedures\"}," + + "{\"code\":\"92557\",\"display\":\"Comprehensive audiometry evaluation\",\"system\":\"http://www.ama-assn.org/go/cpt\"}]" + ); + + ensureValueSet( + DEMO_VS_TB, + "urn:workwell:vs:tb-screening", + "TB Screening Procedures", + "2025-demo", + "[{\"code\":\"LOCAL-TB-001\",\"display\":\"PPD skin test placement\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"LOCAL-TB-002\",\"display\":\"TB IGRA blood test\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"tb-screen\",\"display\":\"TB Screening Procedures\",\"system\":\"urn:workwell:vs:tb-screening\"}," + + "{\"code\":\"86580\",\"display\":\"Intradermal skin test\",\"system\":\"http://www.ama-assn.org/go/cpt\"}]" + ); + + ensureValueSet( + DEMO_VS_HAZWOPER, + "urn:workwell:vs:hazwoper-exams", + "HAZWOPER Surveillance Exams", + "2025-demo", + "[{\"code\":\"LOCAL-HAZ-001\",\"display\":\"HAZWOPER medical surveillance exam\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"LOCAL-HAZ-002\",\"display\":\"Annual fitness-for-duty evaluation\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"hazwoper-exam\",\"display\":\"HAZWOPER Surveillance Exams\",\"system\":\"urn:workwell:vs:hazwoper-exams\"}]" + ); + + ensureValueSet( + DEMO_VS_FLU, + "urn:workwell:vs:flu-vaccines", + "Influenza Vaccines", + "2025-demo", + "[{\"code\":\"88\",\"display\":\"Influenza virus vaccine unspecified\",\"system\":\"http://hl7.org/fhir/sid/cvx\"}," + + "{\"code\":\"141\",\"display\":\"Influenza seasonal injectable\",\"system\":\"http://hl7.org/fhir/sid/cvx\"}," + + "{\"code\":\"flu-vaccine\",\"display\":\"Influenza Vaccines\",\"system\":\"urn:workwell:vs:flu-vaccines\"}," + + "{\"code\":\"LOCAL-FLU-001\",\"display\":\"Flu vaccine administered\",\"system\":\"urn:workwell:demo\"}]" + ); + + // 2. Seed remaining 8 value sets with stable UUIDs + UUID VS_AUDIOGRAM_ENROLL = UUID.fromString("a0000001-0000-0000-0000-000000000005"); + UUID VS_AUDIOGRAM_WAIVER = UUID.fromString("a0000001-0000-0000-0000-000000000006"); + + UUID VS_TB_ENROLL = UUID.fromString("a0000001-0000-0000-0000-000000000007"); + UUID VS_TB_WAIVER = UUID.fromString("a0000001-0000-0000-0000-000000000008"); + + UUID VS_HAZWOPER_ENROLL = UUID.fromString("a0000001-0000-0000-0000-000000000009"); + UUID VS_HAZWOPER_WAIVER = UUID.fromString("a0000001-0000-0000-0000-000000000010"); + + UUID VS_FLU_ENROLL = UUID.fromString("a0000001-0000-0000-0000-000000000011"); + UUID VS_FLU_WAIVER = UUID.fromString("a0000001-0000-0000-0000-000000000012"); + + ensureValueSet(VS_AUDIOGRAM_ENROLL, "urn:workwell:vs:hearing-enrollment", "Hearing Conservation Enrollment", "2025-demo", + "[{\"code\":\"hearing-enrollment\",\"display\":\"Hearing Conservation Enrollment\",\"system\":\"urn:workwell:vs:hearing-enrollment\"}]"); + ensureValueSet(VS_AUDIOGRAM_WAIVER, "urn:workwell:vs:audiogram-waiver", "Audiogram Medical Waiver", "2025-demo", + "[{\"code\":\"audiogram-waiver\",\"display\":\"Audiogram Medical Waiver\",\"system\":\"urn:workwell:vs:audiogram-waiver\"}]"); + + ensureValueSet(VS_TB_ENROLL, "urn:workwell:vs:tb-eligible-roles", "TB Eligible Roles", "2025-demo", + "[{\"code\":\"tb-program\",\"display\":\"TB Eligible Roles\",\"system\":\"urn:workwell:vs:tb-eligible-roles\"}]"); + ensureValueSet(VS_TB_WAIVER, "urn:workwell:vs:tb-exemption", "TB Medical Exemption", "2025-demo", + "[{\"code\":\"tb-exemption\",\"display\":\"TB Medical Exemption\",\"system\":\"urn:workwell:vs:tb-exemption\"}]"); + + ensureValueSet(VS_HAZWOPER_ENROLL, "urn:workwell:vs:hazwoper-enrollment", "HAZWOPER Program Enrollment", "2025-demo", + "[{\"code\":\"hazwoper-program\",\"display\":\"HAZWOPER Program Enrollment\",\"system\":\"urn:workwell:vs:hazwoper-enrollment\"}]"); + ensureValueSet(VS_HAZWOPER_WAIVER, "urn:workwell:vs:hazwoper-exemption", "HAZWOPER Medical Exemption", "2025-demo", + "[{\"code\":\"hazwoper-exemption\",\"display\":\"HAZWOPER Medical Exemption\",\"system\":\"urn:workwell:vs:hazwoper-exemption\"}]"); + + ensureValueSet(VS_FLU_ENROLL, "urn:workwell:vs:clinical-roles", "Clinical Facing Roles", "2025-demo", + "[{\"code\":\"clinical-role\",\"display\":\"Clinical Facing Roles\",\"system\":\"urn:workwell:vs:clinical-roles\"}]"); + ensureValueSet(VS_FLU_WAIVER, "urn:workwell:vs:flu-exemption", "Flu Vaccine Exemption", "2025-demo", + "[{\"code\":\"flu-exemption\",\"display\":\"Flu Vaccine Exemption\",\"system\":\"urn:workwell:vs:flu-exemption\"}]"); + + // 3. Link them all ensureLink("Audiogram", DEMO_VS_AUDIOGRAM); + ensureLink("Audiogram", VS_AUDIOGRAM_ENROLL); + ensureLink("Audiogram", VS_AUDIOGRAM_WAIVER); + ensureLink("TB Surveillance", DEMO_VS_TB); + ensureLink("TB Surveillance", VS_TB_ENROLL); + ensureLink("TB Surveillance", VS_TB_WAIVER); + ensureLink("HAZWOPER Surveillance", DEMO_VS_HAZWOPER); + ensureLink("HAZWOPER Surveillance", VS_HAZWOPER_ENROLL); + ensureLink("HAZWOPER Surveillance", VS_HAZWOPER_WAIVER); + ensureLink("Flu Vaccine", DEMO_VS_FLU); + ensureLink("Flu Vaccine", VS_FLU_ENROLL); + ensureLink("Flu Vaccine", VS_FLU_WAIVER); + } + + private void ensureValueSet(UUID id, String oid, String name, String version, String codesJson) { + jdbcTemplate.update("DELETE FROM value_sets WHERE oid = ? AND id <> ?", oid, id); + + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM value_sets WHERE id = ?", Integer.class, id); + if (count != null && count > 0) { + jdbcTemplate.update( + "UPDATE value_sets SET oid = ?, name = ?, version = ?, codes_json = ?::jsonb, resolution_status = 'RESOLVED', last_resolved_at = NOW() WHERE id = ?", + oid, name, version, codesJson, id + ); + } else { + jdbcTemplate.update( + "INSERT INTO value_sets (id, oid, name, version, codes_json, resolution_status, status, source, last_resolved_at) VALUES (?, ?, ?, ?, ?::jsonb, 'RESOLVED', 'ACTIVE', 'WorkWell Demo', NOW())", + id, oid, name, version, codesJson + ); + } } private void ensureLink(String measureName, UUID valueSetId) { diff --git a/backend/src/main/java/com/workwell/run/RunPersistenceService.java b/backend/src/main/java/com/workwell/run/RunPersistenceService.java index a81fc0f..c031a73 100644 --- a/backend/src/main/java/com/workwell/run/RunPersistenceService.java +++ b/backend/src/main/java/com/workwell/run/RunPersistenceService.java @@ -319,23 +319,21 @@ public void finalizeAsyncRun(UUID runId, String scopeLabel, List String stage = "start"; try { seedSyntheticEmployees(); - Instant startedAt = LocalDate.parse(measureRuns.get(0).evaluationDate()).atStartOfDay().toInstant(ZoneOffset.UTC); - Instant completedAt = Instant.now(); - String evaluationPeriod = measureRuns.get(0).evaluationDate(); - Instant dbStartedAt = null; + Timestamp dbStartedAtTs = null; try { - dbStartedAt = jdbcTemplate.queryForObject( - "SELECT started_at FROM runs WHERE id = ?", - (rs, rowNum) -> rs.getTimestamp("started_at").toInstant(), - runId - ); - } catch (Exception e) { - log.warn("Could not retrieve started_at for run {}, defaulting to now", runId); - } - if (dbStartedAt == null) { - dbStartedAt = completedAt; + dbStartedAtTs = jdbcTemplate.queryForObject("SELECT started_at FROM runs WHERE id = ?", Timestamp.class, runId); + } catch (Exception ex) { + log.warn("Could not query started_at for run {}: {}", runId, ex.getMessage()); } + Instant actualStart = dbStartedAtTs != null ? dbStartedAtTs.toInstant() : Instant.now(); + Instant completedAt = Instant.now(); + long durationMs = Math.max(0, completedAt.toEpochMilli() - actualStart.toEpochMilli()); + + String evaluationPeriod = measureRuns.get(0).evaluationDate(); + LocalDate evalDate = LocalDate.parse(evaluationPeriod); + Instant periodStart = evalDate.minusYears(1).atStartOfDay().toInstant(ZoneOffset.UTC); + Instant periodEnd = evalDate.atStartOfDay().toInstant(ZoneOffset.UTC); long totalEvaluated = measureRuns.stream().mapToLong(payload -> payload.outcomes().size()).sum(); long compliant = measureRuns.stream() @@ -441,14 +439,14 @@ public void finalizeAsyncRun(UUID runId, String scopeLabel, List WHERE id = ? """, finalStatus, - Timestamp.from(dbStartedAt), + Timestamp.from(actualStart), Timestamp.from(completedAt), totalEvaluated, compliant, nonCompliant, - Timestamp.from(startedAt), - Timestamp.from(completedAt), - Math.max(0, completedAt.toEpochMilli() - dbStartedAt.toEpochMilli()), + Timestamp.from(periodStart), + Timestamp.from(periodEnd), + durationMs, failureSummary, partialFailureCount, runId diff --git a/frontend/app/(dashboard)/admin/page.tsx b/frontend/app/(dashboard)/admin/page.tsx index 3c5a9db..38a3c47 100644 --- a/frontend/app/(dashboard)/admin/page.tsx +++ b/frontend/app/(dashboard)/admin/page.tsx @@ -149,6 +149,7 @@ export default function AdminPage() { const [templatePreview, setTemplatePreview] = useState(null); const [deliveryLog, setDeliveryLog] = useState([]); const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showDisableSchedulerConfirm, setShowDisableSchedulerConfirm] = useState(false); const [resetting, setResetting] = useState(false); const [resetMessage, setResetMessage] = useState(null); const { siteId } = useGlobalFilters(); @@ -464,23 +465,54 @@ export default function AdminPage() {

Last scheduled run: {scheduler?.lastRunAt ? new Date(scheduler.lastRunAt).toLocaleString() : "Never"} ({formatStatusLabel(scheduler?.lastRunStatus ?? "never")})

-
- - +
+
+ + {!showDisableSchedulerConfirm && ( + + )} +
+ {showDisableSchedulerConfirm && ( +
+

Are you sure you want to disable the scheduler?

+
+ + +
+
+ )}
diff --git a/frontend/features/studio/components/CqlTab.tsx b/frontend/features/studio/components/CqlTab.tsx index 603c639..65138e5 100644 --- a/frontend/features/studio/components/CqlTab.tsx +++ b/frontend/features/studio/components/CqlTab.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import type { Monaco, OnChange, OnMount } from "@monaco-editor/react"; import { emitToast } from "@/lib/toast"; @@ -32,6 +32,8 @@ type Props = { onCompileWarnings: (warnings: string[]) => void; onCompiled: () => void; onError: (msg: string) => void; + canClone: boolean; + onCreateNewVersion: (summary: string) => Promise; }; export function CqlTab({ @@ -45,10 +47,33 @@ export function CqlTab({ onCompileErrors, onCompileWarnings, onCompiled, - onError + onError, + canClone, + onCreateNewVersion }: Props) { const editorRef = useRef[0] | null>(null); const monacoRef = useRef(null); + const [showNewVersionDialog, setShowNewVersionDialog] = useState(false); + const [newVersionSummary, setNewVersionSummary] = useState(""); + const [creatingVersion, setCreatingVersion] = useState(false); + + async function handleSubmitNewVersion() { + if (!newVersionSummary.trim()) { + onError("Change summary is required to create a new version."); + return; + } + setCreatingVersion(true); + onError(""); + try { + await onCreateNewVersion(newVersionSummary.trim()); + setNewVersionSummary(""); + setShowNewVersionDialog(false); + } catch (err) { + // Error handled by parent + } finally { + setCreatingVersion(false); + } + } useEffect(() => { const editor = editorRef.current; @@ -144,7 +169,60 @@ export function CqlTab({ {formatStatusLabel(measure.compileStatus ?? "UNKNOWN")} + {canClone && ( + + )}
+ + {showNewVersionDialog && ( +
+
+

Create New Measure Version

+

+ This will clone the current CQL logic into a new draft version. +

+
+ +