diff --git a/changes/add_GLT-4261 b/changes/add_GLT-4261 new file mode 100644 index 0000000..345887a --- /dev/null +++ b/changes/add_GLT-4261 @@ -0,0 +1 @@ +TAT trend report table metrics \ No newline at end of file diff --git a/src/main/resources/templates/tat-trend.html b/src/main/resources/templates/tat-trend.html index 8e1a8dd..6981aa7 100644 --- a/src/main/resources/templates/tat-trend.html +++ b/src/main/resources/templates/tat-trend.html @@ -36,6 +36,12 @@

TAT Trend Report

+ +
@@ -55,6 +61,7 @@

TAT Trend Report

+
diff --git a/ts/component/table-builder.ts b/ts/component/table-builder.ts index 2c41a50..2f90f79 100644 --- a/ts/component/table-builder.ts +++ b/ts/component/table-builder.ts @@ -82,6 +82,7 @@ export interface TableDefinition { // when a parent object has no children, noChildrenWarning is displayed with warning highlight. // if noChildrenWarning is not provided, 'N/A' is displayed instead noChildrenWarning?: string; + parentHeaders?: Array<{ title: string; colspan: number }>; } class AcceptedFilter { @@ -690,19 +691,46 @@ export class TableBuilder { private addTableHead(table: HTMLTableElement) { this.thead = table.createTHead(); this.thead.className = "relative"; - const row = this.thead.insertRow(); + const addHeaderRow = (isParent: boolean) => { + if (!this.thead) { + throw new Error("Table head (thead) is not defined"); + } + const row = this.thead.insertRow(); + if (isParent && this.definition.bulkActions) { + // add a blank header cell for alignment + const th = document.createElement("th"); + th.className = "p-4 bg-grey-300"; + row.appendChild(th); + } + return row; + }; + if (this.definition.parentHeaders) { + // create the first row with parent headers + const parentRow = addHeaderRow(true); + this.definition.parentHeaders.forEach((parentHeader) => { + addColumnHeader( + parentRow, + parentHeader.title, + false, + undefined, + "bg-grey-300", + parentHeader.colspan + ); + }); + } + // create child or single row headers + const row = addHeaderRow(false); if (this.definition.bulkActions) { this.addSelectAllHeader(row); - } else { - this.selectAllCheckbox = undefined; } this.columns.forEach((column, i) => { - addColumnHeader( - row, - column.title, - i == 0 && !this.definition.bulkActions, - column.headingClass - ); + const isFirstColumn = i === 0 && !this.definition.bulkActions; + const isChildHeader = !!this.definition.parentHeaders; + const combinedClass = isChildHeader + ? `text-black ${column.headingClass || ""}`.trim() + : `text-white ${column.headingClass || ""}`.trim(); + const bgColor = isChildHeader ? "bg-grey-100" : "bg-grey-300"; + addColumnHeader(row, column.title, isFirstColumn, combinedClass, bgColor); }); } diff --git a/ts/tat-trend.ts b/ts/tat-trend.ts index 63499e2..54e8180 100644 --- a/ts/tat-trend.ts +++ b/ts/tat-trend.ts @@ -1,11 +1,33 @@ import Plotly from "plotly.js-dist-min"; -import { post } from "./util/requests"; +import { get, post } from "./util/requests"; import { getRequiredElementById } from "./util/html-utils"; import { toggleLegend } from "./component/legend"; import { getColorForGate } from "./util/color-mapping"; +import { + TableDefinition, + TableBuilder, + ColumnDefinition, +} from "./component/table-builder"; let jsonData: any[] = []; const uirevision = "true"; +let tableBuilder: TableBuilder | null = null; +const NOT_AVAILABLE = "N/A"; + +interface AssayMetrics { + assay: string; + gate: string; + timeRanges: Array<{ + group: string; + avgDays: string | undefined; + medianDays: string | undefined; + caseCount: string | undefined; + }>; +} + +interface CaseCounts { + [key: string]: number; +} // constants for column names in the Case TAT Report const COLUMN_NAMES = { @@ -68,6 +90,10 @@ interface AssayGroups { }; } +interface GroupDays { + [group: string]: number[]; +} + function getCompletedDateAndDays( row: any, gate: string, @@ -213,6 +239,52 @@ function getColorByGate(): boolean { return toggleColors.checked; } +function getCaseCount(groups: any[]): { [key: string]: number } { + const groupCounts: { [key: string]: number } = {}; + groups.forEach((group) => { + if (!groupCounts[group]) { + groupCounts[group] = 0; + } + groupCounts[group] += 1; + }); + return groupCounts; +} + +function calcMedian(arr: number[]): number { + const sorted = arr.slice().sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function parseDateValue(timeRange: string | undefined): number { + if (!timeRange) return 0; + if (timeRange.includes("FY") && timeRange.includes("Q")) { + const [fiscalYearStart, fiscalYearEnd, quarter] = + timeRange.match(/\d+/g) ?? []; + const fiscalYear = + parseInt(fiscalYearStart ?? "0", 10) * 10000 + + parseInt(fiscalYearEnd ?? "0", 10) * 100 + + parseInt(quarter ?? "0"); + return fiscalYear; + } + if (timeRange.includes("W")) { + const [year, week] = timeRange.split("-W").map(Number); + return year * 100 + week; + } + if (timeRange.includes("-")) { + const [year, month] = timeRange.split("-").map(Number); + return year * 100 + month; + } + const yearMatch = timeRange.match(/\d{4}/); + return yearMatch ? parseInt(yearMatch[0], 10) * 100 : 0; +} + +function sortTimeRanges(timeRanges: string[]): string[] { + return timeRanges.sort((a, b) => parseDateValue(a) - parseDateValue(b)); +} + function plotData( jsonData: any[], selectedGrouping: string, @@ -251,11 +323,7 @@ function plotData( hovertemplate: ` %{text}
Days: %{y}
- N: %{customdata} `, - customdata: Array(assayGroups[assay][gate].x.length).fill( - assayGroups[assay][gate].n - ), boxpoints: "all", jitter: 0.3, pointpos: 0, @@ -371,6 +439,179 @@ function updatePlotWithLegend( } } +function updateMetricsTable( + jsonData: any[], + selectedGrouping: string, + selectedGates: string[], + selectedDataType: string +) { + // build the table data and get the time ranges from the metrics + const { tableData, timeRanges } = buildTableFromMetrics( + jsonData, + selectedGrouping, + selectedGates, + selectedDataType + ); + const sortedTimeRanges = sortTimeRanges(timeRanges); + const dynamicColumns = generateDynamicColumns(sortedTimeRanges); + const parentHeaders = [ + { title: "", colspan: 1 }, + { title: "", colspan: 1 }, + ].concat( + sortedTimeRanges.map((timeRange) => ({ + title: timeRange, + colspan: 3, + })) + ); + const caseTatTableDefinition: TableDefinition = { + generateColumns: () => dynamicColumns, + parentHeaders, // pass parent headers for multi-level header support + disablePageControls: false, + }; + // reuse or create a new TableBuilder instance + if (tableBuilder) { + tableBuilder.clear(); + } + tableBuilder = new TableBuilder(caseTatTableDefinition, "metricContainer"); + tableBuilder.build(tableData); +} + +function generateDynamicColumns( + timeRanges: string[] +): ColumnDefinition[] { + const dynamicColumns: ColumnDefinition[] = []; + dynamicColumns.push({ + title: "Assay", + sortType: "text", + addParentContents(assayMetrics: AssayMetrics, fragment: DocumentFragment) { + fragment.appendChild(document.createTextNode(assayMetrics.assay)); + }, + }); + dynamicColumns.push({ + title: "Step", + sortType: "text", + addParentContents(assayMetrics: AssayMetrics, fragment: DocumentFragment) { + fragment.appendChild(document.createTextNode(assayMetrics.gate)); + }, + }); + timeRanges.forEach((timeRangeLabel) => { + dynamicColumns.push({ + title: `Mean Days`, + addParentContents( + assayMetrics: AssayMetrics, + fragment: DocumentFragment + ) { + const timeRangeData = assayMetrics.timeRanges.find( + (tr) => tr.group === timeRangeLabel + ); + const value = timeRangeData?.avgDays || NOT_AVAILABLE; + fragment.appendChild(document.createTextNode(value)); + }, + getCellHighlight(assayMetrics) { + const timeRangeData = assayMetrics.timeRanges.find( + (tr) => tr.group === timeRangeLabel + ); + return timeRangeData ? null : "na"; + }, + }); + dynamicColumns.push({ + title: `Median Days`, + addParentContents( + assayMetrics: AssayMetrics, + fragment: DocumentFragment + ) { + const timeRangeData = assayMetrics.timeRanges.find( + (tr) => tr.group === timeRangeLabel + ); + const value = timeRangeData?.medianDays || NOT_AVAILABLE; + fragment.appendChild(document.createTextNode(value)); + }, + getCellHighlight(assayMetrics) { + const timeRangeData = assayMetrics.timeRanges.find( + (tr) => tr.group === timeRangeLabel + ); + return timeRangeData ? null : "na"; + }, + }); + dynamicColumns.push({ + title: `Case Count`, + addParentContents( + assayMetrics: AssayMetrics, + fragment: DocumentFragment + ) { + const timeRangeData = assayMetrics.timeRanges.find( + (tr) => tr.group === timeRangeLabel + ); + const value = timeRangeData?.caseCount || NOT_AVAILABLE; + fragment.appendChild(document.createTextNode(value)); + }, + getCellHighlight(assayMetrics) { + const timeRangeData = assayMetrics.timeRanges.find( + (tr) => tr.group === timeRangeLabel + ); + return timeRangeData ? null : "na"; + }, + }); + }); + return dynamicColumns; +} + +function buildTableFromMetrics( + jsonData: any[], + selectedGrouping: string, + selectedGates: string[], + selectedDataType: string +) { + const groupedData = groupData( + jsonData, + selectedGrouping, + selectedGates, + selectedDataType + ); + const tableData: AssayMetrics[] = []; + const timeRanges: string[] = []; + Object.keys(groupedData).forEach((assay) => { + selectedGates.forEach((gate) => { + if (groupedData[assay][gate]) { + const { x: groups, y: daysArray } = groupedData[assay][gate]; + const caseCounts: CaseCounts = getCaseCount(groups); + const groupDays: GroupDays = {}; + groups.forEach((group, index) => { + const days = Number(daysArray[index]); + if (!groupDays[group]) { + groupDays[group] = []; + } + groupDays[group].push(days); + if (!timeRanges.includes(group)) { + timeRanges.push(group); + } + }); + const assayMetrics: AssayMetrics = { + assay, + gate, + timeRanges: [], + }; + Object.keys(caseCounts).forEach((timeRange) => { + const totalCases = caseCounts[timeRange]; + const daysForTimeRange = groupDays[timeRange]; + const totalDays = daysForTimeRange.reduce((a, b) => a + b, 0); + const averageDays = totalDays / totalCases; + const medianDays = calcMedian(daysForTimeRange); + + assayMetrics.timeRanges.push({ + group: timeRange, + avgDays: averageDays.toFixed(1), + medianDays: medianDays.toFixed(1), + caseCount: totalCases.toString(), + }); + }); + tableData.push(assayMetrics); + } + }); + }); + return { tableData, timeRanges }; +} + function parseUrlParams(): { key: string; value: string }[] { const params: { key: string; value: string }[] = []; const searchParams = new URLSearchParams(window.location.search); @@ -414,6 +655,12 @@ window.addEventListener("load", () => { getSelectedGates(), getSelectedDataType() ); + updateMetricsTable( + jsonData, + getSelectedGrouping(), + getSelectedGates(), + getSelectedDataType() + ); }) .catch((error) => { alert("Error fetching data: " + error); @@ -429,6 +676,12 @@ window.addEventListener("load", () => { selectedGates, selectedDataType ); + updateMetricsTable( + jsonData, + selectedGrouping, + selectedGates, + selectedDataType + ); }; const handleNewPlot = (event: Event) => { @@ -447,6 +700,77 @@ window.addEventListener("load", () => { getRequiredElementById(id).addEventListener("change", handlePlotUpdate); }); + const metricsButton = getRequiredElementById("metricsButton"); + const metricContainer = getRequiredElementById("metricContainer"); + metricContainer.classList.add("hidden"); + metricsButton.addEventListener("click", () => { + metricContainer.classList.toggle("hidden"); + }); const legendButton = getRequiredElementById("legendButton"); legendButton.addEventListener("click", () => toggleLegend("gate")); + + function generateCSV( + tableData: AssayMetrics[], + timeRanges: string[] + ): string { + const csvRows: string[] = []; + const parentHeaders = ["", ""]; + timeRanges.forEach((timeRange) => { + parentHeaders.push(timeRange, "", ""); + }); + csvRows.push(parentHeaders.join(",")); + const subHeaders = ["Assay", "Step"]; + timeRanges.forEach(() => { + subHeaders.push("Mean Days", "Median Days", "Case Count"); + }); + csvRows.push(subHeaders.join(",")); + tableData.forEach((row) => { + const rowData: string[] = [row.assay, row.gate]; + timeRanges.forEach((timeRangeLabel) => { + const timeRangeData = row.timeRanges.find( + (tr) => tr.group === timeRangeLabel + ); + rowData.push( + timeRangeData ? timeRangeData.avgDays ?? NOT_AVAILABLE : NOT_AVAILABLE + ); + rowData.push( + timeRangeData + ? timeRangeData.medianDays ?? NOT_AVAILABLE + : NOT_AVAILABLE + ); + rowData.push( + timeRangeData + ? timeRangeData.caseCount ?? NOT_AVAILABLE + : NOT_AVAILABLE + ); + }); + csvRows.push(rowData.join(",")); + }); + return csvRows.join("\n"); + } + + function downloadCSV(content: string, filename: string) { + const blob = new Blob([content], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + + link.setAttribute("href", url); + link.setAttribute("download", filename); + link.style.visibility = "hidden"; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + const metricsDownloadButton = getRequiredElementById("metricsDownload"); + metricsDownloadButton.addEventListener("click", () => { + const { tableData, timeRanges } = buildTableFromMetrics( + jsonData, + getSelectedGrouping(), + getSelectedGates(), + getSelectedDataType() + ); + const csvContent = generateCSV(tableData, sortTimeRanges(timeRanges)); + downloadCSV(csvContent, "metrics.csv"); + }); }); diff --git a/ts/util/html-utils.ts b/ts/util/html-utils.ts index bed877c..decc920 100644 --- a/ts/util/html-utils.ts +++ b/ts/util/html-utils.ts @@ -5,15 +5,20 @@ export function addColumnHeader( thead: HTMLTableRowElement, header: string, firstColumn: boolean, - addClass?: string + addClass?: string, + bgColor: string = "bg-grey-300", + colspan?: number ) { const th = document.createElement("th"); th.className = - "p-4 text-white font-semibold bg-grey-300 text-left align-text-top" + + `p-4 text-white font-semibold ${bgColor} text-left align-text-top` + (firstColumn ? "" : " border-grey-200 border-l-1"); if (addClass) { th.classList.add(addClass); } + if (colspan) { + th.colSpan = colspan; + } // allow line-wrapping on "/" character header.split("/").forEach((part, index, arr) => {