diff --git a/changes/add_GLT-4070 b/changes/add_GLT-4070 new file mode 100644 index 00000000..aa8aa84e --- /dev/null +++ b/changes/add_GLT-4070 @@ -0,0 +1 @@ +TAT trend report generated from the Cases list in Dimsum using the same filters \ No newline at end of file diff --git a/docs/user_manual/reports.md b/docs/user_manual/reports.md index 130e3390..cadd3848 100644 --- a/docs/user_manual/reports.md +++ b/docs/user_manual/reports.md @@ -40,4 +40,4 @@ cases you are interested in, and click the "TAT Report" button. The report will ## TAT Trend Report -(TODO) +The TAT Trend Report provides a box plot visualization of turn-around time trends for selected cases. It includes gates for clinical report and data release turn-around times, with options to group data by time range or QC gate. To generate the report, select cases from any case table, such as the main QC dashboard, and click the "TAT Trend" button. The report will open in a new window. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f42c91bb..860b31eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,11 @@ "name": "dimsum", "version": "1.0.0", "license": "MIT", + "dependencies": { + "plotly.js-dist-min": "^2.33.0" + }, "devDependencies": { + "@types/plotly.js-dist-min": "^2.3.4", "tailwindcss": "^3.0.24", "ts-loader": "^9.3.0", "typescript": "^5.3.3", @@ -160,6 +164,21 @@ "integrity": "sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==", "dev": true }, + "node_modules/@types/plotly.js": { + "version": "2.29.5", + "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-2.29.5.tgz", + "integrity": "sha512-VPaCOCUTHLbtQxur7o7RqKL7/hEDtgYZcXL41o4xe9mqZ4HVxbC70j0lTsCdiVp2SI+BYsER4oPO+ck17jtnkg==", + "dev": true + }, + "node_modules/@types/plotly.js-dist-min": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/plotly.js-dist-min/-/plotly.js-dist-min-2.3.4.tgz", + "integrity": "sha512-ISwLFV6Zs/v3DkaRFLyk2rvYAfVdnYP2VVVy7h+fBDWw52sn7sMUzytkWiN4M75uxr1uz1uiBioePTDpAfoFIg==", + "dev": true, + "dependencies": { + "@types/plotly.js": "*" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", @@ -407,6 +426,14 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -551,6 +578,18 @@ } ] }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -629,6 +668,14 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -659,6 +706,17 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -921,6 +979,14 @@ "node": ">=8" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1208,18 +1274,6 @@ "node": ">=8" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -1449,6 +1503,11 @@ "node": ">=8" } }, + "node_modules/plotly.js-dist-min": { + "version": "2.33.0", + "resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-2.33.0.tgz", + "integrity": "sha512-XTgGZDC/srjk6HvEHu0YmWpSPCDYRBjk8q0bbsCPTpKH961wFXzhWj+qdJ4vMkLQWoO5vhzVAGN9ZlRM3fDQkQ==" + }, "node_modules/postcss": { "version": "8.4.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", @@ -1747,13 +1806,10 @@ } }, "node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -1837,6 +1893,17 @@ "source-map": "^0.6.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -2184,6 +2251,42 @@ "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==", "dev": true }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.6", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.6.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -2193,12 +2296,6 @@ "node": ">=0.4" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/package.json b/package.json index e02fb474..5e3f57fc 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,14 @@ }, "license": "MIT", "devDependencies": { + "@types/plotly.js-dist-min": "^2.3.4", "tailwindcss": "^3.0.24", "ts-loader": "^9.3.0", "typescript": "^5.3.3", "webpack": "^5.76.0", "webpack-cli": "^4.9.2" + }, + "dependencies": { + "plotly.js-dist-min": "^2.33.0" } } diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/controller/mvc/TatTrendController.java b/src/main/java/ca/on/oicr/gsi/dimsum/controller/mvc/TatTrendController.java new file mode 100644 index 00000000..6b25ad86 --- /dev/null +++ b/src/main/java/ca/on/oicr/gsi/dimsum/controller/mvc/TatTrendController.java @@ -0,0 +1,18 @@ +package ca.on.oicr.gsi.dimsum.controller.mvc; + +import java.util.Map; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.ModelAndView; + +@Controller +public class TatTrendController { + + @GetMapping("/tat-trend") + public ModelAndView tatTrend(@RequestParam Map filters) { + ModelAndView modelAndView = new ModelAndView("tat-trend"); + modelAndView.addObject("filters", filters); + return modelAndView; + } +} diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/controller/rest/DownloadRestController.java b/src/main/java/ca/on/oicr/gsi/dimsum/controller/rest/DownloadRestController.java index 75dd0ab3..aa118081 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/controller/rest/DownloadRestController.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/controller/rest/DownloadRestController.java @@ -3,7 +3,6 @@ import java.io.IOException; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; -import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -13,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import ca.on.oicr.gsi.dimsum.controller.BadRequestException; import ca.on.oicr.gsi.dimsum.service.CaseService; import ca.on.oicr.gsi.dimsum.util.reporting.Report; @@ -29,10 +29,12 @@ public class DownloadRestController { @Autowired private CaseService caseService; + @Autowired + private ObjectMapper objectMapper; // Autowire ObjectMapper + @PostMapping("/reports/{reportName}") public HttpEntity generateReport(@PathVariable String reportName, - @RequestBody JsonNode parameters, HttpServletResponse response) - throws IOException { + @RequestBody JsonNode parameters) throws IOException { ReportFormat format = Report.getFormat(parameters); Report report = getReport(reportName); @@ -40,12 +42,17 @@ public HttpEntity generateReport(@PathVariable String reportName, HttpHeaders headers = new HttpHeaders(); headers.setContentType(format.getMediaType()); - response.setHeader("Content-Disposition", - "attachment; filename=" - + String.format("%s-%s.%s", reportName, DateTimeFormatter.ISO_LOCAL_DATE.format( - ZonedDateTime.now()), format.getExtension())); + headers.set("Content-Disposition", + "attachment; filename=" + String.format("%s-%s.%s", reportName, + DateTimeFormatter.ISO_LOCAL_DATE.format(ZonedDateTime.now()), format.getExtension())); + + return new HttpEntity<>(bytes, headers); + } - return new HttpEntity(bytes, headers); + @PostMapping("/reports/{reportName}/data") + public JsonNode getReportData(@PathVariable String reportName, @RequestBody JsonNode parameters) { + Report report = getReport(reportName); + return report.getData(caseService, parameters, objectMapper); // Return JSON directly } private static Report getReport(String reportName) { @@ -62,5 +69,4 @@ private static Report getReport(String reportName) { throw new BadRequestException("Invalid report name"); } } - } diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/Report.java b/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/Report.java index 1568cd19..1c363224 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/Report.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/Report.java @@ -7,6 +7,7 @@ import java.util.List; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import ca.on.oicr.gsi.dimsum.controller.BadRequestException; import ca.on.oicr.gsi.dimsum.service.CaseService; @@ -82,4 +83,10 @@ private byte[] writeDelimitedFile(CaseService caseService, String delimiter, return sb.toString().getBytes(); } + public JsonNode getData(CaseService caseService, JsonNode parameters, + ObjectMapper objectMapper) { + ReportSection section = (ReportSection) sections.get(0); + List objects = section.getData(caseService, parameters); + return section.createJson(objects, objectMapper); + } } diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/ReportSection.java b/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/ReportSection.java index ab210997..347777d7 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/ReportSection.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/ReportSection.java @@ -10,6 +10,9 @@ import org.apache.poi.xssf.usermodel.XSSFSheet; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import ca.on.oicr.gsi.dimsum.controller.BadRequestException; import ca.on.oicr.gsi.dimsum.service.CaseService; @@ -65,6 +68,20 @@ public void writeDelimitedText(StringBuilder sb, List objects, String delimit } } + @Override + public JsonNode createJson(List objects, ObjectMapper objectMapper) { + ArrayNode arrayNode = objectMapper.createArrayNode(); + for (T object : objects) { + ObjectNode objectNode = objectMapper.createObjectNode(); + List> columns = getColumns(); + for (Column column : columns) { + String value = column.getDelimitedColumnString(",", object).replaceAll("\"", ""); + objectNode.put(column.getTitle(), value); + } + arrayNode.add(objectNode); + } + return arrayNode; + } } private final String title; @@ -101,6 +118,8 @@ public void createDelimitedText(StringBuilder sb, CaseService caseService, protected abstract void writeDelimitedText(StringBuilder sb, List objects, String delimiter, boolean includeHeaders); + public abstract JsonNode createJson(List objects, ObjectMapper objectMapper); + /** * Fetches data from the CaseService based on parameters provided * @@ -120,5 +139,4 @@ protected static Set getParameterStringSet(JsonNode parameters, String n } return Stream.of(value.split("\\s*,\\s*")).collect(Collectors.toSet()); } - } diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/reports/CaseTatReport.java b/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/reports/CaseTatReport.java index 22eff00f..bf9d9907 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/reports/CaseTatReport.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/util/reporting/reports/CaseTatReport.java @@ -32,6 +32,7 @@ private static record RowData(Case kase, Test test, CaseDeliverable clinical, CaseDeliverable dataRelease) { } + // the TAT Trend Report depends on the column names defined here private static final ReportSection caseSection = new TableReportSection<>("Case TAT", Arrays.asList( diff --git a/src/main/resources/templates/tat-trend.html b/src/main/resources/templates/tat-trend.html new file mode 100644 index 00000000..232f08f0 --- /dev/null +++ b/src/main/resources/templates/tat-trend.html @@ -0,0 +1,63 @@ + + + + + + TAT Trend Report + + + +
+

