diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000000..3b50678ee1b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,392 @@ +# Cycle KPI Feature Plan + +## Goal + +Implement a new `KPI` entry point from the project cycle list that opens a new cycle KPI screen at: + +`/[workspaceSlug]/projects/[projectId]/cycles/[cycleId]/kpi` + +The first KPI view must show a burndown chart based on estimate points, not ticket count. + +## Confirmed Product Decisions + +- The new action label is `KPI`. +- The new action must appear on the cycle row between the favorite star and the three-dot quick actions menu. +- The KPI page must live under the existing project cycle detail route. +- Version 1 only needs one KPI card/view: estimate-point burndown. +- Estimate points are the sum of each ticket's estimate. +- Cancelled items must not burn down the chart in version 1. +- The implementation should reuse existing route/layout/chart/data flows whenever possible. + +## Maintenance Rules For This File + +- [ ] Update this file every time code changes for this feature. +- [ ] Keep each checklist item in sync with the actual implementation state. +- [ ] Add newly touched files to the change log section after each implementation step. +- [ ] Record every test command that was run and its result in the test log section. +- [ ] Add follow-up tasks here immediately if new scope or blockers are discovered. + +## Change Log + +- [x] 2026-03-17: Created `PLAN.md` after investigating the cycle KPI route, action placement, data source reuse, and current test structure. +- [x] 2026-03-17: Removed mobile-specific implementation and QA scope from the plan per product clarification; this feature only needs desktop handling. +- [x] 2026-03-17: Added open-core import guidance to avoid depending on premium-only modules that are not present in this edition. +- [x] 2026-03-17: Implemented the desktop KPI action button in `apps/web/core/components/cycles/list/cycle-list-item-action.tsx`; the button now routes to the future `/kpi` screen, stays CE-safe, and is intentionally visible to any cycle viewer because it is navigation-only. Touched files: `apps/web/core/components/cycles/list/cycle-list-item-action.tsx`, `PLAN.md`. +- [x] 2026-03-17: Implemented the phase 3 KPI route shell in `apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/kpi/page.tsx` and `apps/web/core/components/cycles/kpi/page-shell.tsx`; the page now lives inside the cycle detail layout, fetches cycle details safely on direct access, reuses cycle analytics loading, and renders the initial KPI shell. Touched files: `apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/kpi/page.tsx`, `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-17: Wired the first KPI burndown card to estimate-point data by updating `apps/web/core/components/cycles/kpi/page-shell.tsx` and extending `apps/web/core/components/core/sidebar/progress-chart.tsx` for KPI-specific axis/legend copy while preserving existing consumers. The KPI page now renders the estimate-point burndown, summary metrics, loading behavior, and empty states for missing dates or missing estimates. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `apps/web/core/components/core/sidebar/progress-chart.tsx`, `PLAN.md`. +- [x] 2026-03-17: Isolated the KPI burndown implementation into `apps/web/core/components/cycles/kpi/burndown-chart.tsx` and restored `apps/web/core/components/core/sidebar/progress-chart.tsx` to its shared behavior so the existing cycle-page burndown remains untouched. Touched files: `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, `apps/web/core/components/cycles/kpi/page-shell.tsx`, `apps/web/core/components/core/sidebar/progress-chart.tsx`, `PLAN.md`. +- [x] 2026-03-17: Corrected the KPI-only burndown transformation in `apps/web/core/components/cycles/kpi/burndown-chart.tsx` so the chart normalizes unexpected completed-progress payloads into remaining points and clamps values to the valid range, fixing the observed `0` to negative line without affecting the existing cycle-page chart. Touched files: `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, `PLAN.md`. +- [x] 2026-03-17: Added a new phase 8 plan for KPI burndown filters so labels and other filter dimensions can be implemented in a dedicated follow-up without changing the current phase ordering. Touched files: `PLAN.md`. +- [x] 2026-03-17: Removed the KPI-only `Tendency remaining points` series from `apps/web/core/components/cycles/kpi/burndown-chart.tsx` so the KPI chart now only shows current and ideal remaining points. Touched files: `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, `PLAN.md`. +- [x] 2026-03-17: Implemented phase 8 user/assignee filtering fully on the frontend by updating `apps/web/core/components/cycles/kpi/filter-utils.ts` and `apps/web/core/components/cycles/kpi/page-shell.tsx` to intersect label and assignee selections, compute the burndown over the combined match, and provide a clear active-filter UI state. Touched files: `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-17: Aligned the KPI user filter button styling with the label filter by passing `hideIcon` and explicit `ChevronDown` to `MemberDropdown` in `apps/web/core/components/cycles/kpi/page-shell.tsx`, matching the default avatar-free look requested in the screenshot. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-17: Fixed a rendering and layout bug where the label filter button was duplicated during UI alignment in `apps/web/core/components/cycles/kpi/page-shell.tsx`. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-17: Fixed the double ChevronDown icon issue in the `LabelDropdown` on the KPI page. The `LabelDropdown` component already rendered its own chevron, so the explicitly added one in the label wrapper caused the duplication. Removed the redundant explicit icon from `apps/web/core/components/cycles/kpi/page-shell.tsx`. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-17: Replaced the KPI summary `Project` card with a business-days-until-cycle-end metric in `apps/web/core/components/cycles/kpi/page-shell.tsx` so the top KPI row stays focused on cycle timing and burndown context. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-17: Refined the business-days KPI card wording in `apps/web/core/components/cycles/kpi/page-shell.tsx` so past cycles show `Cycle ended` instead of `0 business days`, which matches the project's cycle-time context better than `finished`. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-17: Aligned the KPI summary cards with the burndown chart cutoff logic so completed and remaining points now use the same effective date as the chart instead of counting work completed after the cycle end. Touched files: `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-17: Styled weekend days in the KPI burndown x-axis as red labels in `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, keeping the change scoped to the KPI chart. Touched files: `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, `PLAN.md`. +- [x] 2026-03-17: Fixed the KPI weekend-day styling by driving the x-axis from raw ISO dates instead of preformatted labels, so the weekend tick renderer can correctly identify Saturdays and Sundays before formatting them. Touched files: `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, `PLAN.md`. +- [x] 2026-03-17: Forced the KPI burndown x-axis to render every day individually by passing explicit daily ticks and `interval: 0` through the shared area chart component. Touched files: `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, `packages/propel/src/charts/area-chart/root.tsx`, `packages/types/src/charts/index.ts`, `PLAN.md`. +- [x] 2026-03-17: Refined the KPI x-axis rendering with `minTickGap: 0`, rotated tick labels, and extra bottom margin so every day stays visible instead of being visually collapsed. Touched files: `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, `packages/propel/src/charts/area-chart/root.tsx`, `packages/types/src/charts/index.ts`, `PLAN.md`. +- [x] 2026-03-17: Reworked the KPI x-axis tick renderer to show compact per-day labels with day numbers and month markers instead of long rotated full dates, and added shared x-axis height support so the compact daily labels have enough vertical space. Touched files: `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, `packages/propel/src/charts/area-chart/root.tsx`, `packages/types/src/charts/index.ts`, `PLAN.md`. +- [x] 2026-03-17: Properly fixed the KPI daily-axis regression by reverting the raw-date/shared-axis changes and restoring the original short formatted x-axis labels, while coloring weekends through a label-to-date mapping inside `apps/web/core/components/cycles/kpi/burndown-chart.tsx`. This matches the earlier non-collapsed behavior and keeps the fix scoped to the KPI chart. Touched files: `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, `packages/propel/src/charts/area-chart/root.tsx`, `packages/types/src/charts/index.ts`, `PLAN.md`. +- [x] 2026-03-17: Fixed the final missing-days regression on the KPI burndown chart by reapplying explicit `interval: 0`, `minTickGap: 0`, and explicit string ticks to the restored short-date x-axis. Touched files: `apps/web/core/components/cycles/kpi/burndown-chart.tsx`, `packages/propel/src/charts/area-chart/root.tsx`, `packages/types/src/charts/index.ts`, `PLAN.md`. +- [x] 2026-03-19: Added a new KPI block below Burndown KPI with a points-by-label bar chart, implemented in `apps/web/core/components/cycles/kpi/page-shell.tsx`, `apps/web/core/components/cycles/kpi/filter-utils.ts`, and new `apps/web/core/components/cycles/kpi/label-points-chart.tsx`. The chart now reuses the same member filter state (`selectedAssigneeIds`) as burndown so assignee selection consistently scopes both visualizations. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/label-points-chart.tsx`, `PLAN.md`. +- [x] 2026-03-19: Fixed KPI points-by-label filtering to follow the active Burndown KPI filters correctly by making label-store reads reactive in `apps/web/core/components/cycles/kpi/page-shell.tsx` and applying both selected labels and selected assignees in `apps/web/core/components/cycles/kpi/filter-utils.ts`; also consolidated missing label metadata into a single `Unknown label` bucket to avoid duplicate unknown bars. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `apps/web/core/components/cycles/kpi/filter-utils.ts`, `PLAN.md`. +- [x] 2026-03-19: Added a new Status KPI block below the Label KPI with a points-by-status bar chart (To Do/Done/Blocked/Cancelled/custom states), backed by new state aggregation logic in `apps/web/core/components/cycles/kpi/filter-utils.ts`, a dedicated chart component `apps/web/core/components/cycles/kpi/state-points-chart.tsx`, and state-store wiring in `apps/web/core/components/cycles/kpi/page-shell.tsx`. The new chart follows the same active assignee/label filters used by Burndown KPI. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/state-points-chart.tsx`, `PLAN.md`. +- [x] 2026-03-19: Aligned Label KPI empty/no-match messaging in `apps/web/core/components/cycles/kpi/page-shell.tsx` to reference active filters (members + labels) instead of members-only wording, matching the filter behavior now shared by burndown, label, and status charts. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-19: Time-capped both bar-chart KPIs (label/status) to the same burndown cutoff logic in `apps/web/core/components/cycles/kpi/filter-utils.ts` by excluding items completed after the burndown cutoff date from bar aggregations, and wired `cycleEndDate` into both builders in `apps/web/core/components/cycles/kpi/page-shell.tsx`. Updated KPI copy to clarify time-capped behavior. Touched files: `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-19: Revised the bar-chart time-cap implementation in `apps/web/core/components/cycles/kpi/filter-utils.ts` to match burndown math: label KPI now stays aligned with burndown scope, while status KPI keeps full scope but moves issues completed after cutoff into a dedicated `Completed after cycle end` bucket so burndown remaining points are represented instead of disappearing. Updated KPI explanatory copy in `apps/web/core/components/cycles/kpi/page-shell.tsx`. Touched files: `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-19: Removed the extra late-completion status bucket from Status KPI and reworked `apps/web/core/components/cycles/kpi/filter-utils.ts` so issues completed after the burndown cutoff are reclassified into an existing in-cycle state (started/unstarted/backlog fallback) instead of creating a new column, keeping points-by-status aligned with burndown completion logic without introducing synthetic status labels. Updated status KPI copy in `apps/web/core/components/cycles/kpi/page-shell.tsx`. Touched files: `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-19: Added issue-level hover details for bar charts by enriching KPI aggregation outputs with per-bucket issue summaries in `apps/web/core/components/cycles/kpi/filter-utils.ts` and wiring custom tooltip content in `apps/web/core/components/cycles/kpi/label-points-chart.tsx` and `apps/web/core/components/cycles/kpi/state-points-chart.tsx` to show issue lists for the hovered bar. Touched files: `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/label-points-chart.tsx`, `apps/web/core/components/cycles/kpi/state-points-chart.tsx`, `PLAN.md`. +- [x] 2026-03-20: Added a conditional KPI stat card in `apps/web/core/components/cycles/kpi/page-shell.tsx` that appears when user filtering is active and shows the count of filtered tickets without estimate points, positioned next to Remaining in the burndown stats row. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-20: Changed the `Without estimate` KPI stat in `apps/web/core/components/cycles/kpi/page-shell.tsx` to be always visible (not conditional on user filtering), keeping it fixed next to Completed and Remaining while still using the currently active filter scope for its count. Touched files: `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-20: Updated points-by-status aggregation and rendering to include statuses that only contain unestimated issues (0 points), append `*` to status labels with unestimated work, and expose unestimated issue counts in status-chart hover tooltips. Implemented in `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/state-points-chart.tsx`, and `apps/web/core/components/cycles/kpi/page-shell.tsx`. Touched files: `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/state-points-chart.tsx`, `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-20: Added a new `Points by user` KPI block below points-by-status with a stacked bar chart that aggregates per-user status counts in each bar and keeps unestimated visibility by marking users with `*` and showing unestimated counts in tooltip details. Implemented in `apps/web/core/components/cycles/kpi/filter-utils.ts`, new `apps/web/core/components/cycles/kpi/user-points-chart.tsx`, and `apps/web/core/components/cycles/kpi/page-shell.tsx` (including project member fetch for display names). Touched files: `apps/web/core/components/cycles/kpi/filter-utils.ts`, `apps/web/core/components/cycles/kpi/user-points-chart.tsx`, `apps/web/core/components/cycles/kpi/page-shell.tsx`, `PLAN.md`. +- [x] 2026-03-20: Improved points-by-user readability in `apps/web/core/components/cycles/kpi/user-points-chart.tsx` by adding horizontal scroll with dynamic minimum chart width so all user labels remain accessible and by increasing per-column spacing using narrower bar width. Touched files: `apps/web/core/components/cycles/kpi/user-points-chart.tsx`, `PLAN.md`. +- [x] 2026-03-20: Refined points-by-user x-axis readability in `apps/web/core/components/cycles/kpi/user-points-chart.tsx` by tilting member labels and tightening column spacing (smaller gap) while keeping horizontal scroll for dense datasets. Touched files: `apps/web/core/components/cycles/kpi/user-points-chart.tsx`, `PLAN.md`. +- [x] 2026-03-20: Ensured all user names render on points-by-user x-axis by forcing full x-axis ticks (interval/minTickGap/ticks wiring in `packages/propel/src/charts/bar-chart/root.tsx`) and kept status legend/title outside the horizontal scroll area via an external legend in `apps/web/core/components/cycles/kpi/user-points-chart.tsx`. Also tuned column density to keep smaller gaps. Touched files: `packages/propel/src/charts/bar-chart/root.tsx`, `apps/web/core/components/cycles/kpi/user-points-chart.tsx`, `PLAN.md`. +- [x] 2026-03-20: Finalized points-by-user axis rendering to guarantee all users are shown by switching the x-axis category key to stable user IDs and rendering display names via a custom rotated tick label map in `apps/web/core/components/cycles/kpi/user-points-chart.tsx`; removed the x-axis title text (`Users`) as requested. Status legend remains outside the scrollable chart region. Touched files: `apps/web/core/components/cycles/kpi/user-points-chart.tsx`, `PLAN.md`. + +## Test Log + +- [x] 2026-03-17: Attempted `pnpm exec eslint core/components/cycles/list/cycle-list-item-action.tsx` from `apps/web`; failed because `pnpm` is not installed in the shell environment. +- [x] 2026-03-17: Attempted `npm exec pnpm -- exec eslint core/components/cycles/list/cycle-list-item-action.tsx` from `apps/web`; failed because the local ESLint config package `@plane/eslint-config/next.js` is unavailable without workspace dependencies installed. +- [x] 2026-03-17: Attempted `npm exec pnpm -- check:types` from `apps/web`; failed because `tsc` is unavailable and the workspace `node_modules` are not installed. +- [x] 2026-03-17: `pnpm --filter web exec eslint "app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/kpi/page.tsx" "core/components/cycles/kpi/page-shell.tsx"` passed. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after the phase 3 KPI route shell changes. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/core/sidebar/progress-chart.tsx" "core/components/cycles/kpi/page-shell.tsx"` passed after wiring the estimate-point burndown card. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after wiring the estimate-point burndown card. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/core/sidebar/progress-chart.tsx" "core/components/cycles/kpi/burndown-chart.tsx" "core/components/cycles/kpi/page-shell.tsx"` passed after isolating the KPI chart implementation. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after isolating the KPI chart implementation. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/burndown-chart.tsx" "core/components/cycles/kpi/page-shell.tsx"` passed after correcting the KPI burndown transformation. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after correcting the KPI burndown transformation. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/burndown-chart.tsx"` passed after removing the KPI tendency line. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after removing the KPI tendency line. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/page-shell.tsx" "core/components/cycles/kpi/burndown-chart.tsx"` passed after implementing client-side label filtering. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after implementing client-side label filtering. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/page-shell.tsx"` passed after implementing client-side user filtering. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after implementing client-side user filtering. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx"` passed after aligning the user filter button UI. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after aligning the user filter button UI. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx"` passed after fixing the duplicate label filter. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after fixing the duplicate label filter. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx"` passed after removing the duplicate ChevronDown icon. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after removing the duplicate ChevronDown icon. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx"` passed after replacing the project card with the business-days-left metric. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after replacing the project card with the business-days-left metric. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx"` passed after refining the business-days-left wording. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after refining the business-days-left wording. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/page-shell.tsx"` passed after aligning KPI cards with the burndown cutoff logic. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after aligning KPI cards with the burndown cutoff logic. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/burndown-chart.tsx"` passed after styling weekend labels in the KPI burndown chart. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after styling weekend labels in the KPI burndown chart. +- [x] 2026-03-17: `pnpm --filter web exec eslint "core/components/cycles/kpi/burndown-chart.tsx"` passed after fixing the KPI weekend-date tick source. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after fixing the KPI weekend-date tick source. +- [x] 2026-03-17: `pnpm exec eslint "apps/web/core/components/cycles/kpi/burndown-chart.tsx" "packages/propel/src/charts/area-chart/root.tsx"` passed after forcing the KPI x-axis to render every day individually. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after forcing the KPI x-axis to render every day individually. +- [x] 2026-03-17: `pnpm exec eslint "apps/web/core/components/cycles/kpi/burndown-chart.tsx" "packages/propel/src/charts/area-chart/root.tsx"` passed after rotating KPI tick labels and forcing zero tick gap. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after rotating KPI tick labels and forcing zero tick gap. +- [x] 2026-03-17: `pnpm exec eslint "apps/web/core/components/cycles/kpi/burndown-chart.tsx" "packages/propel/src/charts/area-chart/root.tsx" "packages/types/src/charts/index.ts"` passed with existing warnings in `packages/types/src/charts/index.ts` after switching the KPI x-axis to compact day-number tick labels. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after switching the KPI x-axis to compact day-number tick labels. +- [x] 2026-03-17: `pnpm exec eslint "apps/web/core/components/cycles/kpi/burndown-chart.tsx" "packages/propel/src/charts/area-chart/root.tsx" "packages/types/src/charts/index.ts"` passed with existing warnings in `packages/types/src/charts/index.ts` after restoring the original short x-axis labels and KPI-only weekend mapping. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after restoring the original short x-axis labels and KPI-only weekend mapping. +- [x] 2026-03-17: `pnpm exec eslint "apps/web/core/components/cycles/kpi/burndown-chart.tsx" "packages/propel/src/charts/area-chart/root.tsx" "packages/types/src/charts/index.ts"` passed after explicitly forcing all dates to render on the short-date axis. +- [x] 2026-03-17: `pnpm --filter web check:types` passed after explicitly forcing all dates to render on the short-date axis. +- [x] 2026-03-19: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx" "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/label-points-chart.tsx"` passed after adding the points-by-label KPI block. +- [x] 2026-03-19: `pnpm --filter web check:types` passed after adding the points-by-label KPI block. +- [x] 2026-03-19: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx" "core/components/cycles/kpi/filter-utils.ts"` passed after fixing points-by-label filter behavior. +- [x] 2026-03-19: `pnpm --filter web check:types` passed after fixing points-by-label filter behavior. +- [x] 2026-03-19: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx" "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/state-points-chart.tsx"` passed after adding the points-by-status KPI block. +- [x] 2026-03-19: `pnpm --filter web check:types` passed after adding the points-by-status KPI block. +- [x] 2026-03-19: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx" "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/state-points-chart.tsx"` passed after aligning Label KPI active-filter messaging with current filter logic. +- [x] 2026-03-19: `pnpm --filter web check:types` passed after aligning Label KPI active-filter messaging with current filter logic. +- [x] 2026-03-19: `pnpm --filter web exec eslint "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/page-shell.tsx"` passed after adding burndown-style time-capping to label/status KPI charts. +- [x] 2026-03-19: `pnpm --filter web check:types` passed after adding burndown-style time-capping to label/status KPI charts. +- [x] 2026-03-19: `pnpm --filter web exec eslint "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/page-shell.tsx"` passed after revising status KPI to bucket late completions instead of dropping them. +- [x] 2026-03-19: `pnpm --filter web check:types` passed after revising status KPI to bucket late completions instead of dropping them. +- [x] 2026-03-19: `pnpm --filter web exec eslint "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/page-shell.tsx"` passed after removing the extra late-completion status column and reclassifying late completions into existing in-cycle states. +- [x] 2026-03-19: `pnpm --filter web check:types` passed after removing the extra late-completion status column and reclassifying late completions into existing in-cycle states. +- [x] 2026-03-19: `pnpm --filter web exec eslint "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/label-points-chart.tsx" "core/components/cycles/kpi/state-points-chart.tsx"` passed after adding issue-list tooltip details to both KPI bar charts. +- [x] 2026-03-19: `pnpm --filter web check:types` passed after adding issue-list tooltip details to both KPI bar charts. +- [x] 2026-03-20: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx"` passed after adding the conditional "Without estimate" KPI stat for user-filtered views. +- [x] 2026-03-20: `pnpm --filter web check:types` passed after adding the conditional "Without estimate" KPI stat for user-filtered views. +- [x] 2026-03-20: `pnpm --filter web exec eslint "core/components/cycles/kpi/page-shell.tsx"` passed after making the `Without estimate` KPI stat always visible. +- [x] 2026-03-20: `pnpm --filter web check:types` passed after making the `Without estimate` KPI stat always visible. +- [x] 2026-03-20: `pnpm --filter web exec eslint "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/state-points-chart.tsx" "core/components/cycles/kpi/page-shell.tsx"` passed after adding status `*` markers and unestimated counts in points-by-status tooltips. +- [x] 2026-03-20: `pnpm --filter web check:types` passed after adding status `*` markers and unestimated counts in points-by-status tooltips. +- [x] 2026-03-20: `pnpm --filter web exec eslint "core/components/cycles/kpi/filter-utils.ts" "core/components/cycles/kpi/user-points-chart.tsx" "core/components/cycles/kpi/page-shell.tsx"` passed after adding points-by-user stacked status chart and unestimated markers. +- [x] 2026-03-20: `pnpm --filter web check:types` passed after adding points-by-user stacked status chart and unestimated markers. +- [x] 2026-03-20: `pnpm --filter web exec eslint "core/components/cycles/kpi/user-points-chart.tsx"` passed after adding horizontal scroll and larger user-column spacing to points-by-user. +- [x] 2026-03-20: `pnpm --filter web check:types` passed after adding horizontal scroll and larger user-column spacing to points-by-user. +- [x] 2026-03-20: `pnpm --filter web exec eslint "core/components/cycles/kpi/user-points-chart.tsx"` passed after tilting user labels and reducing gaps between user columns. +- [x] 2026-03-20: `pnpm --filter web check:types` passed after tilting user labels and reducing gaps between user columns. +- [x] 2026-03-20: `pnpm --filter web exec eslint "core/components/cycles/kpi/user-points-chart.tsx" "../../packages/propel/src/charts/bar-chart/root.tsx"` passed after forcing all x-axis user ticks and moving status legend outside the scrollable chart area. +- [x] 2026-03-20: `pnpm --filter web check:types` passed after forcing all x-axis user ticks and moving status legend outside the scrollable chart area. +- [x] 2026-03-20: `pnpm --filter web exec eslint "core/components/cycles/kpi/user-points-chart.tsx"` passed after switching x-axis categories to user IDs and mapping all visible tick labels to display names. +- [x] 2026-03-20: `pnpm --filter web check:types` passed after switching x-axis categories to user IDs and mapping all visible tick labels to display names. + +## Investigation Summary + +### Frontend integration points already identified + +- `apps/web/tsconfig.json` + - `@/plane-web/*` resolves to `ce/*` in this edition. + - New code must respect that alias and avoid direct imports from premium-only `ee/*` paths. + - If an integration point looks premium, prefer an existing `@/plane-web/*` export or a local `core/*` import. +- `apps/web/core/components/cycles/list/cycle-list-item-action.tsx` + - Current cycle row action strip. + - Favorite star and `CycleQuickActions` already live here. + - This is the correct insertion point for the new `KPI` button. +- `apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx` + - Existing cycle detail shell. + - A nested `kpi/page.tsx` will inherit the current cycle header and content wrapper. +- `apps/web/core/components/cycles/active-cycle/use-cycles-details.ts` + - Already fetches cycle progress plus `issues` and `points` analytics. + - This should be reused instead of creating a new data-loading hook. +- `apps/web/core/components/core/sidebar/progress-chart.tsx` + - Existing burndown-style chart renderer. + - Good candidate for reuse in version 1, possibly with small prop extensions for copy/labels. + +### Backend data source already identified + +- `apps/web/core/services/cycle.service.ts` + - Already exposes `workspaceActiveCyclesAnalytics(..., "points")`. +- `apps/web/core/store/cycle.store.ts` + - Already stores `estimate_distribution` when `analytic_type === "points"`. +- `apps/api/plane/app/views/cycle/base.py` + - `CycleAnalyticsEndpoint` already supports `?type=points`. +- `apps/api/plane/utils/analytics_plot.py` + - `burndown_plot(..., plot_type="points")` already burns down by summed estimate-point values. + - Current burndown drops only on `completed_at`, which matches version 1 requirements. + +### Test structure investigation + +- Backend automated tests already exist under `apps/api/plane/tests`. +- Backend test runner is `pytest` with markers defined in `apps/api/pytest.ini`: + - `unit` + - `contract` + - `smoke` +- Shared backend fixtures live in `apps/api/plane/tests/conftest.py`. +- Existing cycle API coverage lives in `apps/api/plane/tests/contract/api/test_cycles.py`. +- No frontend automated test suite was found for `apps/web`: + - `apps/web/package.json` has no `test` script. + - No Jest/Vitest/Playwright config files were found in the repo during investigation. +- Existing open-core import pattern includes CE stubs/no-op exports for features that are premium elsewhere. + - Example: `apps/web/ce/components/views/publish/use-view-publish.tsx` provides a fallback implementation for a `@/plane-web/*` import. +- Because this feature adds frontend navigation and page rendering, implementation must include frontend automated test support before the feature can be considered fully covered. + +## Implementation Checklist + +### 1. Plan hygiene before and during implementation + +- [ ] Before changing code, update this file to mark the next work items as in progress in the change log if useful. +- [ ] After each code change, update completed checkboxes in this file before ending the task. +- [ ] After each code change, append the touched file paths to the change log. +- [ ] After each code change, append test commands and results to the test log. + +### 2. KPI action entry on the cycles list + +- [x] Update `apps/web/core/components/cycles/list/cycle-list-item-action.tsx`. +- [x] Keep imports aligned with the edition-safe pattern: use `core/*` or `@/plane-web/*` aliases that resolve in CE, and do not import `ee/*` directly. +- [x] Insert a new `KPI` action between `FavoriteStar` and `CycleQuickActions` in render order. +- [x] Ensure clicking `KPI` does not trigger the parent row click behavior. +- [x] Ensure clicking `KPI` does not toggle `peekCycle` accidentally. +- [x] Route to `/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/kpi`. +- [x] Keep the action styling visually consistent with the existing row actions. +- [ ] Verify layout when the favorite star is hidden (for example archived or permission-limited states). +- [x] Decide and document whether `KPI` should be visible for read-only users; keep the final behavior explicit in code and tests. + +### 3. New KPI route and page shell + +- [x] Create `apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/kpi/page.tsx`. +- [x] Reuse existing open-core-safe imports only; if a shared extension point is needed, prefer an existing `@/plane-web/*` CE export over a premium-only file path. +- [x] Reuse the existing cycle detail layout inherited from `apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx`. +- [x] Reuse existing route params: `workspaceSlug`, `projectId`, and `cycleId`. +- [x] Set an appropriate browser/page title if the page pattern supports it. +- [x] Reuse `useCyclesDetails(...)` so the KPI page does not create duplicate fetch logic. +- [x] Handle the case where the cycle is missing or has been deleted. +- [x] Handle the case where analytics are still loading. +- [x] Keep the first version focused on a single KPI view; do not add extra KPI tabs/cards unless required during implementation. + +### 4. KPI page content + +- [x] Add a clear page heading for KPI content. +- [x] Add supporting copy that explicitly says the chart uses estimate points. +- [x] Render a single burndown card for version 1. +- [x] Use cycle metadata already loaded by the store when helpful (cycle name, dates, project context). +- [x] Add a loading state that does not flash broken chart markup. +- [x] Add an empty state when the cycle has no estimate points. +- [x] Add an empty state when the cycle has no valid start/end dates. +- [x] Ensure the page remains readable in the supported desktop layout. + +### 5. Burndown data wiring + +- [x] Read chart data from `cycle.estimate_distribution.completion_chart`. +- [x] Read total scope from `cycle.total_estimate_points`. +- [x] Do not use `cycle.distribution.completion_chart` on the KPI page. +- [x] Do not allow the KPI page to silently fall back to ticket-count burndown. +- [x] Confirm the ideal line is calculated against total estimate points. +- [x] Confirm future dates continue to render `null` values consistently with the existing API contract. +- [x] Confirm version 1 behavior leaves cancelled issues out of the burndown reduction logic. +- [x] Confirm estimate-less issues do not distort the points chart. + +### 6. Chart component reuse or extension + +- [x] Decide whether `apps/web/core/components/core/sidebar/progress-chart.tsx` can be reused unchanged. +- [x] If reuse is not sufficient, make the smallest backward-compatible extension possible. +- [x] If extending the chart, support KPI-specific copy such as: + - x-axis: `Time` + - y-axis: `Remaining points` +- [x] Keep existing chart consumers working without behavioral regressions. +- [x] Keep legend labels aligned with estimate-point language instead of work-item language on the KPI page. +- [x] Keep the KPI chart focused on current and ideal remaining points only; no tendency series. + +### 7. Backend production code changes (only if truly needed) + +- [x] Verify the existing `?type=points` analytics response is enough before changing backend production code. +- [ ] Only change backend production code if the current response cannot support the KPI page cleanly. +- [x] Do not introduce cancelled-item burndown logic in version 1. +- [x] Do not create a KPI-specific API endpoint unless reuse of the existing analytics endpoint becomes impossible. +- [ ] If backend production code changes are needed, keep them minimal and document exactly why in this file. + +### 8. KPI burndown chart filters + +- [x] Add filter controls to the KPI burndown screen without changing the existing cycle-page burndown behavior. +- [x] Start with label filtering as the first supported filter dimension. +- [x] Place the filter UI near the KPI burndown card header so the relationship to the chart is obvious. +- [x] Support selecting one or more labels to scope the burndown chart. +- [x] Ensure that if a work item has the selected label `bug`, the KPI chart includes only estimate points from work items with that label. +- [x] Define the default filter state as `All work items` so the current KPI chart remains the baseline view. +- [x] Show the active filter state clearly in the UI. +- [x] Handle the case where no labels exist in the cycle. +- [x] Handle the case where the selected label set returns no matching work items. +- [x] Add user/assignee filtering to the KPI burndown screen so the chart can be scoped to specific cycle participants. +- [x] Preserve the desktop-only scope for the KPI route while implementing the filter controls. +- [x] Decide whether filtering should be purely client-side over already-fetched cycle issues or backed by a dedicated filtered analytics request. +- [x] Prefer the simplest correct implementation that does not distort the estimate-point burndown math. +- [x] If client-side filtering is chosen, verify that the necessary issue-label and estimate data are available for all cycle work items used in the KPI view. +- [x] Client-side filtering is chosen; no server-side analytics contract change is needed for this phase. +- [x] Keep cancelled items excluded from the filtered burndown in the same way as the unfiltered KPI chart for version 1. +- [x] Query-string persistence is not added in this phase; the filter remains local to the KPI screen state. +- [x] Document the final filter-state behavior in this plan before implementation is considered complete. +- [x] Add a points-by-label bar chart block below Burndown KPI and scope it with the same member filter selection used by the burndown chart. +- [x] Keep the points-by-label chart scoped to the active KPI filters and ensure unknown/deleted label metadata does not render as multiple indistinguishable `Unknown label` bars. +- [x] Add a points-by-status bar chart block below Label KPI and scope it to the same active KPI filters (members and labels), while supporting custom project states. +- [x] Ensure both bar-chart KPIs (points by label and points by status) apply the same burndown time-cap semantics so work completed after cycle end does not appear in those bar-chart totals. +- [x] Keep bar-chart totals reconcilable with burndown cards by preserving burndown scope in bar charts and representing post-cutoff completions explicitly in status KPIs instead of silently excluding those points. +- [x] Keep points-by-status aligned with burndown completion rules without creating synthetic/new status columns for late completions. +- [x] Show issue-level details in bar-chart tooltips (at least for points-by-status) so hovering a bar reveals which issues compose that bucket. +- [x] When user filtering is active, show a dedicated KPI card beside Remaining with the count of filtered tickets that do not have estimate points. +- [x] Keep the `Without estimate` KPI card fixed/always visible in the burndown stats row, not only in user-filtered mode. +- [x] In points-by-status, include statuses even when all their issues are unestimated (0 points), mark such statuses with `*`, and show unestimated issue counts in hover tooltips. +- [x] Add a points-by-user block below points-by-status as a stacked bar chart by status counts per user, and keep unestimated visibility (markers + tooltip count) for users. +- [x] Improve points-by-user x-axis usability for many members by enabling horizontal scrolling and increasing spacing between user columns. +- [x] Improve points-by-user readability by tilting member labels and slightly reducing gaps between columns. +- [x] Always display all user names on the points-by-user axis (no interleaving/skipped ticks) and keep status legend labels outside the horizontal scroll container. +- [x] Remove the `Users` x-axis title from points-by-user and guarantee all user labels are rendered without category collisions. + +## Automated Test Checklist + +### 9. Frontend automated test setup + +- [ ] Add a frontend automated test runner for `apps/web`. +- [ ] Recommended approach: add Vitest + React Testing Library for route/component coverage with minimal setup cost. +- [ ] Add a `test` script to `apps/web/package.json`. +- [ ] Add supporting config/setup files for: + - path aliases + - jsdom environment + - shared mocks for Next navigation/router hooks + - shared assertions/setup utilities +- [ ] Ensure the new test setup can run in isolation without requiring the full app to boot. + +### 10. Frontend automated tests for the KPI feature + +- [ ] Add a test that the cycle list row renders a `KPI` action. +- [ ] Add a test that the `KPI` action is rendered in the action group before the three-dot quick actions control. +- [ ] Add a test that clicking `KPI` routes to `/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/kpi`. +- [ ] Add a test that clicking `KPI` does not trigger parent row navigation side effects. +- [ ] Add a test that the KPI page requests/uses estimate-point analytics rather than issue-count analytics. +- [ ] Add a test that the KPI page renders a loading state before data is ready. +- [ ] Add a test that the KPI page renders an empty state when `total_estimate_points` is `0`. +- [ ] Add a test that the KPI page renders an empty state when cycle dates are missing. +- [ ] Add a test that the KPI page renders the burndown chart when point analytics are present. +- [ ] Add a test that the KPI page chart copy refers to estimate points. +- [ ] Add a test that cancelled counts do not alter the KPI page chart input in version 1. +- [ ] Add a test that selecting a label filter such as `bug` limits the KPI burndown to work items with that label. +- [ ] Add a test that clearing the filter returns the KPI burndown to the all-work-items baseline. +- [ ] Add a test that an empty filtered result shows the intended no-data state. + +### 11. Backend automated tests for the KPI feature + +- [ ] Add new cycle analytics contract tests in `apps/api/plane/tests/contract/api/test_cycles.py` or a dedicated `test_cycle_analytics.py`. +- [ ] Create reusable fixtures for: + - workspace + - project with point-based estimation enabled + - cycle with explicit start/end dates + - states needed to model backlog, completed, and cancelled cases + - issues attached to the cycle with estimate points +- [ ] Add a contract test for `GET /api/v1/workspaces//projects//cycles//analytics/?type=points` returning `200 OK`. +- [ ] Add a contract test proving `completion_chart` values are based on summed estimate points, not issue count. +- [ ] Add a contract test proving a completed issue burns down the chart by its estimate-point value. +- [ ] Add a contract test proving a cancelled issue does not burn down the chart in version 1. +- [ ] Add a contract test proving issues without estimate points are ignored by the points burndown. +- [ ] Add a contract test proving the response includes the full cycle date range as keys. +- [ ] Add a contract test proving future dates return `null` values when appropriate for active cycles. +- [ ] Add a contract test for a no-estimate cycle response shape if the KPI page depends on it. +- [ ] If server-side label filtering is implemented, add a contract test proving `label=bug` (or equivalent filter parameter) only includes estimate points from matching work items. + +### 12. Manual verification checklist + +- [ ] Open the project cycles list on desktop and confirm the `KPI` action appears in the expected position. +- [ ] Click `KPI` and confirm the browser navigates to the expected `/kpi` URL. +- [ ] Confirm the cycle row does not trigger unwanted alternate navigation when `KPI` is clicked. +- [ ] Confirm the KPI page renders inside the existing cycle detail shell. +- [ ] Confirm the burndown chart is present for a cycle with estimate points. +- [ ] Confirm the chart reflects remaining estimate points over time. +- [ ] Confirm completed items reduce the remaining points. +- [ ] Confirm cancelled items do not reduce the remaining points in this release. +- [ ] Confirm a cycle without estimate points shows the intended empty state. +- [ ] Confirm a missing cycle shows a safe fallback instead of a crash. +- [ ] Confirm selecting the `bug` label filter updates the KPI burndown to only matching work items. +- [ ] Confirm clearing the filter restores the unfiltered KPI burndown. + +## Validation Commands Checklist + +- [ ] `pnpm --filter web check:types` +- [ ] `pnpm --filter web check:lint` +- [ ] `pnpm --filter web test` (after test setup is added) +- [ ] `python -m pytest plane/tests/contract/api/test_cycles.py -m contract -v` from `apps/api` +- [ ] `python run_tests.py -c -v` from `apps/api` when broader contract regression coverage is needed + +## Definition Of Done + +- [ ] The cycle list shows a working `KPI` action in the agreed position. +- [ ] The KPI route exists and renders successfully. +- [ ] The KPI page shows a burndown based on estimate points only. +- [ ] Version 1 does not burn down cancelled items. +- [ ] Frontend automated coverage exists for navigation, rendering, and empty/loading states. +- [ ] Backend automated coverage exists for points analytics behavior. +- [ ] Relevant lint/type/test commands pass. +- [ ] `PLAN.md` has been updated to reflect the final implementation status, touched files, and test results. diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/kpi/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/kpi/page.tsx new file mode 100644 index 00000000000..a9d79b41e9a --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/kpi/page.tsx @@ -0,0 +1,5 @@ +import { CycleKpiPageShell } from "@/components/cycles/kpi/page-shell"; + +export default function CycleKpiPage() { + return ; +} diff --git a/apps/web/core/components/cycles/kpi/burndown-chart.tsx b/apps/web/core/components/cycles/kpi/burndown-chart.tsx new file mode 100644 index 00000000000..cf4bcfa50f1 --- /dev/null +++ b/apps/web/core/components/cycles/kpi/burndown-chart.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import type { ComponentType } from "react"; +// plane imports +import { AreaChart } from "@plane/propel/charts/area-chart"; +import type { TChartData, TCycleCompletionChartDistribution } from "@plane/types"; +import { getDate, renderFormattedDateWithoutYear } from "@plane/utils"; + +type TKpiWeekendXAxisTickProps = { + x?: number; + y?: number; + payload?: { + value?: string; + }; +}; + +const KpiWeekendXAxisTick = React.memo( + ({ x = 0, y = 0, payload, isWeekend = false }) => ( + + + {payload?.value} + + + ) +); +KpiWeekendXAxisTick.displayName = "KpiWeekendXAxisTick"; + +type Props = { + distribution: TCycleCompletionChartDistribution; + totalEstimatePoints: number; + className?: string; +}; + +export const KpiBurndownChart: React.FC = ({ distribution, totalEstimatePoints, className = "" }) => { + const distributionKeys = Object.keys(distribution ?? []); + const stepCount = Math.max(distributionKeys.length - 1, 1); + const weekendLabels = new Set( + distributionKeys + .filter((key) => { + const date = getDate(key); + return !!date && [0, 6].includes(date.getDay()); + }) + .map((key) => renderFormattedDateWithoutYear(key)) + ); + + const rawValues = distributionKeys + .map((key) => distribution[key]) + .filter((value): value is number => typeof value === "number"); + + const shouldTreatAsNegativeCompleted = rawValues.some((value) => value < 0); + const shouldTreatAsCompletedProgress = + !shouldTreatAsNegativeCompleted && rawValues.length > 1 && rawValues[0] <= rawValues[rawValues.length - 1]; + + const normalizeCurrentValue = (value: number | null) => { + if (typeof value !== "number") return value; + + let nextCurrent = value; + + if (shouldTreatAsNegativeCompleted) { + nextCurrent = totalEstimatePoints + value; + } else if (shouldTreatAsCompletedProgress) { + nextCurrent = totalEstimatePoints - value; + } + + return Math.min(totalEstimatePoints, Math.max(0, nextCurrent)); + }; + + const xAxisConfig = { + key: "name", + label: "Time", + interval: 0, + minTickGap: 0, + ticks: distributionKeys.map((key) => renderFormattedDateWithoutYear(key)), + } as unknown as { + key: string; + label?: string; + strokeColor?: string; + dy?: number; + minTickGap?: number; + ticks?: Array; + }; + + const chartData = distributionKeys.map((key, index) => ({ + name: renderFormattedDateWithoutYear(key), + current: normalizeCurrentValue(distribution[key]), + ideal: totalEstimatePoints * (1 - index / stepCount), + })) as unknown as TChartData[]; + + const WeekendTickComponent = ((props: unknown) => ( + + )) as ComponentType; + + return ( +
+ +
+ ); +}; diff --git a/apps/web/core/components/cycles/kpi/filter-utils.ts b/apps/web/core/components/cycles/kpi/filter-utils.ts new file mode 100644 index 00000000000..6aa270384cf --- /dev/null +++ b/apps/web/core/components/cycles/kpi/filter-utils.ts @@ -0,0 +1,545 @@ +import type { IIssueLabel, IState, TIssue, TCycleCompletionChartDistribution } from "@plane/types"; +import { getDate } from "@plane/utils"; + +type TBuildCycleKpiBurndownParams = { + issues: TIssue[]; + selectedLabelIds: string[]; + selectedAssigneeIds: string[]; + cycleStartDate: Date; + cycleEndDate: Date; + getEstimatePointValue: (estimatePointId: string | null) => number; +}; + +type TBuildCycleKpiLabelPointsParams = { + issues: TIssue[]; + projectLabels: IIssueLabel[]; + selectedLabelIds: string[]; + selectedAssigneeIds: string[]; + getEstimatePointValue: (estimatePointId: string | null) => number; +}; + +type TBuildCycleKpiStatePointsParams = { + issues: TIssue[]; + projectStates: IState[]; + selectedLabelIds: string[]; + selectedAssigneeIds: string[]; + cycleEndDate: Date; + getEstimatePointValue: (estimatePointId: string | null) => number; +}; + +type TBuildCycleKpiUserPointsParams = { + issues: TIssue[]; + projectStates: IState[]; + selectedLabelIds: string[]; + selectedAssigneeIds: string[]; + cycleEndDate: Date; + getEstimatePointValue: (estimatePointId: string | null) => number; + getUserDisplayName: (userId: string) => string | undefined; +}; + +export type TCycleKpiBurndownData = { + distribution: TCycleCompletionChartDistribution; + totalEstimatePoints: number; + completedEstimatePoints: number; + pendingEstimatePoints: number; + currentRemainingEstimatePoints: number; + currentCompletedEstimatePoints: number; + matchingIssuesCount: number; + matchingEstimatedIssuesCount: number; +}; + +export type TCycleKpiLabelPointsItem = { + key: string; + name: string; + color: string; + points: number; + issueCount: number; + issues: TCycleKpiIssueSummary[]; +}; + +export type TCycleKpiLabelPointsData = { + data: TCycleKpiLabelPointsItem[]; + matchingIssuesCount: number; + matchingEstimatedIssuesCount: number; +}; + +export type TCycleKpiStatePointsItem = { + key: string; + name: string; + color: string; + points: number; + issueCount: number; + unestimatedIssueCount: number; + issues: TCycleKpiIssueSummary[]; +}; + +export type TCycleKpiIssueSummary = { + id: string; + sequenceId: number; + name: string; +}; + +export type TCycleKpiStatePointsData = { + data: TCycleKpiStatePointsItem[]; + matchingIssuesCount: number; + matchingEstimatedIssuesCount: number; +}; + +export type TCycleKpiUserStatusSeriesItem = { + key: string; + name: string; + color: string; +}; + +export type TCycleKpiUserPointsItem = { + key: string; + name: string; + issueCount: number; + unestimatedIssueCount: number; + estimatedPoints: number; + stateIssueCounts: Record; + issues: TCycleKpiIssueSummary[]; +}; + +export type TCycleKpiUserPointsData = { + data: TCycleKpiUserPointsItem[]; + statusSeries: TCycleKpiUserStatusSeriesItem[]; + matchingIssuesCount: number; +}; + +const NO_LABEL_KEY = "__no_label__"; +const UNKNOWN_LABEL_KEY = "__unknown_label__"; +const NO_STATE_KEY = "__no_state__"; +const UNKNOWN_STATE_KEY = "__unknown_state__"; +const NO_ASSIGNEE_KEY = "__no_assignee__"; +const DEFAULT_BAR_COLOR = "#3F76FF"; + +const getDateKey = (date: Date) => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + + return `${year}-${month}-${day}`; +}; + +const normalizeDate = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()); + +const getChartCutoffDate = (cycleEndDate: Date) => { + const today = normalizeDate(new Date()); + const normalizedCycleEndDate = normalizeDate(cycleEndDate); + + return normalizedCycleEndDate < today ? normalizedCycleEndDate : today; +}; + +const matchesAssigneeFilter = (issue: TIssue, selectedAssigneeSet: Set) => + selectedAssigneeSet.size === 0 || issue.assignee_ids?.some((assigneeId) => selectedAssigneeSet.has(assigneeId)); + +const matchesLabelFilter = (issue: TIssue, selectedLabelSet: Set) => + selectedLabelSet.size === 0 || issue.label_ids?.some((labelId) => selectedLabelSet.has(labelId)); + +const isCompletedLikeGroup = (group: string | null | undefined) => group === "completed" || group === "cancelled"; + +const getLateCompletionFallbackStateId = (projectStates: IState[]) => + projectStates.find((state) => state.group === "started")?.id ?? + projectStates.find((state) => state.group === "unstarted")?.id ?? + projectStates.find((state) => state.group === "backlog")?.id ?? + undefined; + +const getIssueSummary = (issue: TIssue): TCycleKpiIssueSummary => ({ + id: issue.id, + sequenceId: issue.sequence_id, + name: issue.name, +}); + +const resolveStateKeyForIssue = ({ + issue, + chartCutoffDate, + stateById, + lateCompletionFallbackStateId, +}: { + issue: TIssue; + chartCutoffDate: Date; + stateById: Map; + lateCompletionFallbackStateId?: string; +}) => { + const completedDate = getDate(issue.completed_at); + const issueState = issue.state_id ? stateById.get(issue.state_id) : undefined; + const issueGroup = issueState?.group ?? issue.state__group; + const isLateCompletion = !!completedDate && completedDate > chartCutoffDate && isCompletedLikeGroup(issueGroup); + + if (isLateCompletion) return lateCompletionFallbackStateId ?? NO_STATE_KEY; + if (!issue.state_id) return NO_STATE_KEY; + + return stateById.has(issue.state_id) ? issue.state_id : UNKNOWN_STATE_KEY; +}; + +const getStateMeta = (stateKey: string, stateById: Map) => { + if (stateKey === NO_STATE_KEY) { + return { + name: "No state", + color: DEFAULT_BAR_COLOR, + }; + } + + if (stateKey === UNKNOWN_STATE_KEY) { + return { + name: "Unknown state", + color: DEFAULT_BAR_COLOR, + }; + } + + const state = stateById.get(stateKey); + return { + name: state?.name ?? "Unknown state", + color: state?.color ?? DEFAULT_BAR_COLOR, + }; +}; + +const getDateRange = (startDate: Date, endDate: Date) => { + const dates: Date[] = []; + const cursor = normalizeDate(startDate); + const lastDate = normalizeDate(endDate); + + while (cursor <= lastDate) { + dates.push(new Date(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + + return dates; +}; + +export const buildCycleKpiBurndownData = ({ + issues, + selectedLabelIds, + selectedAssigneeIds, + cycleStartDate, + cycleEndDate, + getEstimatePointValue, +}: TBuildCycleKpiBurndownParams): TCycleKpiBurndownData => { + const selectedLabelSet = new Set(selectedLabelIds); + const selectedAssigneeSet = new Set(selectedAssigneeIds); + const chartCutoffDate = getChartCutoffDate(cycleEndDate); + const today = normalizeDate(new Date()); + const matchingIssues = issues.filter( + (issue) => matchesLabelFilter(issue, selectedLabelSet) && matchesAssigneeFilter(issue, selectedAssigneeSet) + ); + + const estimatedIssues = matchingIssues + .map((issue) => ({ + issue, + estimatePoints: getEstimatePointValue(issue.estimate_point), + completedDate: getDate(issue.completed_at), + })) + .filter((item) => item.estimatePoints > 0); + + const totalEstimatePoints = estimatedIssues.reduce((total, item) => total + item.estimatePoints, 0); + const completedEstimatePoints = estimatedIssues.reduce((total, item) => { + if (!item.completedDate || item.issue.state__group === "cancelled") return total; + return total + item.estimatePoints; + }, 0); + const currentCompletedEstimatePoints = estimatedIssues.reduce((total, item) => { + if (!item.completedDate || item.issue.state__group === "cancelled") return total; + return item.completedDate <= chartCutoffDate ? total + item.estimatePoints : total; + }, 0); + + const distribution = getDateRange(cycleStartDate, cycleEndDate).reduce( + (acc, date) => { + const dateKey = getDateKey(date); + + if (date > today) { + acc[dateKey] = null; + return acc; + } + + const completedThroughDate = estimatedIssues.reduce((total, item) => { + if (!item.completedDate || item.issue.state__group === "cancelled") return total; + return item.completedDate <= date ? total + item.estimatePoints : total; + }, 0); + + acc[dateKey] = Math.max(0, totalEstimatePoints - completedThroughDate); + return acc; + }, + {} + ); + + return { + distribution, + totalEstimatePoints, + completedEstimatePoints, + pendingEstimatePoints: Math.max(0, totalEstimatePoints - currentCompletedEstimatePoints), + currentRemainingEstimatePoints: Math.max(0, totalEstimatePoints - currentCompletedEstimatePoints), + currentCompletedEstimatePoints, + matchingIssuesCount: matchingIssues.length, + matchingEstimatedIssuesCount: estimatedIssues.length, + }; +}; + +export const buildCycleKpiLabelPointsData = ({ + issues, + projectLabels, + selectedLabelIds, + selectedAssigneeIds, + getEstimatePointValue, +}: TBuildCycleKpiLabelPointsParams): TCycleKpiLabelPointsData => { + const selectedLabelSet = new Set(selectedLabelIds); + const selectedAssigneeSet = new Set(selectedAssigneeIds); + const matchingIssues = issues.filter( + (issue) => matchesLabelFilter(issue, selectedLabelSet) && matchesAssigneeFilter(issue, selectedAssigneeSet) + ); + const labelById = new Map(projectLabels.map((label) => [label.id, label])); + const labelPointsMap = new Map; issues: TCycleKpiIssueSummary[] }>(); + + let matchingEstimatedIssuesCount = 0; + + matchingIssues.forEach((issue) => { + const estimatePoints = getEstimatePointValue(issue.estimate_point); + if (estimatePoints <= 0) return; + + matchingEstimatedIssuesCount += 1; + const issueLabelIds = issue.label_ids?.length ? Array.from(new Set(issue.label_ids)) : [NO_LABEL_KEY]; + const labelIdsToAggregate = + selectedLabelSet.size > 0 ? issueLabelIds.filter((labelId) => selectedLabelSet.has(labelId)) : issueLabelIds; + + labelIdsToAggregate.forEach((labelId) => { + const aggregationKey = + labelId === NO_LABEL_KEY ? NO_LABEL_KEY : labelById.has(labelId) ? labelId : UNKNOWN_LABEL_KEY; + const current = labelPointsMap.get(aggregationKey) ?? { + points: 0, + issueIds: new Set(), + issues: [], + }; + current.points += estimatePoints; + if (!current.issueIds.has(issue.id)) { + current.issueIds.add(issue.id); + current.issues.push(getIssueSummary(issue)); + } + labelPointsMap.set(aggregationKey, current); + }); + }); + + const data = Array.from(labelPointsMap.entries()) + .map(([labelId, aggregate]) => { + if (labelId === NO_LABEL_KEY) { + return { + key: labelId, + name: "No label", + color: DEFAULT_BAR_COLOR, + points: aggregate.points, + issueCount: aggregate.issueIds.size, + issues: [...aggregate.issues].sort((a, b) => a.sequenceId - b.sequenceId), + }; + } + + if (labelId === UNKNOWN_LABEL_KEY) { + return { + key: labelId, + name: "Unknown label", + color: DEFAULT_BAR_COLOR, + points: aggregate.points, + issueCount: aggregate.issueIds.size, + issues: [...aggregate.issues].sort((a, b) => a.sequenceId - b.sequenceId), + }; + } + + const label = labelById.get(labelId); + return { + key: labelId, + name: label?.name ?? "Unknown label", + color: label?.color ?? DEFAULT_BAR_COLOR, + points: aggregate.points, + issueCount: aggregate.issueIds.size, + issues: [...aggregate.issues].sort((a, b) => a.sequenceId - b.sequenceId), + }; + }) + .sort((a, b) => b.points - a.points || a.name.localeCompare(b.name)); + + return { + data, + matchingIssuesCount: matchingIssues.length, + matchingEstimatedIssuesCount, + }; +}; + +export const buildCycleKpiStatePointsData = ({ + issues, + projectStates, + selectedLabelIds, + selectedAssigneeIds, + cycleEndDate, + getEstimatePointValue, +}: TBuildCycleKpiStatePointsParams): TCycleKpiStatePointsData => { + const selectedLabelSet = new Set(selectedLabelIds); + const selectedAssigneeSet = new Set(selectedAssigneeIds); + const chartCutoffDate = getChartCutoffDate(cycleEndDate); + const matchingIssues = issues.filter( + (issue) => matchesLabelFilter(issue, selectedLabelSet) && matchesAssigneeFilter(issue, selectedAssigneeSet) + ); + const stateById = new Map(projectStates.map((state) => [state.id, state])); + const lateCompletionFallbackStateId = getLateCompletionFallbackStateId(projectStates); + const statePointsMap = new Map< + string, + { + points: number; + issueIds: Set; + issues: TCycleKpiIssueSummary[]; + unestimatedIssueCount: number; + } + >(); + + let matchingEstimatedIssuesCount = 0; + + matchingIssues.forEach((issue) => { + const estimatePoints = getEstimatePointValue(issue.estimate_point); + const isEstimated = estimatePoints > 0; + if (isEstimated) matchingEstimatedIssuesCount += 1; + const stateKey = resolveStateKeyForIssue({ + issue, + chartCutoffDate, + stateById, + lateCompletionFallbackStateId, + }); + + const current = statePointsMap.get(stateKey) ?? { + points: 0, + issueIds: new Set(), + issues: [], + unestimatedIssueCount: 0, + }; + if (isEstimated) { + current.points += estimatePoints; + } else { + current.unestimatedIssueCount += 1; + } + if (!current.issueIds.has(issue.id)) { + current.issueIds.add(issue.id); + current.issues.push(getIssueSummary(issue)); + } + statePointsMap.set(stateKey, current); + }); + + const data = Array.from(statePointsMap.entries()) + .map(([stateKey, aggregate]) => { + const stateMeta = getStateMeta(stateKey, stateById); + return { + key: stateKey, + name: aggregate.unestimatedIssueCount > 0 ? `${stateMeta.name}*` : stateMeta.name, + color: stateMeta.color, + points: aggregate.points, + issueCount: aggregate.issueIds.size, + unestimatedIssueCount: aggregate.unestimatedIssueCount, + issues: [...aggregate.issues].sort((a, b) => a.sequenceId - b.sequenceId), + }; + }) + .sort((a, b) => b.points - a.points || a.name.localeCompare(b.name)); + + return { + data, + matchingIssuesCount: matchingIssues.length, + matchingEstimatedIssuesCount, + }; +}; + +export const buildCycleKpiUserPointsData = ({ + issues, + projectStates, + selectedLabelIds, + selectedAssigneeIds, + cycleEndDate, + getEstimatePointValue, + getUserDisplayName, +}: TBuildCycleKpiUserPointsParams): TCycleKpiUserPointsData => { + const selectedLabelSet = new Set(selectedLabelIds); + const selectedAssigneeSet = new Set(selectedAssigneeIds); + const chartCutoffDate = getChartCutoffDate(cycleEndDate); + const matchingIssues = issues.filter( + (issue) => matchesLabelFilter(issue, selectedLabelSet) && matchesAssigneeFilter(issue, selectedAssigneeSet) + ); + const stateById = new Map(projectStates.map((state) => [state.id, state])); + const lateCompletionFallbackStateId = getLateCompletionFallbackStateId(projectStates); + const userPointsMap = new Map< + string, + { + name: string; + issueIds: Set; + issues: TCycleKpiIssueSummary[]; + unestimatedIssueCount: number; + estimatedPoints: number; + stateIssueCounts: Record; + } + >(); + const encounteredStateKeys = new Set(); + + matchingIssues.forEach((issue) => { + const estimatePoints = getEstimatePointValue(issue.estimate_point); + const isEstimated = estimatePoints > 0; + const stateKey = resolveStateKeyForIssue({ + issue, + chartCutoffDate, + stateById, + lateCompletionFallbackStateId, + }); + + encounteredStateKeys.add(stateKey); + + const issueAssigneeIds = issue.assignee_ids?.length ? Array.from(new Set(issue.assignee_ids)) : [NO_ASSIGNEE_KEY]; + + issueAssigneeIds.forEach((assigneeId) => { + const assigneeName = + assigneeId === NO_ASSIGNEE_KEY ? "Unassigned" : (getUserDisplayName(assigneeId) ?? "Unknown user"); + + const current = userPointsMap.get(assigneeId) ?? { + name: assigneeName, + issueIds: new Set(), + issues: [], + unestimatedIssueCount: 0, + estimatedPoints: 0, + stateIssueCounts: {}, + }; + + current.stateIssueCounts[stateKey] = (current.stateIssueCounts[stateKey] ?? 0) + 1; + + if (!current.issueIds.has(issue.id)) { + current.issueIds.add(issue.id); + current.issues.push(getIssueSummary(issue)); + if (!isEstimated) { + current.unestimatedIssueCount += 1; + } + } + + if (isEstimated) { + current.estimatedPoints += estimatePoints; + } + + userPointsMap.set(assigneeId, current); + }); + }); + + const statusSeries = Array.from(encounteredStateKeys) + .map((stateKey) => { + const stateMeta = getStateMeta(stateKey, stateById); + return { + key: stateKey, + name: stateMeta.name, + color: stateMeta.color, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + const data = Array.from(userPointsMap.entries()) + .map(([userId, aggregate]) => ({ + key: userId, + name: aggregate.unestimatedIssueCount > 0 ? `${aggregate.name}*` : aggregate.name, + issueCount: aggregate.issueIds.size, + unestimatedIssueCount: aggregate.unestimatedIssueCount, + estimatedPoints: aggregate.estimatedPoints, + stateIssueCounts: aggregate.stateIssueCounts, + issues: [...aggregate.issues].sort((a, b) => a.sequenceId - b.sequenceId), + })) + .sort((a, b) => b.issueCount - a.issueCount || a.name.localeCompare(b.name)); + + return { + data, + statusSeries, + matchingIssuesCount: matchingIssues.length, + }; +}; diff --git a/apps/web/core/components/cycles/kpi/label-points-chart.tsx b/apps/web/core/components/cycles/kpi/label-points-chart.tsx new file mode 100644 index 00000000000..86dd806fece --- /dev/null +++ b/apps/web/core/components/cycles/kpi/label-points-chart.tsx @@ -0,0 +1,120 @@ +import React from "react"; +// plane imports +import { BarChart } from "@plane/propel/charts/bar-chart"; +import type { TChartData } from "@plane/types"; +// components +import type { TCycleKpiLabelPointsItem } from "@/components/cycles/kpi/filter-utils"; + +type TLabelPointsChartDatum = TChartData<"name", "points"> & { + key: string; + color: string; + issues: TCycleKpiLabelPointsItem["issues"]; +}; + +type Props = { + data: TCycleKpiLabelPointsItem[]; + className?: string; +}; + +const HiddenXAxisTick = React.memo(() => null); +HiddenXAxisTick.displayName = "HiddenXAxisTick"; + +export const KpiLabelPointsChart: React.FC = ({ data, className = "" }) => { + const chartData = data.map((item) => ({ + key: item.key, + name: item.name, + points: item.points, + color: item.color, + issues: item.issues, + })) as TLabelPointsChartDatum[]; + + const minChartWidth = Math.max(760, chartData.length * 88); + const xAxisTicks = chartData.map((item) => item.name); + const chartXAxis = { + key: "name", + dy: 0, + interval: 0, + minTickGap: 0, + ticks: xAxisTicks, + } as unknown as { key: "name"; dy: number }; + + return ( +
+
+
+ []} + bars={[ + { + key: "points", + label: "Estimate points", + stackId: "bar-one", + fill: (payload) => payload.color ?? "#3F76FF", + textClassName: "", + showPercentage: false, + showTopBorderRadius: () => true, + showBottomBorderRadius: () => true, + }, + ]} + barSize={32} + margin={{ bottom: 8 }} + xAxis={chartXAxis} + yAxis={{ key: "points", label: "Estimate points", offset: -58, dx: -24, allowDecimals: true }} + customTicks={{ + x: HiddenXAxisTick as React.ComponentType, + }} + customTooltipContent={({ active, payload }) => { + const chartItem = Array.isArray(payload) + ? (payload?.[0]?.payload as TLabelPointsChartDatum | undefined) + : undefined; + + if (!active || !chartItem) return null; + + const visibleIssues = chartItem.issues.slice(0, 8); + const remainingIssuesCount = chartItem.issues.length - visibleIssues.length; + + return ( +
+

+ {chartItem.name} +

+

+ Estimate points: {chartItem.points} +

+

Issues ({chartItem.issues.length})

+ +
+ {visibleIssues.map((issue) => ( +

+ #{issue.sequenceId} {issue.name} +

+ ))} + {remainingIssuesCount > 0 && ( +

+{remainingIssuesCount} more issues

+ )} +
+
+ ); + }} + /> + +
+
+ {chartData.map((item) => ( +
+ + {item.name} + +
+ ))} +
+
+
+
+
+ ); +}; diff --git a/apps/web/core/components/cycles/kpi/page-shell.tsx b/apps/web/core/components/cycles/kpi/page-shell.tsx new file mode 100644 index 00000000000..23ec5ca3a19 --- /dev/null +++ b/apps/web/core/components/cycles/kpi/page-shell.tsx @@ -0,0 +1,744 @@ +"use client"; + +import type { FC } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { X, ChevronDown } from "lucide-react"; +// plane imports +import type { TIssue } from "@plane/types"; +import { Loader } from "@plane/ui"; +import { getDate, renderFormattedDateWithoutYear } from "@plane/utils"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { PageHead } from "@/components/core/page-title"; +import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details"; +import { KpiBurndownChart } from "@/components/cycles/kpi/burndown-chart"; +import { + buildCycleKpiBurndownData, + buildCycleKpiLabelPointsData, + buildCycleKpiStatePointsData, + buildCycleKpiUserPointsData, +} from "@/components/cycles/kpi/filter-utils"; +import { KpiLabelPointsChart } from "@/components/cycles/kpi/label-points-chart"; +import { KpiStatePointsChart } from "@/components/cycles/kpi/state-points-chart"; +import { KpiUserPointsChart } from "@/components/cycles/kpi/user-points-chart"; +import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; +import { LabelDropdown } from "@/components/issues/issue-layouts/properties/label-dropdown"; +// hooks +import { useProjectEstimates } from "@/hooks/store/estimates"; +import { useCycle } from "@/hooks/store/use-cycle"; +import { useLabel } from "@/hooks/store/use-label"; +import { useMember } from "@/hooks/store/use-member"; +import { useProject } from "@/hooks/store/use-project"; +import { useProjectState } from "@/hooks/store/use-project-state"; +import { useAppRouter } from "@/hooks/use-app-router"; +// assets +import emptyCycle from "@/public/empty-state/cycle.svg"; +// services +import { CycleService } from "@/services/cycle.service"; + +const cycleService = new CycleService(); + +const KpiStat: FC<{ label: string; value: string }> = ({ label, value }) => ( +
+

{label}

+

{value}

+
+); + +const fetchAllCycleIssues = async (workspaceSlug: string, projectId: string, cycleId: string): Promise => { + const perPage = 1000; + let nextCursor: string | undefined = undefined; + const allIssues: TIssue[] = []; + + while (true) { + const response = await cycleService.getCycleIssues(workspaceSlug, projectId, cycleId, { + per_page: perPage, + ...(nextCursor ? { cursor: nextCursor } : {}), + }); + + const responseIssues = Array.isArray(response.results) ? response.results : []; + allIssues.push(...responseIssues); + + if (!response.next_page_results || !response.next_cursor) break; + nextCursor = response.next_cursor; + } + + return Array.from(new Map(allIssues.map((issue) => [issue.id, issue])).values()); +}; + +const countBusinessDaysUntilEnd = (endDate: Date | null | undefined) => { + if (!endDate) return null; + + const today = new Date(); + const cursor = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const lastDate = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()); + + if (lastDate < cursor) return 0; + + let businessDays = 0; + while (cursor <= lastDate) { + const day = cursor.getDay(); + if (day !== 0 && day !== 6) businessDays += 1; + cursor.setDate(cursor.getDate() + 1); + } + + return businessDays; +}; + +const getBusinessDaysUntilEndLabel = (endDate: Date | null | undefined, businessDaysUntilEnd: number | null) => { + if (!endDate || businessDaysUntilEnd === null) return "Not available"; + + const today = new Date(); + const normalizedToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const normalizedEndDate = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()); + + if (normalizedEndDate < normalizedToday) return "Cycle ended"; + if (businessDaysUntilEnd === 0) return "Cycle ends today"; + + return `${businessDaysUntilEnd} business day${businessDaysUntilEnd === 1 ? "" : "s"}`; +}; + +export const CycleKpiPageShell = observer(() => { + const router = useAppRouter(); + const { workspaceSlug, projectId, cycleId } = useParams() as { + workspaceSlug: string; + projectId: string; + cycleId: string; + }; + + const { getCycleById, fetchCycleDetails } = useCycle(); + const { fetchProjectLabels, getProjectLabels } = useLabel(); + const { fetchProjectStates, getProjectStates } = useProjectState(); + const { + project: { fetchProjectMembers, getProjectMemberDetails }, + } = useMember(); + const { getProjectById } = useProject(); + const { currentActiveEstimateIdByProjectId, getEstimateById, getProjectEstimates } = useProjectEstimates(); + + const cycle = cycleId ? getCycleById(cycleId) : null; + const project = projectId ? getProjectById(projectId) : null; + const rawProjectLabels = getProjectLabels(projectId); + const projectLabels = useMemo(() => rawProjectLabels ?? [], [rawProjectLabels]); + const rawProjectStates = getProjectStates(projectId); + const projectStates = useMemo(() => rawProjectStates ?? [], [rawProjectStates]); + const activeEstimateId = projectId ? currentActiveEstimateIdByProjectId(projectId) : undefined; + const activeEstimate = activeEstimateId ? getEstimateById(activeEstimateId) : undefined; + + const [isCycleLoading, setIsCycleLoading] = useState(() => !!cycleId && !cycle); + const [didCycleFetchFail, setDidCycleFetchFail] = useState(false); + const [cycleIssues, setCycleIssues] = useState([]); + const [isFilterDataLoading, setIsFilterDataLoading] = useState(true); + const [didFilterDataFail, setDidFilterDataFail] = useState(false); + const [selectedLabelIds, setSelectedLabelIds] = useState([]); + const [selectedAssigneeIds, setSelectedAssigneeIds] = useState([]); + + useCyclesDetails({ + workspaceSlug, + projectId, + cycleId, + }); + + useEffect(() => { + if (!workspaceSlug || !projectId || !cycleId || cycle) return; + + let isMounted = true; + setIsCycleLoading(true); + setDidCycleFetchFail(false); + + fetchCycleDetails(workspaceSlug, projectId, cycleId) + .catch(() => { + if (isMounted) setDidCycleFetchFail(true); + }) + .finally(() => { + if (isMounted) setIsCycleLoading(false); + }); + + return () => { + isMounted = false; + }; + }, [workspaceSlug, projectId, cycleId, cycle, fetchCycleDetails]); + + useEffect(() => { + if (!workspaceSlug || !projectId || !cycleId) return; + + let isMounted = true; + setIsFilterDataLoading(true); + setDidFilterDataFail(false); + + Promise.all([ + fetchAllCycleIssues(workspaceSlug, projectId, cycleId), + fetchProjectLabels(workspaceSlug, projectId), + fetchProjectStates(workspaceSlug, projectId), + fetchProjectMembers(workspaceSlug, projectId), + getProjectEstimates(workspaceSlug, projectId), + ]) + .then(([issues]) => { + if (isMounted) setCycleIssues(issues); + }) + .catch(() => { + if (isMounted) { + setDidFilterDataFail(true); + setCycleIssues([]); + } + }) + .finally(() => { + if (isMounted) setIsFilterDataLoading(false); + }); + + return () => { + isMounted = false; + }; + }, [ + workspaceSlug, + projectId, + cycleId, + fetchProjectLabels, + fetchProjectStates, + fetchProjectMembers, + getProjectEstimates, + ]); + + const pageTitle = useMemo(() => { + if (project?.name && cycle?.name) return `${project.name} - ${cycle.name} KPI`; + if (cycle?.name) return `${cycle.name} KPI`; + return "Cycle KPI"; + }, [project?.name, cycle?.name]); + + const cycleStartDate = getDate(cycle?.start_date); + const cycleEndDate = getDate(cycle?.end_date); + const hasValidCycleDates = !!cycleStartDate && !!cycleEndDate && cycleEndDate >= cycleStartDate; + const cycleLabelIds = useMemo( + () => Array.from(new Set(cycleIssues.flatMap((issue) => issue.label_ids ?? []))), + [cycleIssues] + ); + const cycleAssigneeIds = useMemo( + () => Array.from(new Set(cycleIssues.flatMap((issue) => issue.assignee_ids ?? []))), + [cycleIssues] + ); + const availableLabels = useMemo( + () => projectLabels.filter((label) => cycleLabelIds.includes(label.id)), + [projectLabels, cycleLabelIds] + ); + const selectedLabels = useMemo( + () => availableLabels.filter((label) => selectedLabelIds.includes(label.id)), + [availableLabels, selectedLabelIds] + ); + const selectedLabelSummary = + selectedLabels.length === 0 + ? "All labels" + : selectedLabels.length === 1 + ? selectedLabels[0].name + : `${selectedLabels[0].name} +${selectedLabels.length - 1}`; + const selectedAssigneesSummary = + selectedAssigneeIds.length === 0 + ? "All users" + : `${selectedAssigneeIds.length} user${selectedAssigneeIds.length === 1 ? "" : "s"}`; + + const filteredBurndown = useMemo(() => { + if (!cycleStartDate || !cycleEndDate || !activeEstimate) return undefined; + + return buildCycleKpiBurndownData({ + issues: cycleIssues, + selectedLabelIds, + selectedAssigneeIds, + cycleStartDate, + cycleEndDate, + getEstimatePointValue: (estimatePointId) => { + if (!estimatePointId) return 0; + return Number(activeEstimate.estimatePointById(estimatePointId)?.value ?? 0); + }, + }); + }, [cycleIssues, selectedLabelIds, selectedAssigneeIds, cycleStartDate, cycleEndDate, activeEstimate]); + + const labelPointsData = useMemo(() => { + if (!activeEstimate) return undefined; + + return buildCycleKpiLabelPointsData({ + issues: cycleIssues, + projectLabels, + selectedLabelIds, + selectedAssigneeIds, + getEstimatePointValue: (estimatePointId) => { + if (!estimatePointId) return 0; + return Number(activeEstimate.estimatePointById(estimatePointId)?.value ?? 0); + }, + }); + }, [cycleIssues, projectLabels, selectedLabelIds, selectedAssigneeIds, activeEstimate]); + + const statePointsData = useMemo(() => { + if (!activeEstimate || !cycleEndDate) return undefined; + + return buildCycleKpiStatePointsData({ + issues: cycleIssues, + projectStates, + selectedLabelIds, + selectedAssigneeIds, + cycleEndDate, + getEstimatePointValue: (estimatePointId) => { + if (!estimatePointId) return 0; + return Number(activeEstimate.estimatePointById(estimatePointId)?.value ?? 0); + }, + }); + }, [cycleIssues, projectStates, selectedLabelIds, selectedAssigneeIds, cycleEndDate, activeEstimate]); + + const userPointsData = useMemo(() => { + if (!activeEstimate || !cycleEndDate) return undefined; + + return buildCycleKpiUserPointsData({ + issues: cycleIssues, + projectStates, + selectedLabelIds, + selectedAssigneeIds, + cycleEndDate, + getEstimatePointValue: (estimatePointId) => { + if (!estimatePointId) return 0; + return Number(activeEstimate.estimatePointById(estimatePointId)?.value ?? 0); + }, + getUserDisplayName: (userId) => getProjectMemberDetails(userId, projectId)?.member.display_name, + }); + }, [ + cycleIssues, + projectStates, + selectedLabelIds, + selectedAssigneeIds, + cycleEndDate, + activeEstimate, + getProjectMemberDetails, + projectId, + ]); + + const defaultTotalEstimatePoints = + cycle?.progress_snapshot?.total_estimate_points ?? cycle?.total_estimate_points ?? 0; + const defaultCompletedEstimatePoints = + cycle?.progress_snapshot?.completed_estimate_points ?? cycle?.completed_estimate_points ?? 0; + const defaultPendingEstimatePoints = + (cycle?.progress_snapshot?.backlog_estimate_points ?? cycle?.backlog_estimate_points ?? 0) + + (cycle?.progress_snapshot?.unstarted_estimate_points ?? cycle?.unstarted_estimate_points ?? 0) + + (cycle?.progress_snapshot?.started_estimate_points ?? cycle?.started_estimate_points ?? 0); + + const totalEstimatePoints = filteredBurndown?.totalEstimatePoints ?? defaultTotalEstimatePoints; + const completedEstimatePoints = filteredBurndown?.currentCompletedEstimatePoints ?? defaultCompletedEstimatePoints; + const pendingEstimatePoints = filteredBurndown?.currentRemainingEstimatePoints ?? defaultPendingEstimatePoints; + const matchingIssuesCount = filteredBurndown?.matchingIssuesCount ?? 0; + const matchingEstimatedIssuesCount = filteredBurndown?.matchingEstimatedIssuesCount ?? 0; + const unestimatedIssuesCount = Math.max(0, matchingIssuesCount - matchingEstimatedIssuesCount); + const labelPointsChartData = labelPointsData?.data ?? []; + const labelPointsMatchingIssuesCount = labelPointsData?.matchingIssuesCount ?? 0; + const labelPointsMatchingEstimatedIssuesCount = labelPointsData?.matchingEstimatedIssuesCount ?? 0; + const statePointsChartData = statePointsData?.data ?? []; + const statePointsMatchingIssuesCount = statePointsData?.matchingIssuesCount ?? 0; + const userPointsChartData = userPointsData?.data ?? []; + const userPointsStatusSeries = userPointsData?.statusSeries ?? []; + const userPointsMatchingIssuesCount = userPointsData?.matchingIssuesCount ?? 0; + const burndownDistribution = filteredBurndown?.distribution; + const hasBurndownDistribution = !!burndownDistribution && Object.keys(burndownDistribution).length > 0; + const hasEstimatePoints = totalEstimatePoints > 0; + const dateRangeLabel = + cycle?.start_date && cycle?.end_date + ? `${renderFormattedDateWithoutYear(cycle.start_date)} - ${renderFormattedDateWithoutYear(cycle.end_date)}` + : "Dates not configured"; + const businessDaysUntilEnd = countBusinessDaysUntilEnd(cycleEndDate); + const businessDaysUntilEndLabel = getBusinessDaysUntilEndLabel(cycleEndDate, businessDaysUntilEnd); + + useEffect(() => { + const availableLabelSet = new Set(availableLabels.map((label) => label.id)); + setSelectedLabelIds((currentLabelIds) => currentLabelIds.filter((labelId) => availableLabelSet.has(labelId))); + }, [availableLabels]); + + useEffect(() => { + const availableAssigneeSet = new Set(cycleAssigneeIds); + setSelectedAssigneeIds((currentIds) => currentIds.filter((id) => availableAssigneeSet.has(id))); + }, [cycleAssigneeIds]); + + if (!cycle && isCycleLoading) { + return ( + <> + +
+ + + + + + + + + + + + +
+ + ); + } + + if (!cycle && didCycleFetchFail) { + return ( + <> + + router.push(`/${workspaceSlug}/projects/${projectId}/cycles`), + }} + /> + + ); + } + + if (!cycle) return null; + + return ( + <> + +
+
+

+ Cycle Key Performance Indicators +

+
+

{cycle.name}

+
+
+ +
+
+
+

Estimate-point burndown

+
+ +
+ {(selectedLabelIds.length > 0 || selectedAssigneeIds.length > 0) && ( + + )} + + {selectedAssigneesSummary} + +
+ } + optionsClassName="w-64" + /> + + {selectedLabelSummary} +
+ } + buttonClassName="rounded-md border border-custom-border-200 bg-custom-background-90 px-3 py-2 text-custom-text-100" + optionsClassName="w-64" + /> +
+ + +
+ + {selectedLabels.length > 0 || selectedAssigneeIds.length > 0 ? "Filtered view" : "Showing all work items"} + + + {availableLabels.length > 0 + ? `${availableLabels.length} label${availableLabels.length === 1 ? "" : "s"} available` + : isFilterDataLoading + ? "Loading labels..." + : "No labels in this cycle"} + +
+ +
+ + + +
+ +
+ + + +
+ +
+ {!hasValidCycleDates ? ( +
+

Cycle dates are required for burndown.

+

+ The KPI route is available, but this cycle needs valid start and end dates before the estimate-point + burndown can be rendered. +

+
+ ) : isFilterDataLoading ? ( + + + + + + + ) : didFilterDataFail ? ( +
+

Filter data could not be prepared.

+

+ The KPI route loaded successfully, but the client-side issue data required for filtering could not be + loaded. +

+
+ ) : (selectedLabelIds.length > 0 || selectedAssigneeIds.length > 0) && matchingIssuesCount === 0 ? ( +
+

No work items match the active filters.

+

+ Try a different filter combination or clear the filters to return to the full cycle burndown. +

+
+ ) : !hasEstimatePoints || matchingEstimatedIssuesCount === 0 ? ( +
+

No estimate points available yet.

+

+ {selectedLabelIds.length > 0 || selectedAssigneeIds.length > 0 + ? "The selected filters do not have any estimated work items available for the burndown chart." + : "Add estimates to the cycle work items to generate the first burndown view for this KPI screen."} +

+
+ ) : hasBurndownDistribution && burndownDistribution ? ( +
+
+

Burndown chart

+

+ {selectedLabelIds.length > 0 || selectedAssigneeIds.length > 0 + ? "Based only on estimate points from work items that match the active filters." + : "Based only on estimate points from completed work items for this release."} +

+
+ +
+ ) : ( +
+

Burndown data is not available.

+

+ The KPI route loaded successfully, but the estimate-point burndown payload could not be rendered for + this cycle. +

+
+ )} +
+ + +
+
+
+

