From b841cc912728c86db0c6255005f97868cc8bc72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 15:13:24 +0200 Subject: [PATCH 1/2] fix(allocations): expand grouped rows by default --- .../allocations/AllocationsClient.tsx | 28 +++++++------- .../allocations/allocationGroupState.test.ts | 38 +++++++++++++++++++ .../allocations/allocationGroupState.ts | 33 ++++++++++++++++ 3 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/allocations/allocationGroupState.test.ts create mode 100644 apps/web/src/components/allocations/allocationGroupState.ts diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index e562946..110158d 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -22,6 +22,13 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; +import { + collapseAllAllocationGroups, + createInitialCollapsedAllocationGroups, + expandAllAllocationGroups, + toggleCollapsedAllocationGroup, + type CollapsedAllocationGroups, +} from "./allocationGroupState.js"; /** Left-border color by allocation status for instant visual scanning */ const STATUS_LEFT_BORDER: Record = { @@ -190,7 +197,9 @@ export function AllocationsClient() { if (typeof window === "undefined") return "grouped"; return (localStorage.getItem("capakraken:allocations:viewMode") as "grouped" | "flat") ?? "grouped"; }); - const [collapsedGroups, setCollapsedGroups] = useState | "all">("all"); + const [collapsedGroups, setCollapsedGroups] = useState( + () => createInitialCollapsedAllocationGroups(), + ); // Track expanded project sub-groups: key = "resourceId::projectId" const [expandedSubGroups, setExpandedSubGroups] = useState>(new Set()); @@ -293,26 +302,15 @@ export function AllocationsClient() { const groupIds = useMemo(() => groups.map((g) => g.resourceId), [groups]); const toggleGroup = useCallback((resourceId: string) => { - setCollapsedGroups((prev) => { - // "all" → expand just this one (materialize all IDs minus clicked) - if (prev === "all") { - const next = new Set(groupIds); - next.delete(resourceId); - return next; - } - const next = new Set(prev); - if (next.has(resourceId)) next.delete(resourceId); - else next.add(resourceId); - return next; - }); + setCollapsedGroups((prev) => toggleCollapsedAllocationGroup(prev, groupIds, resourceId)); }, [groupIds]); const collapseAll = useCallback(() => { - setCollapsedGroups("all"); + setCollapsedGroups(collapseAllAllocationGroups()); }, []); const expandAll = useCallback(() => { - setCollapsedGroups(new Set()); + setCollapsedGroups(expandAllAllocationGroups()); }, []); const toggleSubGroup = useCallback((resourceId: string, projectId: string) => { diff --git a/apps/web/src/components/allocations/allocationGroupState.test.ts b/apps/web/src/components/allocations/allocationGroupState.test.ts new file mode 100644 index 0000000..f161ccb --- /dev/null +++ b/apps/web/src/components/allocations/allocationGroupState.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + collapseAllAllocationGroups, + createInitialCollapsedAllocationGroups, + expandAllAllocationGroups, + toggleCollapsedAllocationGroup, +} from "./allocationGroupState.js"; + +describe("allocationGroupState", () => { + it("starts grouped allocations expanded so rows are visible on first load", () => { + const initial = createInitialCollapsedAllocationGroups(); + + expect(initial).toBeInstanceOf(Set); + expect([...initial]).toEqual([]); + }); + + it("collapses and re-expands a single group from the expanded default", () => { + const collapsed = toggleCollapsedAllocationGroup(createInitialCollapsedAllocationGroups(), ["r1", "r2"], "r1"); + expect(collapsed).toBeInstanceOf(Set); + expect([...(collapsed as Set)]).toEqual(["r1"]); + + const expandedAgain = toggleCollapsedAllocationGroup(collapsed, ["r1", "r2"], "r1"); + expect(expandedAgain).toBeInstanceOf(Set); + expect([...(expandedAgain as Set)]).toEqual([]); + }); + + it("keeps collapse-all behavior available for bulk controls", () => { + expect(collapseAllAllocationGroups()).toBe("all"); + + const expandedOnlyR2 = toggleCollapsedAllocationGroup("all", ["r1", "r2"], "r2"); + expect(expandedOnlyR2).toBeInstanceOf(Set); + expect([...(expandedOnlyR2 as Set)].sort()).toEqual(["r1"]); + + const expandedAll = expandAllAllocationGroups(); + expect(expandedAll).toBeInstanceOf(Set); + expect([...(expandedAll as Set)]).toEqual([]); + }); +}); diff --git a/apps/web/src/components/allocations/allocationGroupState.ts b/apps/web/src/components/allocations/allocationGroupState.ts new file mode 100644 index 0000000..6a56bf9 --- /dev/null +++ b/apps/web/src/components/allocations/allocationGroupState.ts @@ -0,0 +1,33 @@ +export type CollapsedAllocationGroups = Set | "all"; + +export function createInitialCollapsedAllocationGroups(): CollapsedAllocationGroups { + return new Set(); +} + +export function toggleCollapsedAllocationGroup( + previous: CollapsedAllocationGroups, + groupIds: string[], + resourceId: string, +): CollapsedAllocationGroups { + if (previous === "all") { + const next = new Set(groupIds); + next.delete(resourceId); + return next; + } + + const next = new Set(previous); + if (next.has(resourceId)) { + next.delete(resourceId); + } else { + next.add(resourceId); + } + return next; +} + +export function collapseAllAllocationGroups(): CollapsedAllocationGroups { + return "all"; +} + +export function expandAllAllocationGroups(): CollapsedAllocationGroups { + return new Set(); +} From 57ea9d831087d530f516b15d3d2995644c32bb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 15:16:57 +0200 Subject: [PATCH 2/2] fix(allocations): recover from fully filtered empty state --- .../allocations/AllocationsClient.tsx | 63 ++++++++++++++++- .../allocationVisibilityState.test.ts | 68 +++++++++++++++++++ .../allocations/allocationVisibilityState.ts | 56 +++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/allocations/allocationVisibilityState.test.ts create mode 100644 apps/web/src/components/allocations/allocationVisibilityState.ts diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index 110158d..4e9d2d8 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { formatDate } from "~/lib/format.js"; import { trpc } from "~/lib/trpc/client.js"; import { AllocationModal } from "./AllocationModal.js"; @@ -29,6 +29,7 @@ import { toggleCollapsedAllocationGroup, type CollapsedAllocationGroups, } from "./allocationGroupState.js"; +import { getAllocationEmptyState, shouldAutoRelaxAllocationFilters } from "./allocationVisibilityState.js"; /** Left-border color by allocation status for instant visual scanning */ const STATUS_LEFT_BORDER: Record = { @@ -202,6 +203,7 @@ export function AllocationsClient() { ); // Track expanded project sub-groups: key = "resourceId::projectId" const [expandedSubGroups, setExpandedSubGroups] = useState>(new Set()); + const hasEvaluatedInitialVisibility = useRef(false); const toggleViewMode = useCallback(() => { setViewMode((prev) => { @@ -351,6 +353,49 @@ export function AllocationsClient() { ...(hideDraftProjects ? [{ label: "Hiding draft projects", onRemove: () => setHideDraftProjects(false) }] : []), ]; + const emptyState = getAllocationEmptyState({ + totalAssignments: assignmentList.length, + totalDemands: demandList.length, + visibleAssignments: filteredAllocations.length, + visibleDemands: filteredDemands.length, + hidePastProjects, + hideCompletedProjects, + hideDraftProjects, + }); + + useEffect(() => { + if (isLoading || hasEvaluatedInitialVisibility.current) { + return; + } + + hasEvaluatedInitialVisibility.current = true; + + if ( + shouldAutoRelaxAllocationFilters({ + totalAssignments: assignmentList.length, + totalDemands: demandList.length, + visibleAssignments: filteredAllocations.length, + visibleDemands: filteredDemands.length, + hidePastProjects, + hideCompletedProjects, + hideDraftProjects, + }) + ) { + setHidePastProjects(false); + setHideCompletedProjects(false); + setHideDraftProjects(false); + } + }, [ + assignmentList.length, + demandList.length, + filteredAllocations.length, + filteredDemands.length, + hideCompletedProjects, + hideDraftProjects, + hidePastProjects, + isLoading, + ]); + function formatPeriod(alloc: AllocationWithDetails) { return formatDate(alloc.startDate) + " \u2192 " + formatDate(alloc.endDate); } @@ -633,7 +678,21 @@ export function AllocationsClient() { {!isLoading && sorted.length === 0 && ( - No assignments found. + +
+