TAT Trend Report

+
+
+
+ + + + +
+ + +
+
+
+ + + + + + + + + +
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/ts/data/case.ts b/ts/data/case.ts index 6e6bc4d3..69553cbc 100644 --- a/ts/data/case.ts +++ b/ts/data/case.ts @@ -205,6 +205,10 @@ export const caseDefinition: TableDefinition = { title: "TAT Report", handler: showTatReportDialog, }, + { + title: "TAT Trend", + handler: showTatTrendPage, + }, ], bulkActions: [ { @@ -1986,3 +1990,19 @@ function showTatReportDialog( }; postDownload(urls.rest.downloads.reports("case-tat-report"), params); } + +function showTatTrendPage( + filters: { key: string; value: string }[], + baseFilter: { key: string; value: string } | undefined +) { + const joinedFilters = [...filters]; + if (baseFilter !== undefined) { + joinedFilters.push(baseFilter); + } + const urlParams = new URLSearchParams(); + joinedFilters.forEach((filter) => { + urlParams.append(filter.key, filter.value); + }); + const targetUrl = `/tat-trend?${urlParams.toString()}`; + window.open(targetUrl, "_blank"); +} diff --git a/ts/tat-trend.ts b/ts/tat-trend.ts new file mode 100644 index 00000000..a2390ae6 --- /dev/null +++ b/ts/tat-trend.ts @@ -0,0 +1,442 @@ +import Plotly from "plotly.js-dist-min"; +import { post } from "./util/requests"; +import { getRequiredElementById } from "./util/html-utils"; + +let jsonData: any[] = []; +const uirevision = "true"; + +// constants for column names in the Case TAT Report +const COLUMN_NAMES = { + ASSAY: "Assay", + CASE_ID: "Case ID", + EX_DAYS: "EX Days", + LP_DAYS: "Library Prep. Days", + LQ_DAYS: "LQ Total Days", + FD_DAYS: "FD Total Days", + ALL_DAYS: "ALL Total Days", + RC_COMPLETED: "Receipt Completed", + EX_COMPLETED: "Extraction (EX) Completed", + LP_COMPLETED: "Library Prep. Completed", + LQ_COMPLETED: "Library Qual. (LQ) Completed", + FD_COMPLETED: "Full-Depth (FD) Completed", + CR_COMPLETED: "CR Release Completed", + DR_COMPLETED: "DR Release Completed", + ALL_COMPLETED: "ALL Release Completed", +}; + +// function to construct completion date column name +function getCompletionColumnName(dataType: string, gate: string): string { + return `${DATA_PREFIX_MAPPING[dataType]} ${gate} Completed`.trim(); +} + +// function to construct days column name +function getDaysColumnName(dataType: string, gate: string): string { + return `${DATA_PREFIX_MAPPING[dataType]} ${gate} Days`.trim(); +} + +const DATA_SELECTION = { + CLINICAL_REPORT: "ClinicalReport", + DATA_RELEASE: "DataRelease", + ALL: "All", +}; + +const DATA_PREFIX_MAPPING: { [key: string]: string } = { + [DATA_SELECTION.CLINICAL_REPORT]: "CR", + [DATA_SELECTION.DATA_RELEASE]: "DR", + [DATA_SELECTION.ALL]: "ALL", +}; + +function generateColor(index: number): string { + const colors = [ + "#4477AA", + "#66CCEE", + "#228833", + "#CCBB44", + "#EE6677", + "#AA3377", + "#BBBBBB", + "#000000", + ]; + return colors[index % colors.length]; +} + +interface AssayGroups { + [assay: string]: { + [gate: string]: { x: any[]; y: number[]; text: string[]; n: number }; + }; +} + +function getCompletedDateAndDays( + row: any, + gate: string, + selectedDataType: string +) { + let completedDate: Date | null = null; + let days: number = 0; + + switch (gate) { + case "Receipt": + completedDate = row[COLUMN_NAMES.RC_COMPLETED] + ? new Date(row[COLUMN_NAMES.RC_COMPLETED]) + : null; + case "Extraction": + completedDate = row[COLUMN_NAMES.EX_COMPLETED] + ? new Date(row[COLUMN_NAMES.EX_COMPLETED]) + : null; + days = row[COLUMN_NAMES.EX_DAYS] ?? 0; + break; + case "Library Prep": + completedDate = row[COLUMN_NAMES.LP_COMPLETED] + ? new Date(row[COLUMN_NAMES.LP_COMPLETED]) + : null; + days = row[COLUMN_NAMES.LP_DAYS] ?? 0; + break; + case "Library Qual": + completedDate = row[COLUMN_NAMES.LQ_COMPLETED] + ? new Date(row[COLUMN_NAMES.LQ_COMPLETED]) + : null; + days = row[COLUMN_NAMES.LQ_DAYS] ?? 0; + break; + case "Full-Depth": + completedDate = row[COLUMN_NAMES.FD_COMPLETED] + ? new Date(row[COLUMN_NAMES.FD_COMPLETED]) + : null; + days = row[COLUMN_NAMES.FD_DAYS] ?? 0; + break; + case "Full Case": + completedDate = row[getCompletionColumnName(selectedDataType, "Release")] + ? new Date(row[getCompletionColumnName(selectedDataType, "Release")]) + : null; + days = row[getDaysColumnName(selectedDataType, "Total")] ?? 0; + break; + default: + completedDate = row[getCompletionColumnName(selectedDataType, gate)] + ? new Date(row[getCompletionColumnName(selectedDataType, gate)]) + : null; + days = row[getDaysColumnName(selectedDataType, gate)] ?? 0; + } + + return { completedDate, days }; +} + +function getGroup(date: Date, selectedGrouping: string): string { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + let fiscalYear = year; + let fiscalQuarter: number; + + if (month >= 4) { + fiscalYear = year; // current fiscal year + fiscalQuarter = Math.floor((month - 4) / 3) + 1; + } else { + fiscalYear = year - 1; // previous fiscal year + fiscalQuarter = 4; // last quarter + } + const fiscalYearString = `FY${fiscalYear}/${String(fiscalYear + 1).slice( + -2 + )}`; + switch (selectedGrouping) { + case "week": + return getWeek(date); + case "month": + return `${year}-${String(month).padStart(2, "0")}`; + case "fiscalQuarter": + return `${fiscalYearString} Q${fiscalQuarter}`; + case "fiscalYear": + return fiscalYearString; + default: + throw new Error(`Unsupported grouping type: ${selectedGrouping}`); + } +} + +function groupData( + jsonData: any[], + selectedGrouping: string, + selectedGates: string[], + selectedDataType: string +): AssayGroups { + const assayGroups: AssayGroups = {}; + + jsonData.forEach((row: any) => { + const assay = row[COLUMN_NAMES.ASSAY]; + const caseId = row[COLUMN_NAMES.CASE_ID]; + const completedKey = + `${DATA_PREFIX_MAPPING[selectedDataType]}_COMPLETED` as keyof typeof COLUMN_NAMES; + const caseCompletedDate = row[COLUMN_NAMES[completedKey]]; + + // skip if case completed date is missing + if (!caseCompletedDate) { + return; + } + if (assay && caseId != null) { + if (!assayGroups[assay]) { + assayGroups[assay] = {}; + } + selectedGates.forEach((gate) => { + const { completedDate, days } = getCompletedDateAndDays( + row, + gate, + selectedDataType + ); + if (!completedDate) { + return; + } + const date = new Date(caseCompletedDate); + const group = getGroup(date, selectedGrouping); + if (!assayGroups[assay][gate]) { + assayGroups[assay][gate] = { x: [], y: [], text: [], n: 0 }; + } + assayGroups[assay][gate].x.push(group); + assayGroups[assay][gate].y.push(days); + assayGroups[assay][gate].text.push(`${caseId} (${gate})`); + assayGroups[assay][gate].n += 1; + }); + } + }); + return assayGroups; +} + +function getWeek(date: Date): string { + const onejan = new Date(date.getFullYear(), 0, 1); + const week = Math.ceil( + ((date.getTime() - onejan.getTime()) / 86400000 + onejan.getDay() + 1) / 7 + ); + return `${date.getFullYear()}-W${String(week).padStart(2, "0")}`; +} + +function getColorByGate(): boolean { + const toggleColors = getRequiredElementById( + "toggleColors" + ) as HTMLInputElement; + return toggleColors.checked; +} + +function plotData( + jsonData: any[], + selectedGrouping: string, + selectedGates: string[], + selectedDataType: string +): { newPlot: Partial[]; layout: Partial } { + const assayGroups = groupData( + jsonData, + selectedGrouping, + selectedGates, + selectedDataType + ); + const newPlot: Partial[] = []; + const assayColors: { [assay: string]: string } = {}; + const gateColors: { [gate: string]: string } = {}; + let colorIndex = 0; + const colorByGate = getColorByGate(); + if (colorByGate) { + selectedGates.forEach((gate, index) => { + gateColors[gate] = generateColor(index); + }); + } + Object.keys(assayGroups).forEach((assay) => { + if (!assayColors[assay]) { + assayColors[assay] = generateColor(colorIndex++); + } + selectedGates.forEach((gate) => { + if (assayGroups[assay][gate]) { + newPlot.push({ + x: assayGroups[assay][gate].x, + y: assayGroups[assay][gate].y, + type: "box", + name: `${assay}`, + text: assayGroups[assay][gate].text, + hoverinfo: "text+y", + 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, + marker: { + size: 6, + color: colorByGate ? gateColors[gate] : assayColors[assay], + }, + boxmean: true, + legendgroup: assay, + showlegend: !newPlot.some((d) => d.legendgroup === assay), + } as unknown as Partial); + } + }); + }); + + const layout: Partial = { + xaxis: { + title: + selectedGrouping === "week" + ? "Week" + : selectedGrouping === "month" + ? "Month" + : selectedGrouping === "fiscalQuarter" + ? "Fiscal Quarter" + : "Fiscal Year", + tickformat: + selectedGrouping === "week" + ? "%Y-W%U" + : selectedGrouping === "month" + ? "%Y-%m" + : selectedGrouping === "fiscalQuarter" + ? "%Y Q%q" + : "%Y", + categoryorder: "category ascending", + }, + yaxis: { + title: "Days", + zeroline: false, + range: [0, undefined], + }, + boxmode: "group", + autosize: true, + uirevision, + width: Math.max(window.innerWidth - 50, 800), + height: Math.max(window.innerHeight - 250, 400), + }; + + return { newPlot, layout }; +} + +function newPlot( + selectedGrouping: string, + jsonData: any[], + selectedGates: string[], + selectedDataType: string +) { + const plotContainer = getRequiredElementById("plotContainer"); + plotContainer.textContent = ""; + + const { newPlot, layout } = plotData( + jsonData, + selectedGrouping, + selectedGates, + selectedDataType + ); + Plotly.newPlot("plotContainer", newPlot, layout); + + window.addEventListener("resize", () => { + Plotly.relayout("plotContainer", { + width: Math.max(window.innerWidth - 50, 800), + height: Math.max(window.innerHeight - 250, 400), + }); + }); +} + +function updatePlot( + selectedGrouping: string, + jsonData: any[], + selectedGates: string[], + selectedDataType: string +) { + const { newPlot, layout } = plotData( + jsonData, + selectedGrouping, + selectedGates, + selectedDataType + ); + Plotly.react("plotContainer", newPlot, layout); +} + +function parseUrlParams(): { key: string; value: string }[] { + const params: { key: string; value: string }[] = []; + const searchParams = new URLSearchParams(window.location.search); + searchParams.forEach((value, key) => { + params.push({ key, value }); + }); + return params; +} + +function getSelectedGates(): string[] { + const gatesCheckboxes = document.querySelectorAll( + "#gatesCheckboxes input[type='checkbox']:checked" + ); + return Array.from(gatesCheckboxes).map((checkbox) => checkbox.value); +} + +function getSelectedGrouping(): string { + const selectedButton = document.querySelector( + "#groupingButtons button.active" + ); + return selectedButton + ? selectedButton.id.replace("Button", "") + : "fiscalQuarter"; +} + +function getSelectedDataType(): string { + const dataSelection = getRequiredElementById( + "dataSelection" + ) as HTMLSelectElement; + return dataSelection.value; +} + +document.addEventListener("DOMContentLoaded", () => { + const params = parseUrlParams(); + const requestData = { filters: params.length > 0 ? params : [] }; + + post("/rest/downloads/reports/case-tat-report/data", requestData) + .then((data) => { + jsonData = data; // update jsonData with fetched data + newPlot( + getSelectedGrouping(), + jsonData, + getSelectedGates(), + getSelectedDataType() + ); + }) + .catch((error) => { + alert("Error fetching data: " + error); + }); + + const handlePlotUpdate = () => { + const selectedGates = getSelectedGates(); + const selectedDataType = getSelectedDataType(); + updatePlot( + getSelectedGrouping(), + jsonData, + selectedGates, + selectedDataType + ); + }; + + const handleNewPlot = (event: Event) => { + const buttons = document.querySelectorAll("#groupingButtons button"); + buttons.forEach((button) => button.classList.remove("active")); + (event.currentTarget as HTMLButtonElement).classList.add("active"); + + const selectedGrouping = (event.currentTarget as HTMLButtonElement).dataset + .grouping; + const selectedGates = getSelectedGates(); + const selectedDataType = getSelectedDataType(); + newPlot(selectedGrouping!, jsonData, selectedGates, selectedDataType); + }; + + const weekButton = getRequiredElementById("weekButton"); + const monthButton = getRequiredElementById("monthButton"); + const quarterButton = getRequiredElementById("quarterButton"); + const yearButton = getRequiredElementById("yearButton"); + + weekButton.addEventListener("click", handleNewPlot); + monthButton.addEventListener("click", handleNewPlot); + quarterButton.addEventListener("click", handleNewPlot); + yearButton.addEventListener("click", handleNewPlot); + + getRequiredElementById("dataSelection").addEventListener( + "change", + handlePlotUpdate + ); + getRequiredElementById("gatesCheckboxes").addEventListener( + "change", + handlePlotUpdate + ); + getRequiredElementById("toggleColors").addEventListener( + "change", + handlePlotUpdate + ); +}); diff --git a/webpack.config.js b/webpack.config.js index d12ed3b9..1962fd27 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,6 +14,7 @@ module.exports = { projectList: "./ts/project-list.ts", caseReport: "./ts/case-report.ts", error: "./ts/error.ts", + tatTrend: "./ts/tat-trend.ts", }, module: { rules: [