commit 7cd2d6dc4039756ec90d039017f89a9b76664635 Author: Bastien COIGNOUX Date: Fri Apr 24 07:41:55 2026 +0200 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..87944c5 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Sous-domaine Jira Cloud (sans https), ex. mon-entreprise pour mon-entreprise.atlassian.net +JIRA_DOMAIN=mon-entreprise + +# Optionnel : URL complète du site Jira pour les liens « ouvrir le ticket » en prod (npm run build). +# En dev, les liens utilisent aussi JIRA_DOMAIN via Vite si cette variable est vide. +# VITE_JIRA_BROWSE_BASE_URL=https://mon-entreprise.atlassian.net + +# Compte Atlassian (e-mail lié à Jira Cloud) +JIRA_EMAIL=vous@exemple.com + +# Jeton API : https://id.atlassian.com/manage-profile/security/api-tokens +JIRA_API_KEY= + +# Optionnel : en build de production uniquement, URL d’un proxy HTTPS vers Jira +# (le dev server injecte déjà l’auth via vite.config.js) +# VITE_JIRA_BASE_URL=https://votre-backend.example.com/jira-proxy + +# Optionnel (client) : identité pour le filtre « Ma vue » — accountId prioritaire +# VITE_MY_JIRA_ACCOUNT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# VITE_MY_JIRA_EMAIL=vous@exemple.com + +# Champ Story Points dans Jira (Administration → Issues → champs personnalisés → ID) +# VITE_JIRA_STORY_POINTS_FIELD=customfield_10028 + +# Clé de l’épopée : utilisée dans le JQL (parentEpic + exclusion) et pour le groupement UI +# VITE_JIRA_EPIC_KEY=DCC-5514 + +# Taille de page pour POST /rest/api/3/search/jql (défaut 100, max 100) +# VITE_JIRA_PAGE_SIZE=100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b50664c --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env +.env.* +!.env.example + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/index.html b/index.html new file mode 100644 index 0000000..b59cd6a --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + Migration OroCommerce · Jira + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..42318b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2057 @@ +{ + "name": "jira-descours", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jira-descours", + "version": "0.0.0", + "dependencies": { + "axios": "^1.15.2", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "recharts": "^3.8.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.2.4", + "typescript": "~6.0.2", + "vite": "^8.0.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "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/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "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.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "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/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.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": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "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/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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.0.tgz", + "integrity": "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "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/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+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/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "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": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "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.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "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", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "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/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6727e91 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "jira-descours", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.2.4", + "typescript": "~6.0.2", + "vite": "^8.0.10" + }, + "dependencies": { + "axios": "^1.15.2", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "recharts": "^3.8.1" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..8909940 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,263 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { StoryGroup } from './types/jira' +import { fetchAllIssuesByJql, MIGRATION_EPIC_KEY, MIGRATION_JQL, jiraClient } from './api/jiraClient' +import { groupSubtasksUnderStories } from './lib/groupIssues' +import { statusToPhase } from './lib/statusPhase' +import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory' +import { + loadDashboardConfig, + saveDashboardConfig, + type DashboardConfig, +} from './lib/dashboardConfig' +import { assigneeMatchesMyView } from './lib/assigneeMatch' +import { isAxiosError } from 'axios' +import { ExecutiveSummary } from './components/ExecutiveSummary' +import { BurnupChart } from './components/BurnupChart' +import { StoryCard } from './components/StoryCard' +import { BoardView } from './components/BoardView' +import { DashboardSkeleton } from './components/DashboardSkeleton' +import { MilestonesTimeline } from './components/MilestonesTimeline' +import { DashboardSettingsModal } from './components/DashboardSettingsModal' + +type ViewMode = 'list' | 'board' + +export default function App() { + const [groups, setGroups] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [updatedAt, setUpdatedAt] = useState(null) + const [view, setView] = useState('list') + const [burnupData, setBurnupData] = useState(() => loadBurnupHistory()) + const [dashboardCfg, setDashboardCfg] = useState(() => loadDashboardConfig()) + const [settingsOpen, setSettingsOpen] = useState(false) + + const myViewActive = Boolean(dashboardCfg.myViewActive) + + const displayGroups = useMemo(() => { + if (!myViewActive) return groups + return groups.filter((g) => + g.subtasks.some((st) => assigneeMatchesMyView(st, dashboardCfg)), + ) + }, [groups, myViewActive, dashboardCfg]) + + const toggleMyView = () => { + const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive } + setDashboardCfg(next) + saveDashboardConfig(next) + } + + const saveSettings = (next: DashboardConfig) => { + setDashboardCfg(next) + saveDashboardConfig(next) + } + + const load = useCallback(async (signal?: AbortSignal) => { + setLoading(true) + setError(null) + try { + const issues = await fetchAllIssuesByJql(MIGRATION_JQL, signal) + const grouped = groupSubtasksUnderStories(issues) + setGroups(grouped) + setUpdatedAt(new Date()) + + const totalSubs = grouped.reduce((acc, g) => acc + g.subtasks.length, 0) + const doneSubs = grouped.reduce( + (acc, g) => + acc + + g.subtasks.filter((s) => statusToPhase(s.fields.status.name) === 'done').length, + 0, + ) + setBurnupData(appendBurnupSnapshot(doneSubs, totalSubs)) + } catch (e) { + if (signal?.aborted || (isAxiosError(e) && e.code === 'ERR_CANCELED')) { + return + } + if (isAxiosError(e)) { + const msg = + typeof e.response?.data === 'object' && + e.response.data !== null && + 'errorMessages' in e.response.data && + Array.isArray((e.response.data as { errorMessages: string[] }).errorMessages) + ? (e.response.data as { errorMessages: string[] }).errorMessages.join(' ') + : e.message + setError(msg || 'Erreur réseau Jira') + } else { + setError(e instanceof Error ? e.message : 'Erreur inconnue') + } + setGroups([]) + } finally { + if (!signal?.aborted) setLoading(false) + } + }, []) + + useEffect(() => { + const ac = new AbortController() + void load(ac.signal) + return () => ac.abort() + }, [load]) + + const baseOk = import.meta.env.DEV ? true : Boolean(jiraClient.defaults.baseURL) + + return ( +
+
+
+

+ OroCommerce · Migration exécutive +

+

+ Pilotage migration +

+

+ Périmètre DCC sous l’épopée{' '} + {MIGRATION_EPIC_KEY} (JQL{' '} + parentEpic, sous-tâches incluses), + parent résolu (clé ou id), jalons, export JSON pour Synology. +

