This commit is contained in:
Bastien COIGNOUX
2026-04-24 11:50:39 +02:00
parent 745c8ae133
commit ca4c64bbb0
28 changed files with 2269 additions and 116 deletions

222
package-lock.json generated
View File

@ -9,6 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"axios": "^1.15.2", "axios": "^1.15.2",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"recharts": "^3.8.1" "recharts": "^3.8.1"
@ -23,6 +25,15 @@
"vite": "^8.0.10" "vite": "^8.0.10"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@ -794,6 +805,19 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -814,6 +838,13 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -870,6 +901,15 @@
"proxy-from-env": "^2.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -883,6 +923,26 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -904,6 +964,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -1057,6 +1138,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
"integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1146,6 +1237,17 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -1164,6 +1266,12 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.16.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@ -1319,6 +1427,19 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/immer": { "node_modules/immer": {
"version": "10.2.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
@ -1338,6 +1459,12 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@ -1348,6 +1475,23 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@ -1668,6 +1812,19 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -1726,6 +1883,16 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.5", "version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
@ -1822,12 +1989,29 @@
"redux": "^5.0.0" "redux": "^5.0.0"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/reselect": { "node_modules/reselect": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.17", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
@ -1878,6 +2062,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.2.4", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
@ -1899,6 +2103,15 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@ -1953,6 +2166,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/victory-vendor": { "node_modules/victory-vendor": {
"version": "37.3.6", "version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",

View File

@ -19,6 +19,8 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.15.2", "axios": "^1.15.2",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"recharts": "^3.8.1" "recharts": "^3.8.1"

View File

@ -1,9 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { StoryGroup } from './types/jira' import type { StoryGroup } from './types/jira'
import { fetchAllIssuesByJql, MIGRATION_EPIC_KEY, MIGRATION_JQL, jiraClient } from './api/jiraClient' import { fetchAllIssuesByJql, MIGRATION_EPIC_KEY, MIGRATION_JQL, jiraClient } from './api/jiraClient'
import { groupSubtasksUnderStories } from './lib/groupIssues' import { groupSubtasksUnderStories } from './lib/groupIssues'
import { statusToPhase } from './lib/statusPhase'
import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory' import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory'
import { isIssueDone } from './lib/statusBuckets'
import { computeLandingEstimate } from './lib/executiveLanding'
import {
computeVerdict,
criticalMilestoneImpactMessages,
deadlineHealthScore,
latestMilestoneDateIso,
resourceHealthScore,
scopeCompletionPercent,
} from './lib/executiveHealth'
import { countSubtasksByPhase } from './lib/phaseAggregate'
import { import {
loadDashboardConfig, loadDashboardConfig,
saveDashboardConfig, saveDashboardConfig,
@ -18,10 +28,18 @@ import { BoardView } from './components/BoardView'
import { DashboardSkeleton } from './components/DashboardSkeleton' import { DashboardSkeleton } from './components/DashboardSkeleton'
import { MilestonesTimeline } from './components/MilestonesTimeline' import { MilestonesTimeline } from './components/MilestonesTimeline'
import { DashboardSettingsModal } from './components/DashboardSettingsModal' import { DashboardSettingsModal } from './components/DashboardSettingsModal'
import { ManagementOverview } from './components/ManagementOverview'
import { PhaseDistributionChart } from './components/PhaseDistributionChart'
import { ExportDashboardButton } from './components/ExportDashboardButton'
import { StatusBucketProvider } from './context/StatusBucketContext'
import { LaneLabelsProvider } from './context/LaneLabelsContext'
import { PipelineOverview } from './components/PipelineOverview'
import { LaneTicketsListView } from './components/LaneTicketsListView'
type ViewMode = 'list' | 'board' type ViewMode = 'list' | 'board'
export default function App() { export default function App() {
const dashboardRef = useRef<HTMLDivElement>(null)
const [groups, setGroups] = useState<StoryGroup[]>([]) const [groups, setGroups] = useState<StoryGroup[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -31,6 +49,9 @@ export default function App() {
const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig()) const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig())
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const statusBucketsRef = useRef(dashboardCfg.statusBuckets)
statusBucketsRef.current = dashboardCfg.statusBuckets
const myViewActive = Boolean(dashboardCfg.myViewActive) const myViewActive = Boolean(dashboardCfg.myViewActive)
const displayGroups = useMemo(() => { const displayGroups = useMemo(() => {
@ -40,6 +61,67 @@ export default function App() {
) )
}, [groups, myViewActive, dashboardCfg]) }, [groups, myViewActive, dashboardCfg])
const landing = useMemo(
() =>
computeLandingEstimate(
groups,
burnupData,
dashboardCfg.teamCapacity,
dashboardCfg.baselineCapacity,
dashboardCfg.statusBuckets,
),
[
groups,
burnupData,
dashboardCfg.teamCapacity,
dashboardCfg.baselineCapacity,
dashboardCfg.statusBuckets,
],
)
const finalMilestoneIso = useMemo(
() => latestMilestoneDateIso(dashboardCfg.milestones),
[dashboardCfg.milestones],
)
const health = useMemo(() => {
const deadlineScore = deadlineHealthScore(landing.estimatedLanding, finalMilestoneIso)
const scopeScore = scopeCompletionPercent(groups, dashboardCfg.statusBuckets)
const resourceScore = resourceHealthScore(
landing.remainingSubtasks,
dashboardCfg.teamCapacity,
dashboardCfg.wipSlotsPerDev,
)
const verdict = computeVerdict(deadlineScore, scopeScore, resourceScore)
return { deadlineScore, scopeScore, resourceScore, verdict }
}, [
landing.estimatedLanding,
landing.remainingSubtasks,
finalMilestoneIso,
groups,
dashboardCfg.teamCapacity,
dashboardCfg.wipSlotsPerDev,
dashboardCfg.statusBuckets,
])
const impactMessages = useMemo(
() =>
criticalMilestoneImpactMessages(
dashboardCfg.milestones,
groups,
landing.effectiveVelocityPerDay,
dashboardCfg.statusBuckets,
),
[
dashboardCfg.milestones,
dashboardCfg.statusBuckets,
groups,
landing.effectiveVelocityPerDay,
],
)
const phaseCounts = useMemo(() => countSubtasksByPhase(groups), [groups])
const toggleMyView = () => { const toggleMyView = () => {
const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive } const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive }
setDashboardCfg(next) setDashboardCfg(next)
@ -61,10 +143,9 @@ export default function App() {
setUpdatedAt(new Date()) setUpdatedAt(new Date())
const totalSubs = grouped.reduce((acc, g) => acc + g.subtasks.length, 0) const totalSubs = grouped.reduce((acc, g) => acc + g.subtasks.length, 0)
const cfg = statusBucketsRef.current
const doneSubs = grouped.reduce( const doneSubs = grouped.reduce(
(acc, g) => (acc, g) => acc + g.subtasks.filter((s) => isIssueDone(s, cfg)).length,
acc +
g.subtasks.filter((s) => statusToPhase(s.fields.status.name) === 'done').length,
0, 0,
) )
setBurnupData(appendBurnupSnapshot(doneSubs, totalSubs)) setBurnupData(appendBurnupSnapshot(doneSubs, totalSubs))
@ -99,6 +180,8 @@ export default function App() {
const baseOk = import.meta.env.DEV ? true : Boolean(jiraClient.defaults.baseURL) const baseOk = import.meta.env.DEV ? true : Boolean(jiraClient.defaults.baseURL)
return ( return (
<StatusBucketProvider value={dashboardCfg.statusBuckets}>
<LaneLabelsProvider value={dashboardCfg.laneLabels}>
<div className="min-h-screen px-4 pb-20 pt-8 sm:px-6 md:px-8 md:pt-10"> <div className="min-h-screen px-4 pb-20 pt-8 sm:px-6 md:px-8 md:pt-10">
<header className="mx-auto mb-6 flex max-w-7xl flex-col gap-4 lg:mb-8 lg:flex-row lg:items-end lg:justify-between"> <header className="mx-auto mb-6 flex max-w-7xl flex-col gap-4 lg:mb-8 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
@ -162,6 +245,9 @@ export default function App() {
</button> </button>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{!loading && groups.length > 0 && (
<ExportDashboardButton targetRef={dashboardRef} />
)}
{updatedAt && !loading && ( {updatedAt && !loading && (
<span className="text-xs text-slate-500"> <span className="text-xs text-slate-500">
Mis à jour {updatedAt.toLocaleTimeString('fr-FR')} Mis à jour {updatedAt.toLocaleTimeString('fr-FR')}
@ -206,20 +292,37 @@ export default function App() {
)} )}
{!loading && !error && groups.length > 0 && ( {!loading && !error && groups.length > 0 && (
<> <div ref={dashboardRef} className="space-y-10">
<MilestonesTimeline <MilestonesTimeline
milestones={dashboardCfg.milestones} milestones={dashboardCfg.milestones}
groups={groups} groups={groups}
onOpenSettings={() => setSettingsOpen(true)} onOpenSettings={() => setSettingsOpen(true)}
impactMessages={impactMessages}
/>
<ManagementOverview
deadlineScore={health.deadlineScore}
scopeScore={health.scopeScore}
resourceScore={health.resourceScore}
verdict={health.verdict}
landing={landing}
finalMilestoneDate={finalMilestoneIso}
/> />
<ExecutiveSummary groups={displayGroups} /> <ExecutiveSummary groups={displayGroups} />
<section className="mb-10"> <PipelineOverview groups={displayGroups} />
<LaneTicketsListView groups={displayGroups} />
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400"> <h2 className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Burnup Reporting
</h2> </h2>
<BurnupChart data={burnupData} /> <div className="grid gap-6 lg:grid-cols-2">
<BurnupChart data={burnupData} />
<PhaseDistributionChart counts={phaseCounts} />
</div>
</section> </section>
<section> <section>
@ -248,7 +351,7 @@ export default function App() {
<BoardView groups={displayGroups} /> <BoardView groups={displayGroups} />
)} )}
</section> </section>
</> </div>
)} )}
</main> </main>
@ -259,5 +362,7 @@ export default function App() {
onSave={saveSettings} onSave={saveSettings}
/> />
</div> </div>
</LaneLabelsProvider>
</StatusBucketProvider>
) )
} }

View File

@ -84,6 +84,7 @@ export async function fetchAllIssuesByJql(
'priority', 'priority',
'assignee', 'assignee',
'timetracking', 'timetracking',
'labels',
storyPointsField, storyPointsField,
] as const ] as const

View File

@ -1,5 +1,6 @@
import { import {
CartesianGrid, CartesianGrid,
Legend,
Line, Line,
LineChart, LineChart,
ResponsiveContainer, ResponsiveContainer,
@ -13,6 +14,16 @@ type Props = {
data: BurnupPoint[] data: BurnupPoint[]
} }
function withIdealLine(points: BurnupPoint[]): (BurnupPoint & { ideal: number })[] {
if (points.length === 0) return []
const target = points[points.length - 1]!.total
const n = points.length
return points.map((p, i) => ({
...p,
ideal: n <= 1 ? target : (i / (n - 1)) * target,
}))
}
export function BurnupChart({ data }: Props) { export function BurnupChart({ data }: Props) {
if (data.length === 0) { if (data.length === 0) {
return ( return (
@ -22,18 +33,21 @@ export function BurnupChart({ data }: Props) {
) )
} }
const chartData = withIdealLine(data)
return ( return (
<div className="h-[280px] w-full rounded-2xl border border-white/10 bg-slate-950/30 p-3 backdrop-blur-md sm:p-4"> <div className="flex h-[280px] w-full min-h-[280px] min-w-0 flex-col rounded-2xl border border-white/10 bg-slate-950/30 p-3 backdrop-blur-md sm:p-4">
<p className="mb-2 text-center text-xs font-medium uppercase tracking-wide text-slate-500"> <p className="mb-2 shrink-0 text-center text-xs font-medium uppercase tracking-wide text-slate-500">
Burnup terminés vs périmètre Burn-up objectif vs réalisé
</p> </p>
<ResponsiveContainer width="100%" height="90%"> <div className="min-h-0 min-w-0 flex-1">
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}> <ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<LineChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(148,163,184,0.12)" /> <CartesianGrid strokeDasharray="3 3" stroke="rgba(148,163,184,0.12)" />
<XAxis <XAxis
dataKey="date" dataKey="date"
tick={{ fill: '#94a3b8', fontSize: 10 }} tick={{ fill: '#94a3b8', fontSize: 10 }}
tickFormatter={(d) => d.slice(5)} tickFormatter={(d) => String(d).slice(5)}
/> />
<YAxis tick={{ fill: '#94a3b8', fontSize: 10 }} allowDecimals={false} width={28} /> <YAxis tick={{ fill: '#94a3b8', fontSize: 10 }} allowDecimals={false} width={28} />
<Tooltip <Tooltip
@ -45,24 +59,35 @@ export function BurnupChart({ data }: Props) {
}} }}
labelFormatter={(l) => `Date : ${l}`} labelFormatter={(l) => `Date : ${l}`}
/> />
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
<Line <Line
type="monotone" type="monotone"
dataKey="total" dataKey="total"
name="Périmètre (sous-tâches)" name="Objectif (périmètre)"
stroke="#64748b" stroke="#94a3b8"
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
/> />
<Line
type="monotone"
dataKey="ideal"
name="Objectif linéaire"
stroke="#eab308"
strokeWidth={1.5}
strokeDasharray="6 4"
dot={false}
/>
<Line <Line
type="monotone" type="monotone"
dataKey="done" dataKey="done"
name="Terminées" name="Réalisé (terminées)"
stroke="#34d399" stroke="#34d399"
strokeWidth={2} strokeWidth={2.5}
dot={{ r: 3, fill: '#34d399' }} dot={{ r: 3, fill: '#34d399' }}
/> />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div>
</div> </div>
) )
} }

View File

@ -3,9 +3,57 @@ import {
exportConfigJson, exportConfigJson,
mergeImportedConfig, mergeImportedConfig,
type DashboardConfig, type DashboardConfig,
type LaneLabelsConfig,
type Milestone, type Milestone,
type StatusBucketConfig,
} from '../lib/dashboardConfig' } from '../lib/dashboardConfig'
function parseBucketLines(raw: string): string[] {
return raw
.split(/[\n,;]+/)
.map((s) => s.trim())
.filter(Boolean)
}
const BUCKET_FIELD_DEFS: {
key: keyof StatusBucketConfig
title: string
hint: string
}[] = [
{ key: 'todo', title: 'À faire', hint: 'Ex. À faire, Open, Backlog' },
{ key: 'in_progress', title: 'En cours', hint: 'Ex. In Progress, Code Review, Recette' },
{ key: 'blocked', title: 'Bloqué', hint: 'Ex. Bloqué, Blocked' },
{ key: 'done', title: 'Terminé', hint: 'Ex. Done, Terminé, Closed, Livré' },
{ key: 'cancel', title: 'Annulé', hint: 'Ex. Annulé, Cancelled, Won\'t fix' },
]
const LANE_LABEL_FIELD_DEFS: {
key: keyof LaneLabelsConfig
title: string
hint: string
}[] = [
{
key: 'analyse',
title: 'Piste Analyse',
hint: 'Étiquettes Jira comptées comme Analyse (ex. analyse). Une par ligne ou virgules.',
},
{
key: 'design',
title: 'Piste Design',
hint: 'Ex. design, maquette (si vous utilisez ces étiquettes en Jira).',
},
{
key: 'integration',
title: 'Piste Intégration',
hint: 'Ex. integration — ou tout libellé détiquette utilisé côté dev / recette.',
},
{
key: 'blocked',
title: 'Marquage bloqué (étiquettes)',
hint: 'Ex. blocked : les tickets portant lune de ces étiquettes sont signalés comme bloqués par étiquette (liste et tooltips).',
},
]
type Props = { type Props = {
open: boolean open: boolean
config: DashboardConfig config: DashboardConfig
@ -19,6 +67,7 @@ function newMilestone(): Milestone {
title: '', title: '',
date: new Date().toISOString().slice(0, 10), date: new Date().toISOString().slice(0, 10),
linkedStoryKeys: [], linkedStoryKeys: [],
critical: false,
} }
} }
@ -96,6 +145,66 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
</div> </div>
<div className="space-y-5 px-5 py-4"> <div className="space-y-5 px-5 py-4">
<div className="rounded-xl border border-amber-500/25 bg-amber-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-amber-200/90">
Capacité & reporting
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<div>
<label className="text-[10px] uppercase text-slate-500">Effectif actif</label>
<input
type="number"
min={0.25}
step={0.25}
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm outline-none"
value={draft.teamCapacity}
onChange={(e) =>
setDraft((d) => ({
...d,
teamCapacity: Math.max(0.25, Number(e.target.value) || 0.25),
}))
}
/>
</div>
<div>
<label className="text-[10px] uppercase text-slate-500">Baseline vélocité</label>
<input
type="number"
min={0.25}
step={0.25}
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm outline-none"
value={draft.baselineCapacity}
onChange={(e) =>
setDraft((d) => ({
...d,
baselineCapacity: Math.max(0.25, Number(e.target.value) || 0.25),
}))
}
/>
</div>
<div>
<label className="text-[10px] uppercase text-slate-500">WIP / pers.</label>
<input
type="number"
min={1}
step={1}
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm outline-none"
value={draft.wipSlotsPerDev}
onChange={(e) =>
setDraft((d) => ({
...d,
wipSlotsPerDev: Math.max(1, Math.floor(Number(e.target.value) || 1)),
}))
}
/>
</div>
</div>
<p className="mt-2 text-[10px] text-slate-500">
La date datterrissage utilise : vélocité × (effectif / baseline). WIP = créneaux
nominaux pour la jauge « Ressources ».
</p>
</div>
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wide text-slate-500"> <label className="block text-xs font-medium uppercase tracking-wide text-slate-500">
Mon accountId Jira (recommandé pour « Ma vue ») Mon accountId Jira (recommandé pour « Ma vue »)
@ -120,6 +229,75 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
/> />
</div> </div>
<div className="rounded-xl border border-violet-500/25 bg-violet-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-violet-200/90">
Cartographie des statuts Jira
</p>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
Libellés exacts des statuts dans Jira (un par ligne ou séparés par des virgules). La
comparaison ignore casse et accents. Si un statut nest dans aucune liste, la catégorie
Jira (nouveau, en cours, terminé) sert de secours. Les listes vides au prochain
chargement reprennent les valeurs par défaut.
</p>
<div className="mt-3 space-y-3">
{BUCKET_FIELD_DEFS.map(({ key, title, hint }) => (
<div key={key}>
<label className="text-[10px] font-medium uppercase tracking-wide text-slate-400">
{title}
</label>
<p className="text-[10px] text-slate-600">{hint}</p>
<textarea
rows={3}
spellCheck={false}
className="mt-1 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-slate-200 outline-none ring-cyan-500/20 focus:ring-1"
value={draft.statusBuckets[key].join('\n')}
onChange={(e) =>
setDraft((d) => ({
...d,
statusBuckets: { ...d.statusBuckets, [key]: parseBucketLines(e.target.value) },
}))
}
/>
</div>
))}
</div>
</div>
<div className="rounded-xl border border-cyan-500/25 bg-cyan-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-cyan-200/90">
Étiquettes Jira (pistes & bloqué)
</p>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
Les sous-tâches sont classées en Analyse / Design / Intégration si elles portent au moins
une des étiquettes listées (ordre de priorité : Analyse, puis Design, puis Intégration).
Sinon, lancienne détection par mots dans le résumé et le statut sapplique. Les listes
vides au prochain chargement reprennent les valeurs par défaut (analyse, design,
integration, blocked).
</p>
<div className="mt-3 space-y-3">
{LANE_LABEL_FIELD_DEFS.map(({ key, title, hint }) => (
<div key={key}>
<label className="text-[10px] font-medium uppercase tracking-wide text-slate-400">
{title}
</label>
<p className="text-[10px] text-slate-600">{hint}</p>
<textarea
rows={2}
spellCheck={false}
className="mt-1 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-slate-200 outline-none ring-cyan-500/20 focus:ring-1"
value={draft.laneLabels[key].join('\n')}
onChange={(e) =>
setDraft((d) => ({
...d,
laneLabels: { ...d.laneLabels, [key]: parseBucketLines(e.target.value) },
}))
}
/>
</div>
))}
</div>
</div>
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500"> <span className="text-xs font-medium uppercase tracking-wide text-slate-500">
@ -145,7 +323,16 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
onChange={(e) => updateMilestone(m.id, { title: e.target.value })} onChange={(e) => updateMilestone(m.id, { title: e.target.value })}
placeholder="ex. Fin design" placeholder="ex. Fin design"
/> />
<div className="flex flex-wrap gap-2"> <label className="mt-2 flex cursor-pointer items-center gap-2 text-[11px] text-amber-100/90">
<input
type="checkbox"
checked={Boolean(m.critical)}
onChange={(e) => updateMilestone(m.id, { critical: e.target.checked })}
className="rounded border-amber-400/50"
/>
Jalon critique (alerte dimpact si retard)
</label>
<div className="mt-2 flex flex-wrap gap-2">
<input <input
type="date" type="date"
className="rounded border border-white/10 bg-transparent px-2 py-1 text-xs" className="rounded border border-white/10 bg-transparent px-2 py-1 text-xs"

