From 5eb0890e975756ed5d229f84d946ae9a25ce36a5 Mon Sep 17 00:00:00 2001 From: Yashas Date: Wed, 16 Sep 2026 14:24:31 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20the=20briefing=20=E2=80=94=20four=20cha?= =?UTF-8?q?rts=20on=20the=20case,=20and=20six=20slides=20to=20take=20away?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A case now carries pictures, and a button that turns it into a deck. THE CHARTS (Briefing.tsx) answer the same three questions the page does, visually: the spike (the flagged slice's weekly denials, with the payer's rule change drawn as a line — weeks after it in the accent, before in gray), where it sits (denial rate by insurer, baseline vs now), where the money stands (the slice's denied dollars by what can still happen to them), and appeals about to expire by insurer. Every chart has a table twin. Data comes from six pre-aggregated Databricks views (sm2/…/chart_views.py) read through the ordinary record-view API, filtered by the slice Ava now records in four structured fields (slice_payer, slice_department, slice_reason_code, spike_start). Nothing is computed from raw claims in the browser. COLOUR WAS COMPUTED, NOT EYEBALLED. The palette is the dataviz reference set (blue 1, orange 2, green 6, violet 7, gray) run through validate_palette.js against this console's own surfaces, both themes — all checks pass. Two things the validator caught: the brand teal fails the chroma floor and would read as gray on a chart, so it is never a data hue; and red beside orange in the recovery stack fails adjacent-pair separation even for normal vision (ΔE 7.1), so "lost on appeal" is violet. THE SLIDES (lib/slides.ts) are six native-chart PowerPoint slides built in the browser with pptxgenjs from the same rows: what happened, the spike, where it sits, the money, the recommendations, the method. Text is Ava's verbatim. Lazy-imported so the 700 KB library is paid for only on click. Backend counterpart: sm2/custom-apps/denials-investigator/10_briefing.sql. Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 564 +++++++++++++++++++++++++++++++++++- package.json | 4 +- src/api/charts.ts | 149 ++++++++++ src/components/Briefing.tsx | 282 ++++++++++++++++++ src/lib/slides.ts | 241 +++++++++++++++ src/screens/CaseScreen.tsx | 33 ++- 6 files changed, 1267 insertions(+), 6 deletions(-) create mode 100644 src/api/charts.ts create mode 100644 src/components/Briefing.tsx create mode 100644 src/lib/slides.ts diff --git a/package-lock.json b/package-lock.json index 14fe4ed..641fbbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "dependencies": { "@fontsource-variable/inter": "^5.1.0", "lucide-react": "^0.454.0", + "pptxgenjs": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.26.2" + "react-router-dom": "^6.26.2", + "recharts": "^3.10.1" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -773,6 +775,32 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.23.4", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", @@ -1139,6 +1167,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1456,6 +1496,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1463,18 +1566,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1491,6 +1603,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1580,6 +1698,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1587,13 +1714,140 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1612,6 +1866,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1643,6 +1903,18 @@ "node": ">=10.13.0" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1692,6 +1964,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1724,6 +2002,64 @@ "dev": true, "license": "ISC" }, + "node_modules/https": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", + "license": "ISC" + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1766,6 +2102,27 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2104,6 +2461,12 @@ "node": ">=18" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2140,6 +2503,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pptxgenjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz", + "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.8.1", + "https": "^1.0.0", + "image-size": "^1.2.1", + "jszip": "^3.10.1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -2165,6 +2555,36 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2207,6 +2627,72 @@ "react-dom": ">=16.8" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.63.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", @@ -2253,6 +2739,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2272,6 +2764,12 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2282,6 +2780,15 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2303,6 +2810,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2317,6 +2830,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", @@ -2348,6 +2867,43 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index 86ec53a..75e67a9 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "dependencies": { "@fontsource-variable/inter": "^5.1.0", "lucide-react": "^0.454.0", + "pptxgenjs": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.26.2" + "react-router-dom": "^6.26.2", + "recharts": "^3.10.1" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/src/api/charts.ts b/src/api/charts.ts new file mode 100644 index 0000000..f5403f6 --- /dev/null +++ b/src/api/charts.ts @@ -0,0 +1,149 @@ +import type { ZinoClient } from './client'; +import type { Row } from './types'; +import { num, str } from '../format'; + +/** + * Chart data for the briefing. + * + * Six pre-aggregated Databricks views (sm2/…/chart_views.py), each registered + * as an rdbms record view, fetched through the same /view/recordview the rest + * of the console uses. Nothing here is computed from raw claims in the browser + * — every number is a row the lakehouse produced, filtered to the slice Ava + * flagged (slice_payer / slice_department / slice_reason_code on the case). + */ + +export const CHART_VIEWS = { + weeklyRate: 'dni-rv-chart-weekly-rate', + weeklyBySlice: 'dni-rv-chart-weekly-by-slice', + payerRecentVsBase: 'dni-rv-chart-payer-recent-vs-base', + reasonRecentVsBase: 'dni-rv-chart-reason-recent-vs-base', + expiringAppeals: 'dni-rv-chart-expiring-appeals', + sliceRecovery: 'dni-rv-chart-slice-recovery', +} as const; + +export interface Slice { + payer: string; + department: string; + code: string; + /** ISO date of the week the change started, if Ava named one. */ + spikeStart: string | null; +} + +export function sliceOf(row: Row): Slice | null { + const payer = str(row.slice_payer); + const department = str(row.slice_department); + const code = str(row.slice_reason_code); + if (!payer && !department && !code) return null; + return { payer, department, code, spikeStart: str(row.spike_start).slice(0, 10) || null }; +} + +export interface WeekPoint { + week: string; + claims: number; + denied: number; + pct: number; +} +export interface PayerCompare { + payer: string; + rateBase: number; + rateRecent: number; + deniedRecent: number; +} +export interface ReasonCompare { + code: string; + category: string; + deniedBase: number; + deniedRecent: number; + billedRecent: number; +} +export interface Expiring { + payer: string; + claims: number; + billed: number; + firstDeadline: string; + days: number; +} +export interface RecoveryBucket { + bucket: string; + claims: number; + billed: number; +} + +export interface BriefingData { + weeklyRate: WeekPoint[]; + weeklySlice: WeekPoint[]; + payers: PayerCompare[]; + reasons: ReasonCompare[]; + expiring: Expiring[]; + recovery: RecoveryBucket[]; +} + +const byWeek = (a: { week: string }, b: { week: string }) => a.week.localeCompare(b.week); + +function week(r: Row): WeekPoint { + const claims = num(r.claims) ?? 0; + const denied = num(r.denied) ?? 0; + return { + week: str(r.week).slice(0, 10), + claims, + denied, + pct: num(r.denial_pct) ?? (claims ? Math.round((1000 * denied) / claims) / 10 : 0), + }; +} + +export async function loadBriefing(client: ZinoClient, slice: Slice | null): Promise { + const f = (field_key: string, value: string) => ({ field_key, value, data_type: 'string' }); + const sliceFilters = slice + ? [ + ...(slice.payer ? [f('payer', slice.payer)] : []), + ...(slice.department ? [f('department', slice.department)] : []), + ...(slice.code ? [f('denial_reason_code', slice.code)] : []), + ] + : []; + + const [rate, sl, payers, reasons, expiring, recovery] = await Promise.all([ + client.rows(CHART_VIEWS.weeklyRate, { limit: 200 }), + sliceFilters.length ? client.rows(CHART_VIEWS.weeklyBySlice, { limit: 200, filters: sliceFilters }) : Promise.resolve([] as Row[]), + client.rows(CHART_VIEWS.payerRecentVsBase, { limit: 50 }), + client.rows(CHART_VIEWS.reasonRecentVsBase, { limit: 50 }), + client.rows(CHART_VIEWS.expiringAppeals, { limit: 50 }), + sliceFilters.length ? client.rows(CHART_VIEWS.sliceRecovery, { limit: 50, filters: sliceFilters }) : Promise.resolve([] as Row[]), + ]); + + return { + weeklyRate: rate.map(week).sort(byWeek), + weeklySlice: sl.map(week).sort(byWeek), + payers: payers + .map((r) => ({ + payer: str(r.payer), + rateBase: num(r.rate_base) ?? 0, + rateRecent: num(r.rate_recent) ?? 0, + deniedRecent: num(r.denied_recent) ?? 0, + })) + .sort((a, b) => b.rateRecent - a.rateRecent), + reasons: reasons + .map((r) => ({ + code: str(r.denial_reason_code), + category: str(r.denial_category), + deniedBase: num(r.denied_base) ?? 0, + deniedRecent: num(r.denied_recent) ?? 0, + billedRecent: num(r.billed_recent) ?? 0, + })) + .sort((a, b) => b.deniedRecent - a.deniedRecent), + expiring: expiring + .map((r) => ({ + payer: str(r.payer), + claims: num(r.claims) ?? 0, + billed: num(r.billed) ?? 0, + firstDeadline: str(r.first_deadline).slice(0, 10), + days: num(r.days_to_first_deadline) ?? 0, + })) + .sort((a, b) => b.billed - a.billed), + recovery: recovery + .map((r) => ({ bucket: str(r.bucket), claims: num(r.claims) ?? 0, billed: num(r.billed) ?? 0 })) + .sort((a, b) => RECOVERY_ORDER.indexOf(a.bucket) - RECOVERY_ORDER.indexOf(b.bucket)), + }; +} + +/** Ordered by how alive the money is. Fixed order → fixed colour per bucket. */ +export const RECOVERY_ORDER = ['recovered', 'appeal filed', 'still appealable', 'lost on appeal', 'window closed']; diff --git a/src/components/Briefing.tsx b/src/components/Briefing.tsx new file mode 100644 index 0000000..6ec919f --- /dev/null +++ b/src/components/Briefing.tsx @@ -0,0 +1,282 @@ +import { useState } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ComposedChart, + Line, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { Table2 } from 'lucide-react'; +import type { BriefingData, Slice } from '../api/charts'; +import { RECOVERY_ORDER } from '../api/charts'; +import { useTheme } from '../theme'; +import { money, moneyShort, shortDate } from '../format'; + +/** + * The briefing charts — four pictures that carry the case. + * + * FORM FIRST, COLOUR LAST (the dataviz method): + * 1. The spike — one line, the flagged slice's weekly denials, with the + * bulletin date drawn as a reference line. EMPHASIS form: + * weeks after the spike in the accent hue, before in gray. + * 2. Where it sits — payer denial rate, baseline vs recent, grouped bar. + * Two series: gray = before, accent = now. + * 3. The money — the flagged slice's denied claims by what can still + * happen to them. Stacked horizontal bar, one row. + * 4. Expiring — unfiled appeals closing within 14 days, by payer. + * Single hue, sorted; the axis carries identity. + * + * COLOURS are the validated reference slots (blue 1, orange 2, green 6, red 8) + * stepped per theme — run through scripts/validate_palette.js on THIS console's + * surfaces (#ffffff / #10233a): all checks pass. The brand teal deliberately is + * NOT used as a data hue — it fails the chroma floor and would read as gray on + * a chart. Gray is the de-emphasis hue of the emphasis form, never a series. + * + * Every chart has a table twin (the ⊞ toggle) so no value lives only in colour + * or only in a tooltip. + */ + +const HUES = { + light: { accent: '#2a78d6', gray: '#94a3af', good: '#008300', warn: '#eb6834', bad: '#e34948', violet: '#4a3aa7', ink: '#0f2233', muted: '#5a7089', grid: '#e3eef1', ai: '#5b2ca0' }, + dark: { accent: '#3987e5', gray: '#6f8595', good: '#22a422', warn: '#d95926', bad: '#e66767', violet: '#9085e9', ink: '#e7f1f5', muted: '#93b2c0', grid: '#22405c', ai: '#c9b3f2' }, +}; + +const RECOVERY_HUE: Record = { + recovered: 'good', + 'appeal filed': 'accent', + 'still appealable': 'warn', + // Violet, not red: red sits beside orange in the stack and the two fail the + // adjacent-pair check under deutan and even normal vision (ΔE 7.1). + 'lost on appeal': 'violet', + 'window closed': 'gray', +}; + +export function Briefing({ data, slice }: { data: BriefingData; slice: Slice | null }) { + const { theme } = useTheme(); + const c = HUES[theme]; + const title = slice ? [slice.payer, slice.department, slice.code].filter(Boolean).join(' · ') : 'the hospital'; + + return ( +
+
+ + + {data.recovery.length > 0 && } + {data.expiring.length > 0 && } +
+
+ ); +} + +// --------------------------------------------------------------------------- + +function Frame({ + title, + caption, + table, + children, +}: { + title: string; + caption: string; + table: { head: string[]; rows: (string | number)[][] }; + children: React.ReactNode; +}) { + const [showTable, setShowTable] = useState(false); + return ( +
+
+ + {title} + {caption} + + +
+
+ {showTable ? ( +
+ + + + {table.head.map((h) => ( + + ))} + + + + {table.rows.map((r, i) => ( + + {r.map((v, j) => ( + + ))} + + ))} + +
+ {h} +
+ {v} +
+
+ ) : ( + children + )} +
+
+ ); +} + +const tip = (c: typeof HUES.light) => ({ + contentStyle: { background: 'var(--color-panel)', border: '1px solid var(--color-line)', borderRadius: 8, fontSize: 12, color: c.ink }, + labelStyle: { color: c.muted, fontWeight: 600 }, + itemStyle: { color: c.ink }, + cursor: { stroke: c.grid }, +}); + +const axis = (c: typeof HUES.light) => ({ tick: { fill: c.muted, fontSize: 11 }, axisLine: { stroke: c.grid }, tickLine: false as const }); + +// 1. The spike ---------------------------------------------------------------- +function SpikeChart({ data, slice, c, title }: { data: BriefingData; slice: Slice | null; c: typeof HUES.light; title: string }) { + const pts = data.weeklySlice.length ? data.weeklySlice : data.weeklyRate; + const isSlice = data.weeklySlice.length > 0; + const spike = slice?.spikeStart ?? null; + const rows = pts.map((p) => ({ ...p, label: shortDate(p.week) })); + return ( + [r.label, isSlice ? r.denied : `${r.pct}%`]) }} + > + + + + + (isSlice ? String(v) : `${v}%`)} /> + (isSlice ? [`${Number(v)} denied`, ''] : [`${Number(v)}%`, 'denial rate'])} /> + {isSlice ? ( + + {rows.map((r) => ( + = spike ? c.accent : c.gray} /> + ))} + + ) : ( + + )} + {spike && ( + + )} + + + + ); +} + +// 2. Where it sits ------------------------------------------------------------ +function PayerChart({ data, slice, c }: { data: BriefingData; slice: Slice | null; c: typeof HUES.light }) { + const rows = data.payers.map((p) => ({ ...p, short: p.payer.replace(' Health Plan', '').replace(' Health', '').replace(' Mutual', '') })); + return ( + [r.payer, `${r.rateBase}%`, `${r.rateRecent}%`]) }} + > + + + + + `${v}%`} /> + [`${Number(v)}%`, String(name) === 'rateBase' ? 'before' : 'now']} /> + + + {rows.map((r) => ( + + ))} + + + + + ); +} + +// 3. The money ---------------------------------------------------------------- +function RecoveryChart({ data, c, title }: { data: BriefingData; c: typeof HUES.light; title: string }) { + const total = data.recovery.reduce((t, b) => t + b.billed, 0); + const one = Object.fromEntries(data.recovery.map((b) => [b.bucket, b.billed])) as Record; + return ( + [b.bucket, b.claims, money(b.billed)]) }} + > +
+ + + + ''} /> + [money(Number(v)), String(name)]} /> + {RECOVERY_ORDER.filter((b) => one[b]).map((b, i, arr) => ( + + ))} + + +
    + {data.recovery.map((b) => ( +
  • + + {b.bucket} + {moneyShort(b.billed)} +
  • + ))} +