+
+
+
+ + +
+
+ + +
+
+ {updatedAt && !loading && ( + + Mis à jour {updatedAt.toLocaleTimeString('fr-FR')} + + )} + +
+
+
+ +
+ {!baseOk && ( +
+ Configurez VITE_JIRA_BASE_URL pour + un build de production pointant vers votre proxy HTTPS (le proxy Vite ne s’applique + qu’en npm run dev). +
+ )} + + {error && ( +
+ {error} +
+ )} + + {loading && } + + {!loading && !error && groups.length === 0 && ( +

+ Aucun ticket ne correspond au JQL actuel. +

+ )} + + {!loading && !error && groups.length > 0 && ( + <> + setSettingsOpen(true)} + /> + + + +
+

+ Burnup +

+ +
+ +
+
+

+ {view === 'list' ? 'Stories (liste)' : 'Stories par composant'} +

+ {myViewActive && ( + + Filtre actif : {displayGroups.length} / {groups.length} stories + + )} +
+ {displayGroups.length === 0 ? ( +

+ Aucune story ne correspond à « Ma vue ». Vérifiez vos assignations ou configurez + votre accountId / e-mail dans les réglages. +

+ ) : view === 'list' ? ( +
+ {displayGroups.map((g) => ( + + ))} +
+ ) : ( + + )} +
+ + )} +
+ + setSettingsOpen(false)} + onSave={saveSettings} + /> +
+ ) +} diff --git a/src/api/jiraClient.ts b/src/api/jiraClient.ts new file mode 100644 index 0000000..58d9e23 --- /dev/null +++ b/src/api/jiraClient.ts @@ -0,0 +1,153 @@ +import axios from 'axios' +import type { JiraIssue, JiraSearchJqlResponse } from '../types/jira' + +/** + * Même périmètre qu’un filtre Jira type filter=25111 : tout ce qui est sous l’épopée + * (`parentEpic`), **y compris les sous-tâches** — un JQL sur `parent` / `parent.parent` ne + * reprend pas toute la hiérarchie épique. + */ +export const MIGRATION_EPIC_KEY = + import.meta.env.VITE_JIRA_EPIC_KEY?.trim() || 'DCC-5514' + +export const MIGRATION_JQL = `project IN (DCC) AND parentEpic IN (${MIGRATION_EPIC_KEY}) AND key NOT IN (${MIGRATION_EPIC_KEY}) ORDER BY Rank ASC` + +function clientBaseUrl(): string { + if (import.meta.env.DEV) return '/jira-api' + const fromEnv = import.meta.env.VITE_JIRA_BASE_URL?.replace(/\/$/, '') + return fromEnv ?? '' +} + +export const jiraClient = axios.create({ + baseURL: clientBaseUrl(), + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + timeout: 120_000, +}) + +const MAX_PAGES = 250 + +function pageSize(): number { + const parsedSize = Number(import.meta.env.VITE_JIRA_PAGE_SIZE) + if (Number.isFinite(parsedSize) && parsedSize >= 1 && parsedSize <= 100) { + return Math.floor(parsedSize) + } + return 100 +} + +/** + * Nombre total (approximatif) d’issues pour le JQL (optionnel : KPI, titres, etc.). + * La pagination complète de `fetchAllIssuesByJql` repose sur `nextPageToken`, pas sur ce count. + * @see https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-approximate-count-post + */ +export async function fetchJqlApproximateCount( + jql: string, + signal?: AbortSignal, +): Promise { + try { + const { data } = await jiraClient.post<{ count?: number }>( + '/rest/api/3/search/approximate-count', + { jql }, + { signal }, + ) + return typeof data?.count === 'number' && Number.isFinite(data.count) ? data.count : undefined + } catch { + return undefined + } +} + +/** + * Charge toutes les issues via POST `/rest/api/3/search/jql` (seul endpoint supporté après + * suppression de `/rest/api/3/search` — CHANGE-2046). + * Pagination par `nextPageToken` ; garde-fous si Jira renvoie un token cyclique (bugs signalés en prod). + * @see https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-post + */ +export async function fetchAllIssuesByJql( + jql: string, + signal?: AbortSignal, +): Promise { + const base = clientBaseUrl() + if (!base) { + throw new Error( + 'URL Jira absente : en dev, définissez JIRA_DOMAIN dans .env et utilisez le proxy /jira-api. En prod, définissez VITE_JIRA_BASE_URL vers un proxy HTTPS.', + ) + } + + const maxResults = pageSize() + const storyPointsField = + import.meta.env.VITE_JIRA_STORY_POINTS_FIELD?.trim() || 'customfield_10028' + const fields = [ + 'summary', + 'status', + 'issuetype', + 'parent', + 'subtasks', + 'components', + 'priority', + 'assignee', + 'timetracking', + storyPointsField, + ] as const + + const collected: JiraIssue[] = [] + let nextPageToken: string | undefined + const seenTokens = new Set() + let lastPageFingerprint = '' + + for (let page = 0; page < MAX_PAGES; page += 1) { + const body: Record = { + jql, + fields: [...fields], + maxResults, + ...(nextPageToken ? { nextPageToken } : {}), + } + + const { data } = await jiraClient.post( + '/rest/api/3/search/jql', + body, + { signal }, + ) + + const batch = data.issues ?? [] + const fingerprint = batch.map((i) => i.key).join('\0') + if (fingerprint && fingerprint === lastPageFingerprint) { + console.warn( + '[jira] Pagination search/jql : même jeu de clés qu’à la page précédente — arrêt pour éviter une boucle.', + ) + break + } + lastPageFingerprint = fingerprint + + collected.push(...batch) + if (batch.length === 0) break + + const token = + typeof data.nextPageToken === 'string' && data.nextPageToken.length > 0 + ? data.nextPageToken + : undefined + + if (!token) break + if (seenTokens.has(token)) { + console.warn('[jira] Pagination search/jql : nextPageToken déjà vu — arrêt.') + break + } + seenTokens.add(token) + nextPageToken = token + } + + let approx: number | undefined + try { + approx = await fetchJqlApproximateCount(jql, signal) + } catch { + approx = undefined + } + if ( + approx !== undefined && + approx > collected.length && + collected.length > 0 + ) { + console.warn( + `[jira] Décompte approximatif Jira ≈ ${approx} issue(s), mais seulement ${collected.length} chargée(s). Vérifiez les droits, le JQL, ou un bug du endpoint search/jql sur votre site.`, + ) + } + + return collected +} diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/components/BoardView.tsx b/src/components/BoardView.tsx new file mode 100644 index 0000000..56c0cae --- /dev/null +++ b/src/components/BoardView.tsx @@ -0,0 +1,34 @@ +import type { StoryGroup } from '../types/jira' +import { groupStoriesByComponent } from '../lib/boardGrouping' +import { StoryCard } from './StoryCard' + +type Props = { + groups: StoryGroup[] +} + +export function BoardView({ groups }: Props) { + const columns = groupStoriesByComponent(groups) + + return ( +
+ {[...columns.entries()].map(([name, col]) => ( +
+
+

{name}

+ + {col.length} + +
+
+ {col.map((g) => ( + + ))} +
+
+ ))} +
+ ) +} diff --git a/src/components/BurnupChart.tsx b/src/components/BurnupChart.tsx new file mode 100644 index 0000000..ad61de9 --- /dev/null +++ b/src/components/BurnupChart.tsx @@ -0,0 +1,68 @@ +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import type { BurnupPoint } from '../lib/burnupHistory' + +type Props = { + data: BurnupPoint[] +} + +export function BurnupChart({ data }: Props) { + if (data.length === 0) { + return ( +
+ Le graphique burnup se remplira au fil des actualisations (historique stocké localement). +
+ ) + } + + return ( +
+

+ Burnup — terminés vs périmètre +

+ + + + d.slice(5)} + /> + + `Date : ${l}`} + /> + + + + +
+ ) +} diff --git a/src/components/DashboardSettingsModal.tsx b/src/components/DashboardSettingsModal.tsx new file mode 100644 index 0000000..3adda58 --- /dev/null +++ b/src/components/DashboardSettingsModal.tsx @@ -0,0 +1,227 @@ +import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react' +import { + exportConfigJson, + mergeImportedConfig, + type DashboardConfig, + type Milestone, +} from '../lib/dashboardConfig' + +type Props = { + open: boolean + config: DashboardConfig + onClose: () => void + onSave: (next: DashboardConfig) => void +} + +function newMilestone(): Milestone { + return { + id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `m-${Date.now()}`, + title: '', + date: new Date().toISOString().slice(0, 10), + linkedStoryKeys: [], + } +} + +export function DashboardSettingsModal({ open, config, onClose, onSave }: Props) { + const dialogRef = useRef(null) + const fileRef = useRef(null) + const titleId = useId() + const [draft, setDraft] = useState(config) + + useEffect(() => { + if (open) setDraft(config) + }, [open, config]) + + useEffect(() => { + const el = dialogRef.current + if (!el) return + if (open) { + if (!el.open) el.showModal() + } else if (el.open) { + el.close() + } + }, [open]) + + const updateMilestone = (id: string, patch: Partial) => { + setDraft((d) => ({ + ...d, + milestones: d.milestones.map((m) => (m.id === id ? { ...m, ...patch } : m)), + })) + } + + const removeMilestone = (id: string) => { + setDraft((d) => ({ ...d, milestones: d.milestones.filter((m) => m.id !== id) })) + } + + const addMilestone = () => { + setDraft((d) => ({ ...d, milestones: [...d.milestones, newMilestone()] })) + } + + const onImportFile = (e: ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = () => { + try { + const parsed = JSON.parse(String(reader.result)) as unknown + const merged = mergeImportedConfig(draft, parsed) + if (merged) setDraft(merged) + else alert('Fichier JSON invalide (version 1 attendue).') + } catch { + alert('Impossible de lire ce fichier JSON.') + } + e.target.value = '' + } + reader.readAsText(file) + } + + return ( + { + e.preventDefault() + onClose() + }} + > +
+