View File

@ -8,6 +8,8 @@ import {
goldenCarbonSubtasks, goldenCarbonSubtasks,
maquetteRelatedSubtasks, maquetteRelatedSubtasks,
} from '../lib/executiveKpis' } from '../lib/executiveKpis'
import { useStatusBuckets } from '../context/StatusBucketContext'
import { useLaneLabels } from '../context/LaneLabelsContext'
type Props = { type Props = {
groups: StoryGroup[] groups: StoryGroup[]
@ -23,8 +25,8 @@ function DonutGlobal({ pct }: { pct: number }) {
{ name: 'Reste', value: rest }, { name: 'Reste', value: rest },
] ]
return ( return (
<div className="relative mx-auto h-[140px] w-[140px]"> <div className="relative mx-auto h-[140px] w-[140px] min-h-[140px] min-w-[140px] shrink-0">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<PieChart> <PieChart>
<Pie <Pie
data={data} data={data}
@ -60,10 +62,12 @@ function DonutGlobal({ pct }: { pct: number }) {
} }
export function ExecutiveSummary({ groups }: Props) { export function ExecutiveSummary({ groups }: Props) {
const total = globalProgressPercent(groups) const cfg = useStatusBuckets()
const design = designHealthPercent(groups) const laneCfg = useLaneLabels()
const golden = goldenCarbonHealthPercent(groups) const total = globalProgressPercent(groups, cfg)
const blockers = blockingTicketsCount(groups) const design = designHealthPercent(groups, cfg)
const golden = goldenCarbonHealthPercent(groups, cfg)
const blockers = blockingTicketsCount(groups, cfg, laneCfg)
const maquetteCount = maquetteRelatedSubtasks(groups).length const maquetteCount = maquetteRelatedSubtasks(groups).length
const gcCount = goldenCarbonSubtasks(groups).length const gcCount = goldenCarbonSubtasks(groups).length
@ -124,7 +128,8 @@ export function ExecutiveSummary({ groups }: Props) {
</p> </p>
<p className="mt-4 text-4xl font-semibold tabular-nums text-white">{blockers}</p> <p className="mt-4 text-4xl font-semibold tabular-nums text-white">{blockers}</p>
<p className="mt-2 text-xs leading-relaxed text-slate-400"> <p className="mt-2 text-xs leading-relaxed text-slate-400">
Tickets en statut « Bloqué », « Recette KO » ou assimilés (stories + sous-tâches). Seau « Bloqué » (statuts), étiquettes bloquées (réglages), ou libellés type Recette KO
(stories + sous-tâches).
</p> </p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,80 @@
import { useState, type RefObject } from 'react'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
type Props = {
targetRef: RefObject<HTMLElement | null>
}
export function ExportDashboardButton({ targetRef }: Props) {
const [busy, setBusy] = useState<'png' | 'pdf' | null>(null)
const runExport = async (mode: 'png' | 'pdf') => {
const el = targetRef.current
if (!el) return
setBusy(mode)
try {
const canvas = await html2canvas(el, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: '#020617',
})
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
if (mode === 'png') {
canvas.toBlob((blob) => {
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dashboard-copil-${stamp}.png`
a.click()
URL.revokeObjectURL(url)
}, 'image/png')
} else {
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' })
const pageW = pdf.internal.pageSize.getWidth()
const pageH = pdf.internal.pageSize.getHeight()
const img = new Image()
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error('image'))
img.src = imgData
})
const ratio = Math.min(pageW / img.width, pageH / img.height)
const w = img.width * ratio
const h = img.height * ratio
const x = (pageW - w) / 2
const y = (pageH - h) / 2
pdf.addImage(imgData, 'PNG', x, y, w, h)
pdf.save(`dashboard-copil-${stamp}.pdf`)
}
} catch {
alert('Export impossible (vérifiez les bloqueurs ou réessayez).')
} finally {
setBusy(null)
}
}
return (
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy !== null}
onClick={() => void runExport('png')}
className="rounded-lg border border-violet-500/50 bg-violet-500/15 px-3 py-2 text-xs font-semibold text-violet-100 transition hover:bg-violet-500/25 disabled:opacity-50"
>
{busy === 'png' ? 'Export…' : 'Exporter PNG (Copil)'}
</button>
<button
type="button"
disabled={busy !== null}
onClick={() => void runExport('pdf')}
className="rounded-lg border border-sky-500/50 bg-sky-500/15 px-3 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-500/25 disabled:opacity-50"
>
{busy === 'pdf' ? 'Export…' : 'Exporter PDF (Copil)'}
</button>
</div>
)
}

View File

@ -0,0 +1,310 @@
import { useMemo, useState } from 'react'
import type { JiraIssue, StoryGroup } from '../types/jira'
import { useLaneLabels } from '../context/LaneLabelsContext'
import { useStatusBuckets } from '../context/StatusBucketContext'
import { detectWorkLane, type WorkLane } from '../lib/laneDetection'
import {
resolveWorkBucketFromIssue,
type StatusBucketConfig,
type WorkStatusBucket,
} from '../lib/statusBuckets'
import { getRemainingEstimateUnits } from '../lib/jiraFieldExtractors'
import { jiraBrowseIssueUrl } from '../lib/jiraLinks'
type Props = {
groups: StoryGroup[]
}
type Row = {
issue: JiraIssue
}
const LANES: { id: WorkLane; title: string }[] = [
{ id: 'analyse', title: 'Analyse' },
{ id: 'design', title: 'Design' },
{ id: 'integration', title: 'Intégration' },
]
const BUCKET_FR: Record<WorkStatusBucket, string> = {
todo: 'À faire',
in_progress: 'En cours',
blocked: 'Bloqué',
done: 'Terminé',
cancel: 'Annulé',
}
const BUCKET_OPTIONS: { value: 'all' | WorkStatusBucket; label: string }[] = [
{ value: 'all', label: 'Tous suivi' },
{ value: 'todo', label: 'À faire' },
{ value: 'in_progress', label: 'En cours' },
{ value: 'blocked', label: 'Bloqué' },
{ value: 'done', label: 'Terminé' },
{ value: 'cancel', label: 'Annulé' },
]
const SORT_OPTIONS: { value: 'key' | 'summary' | 'status' | 'remaining' | 'bucket'; label: string }[] = [
{ value: 'key', label: 'Clé' },
{ value: 'summary', label: 'Résumé' },
{ value: 'status', label: 'Statut Jira' },
{ value: 'remaining', label: 'Reste (u.)' },
{ value: 'bucket', label: 'Suivi' },
]
function IssueKeyCell({ issueKey }: { issueKey: string }) {
const href = jiraBrowseIssueUrl(issueKey)
const cls =
'font-mono text-[11px] text-cyan-300/95 decoration-cyan-400/40 hover:text-cyan-200 hover:underline'
if (!href) return <span className={cls}>{issueKey}</span>
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={`${cls} underline-offset-2`}>
{issueKey}
</a>
)
}
function rowMatchesQuery(issue: JiraIssue, q: string): boolean {
if (!q.trim()) return true
const n = q.trim().toLowerCase()
const labels = (issue.fields.labels ?? []).join(' ').toLowerCase()
return (
issue.key.toLowerCase().includes(n) ||
issue.fields.summary.toLowerCase().includes(n) ||
issue.fields.status.name.toLowerCase().includes(n) ||
labels.includes(n)
)
}
function LaneSection({
title,
rows,
bucketCfg,
}: {
title: string
rows: Row[]
bucketCfg: StatusBucketConfig
}) {
const [q, setQ] = useState('')
const [bucketFilter, setBucketFilter] = useState<'all' | WorkStatusBucket>('all')
const [sort, setSort] = useState<'key' | 'summary' | 'status' | 'remaining' | 'bucket'>('key')
const [asc, setAsc] = useState(true)
const processed = useMemo(() => {
let list = rows.filter(({ issue }) => {
if (!rowMatchesQuery(issue, q)) return false
if (bucketFilter === 'all') return true
return resolveWorkBucketFromIssue(issue, bucketCfg) === bucketFilter
})
const dir = asc ? 1 : -1
list = [...list].sort((a, b) => {
const A = a.issue
const B = b.issue
switch (sort) {
case 'key':
return A.key.localeCompare(B.key) * dir
case 'summary':
return A.fields.summary.localeCompare(B.fields.summary, 'fr') * dir
case 'status':
return A.fields.status.name.localeCompare(B.fields.status.name, 'fr') * dir
case 'remaining': {
const ra = getRemainingEstimateUnits(A)
const rb = getRemainingEstimateUnits(B)
return ra === rb ? A.key.localeCompare(B.key) : (ra - rb) * dir
}
case 'bucket': {
const ba = BUCKET_FR[resolveWorkBucketFromIssue(A, bucketCfg)]
const bb = BUCKET_FR[resolveWorkBucketFromIssue(B, bucketCfg)]
const c = ba.localeCompare(bb, 'fr')
return c !== 0 ? c * dir : A.key.localeCompare(B.key) * dir
}
default:
return 0
}
})
return list
}, [rows, q, bucketFilter, sort, asc, bucketCfg])
const totalRemaining = useMemo(
() => rows.reduce((acc, { issue }) => acc + getRemainingEstimateUnits(issue), 0),
[rows],
)
return (
<div>
<h3 className="mb-2 flex flex-wrap items-baseline gap-2 text-sm font-semibold text-white">
{title}
<span className="font-mono text-xs font-normal text-slate-500">
{rows.length} ticket{rows.length !== 1 ? 's' : ''}
<span className="mx-1.5 text-slate-600">·</span>
Σ reste <span className="text-slate-400">{totalRemaining.toFixed(2)} u.</span>
</span>
</h3>
{rows.length === 0 ? (
<p className="text-xs text-slate-500">Aucune sous-tâche sur cette piste.</p>
) : (
<>
<div className="mb-3 flex flex-wrap items-end gap-2 rounded-lg border border-white/[0.06] bg-black/20 p-2">
<div className="min-w-[140px] flex-1">
<label className="text-[9px] font-medium uppercase tracking-wide text-slate-500">
Recherche
</label>
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Clé, résumé, statut, étiquette…"
className="mt-0.5 w-full rounded border border-white/10 bg-slate-950/60 px-2 py-1 text-[11px] text-slate-200 outline-none ring-cyan-500/20 focus:ring-1"
/>
</div>
<div>
<label className="text-[9px] font-medium uppercase tracking-wide text-slate-500">
Suivi
</label>
<select
value={bucketFilter}
onChange={(e) =>
setBucketFilter(e.target.value as 'all' | WorkStatusBucket)
}
className="mt-0.5 block rounded border border-white/10 bg-slate-950/60 px-2 py-1 text-[11px] text-slate-200 outline-none"
>
{BUCKET_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="text-[9px] font-medium uppercase tracking-wide text-slate-500">
Trier par
</label>
<select
value={sort}
onChange={(e) =>
setSort(e.target.value as 'key' | 'summary' | 'status' | 'remaining' | 'bucket')
}
className="mt-0.5 block rounded border border-white/10 bg-slate-950/60 px-2 py-1 text-[11px] text-slate-200 outline-none"
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="text-[9px] font-medium uppercase tracking-wide text-slate-500">
Ordre
</label>
<select
value={asc ? 'asc' : 'desc'}
onChange={(e) => setAsc(e.target.value === 'asc')}
className="mt-0.5 block rounded border border-white/10 bg-slate-950/60 px-2 py-1 text-[11px] text-slate-200 outline-none"
>
<option value="asc">Croissant</option>
<option value="desc">Décroissant</option>
</select>
</div>
</div>
<p className="mb-2 text-[10px] text-slate-600">
{processed.length} ligne{processed.length !== 1 ? 's' : ''} affichée
{processed.length !== rows.length ? ` sur ${rows.length}` : ''}
</p>
<div className="overflow-x-auto rounded-xl border border-white/[0.06] bg-black/20">
<table className="w-full min-w-[480px] border-collapse text-left text-xs">
<thead>
<tr className="border-b border-white/[0.08] bg-slate-900/80 text-[10px] uppercase tracking-wide text-slate-500">
<th className="px-3 py-2 font-medium">Clé</th>
<th className="px-3 py-2 font-medium">Résumé</th>
<th className="px-3 py-2 font-medium">Statut Jira</th>
<th className="px-3 py-2 font-medium">Suivi</th>
<th className="px-3 py-2 font-medium text-right">Reste (u.)</th>
<th className="px-3 py-2 font-medium">Étiquettes</th>
</tr>
</thead>
<tbody>
{processed.map(({ issue }) => {
const bucket = resolveWorkBucketFromIssue(issue, bucketCfg)
const labels = issue.fields.labels ?? []
const rem = getRemainingEstimateUnits(issue)
return (
<tr
key={issue.key}
className="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.03]"
>
<td className="whitespace-nowrap px-3 py-2 align-top">
<IssueKeyCell issueKey={issue.key} />
</td>
<td className="max-w-[240px] px-3 py-2 align-top text-slate-200">
<span className="line-clamp-2" title={issue.fields.summary}>
{issue.fields.summary}
</span>
</td>
<td className="whitespace-nowrap px-3 py-2 align-top text-slate-400">
{issue.fields.status.name}
</td>
<td className="whitespace-nowrap px-3 py-2 align-top text-slate-300">
{BUCKET_FR[bucket]}
</td>
<td className="whitespace-nowrap px-3 py-2 align-top text-right font-mono text-slate-400">
{rem.toFixed(2)}
</td>
<td className="max-w-[180px] px-3 py-2 align-top text-[10px] text-slate-500">
{labels.length === 0 ? (
'—'
) : (
<span className="line-clamp-2" title={labels.join(', ')}>
{labels.join(', ')}
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
)}
</div>
)
}
export function LaneTicketsListView({ groups }: Props) {
const laneCfg = useLaneLabels()
const bucketCfg = useStatusBuckets()
const rowsByLane = useMemo(() => {
const map: Record<WorkLane, Row[]> = { analyse: [], design: [], integration: [] }
for (const g of groups) {
for (const st of g.subtasks) {
const lane = detectWorkLane(st, laneCfg)
map[lane].push({ issue: st })
}
}
for (const lane of Object.keys(map) as WorkLane[]) {
map[lane].sort((a, b) => a.issue.key.localeCompare(b.issue.key))
}
return map
}, [groups, laneCfg])
return (
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/35 p-4 backdrop-blur-xl sm:p-5">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Liste par piste (sous-tâches)
</h2>
<p className="mt-1 text-xs leading-relaxed text-slate-500">
Filtres et tri indépendants par piste. Reste (u.) = remainingEstimate Jira ÷ 27 000 (comme
ailleurs sur le dashboard). Les étiquettes incluent le marquage bloqué si vous lutilisez.
</p>
<div className="mt-6 space-y-10">
{LANES.map(({ id, title }) => (
<LaneSection key={id} title={title} rows={rowsByLane[id]} bucketCfg={bucketCfg} />
))}
</div>
</section>
)
}

View File

@ -0,0 +1,192 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'
import type { LandingEstimate } from '../lib/executiveLanding'
import type { Verdict } from '../lib/executiveHealth'
type Props = {
deadlineScore: number
scopeScore: number
resourceScore: number
verdict: Verdict
landing: LandingEstimate | null
finalMilestoneDate: string | null
}
function GaugeDonut({
label,
hint,
value,
accent,
}: {
label: string
hint: string
value: number
accent: string
}) {
const v = Math.max(0, Math.min(100, value))
const rest = 100 - v
const data = [
{ name: 'score', value: v },
{ name: 'rest', value: rest },
]
return (
<div className="flex min-w-0 flex-col items-center">
<p className="mb-1 text-center text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{label}
</p>
<div className="relative h-[120px] w-[120px] min-h-[120px] min-w-[120px] shrink-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<PieChart>
<Pie
data={data}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={38}
outerRadius={52}
startAngle={90}
endAngle={-270}
stroke="none"
>
<Cell fill={accent} />
<Cell fill="rgb(15 23 42 / 0.9)" />
</Pie>
<Tooltip
formatter={(val) => [`${Math.round(Number(val ?? 0))}%`, '']}
contentStyle={{
background: 'rgba(15,23,42,0.96)',
border: '1px solid rgba(148,163,184,0.25)',
borderRadius: 8,
fontSize: 12,
}}
/>
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xl font-bold tabular-nums text-white">{Math.round(v)}</span>
<span className="text-[9px] text-slate-500">/ 100</span>
</div>
</div>
<p className="mt-1 max-w-[140px] text-center text-[10px] leading-snug text-slate-500">{hint}</p>
</div>
)
}
function verdictBlock(verdict: Verdict): { text: string; className: string } {
switch (verdict) {
case 'on_track':
return {
text: 'DANS LES TEMPS',
className:
'border-emerald-400/70 bg-emerald-500/15 text-emerald-100 shadow-[0_0_32px_rgba(16,185,129,0.35)]',
}
case 'at_risk':
return {
text: 'EN RISQUE',
className:
'border-amber-400/80 bg-amber-500/20 text-amber-50 shadow-[0_0_36px_rgba(251,191,36,0.45)]',
}
case 'critical':
return {
text: 'CRITIQUE',
className:
'border-rose-500/90 bg-rose-600/25 text-rose-50 shadow-[0_0_40px_rgba(244,63,94,0.55)]',
}
}
}
function formatFrDate(d: Date): string {
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })
}
export function ManagementOverview({
deadlineScore,
scopeScore,
resourceScore,
verdict,
landing,
finalMilestoneDate,
}: Props) {
const v = verdictBlock(verdict)
return (
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/50 px-4 py-5 backdrop-blur-xl sm:px-6">
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">
Management overview
</h2>
<p className="mt-1 text-xs text-slate-500">
Santé du projet : délais vs atterrissage, complétion des stories, charge vs capacité
configurée.
</p>
</div>
<div
className={`rounded-2xl border-2 px-6 py-4 text-center transition ${v.className}`}
>
<p className="text-[10px] font-bold uppercase tracking-[0.35em] text-white/80">Verdict</p>
<p className="mt-2 text-2xl font-black tracking-tight sm:text-3xl">{v.text}</p>
</div>
</div>
<div className="mb-6 grid min-h-0 min-w-0 grid-cols-1 gap-6 border-b border-white/[0.06] pb-6 sm:grid-cols-3">
<GaugeDonut
label="Délais"
hint="Marge entre date datterrissage estimée et dernier jalon."
value={deadlineScore}
accent="rgb(56 189 248)"
/>
<GaugeDonut
label="Scope"
hint="Stories entièrement terminées (sous-tâches incluses)."
value={scopeScore}
accent="rgb(167 139 250)"
/>
<GaugeDonut
label="Ressources"
hint="Sous-tâches ouvertes vs capacité (effectif × créneaux / pers.)."
value={resourceScore}
accent="rgb(52 211 153)"
/>
</div>
<div className="grid gap-3 text-sm text-slate-300 sm:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/25 px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Atterrissage estimé
</p>
{landing?.estimatedLanding ? (
<p className="mt-1 font-mono text-lg text-white">
{formatFrDate(landing.estimatedLanding)}
</p>
) : (
<p className="mt-1 text-amber-200/90">Indisponible (vélocité nulle ou pas dhistorique).</p>
)}
<p className="mt-2 text-xs text-slate-500">
Reste <span className="font-mono text-slate-300">{landing?.remainingSubtasks ?? '—'}</span>{' '}
sous-tâche(s) · vélocité ajustée{' '}
<span className="font-mono text-slate-300">
{(landing?.effectiveVelocityPerDay ?? 0).toFixed(2)}
</span>{' '}
/ jour
{finalMilestoneDate && (
<>
{' '}
· jalon cible <span className="font-mono text-slate-400">{finalMilestoneDate}</span>
</>
)}
</p>
</div>
<div className="rounded-xl border border-white/10 bg-black/25 px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Méthode
</p>
<ul className="mt-2 list-inside list-disc space-y-1 text-xs text-slate-500">
<li>Vélocité = moyenne des terminées / jour sur 14 jours (historique burnup).</li>
<li>Projection en jours ouvrés (lunven) à partir daujourdhui.</li>
<li>Capacité : voir Réglages (effectif × baseline).</li>
</ul>
</div>
</div>
</section>
)
}

View File

@ -1,11 +1,22 @@
import type { StoryGroup } from '../types/jira' import type { StoryGroup } from '../types/jira'
import type { Milestone } from '../lib/dashboardConfig' import type { Milestone } from '../lib/dashboardConfig'
import { isMilestoneLate } from '../lib/milestoneStatus' import type { StatusBucketConfig } from '../lib/statusBuckets'
import { useStatusBuckets } from '../context/StatusBucketContext'
import {
isMilestoneLate,
milestoneAverageCompletionPercent,
milestoneCalendarDaysUntil,
milestoneLinkedGroups,
milestoneOpenRemainingUnits,
} from '../lib/milestoneStatus'
import { ProjectRoadmapBar } from './ProjectRoadmapBar'
type Props = { type Props = {
milestones: Milestone[] milestones: Milestone[]
groups: StoryGroup[] groups: StoryGroup[]
onOpenSettings: () => void onOpenSettings: () => void
/** Alertes dimpact (ex. jalons critiques en retard). */
impactMessages?: string[]
} }
function formatFr(iso: string): string { function formatFr(iso: string): string {
@ -20,12 +31,52 @@ function formatFr(iso: string): string {
} }
} }
export function MilestonesTimeline({ milestones, groups, onOpenSettings }: Props) { function delaySummary(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
): { text: string; className: string } {
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
if (pct >= 100) {
return { text: 'Terminé', className: 'text-emerald-400' }
}
if (isMilestoneLate(m, groups, cfg)) {
return { text: 'Retard', className: 'text-rose-400' }
}
const d = milestoneCalendarDaysUntil(m)
if (d > 1) return { text: `Dans ${d} j`, className: d <= 7 ? 'text-amber-200' : 'text-slate-400' }
if (d === 1) return { text: 'Demain', className: 'text-amber-200' }
if (d === 0) return { text: "Aujourd'hui", className: 'text-amber-300' }
return { text: '—', className: 'text-slate-500' }
}
export function MilestonesTimeline({
milestones,
groups,
onOpenSettings,
impactMessages = [],
}: Props) {
const cfg = useStatusBuckets()
const sorted = [...milestones].sort((a, b) => a.date.localeCompare(b.date)) const sorted = [...milestones].sort((a, b) => a.date.localeCompare(b.date))
return ( return (
<section className="mb-8 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-4 backdrop-blur-xl sm:px-6"> <section className="mb-8 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-4 backdrop-blur-xl sm:px-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2"> {impactMessages.length > 0 && (
<div className="mb-4 space-y-2 rounded-xl border-2 border-rose-500/80 bg-rose-600/20 px-4 py-3 text-sm text-rose-50 shadow-[0_0_28px_rgba(244,63,94,0.35)]">
<p className="text-xs font-bold uppercase tracking-[0.2em] text-amber-200">
Impact dépendances
</p>
<ul className="list-inside list-disc space-y-1 text-[13px] leading-relaxed">
{impactMessages.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</div>
)}
<ProjectRoadmapBar milestones={milestones} />
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400"> <h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Timeline de jalons Timeline de jalons
</h2> </h2>
@ -38,45 +89,126 @@ export function MilestonesTimeline({ milestones, groups, onOpenSettings }: Props
</button> </button>
</div> </div>
<p className="mb-4 text-xs leading-relaxed text-slate-500">
Chaque jalon regarde un périmètre : les stories saisies dans « Stories liées », ou toutes les
stories chargées si ce champ est vide. Lavancement est la moyenne des pourcentages de
sous-tâches terminées (même règle que le retard). Le RAF est la somme du temps restant Jira
(unités ÷ 27 000) sur les sous-tâches encore actives de ce périmètre.
</p>
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Aucun jalon défini ouvrez la configuration pour ajouter des dates clés (ex. fin design, Aucun jalon défini ajoutez des jalons avec une date et, si besoin, un sous-ensemble de
recette, mise en ligne). stories pour éviter quune échéance globale ne mélange tout le périmètre DCC.
</p> </p>
) : ( ) : (
<div className="relative pt-2 pb-6"> <>
<div className="absolute left-0 right-0 top-5 h-px bg-gradient-to-r from-transparent via-slate-600 to-transparent" /> <div className="relative pt-2 pb-6">
<ul className="relative flex flex-wrap gap-6 sm:flex-nowrap sm:justify-between"> <div className="absolute left-0 right-0 top-5 h-px bg-gradient-to-r from-transparent via-slate-600 to-transparent" />
{sorted.map((m) => { <ul className="relative flex flex-wrap gap-6 sm:flex-nowrap sm:justify-between">
const late = isMilestoneLate(m, groups) {sorted.map((m) => {
return ( const late = isMilestoneLate(m, groups, cfg)
<li key={m.id} className="flex min-w-[100px] flex-1 flex-col items-center sm:flex-none"> return (
<span <li
className={`relative z-10 mb-2 flex h-4 w-4 rounded-full ring-4 ring-slate-950 ${ key={m.id}
late className="flex min-w-[100px] flex-1 flex-col items-center sm:flex-none"
? 'bg-rose-500 shadow-[0_0_14px_rgba(244,63,94,0.7)]' >
: 'bg-cyan-400 shadow-[0_0_12px_rgba(34,211,238,0.5)]' <span
}`} className={`relative z-10 mb-2 flex h-4 w-4 rounded-full ring-4 ring-slate-950 ${
title={ late
late ? 'bg-rose-500 shadow-[0_0_14px_rgba(244,63,94,0.7)]'
? 'Retard : date dépassée et stories liées non terminées (sous-tâches).' : 'bg-cyan-400 shadow-[0_0_12px_rgba(34,211,238,0.5)]'
: 'À jour ou échéance future.' }`}
} title={
/> late
<span className="max-w-[140px] text-center text-xs font-medium text-white"> ? 'Retard : date dépassée et périmètre non à 100 % (sous-tâches).'
{m.title} : 'À jour ou échéance future.'
</span> }
<span className="text-[10px] text-slate-500">{formatFr(m.date)}</span> />
{late && ( <span className="max-w-[140px] text-center text-xs font-medium text-white">
<span className="mt-1 text-[10px] font-semibold uppercase tracking-wide text-rose-400"> {m.title}
Retard {m.critical && (
<span className="ml-1 text-[9px] font-bold uppercase text-amber-300">
{' '}
(critique)
</span>
)}
</span> </span>
)} <span className="text-[10px] text-slate-500">{formatFr(m.date)}</span>
</li> {late && (
) <span className="mt-1 text-[10px] font-semibold uppercase tracking-wide text-rose-400">
})} Retard
</ul> </span>
</div> )}
</li>
)
})}
</ul>
</div>
<div className="border-t border-white/[0.06] pt-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
Synthèse par jalon
</h3>
<div className="overflow-x-auto rounded-xl border border-white/[0.06] bg-black/20">
<table className="w-full min-w-[720px] border-collapse text-left text-xs">
<thead>
<tr className="border-b border-white/[0.08] bg-slate-900/80 text-[10px] uppercase tracking-wide text-slate-500">
<th className="px-3 py-2 font-medium">Jalon</th>
<th className="px-3 py-2 font-medium">Date</th>
<th className="px-3 py-2 font-medium">Périmètre</th>
<th className="px-3 py-2 font-medium text-right">Avancement</th>
<th className="px-3 py-2 font-medium text-right">RAF (u.)</th>
<th className="px-3 py-2 font-medium">Échéance</th>
</tr>
</thead>
<tbody>
{sorted.map((m) => {
const linked = milestoneLinkedGroups(m, groups)
const nStories = linked.length
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
const raf = milestoneOpenRemainingUnits(m, groups, cfg)
const del = delaySummary(m, groups, cfg)
const scopeHint =
m.linkedStoryKeys && m.linkedStoryKeys.length > 0
? `${nStories} story(s) liée(s)`
: `Toutes (${nStories})`
return (
<tr
key={m.id}
className="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.03]"
>
<td className="px-3 py-2 align-top text-slate-200">
{m.title}
{m.critical && (
<span className="ml-1 text-[9px] font-bold uppercase text-amber-400">
critique
</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-2 align-top text-slate-400">
{formatFr(m.date)}
</td>
<td className="max-w-[200px] px-3 py-2 align-top text-slate-500" title={scopeHint}>
{scopeHint}
</td>
<td className="px-3 py-2 align-top text-right font-mono text-slate-300">
{pct}%
</td>
<td className="px-3 py-2 align-top text-right font-mono text-slate-400">
{raf.toFixed(2)}
</td>
<td className={`whitespace-nowrap px-3 py-2 align-top font-medium ${del.className}`}>
{del.text}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</>
)} )}
</section> </section>
) )

View File

@ -0,0 +1,76 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { PhaseId } from '../types/jira'
type Props = {
counts: Record<PhaseId, number>
}
const PHASE_COLORS: Record<PhaseId, string> = {
analyse: '#a78bfa',
design: '#fbbf24',
integration: '#38bdf8',
done: '#34d399',
}
const LABELS: Record<PhaseId, string> = {
analyse: 'Analyse',
design: 'Design',
integration: 'Intégration',
done: 'Terminé',
}
export function PhaseDistributionChart({ counts }: Props) {
const data = [
{
name: 'Sous-tâches',
[LABELS.analyse]: counts.analyse,
[LABELS.design]: counts.design,
[LABELS.integration]: counts.integration,
[LABELS.done]: counts.done,
},
]
return (
<div className="flex h-[300px] w-full min-h-[300px] min-w-0 flex-col rounded-2xl border border-white/10 bg-slate-950/30 p-3 backdrop-blur-md sm:p-4">
<p className="mb-2 shrink-0 text-center text-xs font-medium uppercase tracking-wide text-slate-500">
Répartition par phase (volume global)
</p>
<div className="min-h-0 min-w-0 flex-1">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(148,163,184,0.12)" horizontal={false} />
<XAxis type="number" tick={{ fill: '#94a3b8', fontSize: 10 }} allowDecimals={false} />
<YAxis type="category" dataKey="name" width={100} tick={{ fill: '#94a3b8', fontSize: 11 }} />
<Tooltip
contentStyle={{
background: 'rgba(15,23,42,0.96)',
border: '1px solid rgba(148,163,184,0.25)',
borderRadius: 8,
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
<Bar dataKey={LABELS.analyse} stackId="a" fill={PHASE_COLORS.analyse} name={LABELS.analyse} />
<Bar dataKey={LABELS.design} stackId="a" fill={PHASE_COLORS.design} name={LABELS.design} />
<Bar
dataKey={LABELS.integration}
stackId="a"
fill={PHASE_COLORS.integration}
name={LABELS.integration}
/>
<Bar dataKey={LABELS.done} stackId="a" fill={PHASE_COLORS.done} name={LABELS.done} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)
}

View File

@ -1,5 +1,8 @@
import type { JiraIssue } from '../types/jira' import type { JiraIssue } from '../types/jira'
import { useStatusBuckets } from '../context/StatusBucketContext'
import { useLaneLabels } from '../context/LaneLabelsContext'
import { laneAggregateState, type WorkLane } from '../lib/laneDetection' import { laneAggregateState, type WorkLane } from '../lib/laneDetection'
import { isIssueDone } from '../lib/statusBuckets'
const LANES: { lane: WorkLane; label: string; hint: string }[] = [ const LANES: { lane: WorkLane; label: string; hint: string }[] = [
{ lane: 'analyse', label: 'A', hint: 'Piste Analyse' }, { lane: 'analyse', label: 'A', hint: 'Piste Analyse' },
@ -25,10 +28,16 @@ type Props = {
} }
export function PhaseLaneIcons({ subtasks }: Props) { export function PhaseLaneIcons({ subtasks }: Props) {
const cfg = useStatusBuckets()
const laneCfg = useLaneLabels()
const isDone = (st: JiraIssue) => isIssueDone(st, cfg)
return ( return (
<div className="flex items-center gap-2" title="État par piste (sous-tâches détectées par libellé)"> <div
className="flex items-center gap-2"
title="État par piste (étiquettes Jira des réglages, puis repli résumé / statut)"
>
{LANES.map(({ lane, label, hint }) => { {LANES.map(({ lane, label, hint }) => {
const st = laneAggregateState(subtasks, lane) const st = laneAggregateState(subtasks, lane, isDone, laneCfg)
return ( return (
<span <span
key={lane} key={lane}

View File

@ -0,0 +1,138 @@
import type { StoryGroup } from '../types/jira'
import { useStatusBuckets } from '../context/StatusBucketContext'
import { useLaneLabels } from '../context/LaneLabelsContext'
import { detectWorkLane, type WorkLane } from '../lib/laneDetection'
import { getRemainingEstimateUnits } from '../lib/jiraFieldExtractors'
import {
resolveWorkBucketFromIssue,
type WorkStatusBucket,
} from '../lib/statusBuckets'
type Props = {
groups: StoryGroup[]
}
const LANES: { id: WorkLane; title: string; subtitle: string }[] = [
{ id: 'analyse', title: 'Analyse', subtitle: 'Spécification, cadrage' },
{ id: 'design', title: 'Design', subtitle: 'Maquettes, UI/UX' },
{ id: 'integration', title: 'Intégration', subtitle: 'Dev, recette, déploiement' },
]
const BUCKET_SEGMENTS: { bucket: WorkStatusBucket; label: string; barClass: string }[] = [
{ bucket: 'todo', label: 'À faire', barClass: 'bg-slate-500' },
{ bucket: 'in_progress', label: 'En cours', barClass: 'bg-sky-500' },
{ bucket: 'blocked', label: 'Bloqué', barClass: 'bg-rose-500' },
{ bucket: 'done', label: 'Terminé', barClass: 'bg-emerald-500' },
{ bucket: 'cancel', label: 'Annulé', barClass: 'bg-slate-700' },
]
function emptyBuckets(): Record<WorkStatusBucket, number> {
return { todo: 0, in_progress: 0, blocked: 0, done: 0, cancel: 0 }
}
export function PipelineOverview({ groups }: Props) {
const cfg = useStatusBuckets()
const laneCfg = useLaneLabels()
const subs = groups.flatMap((g) => g.subtasks)
const laneRows = LANES.map(({ id, title, subtitle }) => {
const inLane = subs.filter((s) => detectWorkLane(s, laneCfg) === id)
const byBucket = emptyBuckets()
for (const s of inLane) {
byBucket[resolveWorkBucketFromIssue(s, cfg)] += 1
}
const total = inLane.length
let rafUnits = 0
for (const s of inLane) {
const b = resolveWorkBucketFromIssue(s, cfg)
if (b !== 'done' && b !== 'cancel') rafUnits += getRemainingEstimateUnits(s)
}
return { id, title, subtitle, byBucket, total, rafUnits }
})
return (
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/35 p-4 backdrop-blur-xl sm:p-5">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Pipeline Analyse / Design / Intégration
</h2>
<p className="mt-1 text-xs leading-relaxed text-slate-500">
Répartition des sous-tâches par piste : étiquettes Jira (réglages), puis repli sur résumé /
statut. Catégories de suivi (À faire, En cours, ) selon la cartographie des statuts. RAF =
somme du reste Jira (remainingEstimate ÷ 27 000) sur les sous-tâches hors terminé et hors
annulé.
</p>
<div className="mt-6 grid gap-5 md:grid-cols-3">
{laneRows.map(({ id, title, subtitle, byBucket, total, rafUnits }) => (
<div
key={id}
className="rounded-xl border border-white/[0.06] bg-black/25 px-4 py-3 shadow-inner"
>
<div className="flex items-baseline justify-between gap-2">
<div>
<p className="text-sm font-semibold text-white">{title}</p>
<p className="text-[11px] text-slate-500">{subtitle}</p>
</div>
<span className="shrink-0 text-right font-mono text-xs text-slate-400">
<span className="text-slate-300">{total}</span> st
<span className="mx-1.5 text-slate-600">·</span>
<span
title="Reste à faire en unités Jira (Σ remainingEstimate / 27 000), sous-tâches hors terminé et hors annulé"
>
RAF{' '}
<span className="text-slate-300">{rafUnits.toFixed(2)}</span>
<span className="text-slate-600"> u.</span>
</span>
</span>
</div>
{total === 0 ? (
<p className="mt-4 text-xs text-slate-500">Aucune sous-tâche sur cette piste.</p>
) : (
<>
<div className="mt-3 flex h-3 w-full overflow-hidden rounded-full bg-slate-800/90 ring-1 ring-inset ring-white/[0.06]">
{BUCKET_SEGMENTS.map(({ bucket, barClass }) => {
const n = byBucket[bucket]
if (n === 0) return null
const pct = (n / total) * 100
return (
<div
key={bucket}
className={`${barClass} min-w-0 transition-[width]`}
style={{ width: `${pct}%` }}
title={`${BUCKET_SEGMENTS.find((b) => b.bucket === bucket)?.label}: ${n}`}
/>
)
})}
</div>
<ul className="mt-3 space-y-1 text-[11px] text-slate-400">
{BUCKET_SEGMENTS.map(({ bucket, label, barClass }) => {
const n = byBucket[bucket]
if (n === 0) return null
return (
<li key={bucket} className="flex items-center justify-between gap-2 tabular-nums">
<span className="flex min-w-0 items-center gap-2">
<span
className={`h-2 w-2 shrink-0 rounded-sm ${barClass}`}
aria-hidden
/>
<span>{label}</span>
</span>
<span className="shrink-0 text-slate-300">
{n}{' '}
<span className="text-slate-500">
({Math.round((n / total) * 100)}%)
</span>
</span>
</li>
)
})}
</ul>
</>
)}
</div>
))}
</div>
</section>
)
}

View File

@ -0,0 +1,39 @@
import type { Milestone } from '../lib/dashboardConfig'
type Props = {
milestones: Milestone[]
}
export function ProjectRoadmapBar({ milestones }: Props) {
const sorted = [...milestones].sort((a, b) => a.date.localeCompare(b.date))
if (sorted.length === 0) return null
const first = sorted[0]!.date
const last = sorted[sorted.length - 1]!.date
const t0 = new Date(first + 'T00:00:00').getTime()
const t1 = new Date(last + 'T23:59:59').getTime()
const span = Math.max(1, t1 - t0)
const now = Date.now()
const pct = Math.max(0, Math.min(100, ((now - t0) / span) * 100))
return (
<div className="mb-6 rounded-xl border border-white/[0.06] bg-black/20 px-4 py-3">
<p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Avancement calendrier (jalons)
</p>
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-slate-800/90">
<div
className="h-full rounded-full bg-gradient-to-r from-cyan-400 via-violet-500 to-fuchsia-500 shadow-[0_0_20px_rgba(34,211,238,0.35)] transition-[width] duration-700"
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-3 flex flex-wrap justify-between gap-x-2 gap-y-1 text-[10px] text-slate-400">
{sorted.map((m) => (
<span key={m.id} className="max-w-[120px] truncate text-center" title={m.title}>
{m.title}
</span>
))}
</div>
</div>
)
}

View File

@ -2,10 +2,12 @@ import { useState } from 'react'
import type { PhaseId, StoryGroup } from '../types/jira' import type { PhaseId, StoryGroup } from '../types/jira'
import { PHASE_LABELS, statusToPhase } from '../lib/statusPhase' import { PHASE_LABELS, statusToPhase } from '../lib/statusPhase'
import { priorityBand, priorityBadgeClass } from '../lib/priorityLabel' import { priorityBand, priorityBadgeClass } from '../lib/priorityLabel'
import { storyProgressPercent, stepperStates } from '../lib/storyMetrics' import { stepperStates, subtaskDoneRatioPercent } from '../lib/storyMetrics'
import { PhaseStepper } from './PhaseStepper' import { PhaseStepper } from './PhaseStepper'
import { PhaseLaneIcons } from './PhaseLaneIcons' import { PhaseLaneIcons } from './PhaseLaneIcons'
import { blockingSummaryForTooltip } from '../lib/executiveKpis' import { blockingSummaryForTooltip } from '../lib/executiveKpis'
import { useStatusBuckets } from '../context/StatusBucketContext'
import { useLaneLabels } from '../context/LaneLabelsContext'
import { getRemainingEstimateUnits, getStoryPoints } from '../lib/jiraFieldExtractors' import { getRemainingEstimateUnits, getStoryPoints } from '../lib/jiraFieldExtractors'
import { jiraBrowseIssueUrl } from '../lib/jiraLinks' import { jiraBrowseIssueUrl } from '../lib/jiraLinks'
@ -55,13 +57,15 @@ function phaseChipClass(phase: PhaseId): string {
} }
export function StoryCard({ group, variant = 'default' }: Props) { export function StoryCard({ group, variant = 'default' }: Props) {
const cfg = useStatusBuckets()
const laneCfg = useLaneLabels()
const { story, subtasks } = group const { story, subtasks } = group
const [subsOpen, setSubsOpen] = useState(true) const [subsOpen, setSubsOpen] = useState(true)
const progress = storyProgressPercent(subtasks) const progress = subtaskDoneRatioPercent(subtasks, cfg)
const steps = stepperStates(subtasks) const steps = stepperStates(subtasks, cfg)
const band = priorityBand(story) const band = priorityBand(story)
const assignee = story.fields.assignee?.displayName const assignee = story.fields.assignee?.displayName
const blockerHint = blockingSummaryForTooltip(group) const blockerHint = blockingSummaryForTooltip(group, cfg, laneCfg)
const isBoard = variant === 'board' const isBoard = variant === 'board'
const spStory = getStoryPoints(story) const spStory = getStoryPoints(story)
const spSubs = subtasks.reduce((a, s) => a + getStoryPoints(s), 0) const spSubs = subtasks.reduce((a, s) => a + getStoryPoints(s), 0)
@ -133,7 +137,7 @@ export function StoryCard({ group, variant = 'default' }: Props) {
</div> </div>
<div className="mt-3 flex items-center justify-between text-xs text-slate-400"> <div className="mt-3 flex items-center justify-between text-xs text-slate-400">
<span>Progression (sous-tâches)</span> <span>Terminées / sous-tâches</span>
<span className="font-semibold tabular-nums text-emerald-300">{progress}%</span> <span className="font-semibold tabular-nums text-emerald-300">{progress}%</span>
</div> </div>
</> </>

View File

@ -0,0 +1,19 @@
import { createContext, useContext, type ReactNode } from 'react'
import type { LaneLabelsConfig } from '../lib/laneDetection'
import { defaultLaneLabelsConfig } from '../lib/laneDetection'
const LaneLabelsContext = createContext<LaneLabelsConfig>(defaultLaneLabelsConfig())
export function LaneLabelsProvider({
value,
children,
}: {
value: LaneLabelsConfig
children: ReactNode
}) {
return <LaneLabelsContext.Provider value={value}>{children}</LaneLabelsContext.Provider>
}
export function useLaneLabels(): LaneLabelsConfig {
return useContext(LaneLabelsContext)
}

View File

@ -0,0 +1,19 @@
import { createContext, useContext, type ReactNode } from 'react'
import type { StatusBucketConfig } from '../lib/statusBuckets'
import { defaultStatusBucketConfig } from '../lib/statusBuckets'
const StatusBucketContext = createContext<StatusBucketConfig>(defaultStatusBucketConfig())
export function StatusBucketProvider({
value,
children,
}: {
value: StatusBucketConfig
children: ReactNode
}) {
return <StatusBucketContext.Provider value={value}>{children}</StatusBucketContext.Provider>
}
export function useStatusBuckets(): StatusBucketConfig {
return useContext(StatusBucketContext)
}

View File

@ -5,11 +5,31 @@ export type Milestone = {
date: string date: string
/** Clés de stories à contrôler pour le statut « retard » ; vide = toutes les stories chargées */ /** Clés de stories à contrôler pour le statut « retard » ; vide = toutes les stories chargées */
linkedStoryKeys?: string[] linkedStoryKeys?: string[]
/** Jalon critique : alerte dimpact si retard après la date. */
critical?: boolean
} }
import type { StatusBucketConfig } from './statusBuckets'
import { defaultStatusBucketConfig, mergeStatusBucketConfig } from './statusBuckets'
import type { LaneLabelsConfig } from './laneDetection'
import { defaultLaneLabelsConfig, mergeLaneLabelsConfig } from './laneDetection'
export type { StatusBucketConfig } from './statusBuckets'
export type { LaneLabelsConfig } from './laneDetection'
export type DashboardConfig = { export type DashboardConfig = {
version: 1 version: 1
milestones: Milestone[] milestones: Milestone[]
/** Cartographie statuts Jira → To do / In progress / Blocked / Done / Cancel. */
statusBuckets: StatusBucketConfig
/** Étiquettes Jira par piste (Analyse / Design / Intégration) et pour le marquage bloqué. */
laneLabels: LaneLabelsConfig
/** Effectif pris en compte pour la projection (développeurs actifs). */
teamCapacity: number
/** Effectif de référence pour léchelle de vélocité (ex. 3). */
baselineCapacity: number
/** Sous-tâches ouvertes « nominale » par personne pour la jauge charge vs capacité. */
wipSlotsPerDev: number
myJiraAccountId?: string myJiraAccountId?: string
myJiraEmail?: string myJiraEmail?: string
/** Filtre « Ma vue » (sous-tâches me concernant). */ /** Filtre « Ma vue » (sous-tâches me concernant). */
@ -21,6 +41,11 @@ const STORAGE_KEY = 'dcc-dashboard-config-v1'
export const defaultDashboardConfig = (): DashboardConfig => ({ export const defaultDashboardConfig = (): DashboardConfig => ({
version: 1, version: 1,
milestones: [], milestones: [],
statusBuckets: defaultStatusBucketConfig(),
laneLabels: defaultLaneLabelsConfig(),
teamCapacity: 3,
baselineCapacity: 3,
wipSlotsPerDev: 5,
}) })
export function loadDashboardConfig(): DashboardConfig { export function loadDashboardConfig(): DashboardConfig {
@ -32,6 +57,28 @@ export function loadDashboardConfig(): DashboardConfig {
return { return {
version: 1, version: 1,
milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [], milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [],
statusBuckets: mergeStatusBucketConfig(
parsed.statusBuckets && typeof parsed.statusBuckets === 'object'
? (parsed.statusBuckets as Partial<StatusBucketConfig>)
: undefined,
),
laneLabels: mergeLaneLabelsConfig(
parsed.laneLabels && typeof parsed.laneLabels === 'object'
? (parsed.laneLabels as Partial<LaneLabelsConfig>)
: undefined,
),
teamCapacity:
typeof parsed.teamCapacity === 'number' && Number.isFinite(parsed.teamCapacity)
? Math.max(0.25, parsed.teamCapacity)
: 3,
baselineCapacity:
typeof parsed.baselineCapacity === 'number' && Number.isFinite(parsed.baselineCapacity)
? Math.max(0.25, parsed.baselineCapacity)
: 3,
wipSlotsPerDev:
typeof parsed.wipSlotsPerDev === 'number' && Number.isFinite(parsed.wipSlotsPerDev)
? Math.max(1, Math.floor(parsed.wipSlotsPerDev))
: 5,
myJiraAccountId: parsed.myJiraAccountId, myJiraAccountId: parsed.myJiraAccountId,
myJiraEmail: parsed.myJiraEmail, myJiraEmail: parsed.myJiraEmail,
myViewActive: parsed.myViewActive, myViewActive: parsed.myViewActive,
@ -65,6 +112,26 @@ export function mergeImportedConfig(
return { return {
version: 1, version: 1,
milestones: Array.isArray(o.milestones) ? o.milestones : current.milestones, milestones: Array.isArray(o.milestones) ? o.milestones : current.milestones,
statusBuckets:
o.statusBuckets && typeof o.statusBuckets === 'object'
? mergeStatusBucketConfig(o.statusBuckets as Partial<StatusBucketConfig>)
: current.statusBuckets,
laneLabels:
o.laneLabels && typeof o.laneLabels === 'object'
? mergeLaneLabelsConfig(o.laneLabels as Partial<LaneLabelsConfig>)
: current.laneLabels,
teamCapacity:
typeof o.teamCapacity === 'number' && Number.isFinite(o.teamCapacity)
? Math.max(0.25, o.teamCapacity)
: current.teamCapacity,
baselineCapacity:
typeof o.baselineCapacity === 'number' && Number.isFinite(o.baselineCapacity)
? Math.max(0.25, o.baselineCapacity)
: current.baselineCapacity,
wipSlotsPerDev:
typeof o.wipSlotsPerDev === 'number' && Number.isFinite(o.wipSlotsPerDev)
? Math.max(1, Math.floor(o.wipSlotsPerDev))
: current.wipSlotsPerDev,
myJiraAccountId: o.myJiraAccountId ?? current.myJiraAccountId, myJiraAccountId: o.myJiraAccountId ?? current.myJiraAccountId,
myJiraEmail: o.myJiraEmail ?? current.myJiraEmail, myJiraEmail: o.myJiraEmail ?? current.myJiraEmail,
myViewActive: o.myViewActive ?? current.myViewActive, myViewActive: o.myViewActive ?? current.myViewActive,

100
src/lib/executiveHealth.ts Normal file
View File

@ -0,0 +1,100 @@
import type { StoryGroup } from '../types/jira'
import type { Milestone } from './dashboardConfig'
import type { StatusBucketConfig } from './statusBuckets'
import { isIssueDone, resolveWorkBucketFromIssue } from './statusBuckets'
export type Verdict = 'on_track' | 'at_risk' | 'critical'
export function isStoryDoneForReporting(g: StoryGroup, cfg: StatusBucketConfig): boolean {
if (g.subtasks.length === 0) return isIssueDone(g.story, cfg)
return g.subtasks.every((s) => isIssueDone(s, cfg))
}
export function scopeCompletionPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
if (groups.length === 0) return 0
const done = groups.filter((g) => isStoryDoneForReporting(g, cfg)).length
return Math.round((done / groups.length) * 100)
}
/** Dernière échéance parmi les jalons (ISO yyyy-mm-dd). */
export function latestMilestoneDateIso(milestones: Milestone[]): string | null {
if (milestones.length === 0) return null
return [...milestones].sort((a, b) => b.date.localeCompare(a.date))[0]!.date
}
/**
* Score 0100 : marge calendaire entre atterrissage estimé et jalon final
* (100 = atterrissage avant ou le jour du jalon, baisse si dépassement).
*/
export function deadlineHealthScore(estimatedLanding: Date | null, finalIso: string | null): number {
if (!estimatedLanding || !finalIso) return 55
const end = new Date(finalIso + 'T23:59:59')
const diffMs = end.getTime() - estimatedLanding.getTime()
const diffDays = diffMs / 86400000
if (diffDays >= 5) return 100
if (diffDays >= 0) return Math.round(70 + (diffDays / 5) * 30)
if (diffDays >= -5) return Math.round(Math.max(0, 70 + diffDays * 14))
return 0
}
/**
* Score 0100 : marge de charge (100 = peu de sous-tâches ouvertes vs capacité nominale).
*/
export function resourceHealthScore(
openSubtasks: number,
teamCapacity: number,
wipSlotsPerDev: number,
): number {
const cap = Math.max(1, teamCapacity * Math.max(1, wipSlotsPerDev))
const util = openSubtasks / cap
return Math.max(0, Math.min(100, Math.round(100 - util * 100)))
}
export function computeVerdict(
deadlineScore: number,
scopePct: number,
resourceScore: number,
): Verdict {
if (deadlineScore < 22 || scopePct < 28 || resourceScore < 18) return 'critical'
if (deadlineScore < 52 || scopePct < 50 || resourceScore < 40) return 'at_risk'
return 'on_track'
}
/** Messages dimpact si un jalon critique est en retard. */
export function criticalMilestoneImpactMessages(
milestones: Milestone[],
groups: StoryGroup[],
velocityPerBusinessDay: number,
cfg: StatusBucketConfig,
): string[] {
const msgs: string[] = []
const v = velocityPerBusinessDay > 0.001 ? velocityPerBusinessDay : null
for (const m of milestones) {
if (!m.critical) continue
const deadline = new Date(m.date + 'T23:59:59')
if (new Date() <= deadline) continue
const keys = m.linkedStoryKeys?.length ? m.linkedStoryKeys : groups.map((g) => g.story.key)
const linked = groups.filter((g) => keys.includes(g.story.key))
const open = linked.reduce(
(acc, g) =>
acc +
g.subtasks.filter((s) => {
const b = resolveWorkBucketFromIssue(s, cfg)
return b !== 'done' && b !== 'cancel'
}).length,
0,
)
if (open === 0) continue
if (v == null) {
msgs.push(
`« ${m.title} » (critique) : échéance dépassée — ${open} sous-tâche(s) encore ouvertes. Vélocité insuffisante pour quantifier le glissement.`,
)
} else {
const slip = Math.ceil(open / v)
msgs.push(
`« ${m.title} » (critique) : retard — ~${slip} jour(s) ouvrés de charge restante à la vélocité actuelle, impact probable sur la date finale.`,
)
}
}
return msgs
}

View File

@ -1,5 +1,8 @@
import type { JiraIssue, StoryGroup } from '../types/jira' import type { JiraIssue, StoryGroup } from '../types/jira'
import { statusToPhase } from './statusPhase' import type { LaneLabelsConfig } from './laneDetection'
import { issueHasBlockedLaneLabel } from './laneDetection'
import type { StatusBucketConfig } from './statusBuckets'
import { isIssueBlocked, isIssueCanceled, isIssueDone } from './statusBuckets'
function norm(s: string): string { function norm(s: string): string {
return s return s
@ -34,12 +37,14 @@ export function goldenCarbonSubtasks(groups: StoryGroup[]): JiraIssue[] {
) )
} }
/** Progression globale : sous-tâches terminées / sous-tâches totales. */ /** Progression globale : sous-tâches Done / (toutes sauf Cancel). */
export function globalProgressPercent(groups: StoryGroup[]): number { export function globalProgressPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
const subs = allSubtasks(groups) const subs = allSubtasks(groups)
if (subs.length === 0) return 0 if (subs.length === 0) return 0
const done = subs.filter((s) => statusToPhase(s.fields.status.name) === 'done').length const active = subs.filter((s) => !isIssueCanceled(s, cfg))
return Math.round((done / subs.length) * 100) if (active.length === 0) return 0
const done = active.filter((s) => isIssueDone(s, cfg)).length
return Math.round((done / active.length) * 100)
} }
/** Sous-tâches considérées comme « maquette » (libellé à ajuster selon votre vocabulaire Jira). */ /** Sous-tâches considérées comme « maquette » (libellé à ajuster selon votre vocabulaire Jira). */
@ -49,11 +54,13 @@ export function isMaquetteRelated(st: JiraIssue): boolean {
} }
/** % de maquettes validées parmi les sous-tâches identifiées comme maquettes. */ /** % de maquettes validées parmi les sous-tâches identifiées comme maquettes. */
export function designHealthPercent(groups: StoryGroup[]): number { export function designHealthPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
const candidates = maquetteRelatedSubtasks(groups) const candidates = maquetteRelatedSubtasks(groups)
if (candidates.length === 0) return 0 if (candidates.length === 0) return 0
const ok = candidates.filter((s) => statusToPhase(s.fields.status.name) === 'done').length const active = candidates.filter((s) => !isIssueCanceled(s, cfg))
return Math.round((ok / candidates.length) * 100) if (active.length === 0) return 0
const ok = active.filter((s) => isIssueDone(s, cfg)).length
return Math.round((ok / active.length) * 100)
} }
function textWithComponents(st: JiraIssue, story: JiraIssue): string { function textWithComponents(st: JiraIssue, story: JiraIssue): string {
@ -68,29 +75,55 @@ function isGoldenCarbonRelated(st: JiraIssue, story: JiraIssue): boolean {
return /golden\s*carbon|goldencarbon/i.test(textWithComponents(st, story)) return /golden\s*carbon|goldencarbon/i.test(textWithComponents(st, story))
} }
export function goldenCarbonHealthPercent(groups: StoryGroup[]): number { export function goldenCarbonHealthPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
const candidates = goldenCarbonSubtasks(groups) const candidates = goldenCarbonSubtasks(groups)
if (candidates.length === 0) return 0 if (candidates.length === 0) return 0
const ok = candidates.filter((s) => statusToPhase(s.fields.status.name) === 'done').length const active = candidates.filter((s) => !isIssueCanceled(s, cfg))
return Math.round((ok / candidates.length) * 100) if (active.length === 0) return 0
const ok = active.filter((s) => isIssueDone(s, cfg)).length
return Math.round((ok / active.length) * 100)
} }
export function blockingTicketsCount(groups: StoryGroup[]): number { function isBlockingIssue(
i: JiraIssue,
statusCfg: StatusBucketConfig,
laneCfg: LaneLabelsConfig,
): boolean {
return (
isIssueBlocked(i, statusCfg) ||
isBlockingStatus(i.fields.status.name) ||
issueHasBlockedLaneLabel(i, laneCfg)
)
}
export function blockingTicketsCount(
groups: StoryGroup[],
statusCfg: StatusBucketConfig,
laneCfg: LaneLabelsConfig,
): number {
const issues: JiraIssue[] = [ const issues: JiraIssue[] = [
...groups.map((g) => g.story), ...groups.map((g) => g.story),
...allSubtasks(groups), ...allSubtasks(groups),
] ]
return issues.filter((i) => isBlockingStatus(i.fields.status.name)).length return issues.filter((i) => isBlockingIssue(i, statusCfg, laneCfg)).length
} }
export function blockingIssuesInGroup(group: StoryGroup): JiraIssue[] { export function blockingIssuesInGroup(
group: StoryGroup,
statusCfg: StatusBucketConfig,
laneCfg: LaneLabelsConfig,
): JiraIssue[] {
return [group.story, ...group.subtasks].filter((i) => return [group.story, ...group.subtasks].filter((i) =>
isBlockingStatus(i.fields.status.name), isBlockingIssue(i, statusCfg, laneCfg),
) )
} }
export function blockingSummaryForTooltip(group: StoryGroup): string { export function blockingSummaryForTooltip(
const list = blockingIssuesInGroup(group) group: StoryGroup,
statusCfg: StatusBucketConfig,
laneCfg: LaneLabelsConfig,
): string {
const list = blockingIssuesInGroup(group, statusCfg, laneCfg)
if (list.length === 0) return 'Aucun ticket bloquant sur cette story.' 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') return list.map((i) => `${i.key}${i.fields.status.name}: ${i.fields.summary}`).join('\n')
} }

110
src/lib/executiveLanding.ts Normal file
View File

@ -0,0 +1,110 @@
import type { StoryGroup } from '../types/jira'
import type { BurnupPoint } from './burnupHistory'
import type { StatusBucketConfig } from './statusBuckets'
import { resolveWorkBucketFromIssue } from './statusBuckets'
function daySpanInclusive(a: string, b: string): number {
const t0 = new Date(a + 'T12:00:00').getTime()
const t1 = new Date(b + 'T12:00:00').getTime()
return Math.max(1, Math.round((t1 - t0) / 86400000))
}
/**
* Vélocité moyenne : sous-tâches **terminées par jour calendaire** sur les `windowDays` derniers jours,
* à partir des deltas de lhistorique burnup (un point par date).
*/
export function velocitySubtasksDonePerDay(
points: BurnupPoint[],
windowDays = 14,
): number {
if (points.length === 0) return 0
const sorted = [...points].sort((a, b) => a.date.localeCompare(b.date))
const last = sorted[sorted.length - 1]!
const end = new Date(last.date + 'T12:00:00')
const start = new Date(end)
start.setDate(start.getDate() - windowDays)
const startStr = start.toISOString().slice(0, 10)
const slice = sorted.filter((p) => p.date >= startStr)
if (slice.length < 2) {
const first = sorted[0]!
const span = daySpanInclusive(first.date, last.date)
return Math.max(0, last.done - first.done) / span
}
let sum = 0
let n = 0
for (let i = 1; i < slice.length; i++) {
const prev = slice[i - 1]!
const cur = slice[i]!
const delta = Math.max(0, cur.done - prev.done)
const span = daySpanInclusive(prev.date, cur.date)
sum += delta / span
n += 1
}
return n > 0 ? sum / n : 0
}
/** Sous-tâches encore à traiter (hors Done et hors Cancel). */
export function countOpenSubtasks(groups: StoryGroup[], cfg: StatusBucketConfig): number {
return groups.reduce(
(acc, g) =>
acc +
g.subtasks.filter((s) => {
const b = resolveWorkBucketFromIssue(s, cfg)
return b !== 'done' && b !== 'cancel'
}).length,
0,
)
}
/** Avance le calendrier en **jours ouvrés** (lunven), sans compter week-ends. */
export function addBusinessDays(from: Date, businessDays: number): Date {
const d = new Date(from.getFullYear(), from.getMonth(), from.getDate())
let left = Math.max(0, Math.ceil(businessDays))
while (left > 0) {
d.setDate(d.getDate() + 1)
const w = d.getDay()
if (w !== 0 && w !== 6) left -= 1
}
return d
}
export type LandingEstimate = {
/** Sous-tâches restantes (non terminées). */
remainingSubtasks: number
/** Moyenne issue de lhistorique burnup (terminées / jour calendaire). */
rawVelocityPerDay: number
/** Après prise en compte de la capacité vs baseline. */
effectiveVelocityPerDay: number
/** Jours ouvrés estimés pour finir (null si vélocité nulle). */
businessDaysToFinish: number | null
/** Date datterrissage projetée (jours ouvrés). */
estimatedLanding: Date | null
}
export function computeLandingEstimate(
groups: StoryGroup[],
burnup: BurnupPoint[],
teamCapacity: number,
baselineCapacity: number,
cfg: StatusBucketConfig,
windowDays = 14,
): LandingEstimate {
const remainingSubtasks = countOpenSubtasks(groups, cfg)
const rawVelocityPerDay = velocitySubtasksDonePerDay(burnup, windowDays)
const base = Math.max(1, baselineCapacity)
const cap = Math.max(0.25, teamCapacity)
const effectiveVelocityPerDay = rawVelocityPerDay * (cap / base)
const businessDaysToFinish =
effectiveVelocityPerDay > 0.001
? Math.ceil(remainingSubtasks / effectiveVelocityPerDay)
: null
const estimatedLanding =
businessDaysToFinish != null ? addBusinessDays(new Date(), businessDaysToFinish) : null
return {
remainingSubtasks,
rawVelocityPerDay,
effectiveVelocityPerDay,
businessDaysToFinish,
estimatedLanding,
}
}

View File

@ -2,12 +2,76 @@ import type { JiraIssue, JiraStatus } from '../types/jira'
export type WorkLane = 'analyse' | 'design' | 'integration' export type WorkLane = 'analyse' | 'design' | 'integration'
/** Regroupe les sous-tâches par « piste » Analyse / Design / Intégration (mots-clés + repli sur le statut). */ /** Étiquettes Jira (comparaison insensible à la casse et aux accents) par piste et pour le marquage bloqué. */
export function detectWorkLane(subtask: JiraIssue): WorkLane { export type LaneLabelsConfig = {
analyse: string[]
design: string[]
integration: string[]
blocked: string[]
}
function normLabel(s: string): string {
return s
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/\p{M}/gu, '')
}
function issueNormalizedLabels(issue: JiraIssue): Set<string> {
const raw = issue.fields.labels ?? []
return new Set(raw.map((l) => normLabel(String(l))))
}
function matchesConfiguredLabels(issue: JiraIssue, configured: string[]): boolean {
if (configured.length === 0) return false
const set = issueNormalizedLabels(issue)
return configured.some((c) => set.has(normLabel(c)))
}
export function defaultLaneLabelsConfig(): LaneLabelsConfig {
return {
analyse: ['analyse'],
design: ['design'],
integration: ['integration'],
blocked: ['blocked'],
}
}
export function mergeLaneLabelsConfig(partial?: Partial<LaneLabelsConfig>): LaneLabelsConfig {
const d = defaultLaneLabelsConfig()
if (!partial) return d
const pick = (key: keyof LaneLabelsConfig): string[] => {
const v = partial[key]
return Array.isArray(v) && v.length > 0 ? [...v] : d[key]
}
return {
analyse: pick('analyse'),
design: pick('design'),
integration: pick('integration'),
blocked: pick('blocked'),
}
}
/**
* Piste déduite des étiquettes uniquement (ordre : analyse → design → intégration).
* `null` si aucune étiquette configurée ne correspond.
*/
export function workLaneFromLabels(issue: JiraIssue, cfg: LaneLabelsConfig): WorkLane | null {
if (matchesConfiguredLabels(issue, cfg.analyse)) return 'analyse'
if (matchesConfiguredLabels(issue, cfg.design)) return 'design'
if (matchesConfiguredLabels(issue, cfg.integration)) return 'integration'
return null
}
export function issueHasBlockedLaneLabel(issue: JiraIssue, cfg: LaneLabelsConfig): boolean {
return matchesConfiguredLabels(issue, cfg.blocked)
}
/** Repli historique : résumé + nom de statut (si pas détiquette de piste reconnue). */
export function detectWorkLaneHeuristic(subtask: JiraIssue): WorkLane {
const s = subtask.fields.summary.toLowerCase() const s = subtask.fields.summary.toLowerCase()
if ( if (/\banalyse\b|spécification|spec\b|cadrage|refinement|backlog\s*analyse/i.test(s)) {
/\banalyse\b|spécification|spec\b|cadrage|refinement|backlog\s*analyse/i.test(s)
) {
return 'analyse' return 'analyse'
} }
if (/\bdesign\b|maquette|figma|wireframe|zoning|ui\b|ux\b/i.test(s)) { if (/\bdesign\b|maquette|figma|wireframe|zoning|ui\b|ux\b/i.test(s)) {
@ -24,6 +88,13 @@ export function detectWorkLane(subtask: JiraIssue): WorkLane {
return 'integration' return 'integration'
} }
/** Piste de travail : étiquettes Jira (réglages) puis heuristique résumé / statut. */
export function detectWorkLane(subtask: JiraIssue, laneLabels: LaneLabelsConfig): WorkLane {
const fromLabels = workLaneFromLabels(subtask, laneLabels)
if (fromLabels) return fromLabels
return detectWorkLaneHeuristic(subtask)
}
export function statusCategoryKey(status: JiraStatus): 'new' | 'indeterminate' | 'done' | 'unknown' { export function statusCategoryKey(status: JiraStatus): 'new' | 'indeterminate' | 'done' | 'unknown' {
const k = status.statusCategory?.key const k = status.statusCategory?.key
if (k === 'new') return 'new' if (k === 'new') return 'new'
@ -32,17 +103,20 @@ export function statusCategoryKey(status: JiraStatus): 'new' | 'indeterminate' |
return 'unknown' return 'unknown'
} }
/** Couleur logique pour une piste : vert = tout terminé, bleu = en cours, gris = à faire / inconnu. */ /**
* Couleur logique pour une piste : vert = tout terminé (via cartographie statuts),
* bleu = mix, gris = à faire.
*/
export function laneAggregateState( export function laneAggregateState(
subtasks: JiraIssue[], subtasks: JiraIssue[],
lane: WorkLane, lane: WorkLane,
isDone: (st: JiraIssue) => boolean,
laneLabels: LaneLabelsConfig,
): 'empty' | 'grey' | 'blue' | 'green' { ): 'empty' | 'grey' | 'blue' | 'green' {
const inLane = subtasks.filter((st) => detectWorkLane(st) === lane) const inLane = subtasks.filter((st) => detectWorkLane(st, laneLabels) === lane)
if (inLane.length === 0) return 'empty' if (inLane.length === 0) return 'empty'
const cats = inLane.map((st) => statusCategoryKey(st.fields.status)) if (inLane.every(isDone)) return 'green'
if (cats.every((c) => c === 'done')) return 'green' if (inLane.some(isDone) && inLane.some((st) => !isDone(st))) return 'blue'
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(' ') const names = inLane.map((st) => st.fields.status.name.toLowerCase()).join(' ')
if ( if (
/en cours|in progress|review|recette|test|qa|intégration|integration|development/i.test(names) /en cours|in progress|review|recette|test|qa|intégration|integration|development/i.test(names)

View File

@ -1,11 +1,14 @@
import type { StoryGroup } from '../types/jira' import type { StoryGroup } from '../types/jira'
import type { Milestone } from './dashboardConfig' import type { Milestone } from './dashboardConfig'
import { storyProgressPercent } from './storyMetrics' import { getRemainingEstimateUnits } from './jiraFieldExtractors'
import type { StatusBucketConfig } from './statusBuckets'
import { resolveWorkBucketFromIssue } from './statusBuckets'
import { subtaskDoneRatioPercent } from './storyMetrics'
/** Pour les jalons : story sans sous-tâche = considérée comme « livrée » côté sous-tâches. */ /** Pour les jalons : story sans sous-tâche = considérée comme « livrée » côté sous-tâches. */
function storyCompletionForMilestone(g: StoryGroup): number { function storyCompletionForMilestone(g: StoryGroup, cfg: StatusBucketConfig): number {
if (g.subtasks.length === 0) return 100 if (g.subtasks.length === 0) return 100
return storyProgressPercent(g.subtasks) return subtaskDoneRatioPercent(g.subtasks, cfg)
} }
function endOfDay(isoDate: string): Date { function endOfDay(isoDate: string): Date {
@ -14,19 +17,61 @@ function endOfDay(isoDate: string): Date {
return d return d
} }
/** Jalon en retard : date dépassée et au moins une story concernée nest pas à 100 %. */ /** Stories du périmètre du jalon (clés liées si renseignées, sinon tout le chargement). */
export function isMilestoneLate(m: Milestone, groups: StoryGroup[]): boolean { export function milestoneLinkedGroups(m: Milestone, groups: StoryGroup[]): StoryGroup[] {
if (groups.length === 0) return false
const deadline = endOfDay(m.date)
if (new Date() <= deadline) return false
const keys = const keys =
m.linkedStoryKeys && m.linkedStoryKeys.length > 0 m.linkedStoryKeys && m.linkedStoryKeys.length > 0
? m.linkedStoryKeys ? m.linkedStoryKeys
: groups.map((g) => g.story.key) : groups.map((g) => g.story.key)
for (const key of keys) { const set = new Set(keys)
const g = groups.find((x) => x.story.key === key) return groups.filter((g) => set.has(g.story.key))
if (!g) continue }
if (storyCompletionForMilestone(g) < 100) return true
/** Moyenne des % sous-tâches terminées sur les stories du périmètre (0100). */
export function milestoneAverageCompletionPercent(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
): number {
const linked = milestoneLinkedGroups(m, groups)
if (linked.length === 0) return 100
const sum = linked.reduce((acc, g) => acc + storyCompletionForMilestone(g, cfg), 0)
return Math.round(sum / linked.length)
}
/** Somme du reste à faire (unités Jira) sur sous-tâches non terminées / non annulées du périmètre. */
export function milestoneOpenRemainingUnits(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
): number {
let u = 0
for (const g of milestoneLinkedGroups(m, groups)) {
for (const s of g.subtasks) {
const b = resolveWorkBucketFromIssue(s, cfg)
if (b !== 'done' && b !== 'cancel') u += getRemainingEstimateUnits(s)
}
}
return u
}
/** Jours calendaires jusquà la fin du jour du jalon (négatif = retard). */
export function milestoneCalendarDaysUntil(m: Milestone): number {
const end = endOfDay(m.date)
return Math.ceil((end.getTime() - Date.now()) / 86400000)
}
/** Jalon en retard : date dépassée et au moins une story concernée nest pas à 100 %. */
export function isMilestoneLate(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
): boolean {
if (groups.length === 0) return false
const deadline = endOfDay(m.date)
if (new Date() <= deadline) return false
for (const g of milestoneLinkedGroups(m, groups)) {
if (storyCompletionForMilestone(g, cfg) < 100) return true
} }
return false return false
} }

13
src/lib/phaseAggregate.ts Normal file
View File

@ -0,0 +1,13 @@
import type { PhaseId, StoryGroup } from '../types/jira'
import { statusToPhase } from './statusPhase'
export function countSubtasksByPhase(groups: StoryGroup[]): Record<PhaseId, number> {
const acc: Record<PhaseId, number> = { analyse: 0, design: 0, integration: 0, done: 0 }
for (const g of groups) {
for (const s of g.subtasks) {
const p = statusToPhase(s.fields.status.name)
acc[p] += 1
}
}
return acc
}

125
src/lib/statusBuckets.ts Normal file
View File

@ -0,0 +1,125 @@
import type { JiraIssue } from '../types/jira'
/** Catégories de suivi (paramétrables selon vos libellés Jira). */
export type WorkStatusBucket = 'todo' | 'in_progress' | 'blocked' | 'done' | 'cancel'
export type StatusBucketConfig = {
todo: string[]
in_progress: string[]
blocked: string[]
done: string[]
cancel: string[]
}
export function normalizeStatusLabel(s: string): string {
return s
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/\p{M}/gu, '')
}
function matchesList(statusName: string, list: string[]): boolean {
const n = normalizeStatusLabel(statusName)
return list.some((entry) => normalizeStatusLabel(entry) === n)
}
/** Libellés Jira par défaut (insensible à la casse / accents au match). */
export function defaultStatusBucketConfig(): StatusBucketConfig {
return {
todo: [
'To Do',
'Todo',
'À faire',
'A faire',
'Open',
'Ouvert',
'0-OUVERT',
'0-Ouvert',
'New',
'Nouveau',
'Backlog',
'Sélectionné',
'Selectionne',
],
in_progress: [
'In Progress',
'En cours',
'In development',
'Code Review',
'Review',
'Recette',
'Test',
'QA',
'Intégration',
'Integration',
'Prêt pour développement',
'Pret pour developpement',
],
blocked: ['Blocked', 'Bloqué', 'Bloque'],
done: [
'Done',
'Terminé',
'Termine',
'Closed',
'Resolved',
'Résolu',
'Resolu',
'Livré',
'Livre',
'Fermé',
'Ferme',
],
cancel: ['Cancelled', 'Canceled', 'Annulé', 'Annule', "Won't fix", 'Wontfix'],
}
}
/** Fusionne la config sauvegardée avec les défauts (liste vide sur un seau → défaut). */
export function mergeStatusBucketConfig(partial?: Partial<StatusBucketConfig>): StatusBucketConfig {
const d = defaultStatusBucketConfig()
if (!partial) return d
const pick = (key: keyof StatusBucketConfig): string[] => {
const v = partial[key]
return Array.isArray(v) && v.length > 0 ? [...v] : d[key]
}
return {
todo: pick('todo'),
in_progress: pick('in_progress'),
blocked: pick('blocked'),
done: pick('done'),
cancel: pick('cancel'),
}
}
/**
* Résout le seau dun ticket : listes explicites puis repli sur `statusCategory` Jira.
*/
export function resolveWorkBucketFromIssue(
issue: JiraIssue,
cfg: StatusBucketConfig,
): WorkStatusBucket {
const name = issue.fields.status?.name ?? ''
if (matchesList(name, cfg.blocked)) return 'blocked'
if (matchesList(name, cfg.cancel)) return 'cancel'
if (matchesList(name, cfg.done)) return 'done'
if (matchesList(name, cfg.in_progress)) return 'in_progress'
if (matchesList(name, cfg.todo)) return 'todo'
const cat = issue.fields.status?.statusCategory?.key
if (cat === 'done') return 'done'
if (cat === 'new') return 'todo'
if (cat === 'indeterminate') return 'in_progress'
return 'todo'
}
export function isIssueDone(issue: JiraIssue, cfg: StatusBucketConfig): boolean {
return resolveWorkBucketFromIssue(issue, cfg) === 'done'
}
export function isIssueCanceled(issue: JiraIssue, cfg: StatusBucketConfig): boolean {
return resolveWorkBucketFromIssue(issue, cfg) === 'cancel'
}
export function isIssueBlocked(issue: JiraIssue, cfg: StatusBucketConfig): boolean {
return resolveWorkBucketFromIssue(issue, cfg) === 'blocked'
}

View File

@ -1,5 +1,7 @@
import type { JiraIssue, PhaseId } from '../types/jira' import type { JiraIssue, PhaseId } from '../types/jira'
import { PHASE_ORDER, statusToPhase } from './statusPhase' import { PHASE_ORDER, statusToPhase } from './statusPhase'
import type { StatusBucketConfig } from './statusBuckets'
import { isIssueCanceled, isIssueDone } from './statusBuckets'
function phaseRank(p: PhaseId): number { function phaseRank(p: PhaseId): number {
const i = PHASE_ORDER.indexOf(p) const i = PHASE_ORDER.indexOf(p)
@ -31,11 +33,28 @@ export function isStepComplete(subtasks: JiraIssue[], stepPhase: PhaseId): boole
return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx) return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx)
} }
export function stepperStates(subtasks: JiraIssue[]): Record<PhaseId, boolean> { /**
* % de sous-tâches **Done** parmi les tickets encore pertinents (hors **Cancel**).
* Aligné sur la cartographie des statuts (Réglages).
*/
export function subtaskDoneRatioPercent(
subtasks: JiraIssue[],
cfg: StatusBucketConfig,
): number {
const active = subtasks.filter((s) => !isIssueCanceled(s, cfg))
if (active.length === 0) return 0
const done = active.filter((s) => isIssueDone(s, cfg)).length
return Math.round((done / active.length) * 100)
}
export function stepperStates(
subtasks: JiraIssue[],
cfg: StatusBucketConfig,
): Record<PhaseId, boolean> {
return { return {
analyse: isStepComplete(subtasks, 'analyse'), analyse: isStepComplete(subtasks, 'analyse'),
design: isStepComplete(subtasks, 'design'), design: isStepComplete(subtasks, 'design'),
integration: isStepComplete(subtasks, 'integration'), integration: isStepComplete(subtasks, 'integration'),
done: subtasks.length > 0 && subtasks.every((st) => statusToPhase(st.fields.status.name) === 'done'), done: subtasks.length > 0 && subtasks.every((st) => isIssueDone(st, cfg)),
} }
} }

View File

@ -58,6 +58,8 @@ export type JiraIssueFields = {
priority?: JiraPriority | null priority?: JiraPriority | null
assignee?: JiraUser | null assignee?: JiraUser | null
timetracking?: JiraTimeTracking timetracking?: JiraTimeTracking
/** Étiquettes Jira (`labels`) si demandé dans `fields` de la recherche. */
labels?: string[]
/** Si demandé dans `fields` : sous-tâches sous le parent (à fusionner dans le lot si absentes en racine). */ /** Si demandé dans `fields` : sous-tâches sous le parent (à fusionner dans le lot si absentes en racine). */
subtasks?: JiraEmbeddedChildIssue[] subtasks?: JiraEmbeddedChildIssue[]
} }