Points by label

+
+ +
+ {`Members: ${selectedAssigneesSummary} • Labels: ${selectedLabelSummary}`} +
+
+ +
+ {isFilterDataLoading ? ( + + + + + + ) : didFilterDataFail ? ( +
+

Label points could not be prepared.

+

+ The KPI route loaded, but issue data required to build the points-by-label chart is unavailable. +

+
+ ) : (selectedAssigneeIds.length > 0 || selectedLabelIds.length > 0) && + labelPointsMatchingIssuesCount === 0 ? ( +
+

No work items match the active filters.

+

+ Update the filter selection or clear filters to view points grouped by label. +

+
+ ) : labelPointsMatchingEstimatedIssuesCount === 0 ? ( +
+

No estimate points available yet.

+

+ {selectedAssigneeIds.length > 0 || selectedLabelIds.length > 0 + ? "The selected filters do not have any estimated work items to chart by label." + : "Add estimates to cycle work items to render points grouped by label."} +

+
+ ) : labelPointsChartData.length > 0 ? ( +
+
+

Points by label chart

+

+ {selectedAssigneeIds.length > 0 || selectedLabelIds.length > 0 + ? "Only estimated work items matching the active filters are included." + : "All estimated work items in the cycle are included."} +

+
+ +
+ ) : ( +
+

Label points data is not available.

+

+ The KPI route loaded, but the points-by-label chart could not be rendered for this cycle. +

+
+ )} +
+
+ +
+
+
+