+ Configuration dashboard +

+

+ Sauvegarde locale (navigateur). Exportez le JSON pour le versionner sur votre Synology. +

+
+ +
+
+ + setDraft((d) => ({ ...d, myJiraAccountId: e.target.value || undefined }))} + placeholder="ex. 5b10a2844c20165700ede21g" + /> +
+
+ + setDraft((d) => ({ ...d, myJiraEmail: e.target.value || undefined }))} + placeholder="vous@entreprise.com" + /> +
+ +
+
+ + Jalons + + +
+
    + {draft.milestones.map((m) => ( +
  • + updateMilestone(m.id, { title: e.target.value })} + placeholder="ex. Fin design" + /> +
    + updateMilestone(m.id, { date: e.target.value })} + /> + + updateMilestone(m.id, { + linkedStoryKeys: e.target.value + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter(Boolean), + }) + } + placeholder="Stories liées (DCC-1, DCC-2) — vide = toutes" + /> + +
    +
  • + ))} +
+
+ +
+ + + +
+
+ +
+ + +
+
+ ) +} diff --git a/src/components/DashboardSkeleton.tsx b/src/components/DashboardSkeleton.tsx new file mode 100644 index 0000000..b193bd3 --- /dev/null +++ b/src/components/DashboardSkeleton.tsx @@ -0,0 +1,39 @@ +function Shimmer({ className }: { className: string }) { + return ( +
+
+
+ ) +} + +export function DashboardSkeleton() { + return ( +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ + +
+ ))} +
+ +
+ {[1, 2, 3].map((i) => ( +
+ + + + +
+ ))} +
+
+ ) +} diff --git a/src/components/ExecutiveSummary.tsx b/src/components/ExecutiveSummary.tsx new file mode 100644 index 0000000..9bdca2f --- /dev/null +++ b/src/components/ExecutiveSummary.tsx @@ -0,0 +1,133 @@ +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts' +import type { StoryGroup } from '../types/jira' +import { + blockingTicketsCount, + designHealthPercent, + globalProgressPercent, + goldenCarbonHealthPercent, + goldenCarbonSubtasks, + maquetteRelatedSubtasks, +} from '../lib/executiveKpis' + +type Props = { + groups: StoryGroup[] +} + +const neonCard = + 'rounded-2xl border bg-slate-950/35 p-4 backdrop-blur-xl transition hover:bg-slate-950/45 sm:p-5' + +function DonutGlobal({ pct }: { pct: number }) { + const rest = Math.max(0, 100 - pct) + const data = [ + { name: 'Terminé', value: pct }, + { name: 'Reste', value: rest }, + ] + return ( +
+ + + + + + + [`${value ?? 0}%`, '']} + contentStyle={{ + background: 'rgba(15,23,42,0.95)', + border: '1px solid rgba(148,163,184,0.25)', + borderRadius: 8, + fontSize: 12, + }} + /> + + +
+ {pct}% + global +
+
+ ) +} + +export function ExecutiveSummary({ groups }: Props) { + const total = globalProgressPercent(groups) + const design = designHealthPercent(groups) + const golden = goldenCarbonHealthPercent(groups) + const blockers = blockingTicketsCount(groups) + + const maquetteCount = maquetteRelatedSubtasks(groups).length + const gcCount = goldenCarbonSubtasks(groups).length + + return ( +
+

+ Executive summary +

+
+
+

+ Progression totale +

+ +

+ Sous-tâches terminées / sous-tâches liées aux stories +

+
+ +
+

+ Santé du design +

+

+ {maquetteCount === 0 ? '—' : `${design}%`} +

+

+ Maquettes validées (sous-tâches dont le résumé évoque maquette / Figma / wireframe). + {maquetteCount === 0 && ' Aucune sous-tâche détectée avec ces mots-clés.'} +

+
+ +
+

+ Santé du dev +

+

+ {gcCount === 0 ? '—' : `${golden}%`} +

+

+ Intégration Golden Carbon (mot-clé « golden carbon » dans résumé ou composants). + {gcCount === 0 && ' Aucun ticket correspondant — ajustez les filtres dans le code si besoin.'} +

+
+ +
+

+ Points de blocage +

+

{blockers}

+

+ Tickets en statut « Bloqué », « Recette KO » ou assimilés (stories + sous-tâches). +

+
+
+
+ ) +} diff --git a/src/components/MilestonesTimeline.tsx b/src/components/MilestonesTimeline.tsx new file mode 100644 index 0000000..186f3be --- /dev/null +++ b/src/components/MilestonesTimeline.tsx @@ -0,0 +1,83 @@ +import type { StoryGroup } from '../types/jira' +import type { Milestone } from '../lib/dashboardConfig' +import { isMilestoneLate } from '../lib/milestoneStatus' + +type Props = { + milestones: Milestone[] + groups: StoryGroup[] + onOpenSettings: () => void +} + +function formatFr(iso: string): string { + try { + return new Date(iso + 'T12:00:00').toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + } catch { + return iso + } +} + +export function MilestonesTimeline({ milestones, groups, onOpenSettings }: Props) { + const sorted = [...milestones].sort((a, b) => a.date.localeCompare(b.date)) + + return ( +
+
+

+ Timeline de jalons +

+ +
+ + {sorted.length === 0 ? ( +

+ Aucun jalon défini — ouvrez la configuration pour ajouter des dates clés (ex. fin design, + recette, mise en ligne). +

+ ) : ( +
+
+
    + {sorted.map((m) => { + const late = isMilestoneLate(m, groups) + return ( +
  • + + + {m.title} + + {formatFr(m.date)} + {late && ( + + Retard + + )} +
  • + ) + })} +
+
+ )} +
+ ) +} diff --git a/src/components/PhaseLaneIcons.tsx b/src/components/PhaseLaneIcons.tsx new file mode 100644 index 0000000..ed605cf --- /dev/null +++ b/src/components/PhaseLaneIcons.tsx @@ -0,0 +1,44 @@ +import type { JiraIssue } from '../types/jira' +import { laneAggregateState, type WorkLane } from '../lib/laneDetection' + +const LANES: { lane: WorkLane; label: string; hint: string }[] = [ + { lane: 'analyse', label: 'A', hint: 'Piste Analyse' }, + { lane: 'design', label: 'D', hint: 'Piste Design' }, + { lane: 'integration', label: 'I', hint: 'Piste Intégration' }, +] + +function stateClasses(state: ReturnType): string { + switch (state) { + case 'empty': + return 'bg-slate-800/80 text-slate-600 ring-slate-700/50' + case 'grey': + return 'bg-slate-700 text-slate-300 ring-slate-500/40' + case 'blue': + return 'bg-sky-600 text-white ring-sky-400/60 shadow-[0_0_12px_rgba(56,189,248,0.45)]' + case 'green': + return 'bg-emerald-500 text-white ring-emerald-400/50 shadow-[0_0_12px_rgba(52,211,153,0.45)]' + } +} + +type Props = { + subtasks: JiraIssue[] +} + +export function PhaseLaneIcons({ subtasks }: Props) { + return ( +
+ {LANES.map(({ lane, label, hint }) => { + const st = laneAggregateState(subtasks, lane) + return ( + + {label} + + ) + })} +
+ ) +} diff --git a/src/components/PhaseStepper.tsx b/src/components/PhaseStepper.tsx new file mode 100644 index 0000000..24935cf --- /dev/null +++ b/src/components/PhaseStepper.tsx @@ -0,0 +1,35 @@ +import type { PhaseId } from '../types/jira' +import { PHASE_LABELS } from '../lib/statusPhase' + +const STEPS: PhaseId[] = ['analyse', 'design', 'integration'] + +type Props = { + stepDone: Record +} + +export function PhaseStepper({ stepDone }: Props) { + return ( +
+ {STEPS.map((phase, idx) => { + const done = stepDone[phase] + return ( +
+
+ {idx < STEPS.length - 1 && ( + + → + + )} +
+ ) + })} +
+ ) +} diff --git a/src/components/StoryCard.tsx b/src/components/StoryCard.tsx new file mode 100644 index 0000000..a9ada04 --- /dev/null +++ b/src/components/StoryCard.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react' +import type { PhaseId, StoryGroup } from '../types/jira' +import { PHASE_LABELS, statusToPhase } from '../lib/statusPhase' +import { priorityBand, priorityBadgeClass } from '../lib/priorityLabel' +import { storyProgressPercent, stepperStates } from '../lib/storyMetrics' +import { PhaseStepper } from './PhaseStepper' +import { PhaseLaneIcons } from './PhaseLaneIcons' +import { blockingSummaryForTooltip } from '../lib/executiveKpis' +import { getRemainingEstimateUnits, getStoryPoints } from '../lib/jiraFieldExtractors' +import { jiraBrowseIssueUrl } from '../lib/jiraLinks' + +function IssueKeyLink({ + issueKey, + className, +}: { + issueKey: string + className?: string +}) { + const href = jiraBrowseIssueUrl(issueKey) + const base = + className ?? + 'font-mono text-xs text-cyan-300/95 decoration-cyan-400/40 hover:text-cyan-200 hover:decoration-cyan-300/60' + if (!href) { + return {issueKey} + } + return ( + + {issueKey} + + ) +} + +type Props = { + group: StoryGroup + /** Board : icônes A/D/I par piste + carte un peu plus compacte. */ + variant?: 'default' | 'board' +} + +function phaseChipClass(phase: PhaseId): string { + const base = + 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset' + const map: Record = { + analyse: `${base} bg-violet-500/15 text-violet-200 ring-violet-500/35`, + design: `${base} bg-amber-500/15 text-amber-100 ring-amber-500/35`, + integration: `${base} bg-sky-500/15 text-sky-100 ring-sky-500/35`, + done: `${base} bg-emerald-500/15 text-emerald-100 ring-emerald-500/35`, + } + return map[phase] ?? `${base} bg-slate-500/15 text-slate-200 ring-slate-500/35` +} + +export function StoryCard({ group, variant = 'default' }: Props) { + const { story, subtasks } = group + const [subsOpen, setSubsOpen] = useState(true) + const progress = storyProgressPercent(subtasks) + const steps = stepperStates(subtasks) + const band = priorityBand(story) + const assignee = story.fields.assignee?.displayName + const blockerHint = blockingSummaryForTooltip(group) + const isBoard = variant === 'board' + const spStory = getStoryPoints(story) + const spSubs = subtasks.reduce((a, s) => a + getStoryPoints(s), 0) + const remStory = getRemainingEstimateUnits(story) + const remSubs = subtasks.reduce((a, s) => a + getRemainingEstimateUnits(s), 0) + + return ( +
+
+
+
+
+ + {band && ( + + {band} + + )} + {isBoard && subtasks.length > 0 && } +
+

+ {story.fields.summary} +

+

+ + SP story : {spStory} + + + SP sous-tâches :{' '} + {spSubs} + + + Reste (Σ) :{' '} + + {(remStory + remSubs).toFixed(2)} + + +

+
+ + {PHASE_LABELS[statusToPhase(story.fields.status.name)]} + + + Jira : {story.fields.status.name} + +
+
+ {assignee && ( +
+ {assignee} +
+ )} +
+ + {subtasks.length > 0 && ( + <> +
+
+ Analyse + Design + Intégration +
+ +
+ +
+ Progression (sous-tâches) + {progress}% +
+ + )} +
+ +
+ {subtasks.length > 0 && ( + <> +
+ +
+ {subsOpen && ( +
    + {subtasks.map((st) => { + const ph = statusToPhase(st.fields.status.name) + return ( +
  • +
    + +

    {st.fields.summary}

    +
    +
    + {getStoryPoints(st) > 0 && ( + + SP {getStoryPoints(st)} + + )} + {st.fields.status.name} + {PHASE_LABELS[ph]} +
    +
  • + ) + })} +
+ )} + + )} +
+ + {subtasks.length > 0 && ( +
+
+
+
+
+ )} +
+ ) +} diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..6dd64b1 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,2 @@ +/** Renseigné par Vite (`vite.config.js`) à partir de `JIRA_DOMAIN` pour les liens navigateur. */ +declare const __JIRA_ORIGIN__: string diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..8e41731 --- /dev/null +++ b/src/index.css @@ -0,0 +1,28 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font-sans); + background: radial-gradient(1200px 800px at 10% -10%, #1e1b4b 0%, transparent 55%), + radial-gradient(900px 600px at 100% 0%, #0c4a6e 0%, transparent 50%), + #020617; + color: #e2e8f0; +} + +@keyframes shimmer-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +.animate-shimmer { + animation: shimmer-slide 1.5s ease-in-out infinite; +} diff --git a/src/lib/assigneeMatch.ts b/src/lib/assigneeMatch.ts new file mode 100644 index 0000000..babc5f2 --- /dev/null +++ b/src/lib/assigneeMatch.ts @@ -0,0 +1,18 @@ +import type { JiraIssue } from '../types/jira' +import type { DashboardConfig } from './dashboardConfig' + +/** Correspondance utilisateur « Ma vue » : compte Atlassian ou e-mail. */ +export function assigneeMatchesMyView(issue: JiraIssue, cfg: DashboardConfig): boolean { + const a = issue.fields.assignee + if (!a) return false + const cfgId = cfg.myJiraAccountId?.trim() + const cfgEmail = cfg.myJiraEmail?.trim().toLowerCase() + const envId = import.meta.env.VITE_MY_JIRA_ACCOUNT_ID?.trim() + const envEmail = import.meta.env.VITE_MY_JIRA_EMAIL?.trim().toLowerCase() + + if (cfgId && a.accountId && a.accountId === cfgId) return true + if (envId && a.accountId && a.accountId === envId) return true + if (cfgEmail && a.emailAddress && a.emailAddress.toLowerCase() === cfgEmail) return true + if (envEmail && a.emailAddress && a.emailAddress.toLowerCase() === envEmail) return true + return false +} diff --git a/src/lib/boardGrouping.ts b/src/lib/boardGrouping.ts new file mode 100644 index 0000000..4262311 --- /dev/null +++ b/src/lib/boardGrouping.ts @@ -0,0 +1,17 @@ +import type { StoryGroup } from '../types/jira' + +/** Regroupe les stories par premier composant Jira, sinon « Autres ». */ +export function groupStoriesByComponent(groups: StoryGroup[]): Map { + const map = new Map() + for (const g of groups) { + const comps = g.story.fields.components + const label = + comps && comps.length > 0 ? comps[0]!.name : 'Autres' + if (!map.has(label)) map.set(label, []) + map.get(label)!.push(g) + } + const keys = [...map.keys()].sort((a, b) => a.localeCompare(b, 'fr')) + const sorted = new Map() + for (const k of keys) sorted.set(k, map.get(k)!) + return sorted +} diff --git a/src/lib/burnupHistory.ts b/src/lib/burnupHistory.ts new file mode 100644 index 0000000..14545cd --- /dev/null +++ b/src/lib/burnupHistory.ts @@ -0,0 +1,36 @@ +const STORAGE_KEY = 'jira-descours-burnup-v1' + +export type BurnupPoint = { + date: string + done: number + total: number +} + +function todayISO(): string { + const d = new Date() + return d.toISOString().slice(0, 10) +} + +export function loadBurnupHistory(): BurnupPoint[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as BurnupPoint[] + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +/** Enregistre le snapshot du jour (remplace le point s’il existe déjà pour cette date). */ +export function appendBurnupSnapshot(done: number, total: number): BurnupPoint[] { + const day = todayISO() + const prev = loadBurnupHistory() + const filtered = prev.filter((p) => p.date !== day) + const next = [...filtered, { date: day, done, total }].sort((a, b) => + a.date.localeCompare(b.date), + ) + const trimmed = next.slice(-45) + localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)) + return trimmed +} diff --git a/src/lib/dashboardConfig.ts b/src/lib/dashboardConfig.ts new file mode 100644 index 0000000..bef968e --- /dev/null +++ b/src/lib/dashboardConfig.ts @@ -0,0 +1,72 @@ +export type Milestone = { + id: string + title: string + /** ISO date (yyyy-mm-dd) */ + date: string + /** Clés de stories à contrôler pour le statut « retard » ; vide = toutes les stories chargées */ + linkedStoryKeys?: string[] +} + +export type DashboardConfig = { + version: 1 + milestones: Milestone[] + myJiraAccountId?: string + myJiraEmail?: string + /** Filtre « Ma vue » (sous-tâches me concernant). */ + myViewActive?: boolean +} + +const STORAGE_KEY = 'dcc-dashboard-config-v1' + +export const defaultDashboardConfig = (): DashboardConfig => ({ + version: 1, + milestones: [], +}) + +export function loadDashboardConfig(): DashboardConfig { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return defaultDashboardConfig() + const parsed = JSON.parse(raw) as Partial + if (!parsed || parsed.version !== 1) return defaultDashboardConfig() + return { + version: 1, + milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [], + myJiraAccountId: parsed.myJiraAccountId, + myJiraEmail: parsed.myJiraEmail, + myViewActive: parsed.myViewActive, + } + } catch { + return defaultDashboardConfig() + } +} + +export function saveDashboardConfig(cfg: DashboardConfig): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)) +} + +export function exportConfigJson(cfg: DashboardConfig): void { + const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `dcc-dashboard-config-${new Date().toISOString().slice(0, 10)}.json` + a.click() + URL.revokeObjectURL(url) +} + +export function mergeImportedConfig( + current: DashboardConfig, + imported: unknown, +): DashboardConfig | null { + if (!imported || typeof imported !== 'object') return null + const o = imported as Partial + if (o.version !== 1) return null + return { + version: 1, + milestones: Array.isArray(o.milestones) ? o.milestones : current.milestones, + myJiraAccountId: o.myJiraAccountId ?? current.myJiraAccountId, + myJiraEmail: o.myJiraEmail ?? current.myJiraEmail, + myViewActive: o.myViewActive ?? current.myViewActive, + } +} diff --git a/src/lib/executiveKpis.ts b/src/lib/executiveKpis.ts new file mode 100644 index 0000000..40388f6 --- /dev/null +++ b/src/lib/executiveKpis.ts @@ -0,0 +1,96 @@ +import type { JiraIssue, StoryGroup } from '../types/jira' +import { statusToPhase } from './statusPhase' + +function norm(s: string): string { + return s + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/\p{M}/gu, '') +} + +export function isBlockingStatus(statusName: string): boolean { + const n = norm(statusName) + return ( + n.includes('bloque') || + n.includes('blocked') || + n.includes('recette ko') || + n.includes('recetteko') || + n.includes('recette nok') + ) +} + +function allSubtasks(groups: StoryGroup[]): JiraIssue[] { + return groups.flatMap((g) => g.subtasks) +} + +export function maquetteRelatedSubtasks(groups: StoryGroup[]): JiraIssue[] { + return allSubtasks(groups).filter(isMaquetteRelated) +} + +export function goldenCarbonSubtasks(groups: StoryGroup[]): JiraIssue[] { + return groups.flatMap((g) => + g.subtasks.filter((st) => isGoldenCarbonRelated(st, g.story)), + ) +} + +/** Progression globale : sous-tâches terminées / sous-tâches totales. */ +export function globalProgressPercent(groups: StoryGroup[]): number { + const subs = allSubtasks(groups) + if (subs.length === 0) return 0 + const done = subs.filter((s) => statusToPhase(s.fields.status.name) === 'done').length + return Math.round((done / subs.length) * 100) +} + +/** Sous-tâches considérées comme « maquette » (libellé à ajuster selon votre vocabulaire Jira). */ +export function isMaquetteRelated(st: JiraIssue): boolean { + const t = `${st.fields.summary} ${st.key}`.toLowerCase() + return /maquette|mockup|figma|wireframe|zoning|ui\s*design/i.test(t) +} + +/** % de maquettes validées parmi les sous-tâches identifiées comme maquettes. */ +export function designHealthPercent(groups: StoryGroup[]): number { + const candidates = maquetteRelatedSubtasks(groups) + if (candidates.length === 0) return 0 + const ok = candidates.filter((s) => statusToPhase(s.fields.status.name) === 'done').length + return Math.round((ok / candidates.length) * 100) +} + +function textWithComponents(st: JiraIssue, story: JiraIssue): string { + const comps = [...(st.fields.components ?? []), ...(story.fields.components ?? [])] + .map((c) => c.name) + .join(' ') + return `${st.fields.summary} ${comps}`.toLowerCase() +} + +/** Intégration « Golden Carbon » : filtre par mot-clé ou composant. */ +function isGoldenCarbonRelated(st: JiraIssue, story: JiraIssue): boolean { + return /golden\s*carbon|goldencarbon/i.test(textWithComponents(st, story)) +} + +export function goldenCarbonHealthPercent(groups: StoryGroup[]): number { + const candidates = goldenCarbonSubtasks(groups) + if (candidates.length === 0) return 0 + const ok = candidates.filter((s) => statusToPhase(s.fields.status.name) === 'done').length + return Math.round((ok / candidates.length) * 100) +} + +export function blockingTicketsCount(groups: StoryGroup[]): number { + const issues: JiraIssue[] = [ + ...groups.map((g) => g.story), + ...allSubtasks(groups), + ] + return issues.filter((i) => isBlockingStatus(i.fields.status.name)).length +} + +export function blockingIssuesInGroup(group: StoryGroup): JiraIssue[] { + return [group.story, ...group.subtasks].filter((i) => + isBlockingStatus(i.fields.status.name), + ) +} + +export function blockingSummaryForTooltip(group: StoryGroup): string { + const list = blockingIssuesInGroup(group) + if (list.length === 0) return 'Aucun ticket bloquant sur cette story.' + return list.map((i) => `${i.key} — ${i.fields.status.name}: ${i.fields.summary}`).join('\n') +} diff --git a/src/lib/groupIssues.ts b/src/lib/groupIssues.ts new file mode 100644 index 0000000..59e2173 --- /dev/null +++ b/src/lib/groupIssues.ts @@ -0,0 +1,144 @@ +import type { JiraEmbeddedChildIssue, JiraIssue, StoryGroup } from '../types/jira' +import { MIGRATION_EPIC_KEY } from '../api/jiraClient' +import { getStoryPoints } from './jiraFieldExtractors' +import { isJiraSubtask } from './subtaskUtils' +import { buildIssueIdToKeyMap, resolveParentIssueKey } from './parentResolve' + +/** Certaines réponses ne listent les sous-tâches que sous `fields.subtasks` du parent. */ +function embeddedChildToIssue(parentKey: string, emb: JiraEmbeddedChildIssue): JiraIssue { + const f = emb.fields ?? {} + const status = f.status as JiraIssue['fields']['status'] | undefined + const issuetype = f.issuetype as JiraIssue['fields']['issuetype'] | undefined + const skip = new Set([ + 'summary', + 'status', + 'issuetype', + 'parent', + 'components', + 'priority', + 'assignee', + 'timetracking', + 'subtasks', + ]) + const extras: Record = {} + for (const [k, v] of Object.entries(f)) { + if (!skip.has(k)) extras[k] = v + } + return { + id: emb.id, + key: emb.key, + fields: { + summary: typeof f.summary === 'string' ? f.summary : '—', + status: status && typeof status.name === 'string' ? status : { name: '—' }, + issuetype: + issuetype && typeof issuetype.name === 'string' + ? issuetype + : { name: 'Sous-tâche', subtask: true }, + parent: { key: parentKey }, + components: f.components as JiraIssue['fields']['components'], + priority: (f.priority as JiraIssue['fields']['priority']) ?? null, + assignee: (f.assignee as JiraIssue['fields']['assignee']) ?? null, + timetracking: f.timetracking as JiraIssue['fields']['timetracking'], + ...extras, + } as JiraIssue['fields'], + } +} + +function mergeEmbeddedSubtasksFromParents(issues: JiraIssue[]): JiraIssue[] { + const byKey = new Map(issues.map((i) => [i.key, i])) + const merged = [...issues] + for (const parent of issues) { + const subs = parent.fields.subtasks + if (!Array.isArray(subs) || subs.length === 0) continue + for (const emb of subs) { + if (!emb?.key || byKey.has(emb.key)) continue + const row = embeddedChildToIssue(parent.key, emb) + byKey.set(emb.key, row) + merged.push(row) + } + } + return merged +} + +/** En cas de doublon de clé, garde l’entrée la plus riche (SP + nombre de champs) pour éviter un double comptage des SP. */ +function dedupeIssuesByKey(list: JiraIssue[]): JiraIssue[] { + const map = new Map() + const score = (x: JiraIssue) => { + const n = Object.keys(x.fields as object).length + return getStoryPoints(x) * 10 + n + } + for (const i of list) { + const prev = map.get(i.key) + if (!prev || score(i) >= score(prev)) map.set(i.key, i) + } + return [...map.values()] +} + +function placeholderStory(key: string, parent?: JiraIssue['fields']['parent']): JiraIssue { + return { + key, + fields: { + summary: parent?.fields?.summary ?? `Story ${key}`, + status: { name: '—' }, + issuetype: { name: 'Story', subtask: false }, + parent: undefined, + }, + } +} + +/** + * Rattache les tickets « enfants » au parent présent dans le lot : + * - **Sous-tâche Jira** (`Sub-task` / `Sous-tâche`…) → parent (même hors lot : placeholder). + * - **Tâche, Bug, autre** dont le `parent` est une **autre issue du même résultat** et **≠ épopée** → rangée sous ce parent (ex. tâche liée à un Récit). + * Les tickets dont le seul parent est l’épopée (`MIGRATION_EPIC_KEY`) restent des cartes racine (un groupe = une ligne métier). + */ +export function groupSubtasksUnderStories(issues: JiraIssue[]): StoryGroup[] { + issues = dedupeIssuesByKey(mergeEmbeddedSubtasksFromParents(issues)) + const idToKey = buildIssueIdToKeyMap(issues) + const byKey = new Map(issues.map((i) => [i.key, i])) + const epicKey = MIGRATION_EPIC_KEY + + const childrenByParent = new Map() + const nestedIssueKeys = new Set() + + for (const issue of issues) { + const parentKey = resolveParentIssueKey(issue, idToKey) + if (!parentKey) continue + + const parentInBatch = byKey.has(parentKey) + const parentIsEpic = parentKey === epicKey + const sub = isJiraSubtask(issue) + + const nestUnderParentInBatch = parentInBatch && !parentIsEpic + /** Sous-tâche Jira dont le parent n’est pas dans ce lot (ex. story hors JQL) : groupe placeholder. */ + const nestSubtaskPlaceholder = sub && !parentInBatch + + if (!nestUnderParentInBatch && !nestSubtaskPlaceholder) continue + + nestedIssueKeys.add(issue.key) + if (!childrenByParent.has(parentKey)) childrenByParent.set(parentKey, []) + childrenByParent.get(parentKey)!.push(issue) + } + + const roots = issues.filter((i) => !nestedIssueKeys.has(i.key)) + + const groups = new Map() + + for (const root of roots) { + groups.set(root.key, { + story: root, + subtasks: dedupeIssuesByKey([...(childrenByParent.get(root.key) ?? [])]), + }) + } + + for (const [parentKey, list] of childrenByParent) { + if (!groups.has(parentKey)) { + groups.set(parentKey, { + story: placeholderStory(parentKey, list[0]?.fields.parent), + subtasks: dedupeIssuesByKey([...list]), + }) + } + } + + for (const g of groups.values()) { + g.subtasks = dedupeIssu \ No newline at end of file diff --git a/src/lib/jiraFieldExtractors.ts b/src/lib/jiraFieldExtractors.ts new file mode 100644 index 0000000..2d65f78 --- /dev/null +++ b/src/lib/jiraFieldExtractors.ts @@ -0,0 +1,64 @@ +import type { JiraIssue } from '../types/jira' +import { buildIssueIdToKeyMap, resolveParentIssueKey } from './parentResolve' + +/** ID du champ Story Points (souvent `customfield_10028` — à vérifier dans Jira). */ +export function getStoryPointsFieldId(): string { + return import.meta.env.VITE_JIRA_STORY_POINTS_FIELD?.trim() || 'customfield_10028' +} + +function coerceNumber(v: unknown): number | null { + if (v == null || v === '') return null + if (typeof v === 'number' && Number.isFinite(v)) return v + if (typeof v === 'string') { + const n = Number(v) + return Number.isFinite(n) ? n : null + } + if (typeof v === 'object' && v !== null && 'value' in v) { + return coerceNumber((v as { value: unknown }).value) + } + return null +} + +/** Story Points bruts depuis le champ custom Jira (nombre, chaîne, ou `{ value }`). */ +export function getStoryPoints(issue: JiraIssue): number { + const id = getStoryPointsFieldId() + const raw = (issue.fields as Record)[id] + const n = coerceNumber(raw) + return n ?? 0 +} + +/** + * Reste « en unités » comme dans ton export : secondes / 27 000 + * (27 000 s ≈ 7,5 h — une journée-type Atlassian). + */ +export function getRemainingEstimateUnits(issue: JiraIssue): number { + const sec = issue.fields.timetracking?.remainingEstimateSeconds + if (sec == null || !Number.isFinite(sec)) return 0 + const v = sec / 27000 + return Number.isFinite(v) ? v : 0 +} + +/** Objet plat proche de ton ancien `issues.map` + clé parent résolue pour le debug. */ +export function toTicketRow(issue: JiraIssue, allIssues: JiraIssue[]): { + key: string + summary: string + sp: number + remaining: number + status: string + issuetype: string + assignee: string + parentKey?: string +} { + const idToKey = buildIssueIdToKeyMap(allIssues) + const parentKey = resolveParentIssueKey(issue, idToKey) + return { + key: issue.key, + summary: issue.fields.summary, + sp: getStoryPoints(issue), + remaining: getRemainingEstimateUnits(issue), + status: issue.fields.status?.name ?? 'Inconnu', + issuetype: issue.fields.issuetype?.name ?? 'Inconnu', + assignee: issue.fields.assignee?.displayName ?? 'Inconnu', + ...(parentKey ? { parentKey } : {}), + } +} diff --git a/src/lib/jiraLinks.ts b/src/lib/jiraLinks.ts new file mode 100644 index 0000000..ba3db91 --- /dev/null +++ b/src/lib/jiraLinks.ts @@ -0,0 +1,7 @@ +/** URL « Ouvrir dans Jira » : `VITE_JIRA_BROWSE_BASE_URL` en priorité, sinon `JIRA_DOMAIN` via Vite (`__JIRA_ORIGIN__`). */ +export function jiraBrowseIssueUrl(issueKey: string): string | null { + const explicit = import.meta.env.VITE_JIRA_BROWSE_BASE_URL?.trim().replace(/\/$/, '') + const fromEnv = explicit || __JIRA_ORIGIN__.trim().replace(/\/$/, '') + if (!fromEnv) return null + return `${fromEnv}/browse/${encodeURIComponent(issueKey)}` +} diff --git a/src/lib/laneDetection.ts b/src/lib/laneDetection.ts new file mode 100644 index 0000000..45bc2fa --- /dev/null +++ b/src/lib/laneDetection.ts @@ -0,0 +1,53 @@ +import type { JiraIssue, JiraStatus } from '../types/jira' + +export type WorkLane = 'analyse' | 'design' | 'integration' + +/** Regroupe les sous-tâches par « piste » Analyse / Design / Intégration (mots-clés + repli sur le statut). */ +export function detectWorkLane(subtask: JiraIssue): WorkLane { + const s = subtask.fields.summary.toLowerCase() + if ( + /\banalyse\b|spécification|spec\b|cadrage|refinement|backlog\s*analyse/i.test(s) + ) { + return 'analyse' + } + if (/\bdesign\b|maquette|figma|wireframe|zoning|ui\b|ux\b/i.test(s)) { + return 'design' + } + if ( + /\bintégration\b|\bintegration\b|recette|développement|developpement|dev\b|golden|carbon|déploiement|deploy/i.test(s) + ) { + return 'integration' + } + const st = subtask.fields.status.name.toLowerCase() + if (/analyse|spec|backlog|nouveau|à faire|todo|open/i.test(st)) return 'analyse' + if (/design|maquette|mockup/i.test(st)) return 'design' + return 'integration' +} + +export function statusCategoryKey(status: JiraStatus): 'new' | 'indeterminate' | 'done' | 'unknown' { + const k = status.statusCategory?.key + if (k === 'new') return 'new' + if (k === 'done') return 'done' + if (k === 'indeterminate') return 'indeterminate' + return 'unknown' +} + +/** Couleur logique pour une piste : vert = tout terminé, bleu = en cours, gris = à faire / inconnu. */ +export function laneAggregateState( + subtasks: JiraIssue[], + lane: WorkLane, +): 'empty' | 'grey' | 'blue' | 'green' { + const inLane = subtasks.filter((st) => detectWorkLane(st) === lane) + if (inLane.length === 0) return 'empty' + const cats = inLane.map((st) => statusCategoryKey(st.fields.status)) + if (cats.every((c) => c === 'done')) return 'green' + if (cats.some((c) => c === 'indeterminate')) return 'blue' + if (cats.some((c) => c === 'done') && cats.some((c) => c !== 'done')) return 'blue' + const names = inLane.map((st) => st.fields.status.name.toLowerCase()).join(' ') + if ( + /en cours|in progress|review|recette|test|qa|intégration|integration|development/i.test(names) + ) { + return 'blue' + } + return 'grey' +} diff --git a/src/lib/milestoneStatus.ts b/src/lib/milestoneStatus.ts new file mode 100644 index 0000000..c26e2c5 --- /dev/null +++ b/src/lib/milestoneStatus.ts @@ -0,0 +1,32 @@ +import type { StoryGroup } from '../types/jira' +import type { Milestone } from './dashboardConfig' +import { storyProgressPercent } from './storyMetrics' + +/** Pour les jalons : story sans sous-tâche = considérée comme « livrée » côté sous-tâches. */ +function storyCompletionForMilestone(g: StoryGroup): number { + if (g.subtasks.length === 0) return 100 + return storyProgressPercent(g.subtasks) +} + +function endOfDay(isoDate: string): Date { + const d = new Date(isoDate + 'T12:00:00') + d.setHours(23, 59, 59, 999) + return d +} + +/** Jalon en retard : date dépassée et au moins une story concernée n’est pas à 100 %. */ +export function isMilestoneLate(m: Milestone, groups: StoryGroup[]): boolean { + if (groups.length === 0) return false + const deadline = endOfDay(m.date) + if (new Date() <= deadline) return false + const keys = + m.linkedStoryKeys && m.linkedStoryKeys.length > 0 + ? m.linkedStoryKeys + : groups.map((g) => g.story.key) + for (const key of keys) { + const g = groups.find((x) => x.story.key === key) + if (!g) continue + if (storyCompletionForMilestone(g) < 100) return true + } + return false +} diff --git a/src/lib/parentResolve.ts b/src/lib/parentResolve.ts new file mode 100644 index 0000000..fd7f54e --- /dev/null +++ b/src/lib/parentResolve.ts @@ -0,0 +1,39 @@ +import type { JiraIssue } from '../types/jira' + +/** Construit la table id numérique Jira → clé (DCC-xxx), indispensable quand `parent` n’a pas `key`. */ +export function buildIssueIdToKeyMap(issues: JiraIssue[]): Map { + const map = new Map() + for (const issue of issues) { + if (issue.id) map.set(String(issue.id), issue.key) + } + return map +} + +type ParentField = NonNullable + +function parentKeyFromSelf(self: string): string | undefined { + const m = /\/rest\/api\/(?:\d+|latest)\/issue\/([^/?]+)/.exec(self) + if (m?.[1]) return m[1] + const m2 = /\/browse\/([^/?]+)/.exec(self) + if (m2?.[1]) return m2[1] + return undefined +} + +/** Résout la clé du parent : `parent.key`, sinon `id` → clé, sinon `parent.self`. */ +export function resolveParentIssueKey( + issue: JiraIssue, + idToKey: Map, +): string | undefined { + const p = issue.fields.parent as ParentField | undefined + if (!p) return undefined + if (typeof p === 'object' && p !== null && 'key' in p && p.key) return p.key + const rawId = (p as { id?: string | number }).id + if (rawId !== undefined && rawId !== null) { + const idStr = String(rawId) + const fromMap = idToKey.get(idStr) + if (fromMap) return fromMap + } + const self = (p as { self?: string }).self + if (typeof self === 'string' && self.length > 0) return parentKeyFromSelf(self) + return undefined +} diff --git a/src/lib/priorityLabel.ts b/src/lib/priorityLabel.ts new file mode 100644 index 0000000..a3b3e13 --- /dev/null +++ b/src/lib/priorityLabel.ts @@ -0,0 +1,25 @@ +import type { JiraIssue } from '../types/jira' + +export type PriorityBand = 'Haute' | 'Moyenne' | 'Basse' + +export function priorityBand(issue: JiraIssue): PriorityBand | null { + const raw = issue.fields.priority?.name?.trim() + if (!raw) return null + const n = raw.toLowerCase() + if (/highest|critical|blocker|haute|maximale|p1/i.test(n)) return 'Haute' + if (/high|élev|eleve|major|p2/i.test(n)) return 'Haute' + if (/medium|moyen|normale|p3/i.test(n)) return 'Moyenne' + if (/low|lowest|mineur|faible|p4|p5/i.test(n)) return 'Basse' + return 'Moyenne' +} + +export function priorityBadgeClass(band: PriorityBand): string { + switch (band) { + case 'Haute': + return 'bg-rose-500/20 text-rose-100 ring-rose-400/50 shadow-[0_0_12px_rgba(244,63,94,0.35)]' + case 'Moyenne': + return 'bg-amber-500/15 text-amber-100 ring-amber-400/40 shadow-[0_0_10px_rgba(251,191,36,0.2)]' + case 'Basse': + return 'bg-slate-500/20 text-slate-200 ring-slate-400/35' + } +} diff --git a/src/lib/statusPhase.ts b/src/lib/statusPhase.ts new file mode 100644 index 0000000..2b2444d --- /dev/null +++ b/src/lib/statusPhase.ts @@ -0,0 +1,96 @@ +import type { PhaseId } from '../types/jira' + +/** + * Ajustez ce mapping pour refléter exactement vos 14 statuts Jira → 4 phases. + * Les clés sont comparées en insensible à la casse (trim). + */ +const STATUS_TO_PHASE: Record = { + // Analyse + backlog: 'analyse', + nouveau: 'analyse', + new: 'analyse', + 'à faire': 'analyse', + 'a faire': 'analyse', + 'to do': 'analyse', + todo: 'analyse', + open: 'analyse', + sélectionné: 'analyse', + selectionne: 'analyse', + 'en attente': 'analyse', + 'à analyser': 'analyse', + 'a analyser': 'analyse', + analyse: 'analyse', + refinement: 'analyse', + + // Design + spécification: 'design', + specification: 'design', + spec: 'design', + design: 'design', + maquette: 'design', + 'design review': 'design', + 'en design': 'design', + + // Intégration + 'prêt pour développement': 'integration', + 'pret pour developpement': 'integration', + 'ready for development': 'integration', + 'ready for dev': 'integration', + 'en cours': 'integration', + 'in progress': 'integration', + développement: 'integration', + developpement: 'integration', + dev: 'integration', + 'code review': 'integration', + review: 'integration', + 'en test': 'integration', + test: 'integration', + qa: 'integration', + recette: 'integration', + 'en recette': 'integration', + staging: 'integration', + bloqué: 'integration', + bloque: 'integration', + blocked: 'integration', + 'en intégration': 'integration', + 'en integration': 'integration', + + // Terminé + terminé: 'done', + termine: 'done', + done: 'done', + closed: 'done', + resolved: 'done', + livré: 'done', + livre: 'done', + déployé: 'done', + deploye: 'done', + annulé: 'done', + annule: 'done', + cancelled: 'done', + canceled: 'done', + wontfix: 'done', + "won't fix": 'done', +} + +function normalizeStatus(name: string): string { + return name + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/\p{M}/gu, '') +} + +export function statusToPhase(statusName: string): PhaseId { + const key = normalizeStatus(statusName) + return STATUS_TO_PHASE[key] ?? 'analyse' +} + +export const PHASE_LABELS: Record = { + analyse: 'Analyse', + design: 'Design', + integration: 'Intégration', + done: 'Terminé', +} + +export const PHASE_ORDER: PhaseId[] = ['analyse', 'design', 'integration', 'done'] diff --git a/src/lib/storyMetrics.ts b/src/lib/storyMetrics.ts new file mode 100644 index 0000000..0818578 --- /dev/null +++ b/src/lib/storyMetrics.ts @@ -0,0 +1,41 @@ +import type { JiraIssue, PhaseId } from '../types/jira' +import { PHASE_ORDER, statusToPhase } from './statusPhase' + +function phaseRank(p: PhaseId): number { + const i = PHASE_ORDER.indexOf(p) + return i >= 0 ? i : 0 +} + +const MAX_PHASE_RANK = PHASE_ORDER.length - 1 + +/** + * % d’avancement moyen des sous-tâches sur le pipeline Analyse → Design → Intégration → Terminé + * (0 % = tout en analyse, 100 % = tout terminé). + */ +export function storyProgressPercent(subtasks: JiraIssue[]): number { + if (subtasks.length === 0) return 0 + const sum = subtasks.reduce( + (acc, st) => acc + phaseRank(statusToPhase(st.fields.status.name)), + 0, + ) + return Math.round((sum / (subtasks.length * MAX_PHASE_RANK)) * 100) +} + +/** + * Étape A/D/I « passée » : toutes les sous-tâches sont **strictement** au-delà de cette phase + * (évite les barres vertes alors que tout est encore en analyse / ouvert). + */ +export function isStepComplete(subtasks: JiraIssue[], stepPhase: PhaseId): boolean { + if (subtasks.length === 0) return false + const stepIdx = phaseRank(stepPhase) + return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx) +} + +export function stepperStates(subtasks: JiraIssue[]): Record { + return { + analyse: isStepComplete(subtasks, 'analyse'), + design: isStepComplete(subtasks, 'design'), + integration: isStepComplete(subtasks, 'integration'), + done: subtasks.length > 0 && subtasks.every((st) => statusToPhase(st.fields.status.name) === 'done'), + } +} diff --git a/src/lib/subtaskUtils.ts b/src/lib/subtaskUtils.ts new file mode 100644 index 0000000..6abf097 --- /dev/null +++ b/src/lib/subtaskUtils.ts @@ -0,0 +1,11 @@ +import type { JiraIssue } from '../types/jira' + +/** L’API Jira n’expose pas toujours `issuetype.subtask` ; on complète par le nom du type. */ +export function isJiraSubtask(issue: JiraIssue): boolean { + const t = issue.fields.issuetype + if (t.subtask === true) return true + const name = (t.name ?? '').toLowerCase() + return /sub-task|subtask|sous-tâche|sous-tache|sous tâche|sub task|technical task|tech task/i.test( + name, + ) +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..3822a94 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,7 @@ +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +// Sans StrictMode : en dev, React 18+ remontait l’effet deux fois et annulait le 1er fetch +// Jira (requête « canceled » dans Network alors que le 2e appel réussit). +createRoot(document.getElementById('root')!).render() diff --git a/src/types/jira.ts b/src/types/jira.ts new file mode 100644 index 0000000..09d30d3 --- /dev/null +++ b/src/types/jira.ts @@ -0,0 +1,84 @@ +export type PhaseId = 'analyse' | 'design' | 'integration' | 'done' + +export type JiraIssueType = { + name: string + subtask?: boolean +} + +export type JiraStatus = { + name: string + statusCategory?: { + key: string + } +} + +export type JiraComponent = { + name: string +} + +export type JiraPriority = { + name: string +} + +export type JiraUser = { + accountId?: string + emailAddress?: string + displayName?: string + avatarUrls?: Record +} + +export type JiraParentRef = { + /** Parfois absent dans la recherche JQL « enhanced » : utiliser `id` + table id→clé. */ + key?: string + id?: string + self?: string + fields?: { summary?: string } +} + +/** Entrées du champ `subtasks` renvoyées sur le parent (souvent compactes). */ +export type JiraEmbeddedChildIssue = { + id?: string + key: string + self?: string + fields?: Record +} + +export type JiraTimeTracking = { + remainingEstimateSeconds?: number + originalEstimateSeconds?: number +} + +export type JiraIssueFields = { + summary: string + status: JiraStatus + issuetype: JiraIssueType + /** Sous-tâches : parent de la story (clé et/ou id selon l’API). */ + parent?: JiraParentRef + components?: JiraComponent[] + priority?: JiraPriority | null + assignee?: JiraUser | null + timetracking?: JiraTimeTracking + /** Si demandé dans `fields` : sous-tâches sous le parent (à fusionner dans le lot si absentes en racine). */ + subtasks?: JiraEmbeddedChildIssue[] +} + +export type JiraIssue = { + /** Identifiant numérique Jira (requis pour résoudre parent sans `key`). */ + id?: string + key: string + fields: JiraIssueFields +} + +/** Réponse de POST/GET `/rest/api/3/search/jql` (recherche JQL « enhanced »). */ +export type JiraSearchJqlResponse = { + issues: JiraIssue[] + isLast?: boolean + nextPageToken?: string + /** Présent sur certaines versions / extensions du schéma */ + total?: number +} + +export type StoryGroup = { + story: JiraIssue + subtasks: JiraIssue[] +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..59569f2 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,21 @@ +/// + +interface ImportMetaEnv { + readonly VITE_JIRA_BASE_URL?: string + /** URL du site Jira pour les liens `/browse/KEY` (prod ou si `JIRA_DOMAIN` absent au build). */ + readonly VITE_JIRA_BROWSE_BASE_URL?: string + /** Optionnel : accountId Atlassian pour le filtre « Ma vue » (prioritaire sur l’e-mail). */ + readonly VITE_MY_JIRA_ACCOUNT_ID?: string + /** Optionnel : si l’API expose l’e-mail de l’assigné (RGPD). */ + readonly VITE_MY_JIRA_EMAIL?: string + /** Champ Jira des Story Points (ex. customfield_10028). */ + readonly VITE_JIRA_STORY_POINTS_FIELD?: string + /** Clé de l’épopée : insérée dans le JQL (`parentEpic`, `key NOT IN`) et pour le groupement UI. */ + readonly VITE_JIRA_EPIC_KEY?: string + /** Taille de page `/search/jql` (1–100, défaut 100). Sans maxResults, Jira renvoie souvent 20 issues. */ + readonly VITE_JIRA_PAGE_SIZE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..513190b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client"], + "skipLibCheck": true, + "jsx": "react-jsx", + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..e9e083a --- /dev/null +++ b/vite.config.js @@ -0,0 +1,63 @@ +import { defineConfig, loadEnv } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +/** @param {string | undefined} domain */ +function jiraOrigin(domain) { + if (!domain?.trim()) return null + const d = domain.trim().replace(/\/$/, '') + if (d.startsWith('http://') || d.startsWith('https://')) return d + if (d.includes('atlassian.net')) return `https://${d.replace(/^https?:\/\//, '')}` + return `https://${d}.atlassian.net` +} + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const origin = jiraOrigin(env.JIRA_DOMAIN) + + return { + define: { + __JIRA_ORIGIN__: JSON.stringify(origin ?? ''), + }, + plugins: [react(), tailwindcss()], + server: { + proxy: origin + ? { + '/jira-api': { + target: origin, + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/jira-api/, ''), + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + // Évite qu’un Authorization invalide (extension, cache) écrase le Basic Jira + proxyReq.removeHeader('authorization') + proxyReq.removeHeader('cookie') + + // Jira Cloud : contrôle XSRF — il faut Origin OU X-Requested-With. + // Le navigateur → Vite est souvent same-origin sans en-tête Origin ; sans cela, 403. + proxyReq.setHeader('X-Requested-With', 'XMLHttpRequest') + proxyReq.setHeader('Origin', origin) + proxyReq.setHeader('Referer', `${origin}/`) + + const email = env.JIRA_EMAIL?.trim() + const apiKey = env.JIRA_API_KEY?.trim() + if (email && apiKey) { + const basic = Buffer.from(`${email}:${apiKey}`, 'utf8').toString( + 'base64', + ) + proxyReq.setHeader('Authorization', `Basic ${basic}`) + } else { + console.warn( + '[vite] Proxy Jira : JIRA_EMAIL ou JIRA_API_KEY manquant dans .env — la requête partira sans authentification.', + ) + } + proxyReq.setHeader('Accept', 'application/json') + }) + }, + }, + } + : {}, + }, + } +})