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