Points by status

+
+ +
+ {`Members: ${selectedAssigneesSummary} • Labels: ${selectedLabelSummary}`} +
+
+ +
+ {isFilterDataLoading ? ( + + + + + + ) : didFilterDataFail ? ( +
+

State points could not be prepared.

+

+ The KPI route loaded, but issue/state data required to build the points-by-status chart is + unavailable. +

+
+ ) : (selectedAssigneeIds.length > 0 || selectedLabelIds.length > 0) && + statePointsMatchingIssuesCount === 0 ? ( +
+

No work items match the active filters.

+

+ Update the filter selection or clear filters to view points grouped by status. +

+
+ ) : statePointsChartData.length > 0 ? ( +
+
+

Points by status chart

+

+ {selectedAssigneeIds.length > 0 || selectedLabelIds.length > 0 + ? "All work items matching the active filters are included. * marks statuses with unestimated issues." + : "All cycle work items are included. * marks statuses with unestimated issues."} +

+
+ +
+ ) : ( +
+

State points data is not available.

+

+ The KPI route loaded, but the points-by-status chart could not be rendered for this cycle. +

+
+ )} +
+
+ +
+
+
+

Points by user

+
+ +
+ {`Members: ${selectedAssigneesSummary} • Labels: ${selectedLabelSummary}`} +
+
+ +
+ {isFilterDataLoading ? ( + + + + + + ) : didFilterDataFail ? ( +
+

User points could not be prepared.

+

+ The KPI route loaded, but issue/member data required to build the points-by-user chart is unavailable. +

+
+ ) : (selectedAssigneeIds.length > 0 || selectedLabelIds.length > 0) && + userPointsMatchingIssuesCount === 0 ? ( +
+

No work items match the active filters.

+

+ Update the filter selection or clear filters to view points grouped by user. +

+
+ ) : userPointsChartData.length > 0 && userPointsStatusSeries.length > 0 ? ( +
+
+

Points by user chart

+

+ {selectedAssigneeIds.length > 0 || selectedLabelIds.length > 0 + ? "Each user bar stacks issue counts by status for work matching active filters. * marks users with unestimated issues." + : "Each user bar stacks issue counts by status for all cycle work. * marks users with unestimated issues."} +

+
+ +
+ ) : ( +
+

User points data is not available.

+

+ The KPI route loaded, but the points-by-user chart could not be rendered for this cycle. +

+
+ )} +
+
+ + + ); +}); diff --git a/apps/web/core/components/cycles/kpi/state-points-chart.tsx b/apps/web/core/components/cycles/kpi/state-points-chart.tsx new file mode 100644 index 00000000000..1b9a8694475 --- /dev/null +++ b/apps/web/core/components/cycles/kpi/state-points-chart.tsx @@ -0,0 +1,126 @@ +import React from "react"; +// plane imports +import { BarChart } from "@plane/propel/charts/bar-chart"; +import type { TChartData } from "@plane/types"; +// components +import type { TCycleKpiStatePointsItem } from "@/components/cycles/kpi/filter-utils"; + +type TStatePointsChartDatum = TChartData<"name", "points"> & { + key: string; + color: string; + unestimatedIssueCount: number; + issues: TCycleKpiStatePointsItem["issues"]; +}; + +type Props = { + data: TCycleKpiStatePointsItem[]; + className?: string; +}; + +const HiddenXAxisTick = React.memo(() => null); +HiddenXAxisTick.displayName = "HiddenXAxisTick"; + +export const KpiStatePointsChart: React.FC = ({ data, className = "" }) => { + const chartData = data.map((item) => ({ + key: item.key, + name: item.name, + points: item.points, + color: item.color, + unestimatedIssueCount: item.unestimatedIssueCount, + issues: item.issues, + })) as TStatePointsChartDatum[]; + + const minChartWidth = Math.max(760, chartData.length * 88); + const xAxisTicks = chartData.map((item) => item.name); + const chartXAxis = { + key: "name", + dy: 0, + interval: 0, + minTickGap: 0, + ticks: xAxisTicks, + } as unknown as { key: "name"; dy: number }; + + return ( +
+
+
+ []} + bars={[ + { + key: "points", + label: "Estimate points", + stackId: "bar-one", + fill: (payload) => payload.color ?? "#3F76FF", + textClassName: "", + showPercentage: false, + showTopBorderRadius: () => true, + showBottomBorderRadius: () => true, + }, + ]} + barSize={32} + margin={{ bottom: 8 }} + xAxis={chartXAxis} + yAxis={{ key: "points", label: "Estimate points", offset: -58, dx: -24, allowDecimals: true }} + customTicks={{ + x: HiddenXAxisTick as React.ComponentType, + }} + customTooltipContent={({ active, payload }) => { + const chartItem = Array.isArray(payload) + ? (payload?.[0]?.payload as TStatePointsChartDatum | undefined) + : undefined; + + if (!active || !chartItem) return null; + + const visibleIssues = chartItem.issues.slice(0, 8); + const remainingIssuesCount = chartItem.issues.length - visibleIssues.length; + + return ( +
+

+ {chartItem.name} +

+

+ Estimate points: {chartItem.points} +

+

+ Unestimated issues:{" "} + {chartItem.unestimatedIssueCount} +

+

Issues ({chartItem.issues.length})

+ +
+ {visibleIssues.map((issue) => ( +

+ #{issue.sequenceId} {issue.name} +

+ ))} + {remainingIssuesCount > 0 && ( +

+{remainingIssuesCount} more issues

+ )} +
+
+ ); + }} + /> + +
+
+ {chartData.map((item) => ( +
+ + {item.name} + +
+ ))} +
+
+
+
+
+ ); +}; diff --git a/apps/web/core/components/cycles/kpi/user-points-chart.tsx b/apps/web/core/components/cycles/kpi/user-points-chart.tsx new file mode 100644 index 00000000000..851e13789e4 --- /dev/null +++ b/apps/web/core/components/cycles/kpi/user-points-chart.tsx @@ -0,0 +1,176 @@ +import React from "react"; +// plane imports +import { BarChart } from "@plane/propel/charts/bar-chart"; +import type { TChartData } from "@plane/types"; +// components +import type { TCycleKpiUserPointsItem, TCycleKpiUserStatusSeriesItem } from "@/components/cycles/kpi/filter-utils"; + +type TUserPointsChartDatum = TChartData<"name", string> & { + key: string; + displayName: string; + issueCount: number; + unestimatedIssueCount: number; + estimatedPoints: number; + issues: TCycleKpiUserPointsItem["issues"]; + stateIssueCounts: Record; +}; + +type Props = { + data: TCycleKpiUserPointsItem[]; + statusSeries: TCycleKpiUserStatusSeriesItem[]; + className?: string; +}; + +const HiddenXAxisTick = React.memo(() => null); +HiddenXAxisTick.displayName = "HiddenXAxisTick"; + +export const KpiUserPointsChart: React.FC = ({ data, statusSeries, className = "" }) => { + const chartData = data.map((item) => { + const stateCounts = statusSeries.reduce>((acc, seriesItem) => { + acc[seriesItem.key] = item.stateIssueCounts[seriesItem.key] ?? 0; + return acc; + }, {}); + + return { + key: item.key, + name: item.key, + displayName: item.name, + issueCount: item.issueCount, + unestimatedIssueCount: item.unestimatedIssueCount, + estimatedPoints: item.estimatedPoints, + issues: item.issues, + stateIssueCounts: item.stateIssueCounts, + ...stateCounts, + }; + }) as TUserPointsChartDatum[]; + const minChartWidth = Math.max(760, chartData.length * 88); + const xAxisTicks = chartData.map((item) => item.key); + const labelMap = chartData.reduce>((acc, item) => { + acc[item.key] = item.displayName; + return acc; + }, {}); + const userXAxis = { + key: "name", + dy: 0, + interval: 0, + minTickGap: 0, + ticks: xAxisTicks, + } as unknown as { key: "name"; dy: number }; + + return ( +
+
+
+ []} + bars={statusSeries.map((seriesItem) => ({ + key: seriesItem.key, + label: seriesItem.name, + stackId: "user-status-stack", + fill: seriesItem.color, + textClassName: "", + showPercentage: false, + showTopBorderRadius: () => true, + showBottomBorderRadius: () => true, + }))} + barSize={32} + margin={{ bottom: 8 }} + xAxis={userXAxis} + yAxis={{ + key: statusSeries[0]?.key ?? "issueCount", + label: "Points", + offset: -58, + dx: -24, + allowDecimals: false, + }} + customTicks={{ + x: HiddenXAxisTick as React.ComponentType, + }} + customTooltipContent={({ active, payload }) => { + const chartItem = Array.isArray(payload) + ? (payload?.[0]?.payload as TUserPointsChartDatum | undefined) + : undefined; + + if (!active || !chartItem) return null; + + const visibleIssues = chartItem.issues.slice(0, 8); + const remainingIssuesCount = chartItem.issues.length - visibleIssues.length; + const statusCounts = statusSeries.filter( + (seriesItem) => (chartItem.stateIssueCounts[seriesItem.key] ?? 0) > 0 + ); + + return ( +
+

+ {chartItem.displayName} +

+

+ Total issues: {chartItem.issueCount} +

+

+ Estimated points:{" "} + {chartItem.estimatedPoints} +

+

+ Unestimated issues:{" "} + {chartItem.unestimatedIssueCount} +

+ + {statusCounts.length > 0 && ( +
+

Status breakdown

+ {statusCounts.map((seriesItem) => ( +

+ {seriesItem.name}: {chartItem.stateIssueCounts[seriesItem.key]} +

+ ))} +
+ )} + +
+

Issues ({chartItem.issues.length})

+ {visibleIssues.map((issue) => ( +

+ #{issue.sequenceId} {issue.name} +

+ ))} + {remainingIssuesCount > 0 && ( +

+{remainingIssuesCount} more issues

+ )} +
+
+ ); + }} + /> + +
+
+ {chartData.map((item) => ( +
+ + {labelMap[item.key] ?? item.key} + +
+ ))} +
+
+
+
+ + {statusSeries.length > 0 && ( +
+ {statusSeries.map((seriesItem) => ( +
+ + {seriesItem.name} +
+ ))} +
+ )} +
+ ); +}; diff --git a/apps/web/core/components/cycles/list/cycle-list-item-action.tsx b/apps/web/core/components/cycles/list/cycle-list-item-action.tsx index 9a3e256b0a1..194475c49ef 100644 --- a/apps/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/apps/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -202,6 +202,13 @@ export const CycleListItemAction: FC = observer((props) => { } }; + const openCycleKpi = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/kpi`, { showProgress: false }); + }; + return ( <> = observer((props) => { selected={!!cycleDetails.is_favorite} /> )} +
(props: showTooltip = true, comparisonLine, } = props; + const extendedXAxis = xAxis as typeof xAxis & { + interval?: number | "preserveStartEnd" | "preserveStart" | "preserveEnd"; + minTickGap?: number; + ticks?: Array; + }; // states const [activeArea, setActiveArea] = useState(null); const [activeLegend, setActiveLegend] = useState(null); @@ -115,6 +120,9 @@ export const AreaChart = React.memo((props: { const TickComponent = customTicks?.x || CustomXAxisTick; return ; diff --git a/packages/propel/src/charts/bar-chart/root.tsx b/packages/propel/src/charts/bar-chart/root.tsx index a0ea10d3ccc..ae0e1ee01d5 100644 --- a/packages/propel/src/charts/bar-chart/root.tsx +++ b/packages/propel/src/charts/bar-chart/root.tsx @@ -146,6 +146,9 @@ export const BarChart = React.memo((props: T className: AXIS_LABEL_CLASSNAME, }} tickCount={tickCount.x} + interval={(xAxis as any).interval} + minTickGap={(xAxis as any).minTickGap} + ticks={(xAxis as any).ticks} /> = TBaseChartProp label?: string; strokeColor?: string; dy?: number; + interval?: number | "preserveStartEnd" | "preserveStart" | "preserveEnd"; + minTickGap?: number; + ticks?: Array; }; yAxis: { allowDecimals?: boolean;