feat: the briefing — four charts on the case, and six slides to take away

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 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-16 14:24:31 +05:30
parent 92ea27d27e
commit 5eb0890e97
6 changed files with 1267 additions and 6 deletions

564
package-lock.json generated
View File

@ -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",

View File

@ -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",

149
src/api/charts.ts Normal file
View File

@ -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<BriefingData> {
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'];

282
src/components/Briefing.tsx Normal file
View File

@ -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<string, keyof typeof HUES.light> = {
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 (
<section className="space-y-3">
<div className="grid gap-3 lg:grid-cols-2">
<SpikeChart data={data} slice={slice} c={c} title={title} />
<PayerChart data={data} slice={slice} c={c} />
{data.recovery.length > 0 && <RecoveryChart data={data} c={c} title={title} />}
{data.expiring.length > 0 && <ExpiringChart data={data} c={c} />}
</div>
</section>
);
}
// ---------------------------------------------------------------------------
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 (
<figure className="lift rounded-xl border border-line bg-panel px-4 pt-3.5 pb-2">
<figcaption className="flex items-start gap-2">
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-ink">{title}</span>
<span className="block text-xs text-muted">{caption}</span>
</span>
<button
onClick={() => setShowTable(!showTable)}
title={showTable ? 'Show chart' : 'Show as table'}
aria-label={showTable ? 'Show chart' : 'Show as table'}
className="rounded p-1 text-faint hover:bg-panel2 hover:text-ink"
>
<Table2 className="h-3.5 w-3.5" />
</button>
</figcaption>
<div className="mt-2 h-[220px]">
{showTable ? (
<div className="h-full overflow-auto">
<table className="w-full text-xs">
<thead>
<tr>
{table.head.map((h) => (
<th key={h} className="border-b border-line px-2 py-1 text-left font-semibold text-faint">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map((r, i) => (
<tr key={i}>
{r.map((v, j) => (
<td key={j} className={`tnum border-b border-line px-2 py-1 ${j ? 'text-right' : ''} text-ink`}>
{v}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
children
)}
</div>
</figure>
);
}
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 (
<Frame
title={isSlice ? `Denied claims per week — ${title}` : 'Hospital denial rate per week'}
caption={spike ? `Vertical line: the payer's rule change, ${shortDate(spike)}. Bars after it are the spike.` : 'Six months, one point per week'}
table={{ head: ['Week', isSlice ? 'Denied' : 'Denial %'], rows: rows.map((r) => [r.label, isSlice ? r.denied : `${r.pct}%`]) }}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -18 }}>
<CartesianGrid vertical={false} stroke={c.grid} />
<XAxis dataKey="label" {...axis(c)} interval="preserveStartEnd" minTickGap={28} />
<YAxis {...axis(c)} width={44} tickFormatter={(v) => (isSlice ? String(v) : `${v}%`)} />
<Tooltip {...tip(c)} formatter={(v: unknown) => (isSlice ? [`${Number(v)} denied`, ''] : [`${Number(v)}%`, 'denial rate'])} />
{isSlice ? (
<Bar dataKey="denied" radius={[4, 4, 0, 0]} maxBarSize={18}>
{rows.map((r) => (
<Cell key={r.week} fill={spike && r.week >= spike ? c.accent : c.gray} />
))}
</Bar>
) : (
<Line type="monotone" dataKey="pct" stroke={c.accent} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
)}
{spike && (
<ReferenceLine
x={shortDate(spike)}
stroke={c.ai}
strokeDasharray="4 3"
label={{ value: 'rule change', position: 'insideTopLeft', fill: c.ai, fontSize: 11 }}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</Frame>
);
}
// 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 (
<Frame
title="Denial rate by insurer — before vs now"
caption="Gray is the 8-week baseline; blue is the last 6 weeks. One insurer moved."
table={{ head: ['Insurer', 'Before', 'Now'], rows: rows.map((r) => [r.payer, `${r.rateBase}%`, `${r.rateRecent}%`]) }}
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -18 }} barGap={2} barCategoryGap="28%">
<CartesianGrid vertical={false} stroke={c.grid} />
<XAxis dataKey="short" {...axis(c)} interval={0} />
<YAxis {...axis(c)} width={44} tickFormatter={(v) => `${v}%`} />
<Tooltip {...tip(c)} formatter={(v: unknown, name: unknown) => [`${Number(v)}%`, String(name) === 'rateBase' ? 'before' : 'now']} />
<Bar dataKey="rateBase" name="rateBase" fill={c.gray} radius={[4, 4, 0, 0]} maxBarSize={22} />
<Bar dataKey="rateRecent" name="rateRecent" radius={[4, 4, 0, 0]} maxBarSize={22}>
{rows.map((r) => (
<Cell key={r.payer} fill={!slice?.payer || r.payer === slice.payer ? c.accent : c.gray} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Frame>
);
}
// 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<string, number>;
return (
<Frame
title={`Where the money stands — ${title}`}
caption={`${money(total)} denied since the baseline began. Orange is still recoverable if appeals are filed.`}
table={{ head: ['Status', 'Claims', 'Billed'], rows: data.recovery.map((b) => [b.bucket, b.claims, money(b.billed)]) }}
>
<div className="flex h-full flex-col justify-center gap-3">
<ResponsiveContainer width="100%" height={56}>
<BarChart data={[one]} layout="vertical" margin={{ top: 0, right: 8, bottom: 0, left: 8 }} barCategoryGap={0}>
<XAxis type="number" hide />
<YAxis type="category" hide dataKey={() => ''} />
<Tooltip {...tip(c)} formatter={(v: unknown, name: unknown) => [money(Number(v)), String(name)]} />
{RECOVERY_ORDER.filter((b) => one[b]).map((b, i, arr) => (
<Bar
key={b}
dataKey={b}
stackId="m"
fill={c[RECOVERY_HUE[b]]}
stroke="var(--color-panel)"
strokeWidth={2}
radius={i === 0 ? [6, 0, 0, 6] : i === arr.length - 1 ? [0, 6, 6, 0] : 0}
/>
))}
</BarChart>
</ResponsiveContainer>
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3">
{data.recovery.map((b) => (
<li key={b.bucket} className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: c[RECOVERY_HUE[b.bucket]] }} />
<span className="text-muted">{b.bucket}</span>
<span className="tnum ml-auto font-semibold text-ink">{moneyShort(b.billed)}</span>
</li>
))}
</ul>
</div>
</Frame>
);
}
// 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 (
<Frame
title="Appeals about to expire, by insurer"
caption={`${money(total)} of unfiled appeals close within 14 days. Nobody has to find these — she did.`}
table={{ head: ['Insurer', 'Claims', 'Billed', 'First deadline'], rows: rows.map((r) => [r.payer, r.claims, money(r.billed), shortDate(r.firstDeadline)]) }}
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} layout="vertical" margin={{ top: 4, right: 56, bottom: 0, left: 8 }} barCategoryGap="30%">
<CartesianGrid horizontal={false} stroke={c.grid} />
<XAxis type="number" hide />
<YAxis type="category" dataKey="short" {...axis(c)} width={70} />
<Tooltip {...tip(c)} formatter={(v: unknown) => [money(Number(v)), 'unfiled, expiring']} />
<Bar dataKey="billed" fill={c.accent} radius={[0, 4, 4, 0]} maxBarSize={18} label={{ position: 'right', fill: c.ink, fontSize: 11, formatter: (v: unknown) => moneyShort(Number(v)) }} />
</BarChart>
</ResponsiveContainer>
</Frame>
);
}

241
src/lib/slides.ts Normal file
View File

@ -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<string, string> = {
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<void> {
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());
}

View File

@ -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<Action | null>(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)}` : ''}
</span>
{hasFindings && (
<Button kind="secondary" onClick={exportDeck} busy={exporting} disabled={!briefing.data} title="Six slides: what happened, the spike, where it sits, the money, the recommendations, the method">
<Download className="h-4 w-4" /> Slides
</Button>
)}
</div>
<Rail state={state} />
@ -141,6 +171,7 @@ export function CaseScreen() {
{hasFindings && (
<>
<Verdict row={row} />
{briefing.data && <Briefing data={briefing.data} slice={slice} />}
<Recheck row={row} />
<Recommendations row={row} />
</>