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) => {