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:
parent
92ea27d27e
commit
5eb0890e97
564
package-lock.json
generated
564
package-lock.json
generated
@ -10,9 +10,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.1.0",
|
"@fontsource-variable/inter": "^5.1.0",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
|
"pptxgenjs": "^4.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@ -773,6 +775,32 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"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": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.4",
|
"version": "1.23.4",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||||
@ -1139,6 +1167,18 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||||
@ -1456,6 +1496,69 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@ -1463,18 +1566,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "18.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@ -1491,6 +1603,12 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@ -1580,6 +1698,15 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@ -1587,13 +1714,140 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@ -1643,6 +1903,18 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@ -1692,6 +1964,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@ -1724,6 +2002,64 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@ -1766,6 +2102,27 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@ -2104,6 +2461,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -2140,6 +2503,33 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@ -2165,6 +2555,36 @@
|
|||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@ -2207,6 +2627,72 @@
|
|||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
|
||||||
@ -2253,6 +2739,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@ -2272,6 +2764,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -2282,6 +2780,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||||
@ -2303,6 +2810,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@ -2317,6 +2830,12 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
|
||||||
@ -2348,6 +2867,43 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@ -13,9 +13,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.1.0",
|
"@fontsource-variable/inter": "^5.1.0",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
|
"pptxgenjs": "^4.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
|||||||
149
src/api/charts.ts
Normal file
149
src/api/charts.ts
Normal 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
282
src/components/Briefing.tsx
Normal 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
241
src/lib/slides.ts
Normal 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());
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
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 { useQuery, useZino } from '../api/provider';
|
||||||
import {
|
import {
|
||||||
ACT,
|
ACT,
|
||||||
@ -16,6 +16,8 @@ import { Badge, Button, ErrorNote, Facts, Spinner } from '../components/core';
|
|||||||
import { ActivityForm } from '../components/ActivityForm';
|
import { ActivityForm } from '../components/ActivityForm';
|
||||||
import { AvaLive } from '../components/AvaLive';
|
import { AvaLive } from '../components/AvaLive';
|
||||||
import { Disclosure, FullStory, Recheck, Recommendations, SqlEvidence, Verdict } from '../components/Findings';
|
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 type { Action } from '../api/config';
|
||||||
import { dateTime, num, str } from '../format';
|
import { dateTime, num, str } from '../format';
|
||||||
|
|
||||||
@ -40,6 +42,7 @@ export function CaseScreen() {
|
|||||||
const { client, user } = useZino();
|
const { client, user } = useZino();
|
||||||
const [form, setForm] = useState<Action | null>(null);
|
const [form, setForm] = useState<Action | null>(null);
|
||||||
const [showWork, setShowWork] = useState(false);
|
const [showWork, setShowWork] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
const cases = useQuery(
|
const cases = useQuery(
|
||||||
() =>
|
() =>
|
||||||
@ -60,6 +63,28 @@ export function CaseScreen() {
|
|||||||
const quiet = flagged === 0 && !str(row?.summary);
|
const quiet = flagged === 0 && !str(row?.summary);
|
||||||
const hasFindings = !!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 trace = useQuery(() => client.avaTrace(String(id)), [id], true, aiOwned ? 4000 : 0);
|
||||||
const decisions = useQuery(() => client.avaDecisions(String(id)), [id], true, aiOwned ? 8000 : 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)}
|
Daily review · {dateTime(row.created_at)}
|
||||||
{str(row.review_as_of) ? ` · as of ${str(row.review_as_of).slice(0, 10)}` : ''}
|
{str(row.review_as_of) ? ` · as of ${str(row.review_as_of).slice(0, 10)}` : ''}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Rail state={state} />
|
<Rail state={state} />
|
||||||
@ -141,6 +171,7 @@ export function CaseScreen() {
|
|||||||
{hasFindings && (
|
{hasFindings && (
|
||||||
<>
|
<>
|
||||||
<Verdict row={row} />
|
<Verdict row={row} />
|
||||||
|
{briefing.data && <Briefing data={briefing.data} slice={slice} />}
|
||||||
<Recheck row={row} />
|
<Recheck row={row} />
|
||||||
<Recommendations row={row} />
|
<Recommendations row={row} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user