+
+ + ); +} + +// 4. Expiring ------------------------------------------------------------------- +function ExpiringChart({ data, c }: { data: BriefingData; c: typeof HUES.light }) { + const rows = data.expiring.map((e) => ({ ...e, short: e.payer.replace(' Health Plan', '').replace(' Health', '').replace(' Mutual', '') })); + const total = rows.reduce((t, r) => t + r.billed, 0); + return ( + [r.payer, r.claims, money(r.billed), shortDate(r.firstDeadline)]) }} + > + + + + + + [money(Number(v)), 'unfiled, expiring']} /> + moneyShort(Number(v)) }} /> + + + + ); +} diff --git a/src/lib/slides.ts b/src/lib/slides.ts new file mode 100644 index 0000000..b9a9890 --- /dev/null +++ b/src/lib/slides.ts @@ -0,0 +1,241 @@ +import pptxgen from 'pptxgenjs'; +import type { BriefingData, Slice } from '../api/charts'; +import { RECOVERY_ORDER } from '../api/charts'; +import type { Row } from '../api/types'; +import { headline, money, num, recommendations, shortDate, splitRecommendation, str } from '../format'; + +/** + * The deck. Six slides a manager can walk into a CFO's office with. + * + * 1. What happened — the headline sentence and three numbers + * 2. The spike — weekly denials for the flagged slice, bulletin marked + * 3. Where it sits — denial rate by insurer, before vs now + * 4. Where the money stands — recovery buckets + expiring appeals + * 5. What she recommends — the ranked list, owners and dollars + * 6. How she got there — the sweep, the checks, her confidence + * + * Charts are NATIVE PowerPoint charts built from the same rows the console + * drew, so they stay editable in PowerPoint and match the screen. Text is + * Ava's own, verbatim — the deck asserts nothing the case does not. + * + * Colours are the validated reference slots used on screen (see Briefing.tsx). + * Built in the browser with pptxgenjs; nothing leaves the user's machine. + */ + +const INK = '0F2233'; +const MUTED = '5A7089'; +const FAINT = '93A6B5'; +const ACCENT = '2A78D6'; +const GRAY = '94A3AF'; +const GOOD = '008300'; +const WARN = 'EB6834'; +const VIOLET = '4A3AA7'; +const AI = '5B2CA0'; +const BRAND = '0E7C86'; +const PANEL = 'F2F7F8'; +const GRID = 'E3EEF1'; + +const RECOVERY_COLOR: Record = { + recovered: GOOD, + 'appeal filed': ACCENT, + 'still appealable': WARN, + 'lost on appeal': VIOLET, + 'window closed': GRAY, +}; + +export async function buildDeck(row: Row, data: BriefingData, slice: Slice | null): Promise { + const pres = new pptxgen(); + pres.layout = 'LAYOUT_WIDE'; // 13.33 x 7.5 in + pres.author = 'Ava — Denials Analyst, Northlake Health'; + pres.title = `Denials briefing — case #${str(row.instance_id)}`; + + const caseNo = str(row.instance_id); + const sliceTitle = slice ? [slice.payer, slice.department, slice.code].filter(Boolean).join(' · ') : 'Northlake Health'; + + const chrome = (s: pptxgen.Slide, title: string, kicker?: string) => { + s.background = { color: 'FFFFFF' }; + s.addShape(pres.ShapeType.rect, { x: 0, y: 0, w: 13.33, h: 0.08, fill: { color: BRAND } }); + if (kicker) s.addText(kicker.toUpperCase(), { x: 0.6, y: 0.35, w: 12, h: 0.3, fontSize: 10, bold: true, color: AI, charSpacing: 1 }); + s.addText(title, { x: 0.6, y: kicker ? 0.62 : 0.4, w: 12.1, h: 0.7, fontSize: 22, bold: true, color: INK, valign: 'top' }); + s.addText(`Northlake Health · Denials Desk · case #${caseNo} · prepared by Ava`, { x: 0.6, y: 7.0, w: 12, h: 0.3, fontSize: 9, color: FAINT }); + }; + + // ---- 1. What happened ------------------------------------------------------- + { + const s = pres.addSlide(); + chrome(s, headline(row.summary, 240), 'What Ava found'); + const tiles: Array<[string, string, string]> = [ + ['Claims denied', num(row.affected_claims)?.toLocaleString('en-US') ?? '—', INK], + ['Money at stake', money(row.amount_at_risk), 'B31E23'], + ['Still recoverable', money(row.recoverable_amount), '00713D'], + ]; + tiles.forEach(([label, value, color], i) => { + const x = 0.6 + i * 4.1; + s.addShape(pres.ShapeType.roundRect, { x, y: 1.9, w: 3.8, h: 1.7, fill: { color: PANEL }, line: { color: 'D5E4E8', width: 0.75 }, rectRadius: 0.08 }); + s.addText(label.toUpperCase(), { x: x + 0.25, y: 2.05, w: 3.3, h: 0.3, fontSize: 10, bold: true, color: MUTED, charSpacing: 1 }); + s.addText(value, { x: x + 0.25, y: 2.4, w: 3.3, h: 0.9, fontSize: 36, bold: true, color }); + }); + const cause = str(row.root_cause); + if (cause) { + s.addText('WHY IT HAPPENED', { x: 0.6, y: 3.95, w: 12, h: 0.3, fontSize: 10, bold: true, color: MUTED, charSpacing: 1 }); + s.addText(cause, { x: 0.6, y: 4.25, w: 12.1, h: 2.5, fontSize: 13, color: INK, valign: 'top' }); + } + } + + // ---- 2. The spike ----------------------------------------------------------- + const spikePts = data.weeklySlice.length ? data.weeklySlice : data.weeklyRate; + if (spikePts.length) { + const s = pres.addSlide(); + const isSlice = data.weeklySlice.length > 0; + chrome(s, isSlice ? `Denied claims per week — ${sliceTitle}` : 'Hospital denial rate per week', 'The spike'); + const labels = spikePts.map((p) => shortDate(p.week)); + const spike = slice?.spikeStart; + if (isSlice) { + // Emphasis form: before the rule change in gray, after in the accent. + const before = spikePts.map((p) => (spike && p.week >= spike ? 0 : p.denied)); + const after = spikePts.map((p) => (spike && p.week >= spike ? p.denied : 0)); + s.addChart( + pres.ChartType.bar, + [ + { name: 'before the rule change', labels, values: before }, + { name: 'after the rule change', labels, values: after }, + ], + { + x: 0.6, y: 1.5, w: 12.1, h: 5.1, barDir: 'col', barGrouping: 'stacked', barGapWidthPct: 40, + chartColors: [GRAY, ACCENT], catAxisLabelColor: MUTED, valAxisLabelColor: MUTED, catAxisLabelFontSize: 9, valAxisLabelFontSize: 9, + valGridLine: { color: GRID, size: 0.5 }, catGridLine: { style: 'none' }, + showLegend: true, legendPos: 'b', legendFontSize: 10, legendColor: MUTED, showValue: false, + }, + ); + } else { + s.addChart(pres.ChartType.line, [{ name: 'denial rate %', labels, values: spikePts.map((p) => p.pct) }], { + x: 0.6, y: 1.5, w: 12.1, h: 5.1, chartColors: [ACCENT], lineSize: 2, lineDataSymbol: 'none', + catAxisLabelColor: MUTED, valAxisLabelColor: MUTED, catAxisLabelFontSize: 9, valAxisLabelFontSize: 9, + valGridLine: { color: GRID, size: 0.5 }, catGridLine: { style: 'none' }, showLegend: false, + }); + } + if (spike) s.addText(`Rule change effective ${shortDate(spike)}. Everything to the right of it is the spike.`, { x: 0.6, y: 6.65, w: 12, h: 0.3, fontSize: 11, color: AI, italic: true }); + } + + // ---- 3. Where it sits ------------------------------------------------------- + if (data.payers.length) { + const s = pres.addSlide(); + chrome(s, 'Denial rate by insurer — before vs now', 'Where it sits'); + const labels = data.payers.map((p) => p.payer); + s.addChart( + pres.ChartType.bar, + [ + { name: '8-week baseline', labels, values: data.payers.map((p) => p.rateBase) }, + { name: 'last 6 weeks', labels, values: data.payers.map((p) => p.rateRecent) }, + ], + { + x: 0.6, y: 1.5, w: 12.1, h: 4.9, barDir: 'col', barGrouping: 'clustered', barGapWidthPct: 60, + chartColors: [GRAY, ACCENT], catAxisLabelColor: MUTED, valAxisLabelColor: MUTED, catAxisLabelFontSize: 10, valAxisLabelFontSize: 9, + valAxisLabelFormatCode: '0"%"', valGridLine: { color: GRID, size: 0.5 }, catGridLine: { style: 'none' }, + showLegend: true, legendPos: 'b', legendFontSize: 10, legendColor: MUTED, + showValue: true, dataLabelFormatCode: '0.0"%"', dataLabelColor: INK, dataLabelFontSize: 9, + }, + ); + if (slice?.payer) { + const p = data.payers.find((x) => x.payer === slice.payer); + if (p) s.addText(`${p.payer}: ${p.rateBase}% → ${p.rateRecent}%. Every other insurer is within noise.`, { x: 0.6, y: 6.55, w: 12, h: 0.35, fontSize: 12, bold: true, color: INK }); + } + } + + // ---- 4. Where the money stands ---------------------------------------------- + if (data.recovery.length || data.expiring.length) { + const s = pres.addSlide(); + chrome(s, 'Where the money stands', 'The money'); + if (data.recovery.length) { + const ordered = RECOVERY_ORDER.filter((b) => data.recovery.some((r) => r.bucket === b)); + s.addText(sliceTitle.toUpperCase(), { x: 0.6, y: 1.45, w: 6, h: 0.3, fontSize: 10, bold: true, color: MUTED, charSpacing: 1 }); + s.addChart( + pres.ChartType.bar, + ordered.map((b) => ({ name: b, labels: ['denied since baseline'], values: [data.recovery.find((r) => r.bucket === b)?.billed ?? 0] })), + { + x: 0.6, y: 1.8, w: 6.0, h: 1.4, barDir: 'bar', barGrouping: 'stacked', barGapWidthPct: 20, + chartColors: ordered.map((b) => RECOVERY_COLOR[b]), catAxisHidden: true, valAxisHidden: true, + showLegend: true, legendPos: 'b', legendFontSize: 9, legendColor: MUTED, valGridLine: { style: 'none' }, + }, + ); + const rows = [ + [ + { text: 'Status', options: { bold: true, color: MUTED } }, + { text: 'Claims', options: { bold: true, color: MUTED, align: 'right' as const } }, + { text: 'Billed', options: { bold: true, color: MUTED, align: 'right' as const } }, + ], + ...ordered.map((b) => { + const r = data.recovery.find((x) => x.bucket === b)!; + return [ + { text: b }, + { text: String(r.claims), options: { align: 'right' as const } }, + { text: money(r.billed), options: { align: 'right' as const, bold: b === 'still appealable' } }, + ]; + }), + ]; + s.addTable(rows, { x: 0.6, y: 3.4, w: 6.0, colW: [3.0, 1.2, 1.8], fontSize: 11, color: INK, border: { type: 'solid', color: GRID, pt: 0.5 }, rowH: 0.32 }); + } + if (data.expiring.length) { + const total = data.expiring.reduce((t, e) => t + e.billed, 0); + s.addText('UNFILED APPEALS EXPIRING WITHIN 14 DAYS', { x: 7.0, y: 1.45, w: 5.7, h: 0.3, fontSize: 10, bold: true, color: MUTED, charSpacing: 1 }); + s.addText(money(total), { x: 7.0, y: 1.75, w: 5.7, h: 0.7, fontSize: 30, bold: true, color: WARN }); + s.addChart( + pres.ChartType.bar, + [{ name: 'billed', labels: data.expiring.map((e) => e.payer), values: data.expiring.map((e) => e.billed) }], + { + x: 7.0, y: 2.5, w: 5.7, h: 3.9, barDir: 'bar', barGapWidthPct: 45, chartColors: [ACCENT], + catAxisLabelColor: MUTED, catAxisLabelFontSize: 10, valAxisHidden: true, valGridLine: { style: 'none' }, catGridLine: { style: 'none' }, + showValue: true, dataLabelFormatCode: '$#,##0', dataLabelColor: INK, dataLabelFontSize: 9, showLegend: false, + }, + ); + const first = data.expiring.slice().sort((a, b) => a.firstDeadline.localeCompare(b.firstDeadline))[0]; + if (first) s.addText(`First deadline: ${shortDate(first.firstDeadline)} (${first.payer}).`, { x: 7.0, y: 6.5, w: 5.7, h: 0.3, fontSize: 11, color: MUTED }); + } + } + + // ---- 5. What she recommends ------------------------------------------------- + const recs = recommendations(row.recommendations); + if (recs.length) { + const s = pres.addSlide(); + chrome(s, 'What she recommends', 'For your approval'); + let y = 1.5; + recs.slice(0, 5).forEach((block, i) => { + const { title, body } = splitRecommendation(block); + s.addShape(pres.ShapeType.ellipse, { x: 0.6, y: y + 0.05, w: 0.36, h: 0.36, fill: { color: BRAND } }); + s.addText(String(i + 1), { x: 0.6, y: y + 0.05, w: 0.36, h: 0.36, fontSize: 11, bold: true, color: 'FFFFFF', align: 'center', valign: 'middle' }); + s.addText( + [ + { text: sentence(title) || body.slice(0, 80), options: { bold: true, fontSize: 13, color: INK, breakLine: true } }, + { text: body.replace(/\s+/g, ' ').slice(0, 320), options: { fontSize: 10.5, color: MUTED } }, + ], + { x: 1.15, y, w: 11.5, h: 1.0, valign: 'top' }, + ); + y += 1.05; + }); + } + + // ---- 6. How she got there ---------------------------------------------------- + { + const s = pres.addSlide(); + chrome(s, 'How she got there', 'Method'); + const reconciled = row.data_reconciled === true || str(row.data_reconciled) === 'true'; + const conf = num(row.confidence); + const bullets = [ + str(row.scanned_summary) || 'Reviewed every payer, department and front desk against an 8-week baseline.', + reconciled ? 'Claims data reconciled against the finance report before any analysis.' : 'Claims data could not be fully reconciled against finance — treat numbers as provisional.', + `Cause matched to the payer's own policy bulletin${slice?.spikeStart ? ` effective ${shortDate(slice.spikeStart)}` : ''}.`, + conf != null ? `Ava's stated confidence: ${Math.round(conf * 100)}%.` : '', + 'Read-only against Databricks (workspace.rcm). Every query is recorded and reviewable on the case.', + ].filter(Boolean); + s.addText(bullets.map((b) => ({ text: b, options: { bullet: true, breakLine: true, fontSize: 13, color: INK, paraSpaceAfter: 8 } })), { x: 0.6, y: 1.5, w: 12.1, h: 4.8, valign: 'top' }); + s.addText('Ava proposes; a revenue-cycle manager decides. She has no write access to any system.', { x: 0.6, y: 6.4, w: 12, h: 0.4, fontSize: 11, italic: true, color: AI }); + } + + await pres.writeFile({ fileName: `Northlake-denials-briefing-case-${caseNo}.pptx` }); +} + +function sentence(s: string): string { + if (!s) return s; + if (s !== s.toUpperCase()) return s; + return s.toLowerCase().replace(/^\w/, (c) => c.toUpperCase()); +} diff --git a/src/screens/CaseScreen.tsx b/src/screens/CaseScreen.tsx index 1007d1a..9aa51f9 100644 --- a/src/screens/CaseScreen.tsx +++ b/src/screens/CaseScreen.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Link, useParams } from 'react-router-dom'; -import { ArrowLeft, Brain, Radar } from 'lucide-react'; +import { ArrowLeft, Brain, Download, Radar } from 'lucide-react'; import { useQuery, useZino } from '../api/provider'; import { ACT, @@ -16,6 +16,8 @@ import { Badge, Button, ErrorNote, Facts, Spinner } from '../components/core'; import { ActivityForm } from '../components/ActivityForm'; import { AvaLive } from '../components/AvaLive'; import { Disclosure, FullStory, Recheck, Recommendations, SqlEvidence, Verdict } from '../components/Findings'; +import { Briefing } from '../components/Briefing'; +import { loadBriefing, sliceOf } from '../api/charts'; import type { Action } from '../api/config'; import { dateTime, num, str } from '../format'; @@ -40,6 +42,7 @@ export function CaseScreen() { const { client, user } = useZino(); const [form, setForm] = useState(null); const [showWork, setShowWork] = useState(false); + const [exporting, setExporting] = useState(false); const cases = useQuery( () => @@ -60,6 +63,28 @@ export function CaseScreen() { const quiet = flagged === 0 && !str(row?.summary); const hasFindings = !!str(row?.summary); + const slice = row ? sliceOf(row) : null; + // Chart rows come from six Databricks views through the record-view API. Loaded + // once there are findings to picture; the slice keys decide which weekly line + // and which recovery breakdown are drawn. + const briefing = useQuery( + () => loadBriefing(client, slice), + [id, slice?.payer, slice?.department, slice?.code], + hasFindings, + ); + + async function exportDeck() { + if (!row || !briefing.data) return; + setExporting(true); + try { + // pptxgenjs is ~700 KB; nobody pays for it until they ask for slides. + const { buildDeck } = await import('../lib/slides'); + await buildDeck(row, briefing.data, slice); + } finally { + setExporting(false); + } + } + const trace = useQuery(() => client.avaTrace(String(id)), [id], true, aiOwned ? 4000 : 0); const decisions = useQuery(() => client.avaDecisions(String(id)), [id], true, aiOwned ? 8000 : 0); @@ -92,6 +117,11 @@ export function CaseScreen() { Daily review · {dateTime(row.created_at)} {str(row.review_as_of) ? ` · as of ${str(row.review_as_of).slice(0, 10)}` : ''} + {hasFindings && ( + + )} @@ -141,6 +171,7 @@ export function CaseScreen() { {hasFindings && ( <> + {briefing.data && }