From 38cdf9476fef055351dfed5fcd03c296207922ba Mon Sep 17 00:00:00 2001 From: Laurin Eichberger <98164279+laurin-eichberger@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:22:38 +0200 Subject: [PATCH 1/2] feat(web): show estimate point sums in list view group headers --- .../display-filters-selection.tsx | 1 + .../header/display-filters/extra-options.tsx | 5 ++ .../issue-layouts/list/base-list-root.tsx | 2 + .../issues/issue-layouts/list/default.tsx | 3 + .../list/headers/group-by-card.tsx | 13 +++- .../issues/issue-layouts/list/list-group.tsx | 59 +++++++++++++++---- packages/constants/src/issue/filter.ts | 2 +- packages/i18n/src/locales/en/work-item.json | 3 +- packages/types/src/view-props.ts | 4 +- packages/utils/src/work-item/base.ts | 1 + 10 files changed, 77 insertions(+), 16 deletions(-) diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx index 40ad2d18295..3f28fd4ea87 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx @@ -130,6 +130,7 @@ export const DisplayFiltersSelection = observer(function DisplayFiltersSelection selectedExtraOptions={{ show_empty_groups: displayFilters?.show_empty_groups ?? true, sub_issue: displayFilters?.sub_issue ?? true, + show_estimates: displayFilters?.show_estimates ?? false, }} handleUpdate={(key, val) => handleDisplayFiltersUpdate({ diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/extra-options.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/extra-options.tsx index 7daaec7aea5..aec7ae9ffd5 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/extra-options.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/extra-options.tsx @@ -24,12 +24,17 @@ const ISSUE_EXTRA_OPTIONS: { key: "show_empty_groups", titleTranslationKey: "issue.display.extra.show_empty_groups", }, // filter on front-end + { + key: "show_estimates", + titleTranslationKey: "issue.display.extra.show_estimates", + }, ]; type Props = { selectedExtraOptions: { sub_issue: boolean; show_empty_groups: boolean; + show_estimates: boolean; }; handleUpdate: (key: keyof IIssueDisplayFilterOptions, val: boolean) => void; enabledExtraOptions: TIssueExtraOptions[]; diff --git a/apps/web/core/components/issues/issue-layouts/list/base-list-root.tsx b/apps/web/core/components/issues/issue-layouts/list/base-list-root.tsx index 4122b7e9538..f5528e2e585 100644 --- a/apps/web/core/components/issues/issue-layouts/list/base-list-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/base-list-root.tsx @@ -80,6 +80,7 @@ export const BaseListRoot = observer(function BaseListRoot(props: IBaseListRoot) const group_by = (displayFilters?.group_by || null) as GroupByColumnTypes | null; const showEmptyGroup = displayFilters?.show_empty_groups ?? false; + const showEstimates = displayFilters?.show_estimates ?? false; const { workspaceSlug, projectId } = useParams(); const { updateFilters } = useIssuesActions(storeType); @@ -165,6 +166,7 @@ export const BaseListRoot = observer(function BaseListRoot(props: IBaseListRoot) groupedIssueIds={groupedIssueIds ?? {}} loadMoreIssues={loadMoreIssues} showEmptyGroup={showEmptyGroup} + showEstimates={showEstimates} quickAddCallback={quickAddIssue} enableIssueQuickAdd={!!enableQuickAdd} canEditProperties={canEditProperties} diff --git a/apps/web/core/components/issues/issue-layouts/list/default.tsx b/apps/web/core/components/issues/issue-layouts/list/default.tsx index 28db68addc9..8e1275a309e 100644 --- a/apps/web/core/components/issues/issue-layouts/list/default.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/default.tsx @@ -46,6 +46,7 @@ export interface IList { displayProperties: IIssueDisplayProperties | undefined; enableIssueQuickAdd: boolean; showEmptyGroup?: boolean; + showEstimates?: boolean; canEditProperties: (projectId: string | undefined) => boolean; quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; disableIssueCreation?: boolean; @@ -69,6 +70,7 @@ export const List = observer(function List(props: IList) { displayProperties, enableIssueQuickAdd, showEmptyGroup, + showEstimates, canEditProperties, quickAddCallback, disableIssueCreation, @@ -157,6 +159,7 @@ export const List = observer(function List(props: IList) { displayProperties={displayProperties} enableIssueQuickAdd={enableIssueQuickAdd} showEmptyGroup={showEmptyGroup} + showEstimates={showEstimates} canEditProperties={canEditProperties} quickAddCallback={quickAddCallback} disableIssueCreation={disableIssueCreation} diff --git a/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx b/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx index 9fec4fc10a1..833c06d843e 100644 --- a/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -8,7 +8,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { CircleDashed } from "lucide-react"; -import { PlusIcon } from "@plane/propel/icons"; +import { PlusIcon, EstimatePropertyIcon } from "@plane/propel/icons"; // types import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { TIssue, ISearchIssueResponse, TIssueGroupByOptions } from "@plane/types"; @@ -33,6 +33,8 @@ interface IHeaderGroupByCard { icon?: React.ReactNode; title: string; count: number; + estimateSum?: number | null; + isPartialCount?: boolean; issuePayload: Partial; canEditProperties: (projectId: string | undefined) => boolean; disableIssueCreation?: boolean; @@ -49,6 +51,8 @@ export const HeaderGroupByCard = observer(function HeaderGroupByCard(props: IHea icon, title, count, + estimateSum, + isPartialCount, issuePayload, canEditProperties, disableIssueCreation, @@ -120,6 +124,13 @@ export const HeaderGroupByCard = observer(function HeaderGroupByCard(props: IHea >
{title}
{count || 0}
+ {estimateSum != null && estimateSum > 0 && ( + + + {isPartialCount ? "\u2265 " : ""} + {estimateSum} + + )}
diff --git a/apps/web/core/components/issues/issue-layouts/list/list-group.tsx b/apps/web/core/components/issues/issue-layouts/list/list-group.tsx index 5c8f56fb949..f36a83319d8 100644 --- a/apps/web/core/components/issues/issue-layouts/list/list-group.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/list-group.tsx @@ -22,12 +22,14 @@ import type { IIssueDisplayProperties, TIssueKanbanFilters, } from "@plane/types"; +import { EEstimateSystem } from "@plane/types"; import { EIssueLayoutTypes } from "@plane/types"; import { Row } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { ListLoaderItemRow } from "@/components/ui/loader/layouts/list-layout-loader"; // hooks +import { useProjectEstimates } from "@/hooks/store/estimates"; import { useProjectState } from "@/hooks/store/use-project-state"; import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import { useIssuesStore } from "@/hooks/use-issue-layout-store"; @@ -67,6 +69,7 @@ interface Props { addIssuesToView?: (issueIds: string[]) => Promise; isCompletedCycle?: boolean; showEmptyGroup?: boolean; + showEstimates?: boolean; loadMoreIssues: (groupId?: string) => void; selectionHelpers: TSelectionHelper; handleCollapsedGroups: (value: string) => void; @@ -94,6 +97,7 @@ export const ListGroup = observer(function ListGroup(props: Props) { addIssuesToView, isCompletedCycle, showEmptyGroup, + showEstimates, loadMoreIssues, selectionHelpers, handleCollapsedGroups, @@ -107,21 +111,51 @@ export const ListGroup = observer(function ListGroup(props: Props) { const groupRef = useRef(null); const { t } = useTranslation(); const projectState = useProjectState(); + const { areEstimateEnabledByProjectId, currentActiveEstimateIdByProjectId, estimateById } = useProjectEstimates(); const { issues: { getGroupIssueCount, getPaginationData, getIssueLoader }, } = useIssuesStore(); + const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0; + const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults; + const isPaginating = !!getIssueLoader(group.id); + const isPartialGroup = groupIssueIds ? groupIssueIds.length < groupIssueCount || !!nextPageResults : false; + const estimateSum = (() => { + if (!showEstimates) return null; + if (!groupIssueIds || groupIssueIds.length === 0) return null; + + const firstIssue = issuesMap[groupIssueIds[0]]; + const issueProjectId = firstIssue?.project_id; + if (!issueProjectId) return null; + + if (!areEstimateEnabledByProjectId(issueProjectId)) return null; + + const activeEstimateId = currentActiveEstimateIdByProjectId(issueProjectId); + if (!activeEstimateId) return null; + const activeEstimate = estimateById(activeEstimateId); + if (!activeEstimate?.type || activeEstimate.type === EEstimateSystem.CATEGORIES) return null; + + let sum = 0; + for (const issueId of groupIssueIds) { + const issue = issuesMap[issueId]; + if (!issue?.estimate_point) continue; + const point = activeEstimate.estimatePointById(issue.estimate_point); + if (!point?.value) continue; + const numericValue = Number(point.value); + if (!Number.isNaN(numericValue)) { + sum += numericValue; + } + } + return sum; + })(); + const [intersectionElement, setIntersectionElement] = useState(null); const { workflowDisabledSource, isWorkflowDropDisabled, handleWorkFlowState, getIsWorkflowWorkItemCreationDisabled } = useWorkFlowFDragNDrop(group_by); const isWorkflowIssueCreationDisabled = getIsWorkflowWorkItemCreationDisabled(group.id); - const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0; - const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults; - const isPaginating = !!getIssueLoader(group.id); - useIntersectionObserver(containerRef, isPaginating ? null : intersectionElement, loadMoreIssues, `100% 0% 100% 0%`); const shouldLoadMore = @@ -237,14 +271,13 @@ export const ListGroup = observer(function ListGroup(props: Props) { }) ); }, [ - groupRef?.current, - group, - orderBy, - getGroupIndex, - setDragColumnOrientation, - setIsDraggingOverColumn, - isWorkflowDropDisabled, - ]); + group, + orderBy, + getGroupIndex, + setDragColumnOrientation, + setIsDraggingOverColumn, + isWorkflowDropDisabled +]); const isDragAllowed = group_by ? DRAG_ALLOWED_GROUPS.includes(group_by) : true; const canOverlayBeVisible = isWorkflowDropDisabled || orderBy !== "sort_order" || !!group.isDropDisabled; @@ -272,6 +305,8 @@ export const ListGroup = observer(function ListGroup(props: Props) { icon={group.icon} title={group.name} count={groupIssueCount} + estimateSum={estimateSum} + isPartialCount={isPartialGroup} issuePayload={group.payload} canEditProperties={canEditProperties} disableIssueCreation={ diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts index 115b8856f41..4dba28c2e96 100644 --- a/packages/constants/src/issue/filter.ts +++ b/packages/constants/src/issue/filter.ts @@ -227,7 +227,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { }, extra_options: { access: true, - values: ["show_empty_groups", "sub_issue"], + values: ["show_empty_groups", "sub_issue", "show_estimates"], }, }, kanban: { diff --git a/packages/i18n/src/locales/en/work-item.json b/packages/i18n/src/locales/en/work-item.json index b7935a125c3..c5fc6867a0e 100644 --- a/packages/i18n/src/locales/en/work-item.json +++ b/packages/i18n/src/locales/en/work-item.json @@ -65,7 +65,8 @@ }, "extra": { "show_sub_issues": "Show sub-work items", - "show_empty_groups": "Show empty groups" + "show_empty_groups": "Show empty groups", + "show_estimates": "Show estimate totals" } }, "layouts": { diff --git a/packages/types/src/view-props.ts b/packages/types/src/view-props.ts index 638afa0fd13..029616443e6 100644 --- a/packages/types/src/view-props.ts +++ b/packages/types/src/view-props.ts @@ -58,7 +58,7 @@ export type TIssueOrderByOptions = export type TIssueGroupingFilters = "active" | "backlog"; -export type TIssueExtraOptions = "show_empty_groups" | "sub_issue"; +export type TIssueExtraOptions = "show_empty_groups" | "sub_issue" | "show_estimates"; export type TIssueParams = | "priority" @@ -81,6 +81,7 @@ export type TIssueParams = | "type" | "sub_issue" | "show_empty_groups" + | "show_estimates" | "cursor" | "per_page" | "issue_type" @@ -157,6 +158,7 @@ export interface IIssueDisplayFilterOptions { order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; sub_issue?: boolean; + show_estimates?: boolean; } export interface IIssueDisplayProperties { assignee?: boolean; diff --git a/packages/utils/src/work-item/base.ts b/packages/utils/src/work-item/base.ts index c79b805cfa5..4ce3115aecb 100644 --- a/packages/utils/src/work-item/base.ts +++ b/packages/utils/src/work-item/base.ts @@ -283,6 +283,7 @@ export const getComputedDisplayFilters = ( sub_group_by: filters?.sub_group_by || null, sub_issue: filters?.sub_issue || false, show_empty_groups: filters?.show_empty_groups || false, + show_estimates: filters?.show_estimates || false, }; }; From 1040fd531ef20cdb32175e50d330bc3220843f07 Mon Sep 17 00:00:00 2001 From: Laurin Eichberger <98164279+laurin-eichberger@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:17:17 +0200 Subject: [PATCH 2/2] refactor(web): don't sum up estimates across projects --- .../issues/issue-layouts/list/list-group.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/web/core/components/issues/issue-layouts/list/list-group.tsx b/apps/web/core/components/issues/issue-layouts/list/list-group.tsx index f36a83319d8..781fe56cd43 100644 --- a/apps/web/core/components/issues/issue-layouts/list/list-group.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/list-group.tsx @@ -126,17 +126,23 @@ export const ListGroup = observer(function ListGroup(props: Props) { if (!groupIssueIds || groupIssueIds.length === 0) return null; const firstIssue = issuesMap[groupIssueIds[0]]; - const issueProjectId = firstIssue?.project_id; - if (!issueProjectId) return null; + const projectId = firstIssue?.project_id; + if (!projectId) return null; - if (!areEstimateEnabledByProjectId(issueProjectId)) return null; + // safeguard: don't sum up across multiple projects + for (const issueId of groupIssueIds) { + const issue = issuesMap[issueId]; + if (issue?.project_id && issue.project_id !== projectId) return null; + } - const activeEstimateId = currentActiveEstimateIdByProjectId(issueProjectId); + if (!areEstimateEnabledByProjectId(projectId)) return null; + const activeEstimateId = currentActiveEstimateIdByProjectId(projectId); if (!activeEstimateId) return null; const activeEstimate = estimateById(activeEstimateId); if (!activeEstimate?.type || activeEstimate.type === EEstimateSystem.CATEGORIES) return null; let sum = 0; + let hasAnyEstimate = false; for (const issueId of groupIssueIds) { const issue = issuesMap[issueId]; if (!issue?.estimate_point) continue; @@ -145,9 +151,10 @@ export const ListGroup = observer(function ListGroup(props: Props) { const numericValue = Number(point.value); if (!Number.isNaN(numericValue)) { sum += numericValue; + hasAnyEstimate = true; } } - return sum; + return hasAnyEstimate ? sum : null; })(); const [intersectionElement, setIntersectionElement] = useState(null);