{emptyState.title}

+

{emptyState.detail}

+ {emptyState.showResetAction && ( + + )} +
+ )} diff --git a/apps/web/src/components/allocations/allocationVisibilityState.test.ts b/apps/web/src/components/allocations/allocationVisibilityState.test.ts new file mode 100644 index 0000000..18a31d8 --- /dev/null +++ b/apps/web/src/components/allocations/allocationVisibilityState.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { getAllocationEmptyState, shouldAutoRelaxAllocationFilters } from "./allocationVisibilityState.js"; + +describe("allocationVisibilityState", () => { + it("auto-relaxes default project filters when they hide every available row", () => { + expect( + shouldAutoRelaxAllocationFilters({ + totalAssignments: 8, + totalDemands: 2, + visibleAssignments: 0, + visibleDemands: 0, + hidePastProjects: true, + hideCompletedProjects: true, + hideDraftProjects: false, + }), + ).toBe(true); + }); + + it("does not auto-relax when there is no underlying allocation data", () => { + expect( + shouldAutoRelaxAllocationFilters({ + totalAssignments: 0, + totalDemands: 0, + visibleAssignments: 0, + visibleDemands: 0, + hidePastProjects: true, + hideCompletedProjects: true, + hideDraftProjects: false, + }), + ).toBe(false); + }); + + it("builds a filter-aware empty state when rows are hidden", () => { + expect( + getAllocationEmptyState({ + totalAssignments: 5, + totalDemands: 1, + visibleAssignments: 0, + visibleDemands: 0, + hidePastProjects: true, + hideCompletedProjects: true, + hideDraftProjects: false, + }), + ).toEqual({ + title: "No assignments match the active filters.", + detail: "5 assignments and 1 demand are hidden by the active project filters.", + showResetAction: true, + }); + }); + + it("keeps the plain empty state when there is genuinely no data", () => { + expect( + getAllocationEmptyState({ + totalAssignments: 0, + totalDemands: 0, + visibleAssignments: 0, + visibleDemands: 0, + hidePastProjects: false, + hideCompletedProjects: false, + hideDraftProjects: false, + }), + ).toEqual({ + title: "No assignments found.", + detail: "Create a planning entry or relax the current project filters once data is available.", + showResetAction: false, + }); + }); +}); diff --git a/apps/web/src/components/allocations/allocationVisibilityState.ts b/apps/web/src/components/allocations/allocationVisibilityState.ts new file mode 100644 index 0000000..0cf37d4 --- /dev/null +++ b/apps/web/src/components/allocations/allocationVisibilityState.ts @@ -0,0 +1,56 @@ +export interface AllocationVisibilityInput { + totalAssignments: number; + totalDemands: number; + visibleAssignments: number; + visibleDemands: number; + hidePastProjects: boolean; + hideCompletedProjects: boolean; + hideDraftProjects: boolean; +} + +export interface AllocationEmptyState { + title: string; + detail: string; + showResetAction: boolean; +} + +export function shouldAutoRelaxAllocationFilters(input: AllocationVisibilityInput): boolean { + const totalRows = input.totalAssignments + input.totalDemands; + const visibleRows = input.visibleAssignments + input.visibleDemands; + const hasActiveProjectFilters = input.hidePastProjects || input.hideCompletedProjects || input.hideDraftProjects; + + return totalRows > 0 && visibleRows === 0 && hasActiveProjectFilters; +} + +export function getAllocationEmptyState(input: AllocationVisibilityInput): AllocationEmptyState { + const totalRows = input.totalAssignments + input.totalDemands; + + if (shouldAutoRelaxAllocationFilters(input)) { + const assignmentLabel = input.totalAssignments === 1 ? "assignment" : "assignments"; + const demandLabel = input.totalDemands === 1 ? "demand" : "demands"; + const hiddenSummary = + input.totalDemands > 0 + ? `${input.totalAssignments} ${assignmentLabel} and ${input.totalDemands} ${demandLabel} are hidden by the active project filters.` + : `${input.totalAssignments} ${assignmentLabel} are hidden by the active project filters.`; + + return { + title: "No assignments match the active filters.", + detail: hiddenSummary, + showResetAction: true, + }; + } + + if (totalRows === 0) { + return { + title: "No assignments found.", + detail: "Create a planning entry or relax the current project filters once data is available.", + showResetAction: false, + }; + } + + return { + title: "No assignments found.", + detail: "No visible assignment rows remain after the current filters were applied.", + showResetAction: false, + }; +}