From 158bef6dfc3e347b8f0b207d6ff8442f1f8207c5 Mon Sep 17 00:00:00 2001 From: "Claude (engine)" Date: Sun, 12 Jul 2026 01:08:53 +0000 Subject: [PATCH] Make filtered Kanban board authoritative over the whole active set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the Goals Kanban board, a filter-matching goal that sorts beyond the initially-loaded active page was invisible in every column and the column counts under-reported to 0 (repro: "Owner: My goals" + hide-unassigned active, a freshly-created ready-to-deliver goal owned by the user shows nowhere and Ready auto-collapses). The board filters + fallback counts only ran over the client-loaded page(s) of the server-paginated active set, and a brand-new goal sorts to the END of its priority band (createdAt ASC) so it's the most likely active goal to fall beyond page 1. Fix (client-side eager-load, consistent with the all-client-side facet filtering the board already uses): while ANY facet filter or a search is active, page through the ENTIRE non-terminal active set until its cursor is exhausted, so kanbanGoals/byColumn and the fallback counts become correct automatically. The unfiltered path is untouched — with no filter we keep the paginated initial load, DB-accurate project-wide bucket counts, and manual "Load more"; we do NOT eagerly fetch extra pages. (Server-side facet-filter + filter-aware counts is the scale follow-up if needed.) Also keep the Ready column expanded (not collapsed to a strip) while the active set is still loading under a filter, so a transient 0 count mid-load doesn't hide it; once fully loaded a genuine 0 collapses as before. Desktop is byte-identical when no filter is active. UI/board behavior only — no PM Agent tool/config surface changes, so no agent-config update is needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../goals/goals-kanban-view.test.tsx | 36 ++++++++++ .../[projectSlug]/goals/goals-kanban-view.tsx | 9 ++- .../goals/use-goals-view.test.ts | 67 ++++++++++++++++++- .../p/[projectSlug]/goals/use-goals-view.ts | 24 +++++++ 4 files changed, 134 insertions(+), 2 deletions(-) diff --git a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/goals-kanban-view.test.tsx b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/goals-kanban-view.test.tsx index 092d9ae0b..4a2240912 100644 --- a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/goals-kanban-view.test.tsx +++ b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/goals-kanban-view.test.tsx @@ -228,6 +228,9 @@ function renderBoard( hasMoreCancelled, loadingMoreCancelled, onLoadMoreCancelled, + hasMoreActive, + loadingMoreActive, + onLoadMoreActive, applyEngineChange, reconcile, }: { @@ -240,6 +243,9 @@ function renderBoard( hasMoreCancelled?: boolean; loadingMoreCancelled?: boolean; onLoadMoreCancelled?: () => void; + hasMoreActive?: boolean; + loadingMoreActive?: boolean; + onLoadMoreActive?: () => void; applyEngineChange?: (change: { goalId: string; action?: "created" | "updated" | "deleted" }) => void; reconcile?: () => void; } = {}, @@ -258,6 +264,9 @@ function renderBoard( hasMoreCancelled={hasMoreCancelled} loadingMoreCancelled={loadingMoreCancelled} onLoadMoreCancelled={onLoadMoreCancelled} + hasMoreActive={hasMoreActive} + loadingMoreActive={loadingMoreActive} + onLoadMoreActive={onLoadMoreActive} applyEngineChange={applyEngineChange} reconcile={reconcile} />, @@ -277,6 +286,9 @@ function renderBoard( hasMoreCancelled={hasMoreCancelled} loadingMoreCancelled={loadingMoreCancelled} onLoadMoreCancelled={onLoadMoreCancelled} + hasMoreActive={hasMoreActive} + loadingMoreActive={loadingMoreActive} + onLoadMoreActive={onLoadMoreActive} applyEngineChange={applyEngineChange} reconcile={reconcile} />, @@ -2411,6 +2423,30 @@ describe("GoalsKanbanView — Ready column count-driven auto-collapse", () => { expect(ready.querySelector("[aria-label='Expand Ready column']")).toBeNull(); expect(within(ready).getByTestId("ready-collapsed-bar")).not.toHaveAttribute("role", "button"); }); + + it("stays EXPANDED at a 0 count while the active set is still loading under a filter (hasActiveFilters + hasMoreActive)", () => { + // Under a board filter the store eager-loads every active page; a filter-matching Ready + // goal can sit beyond the loaded window. A transient 0 count mid-load must NOT collapse + // Ready to a strip and hide it. + renderBoard([], { hasActiveFilters: true, hasMoreActive: true }); + const ready = column("Ready"); + expect(within(ready).queryByTestId("ready-collapsed-bar")).not.toBeInTheDocument(); + }); + + it("collapses at a 0 count once the filtered active set is fully loaded (hasMoreActive false)", () => { + // Eager-load finished (no more active pages) and Ready genuinely has 0 matches → collapse. + renderBoard([], { hasActiveFilters: true, hasMoreActive: false }); + const ready = column("Ready"); + expect(within(ready).getByTestId("ready-collapsed-bar")).toBeInTheDocument(); + }); + + it("with NO filter active, a still-paginating active set does NOT keep Ready expanded (desktop byte-identical)", () => { + // hasMoreActive can be true on the unfiltered manual-pagination path; without a filter the + // guard is inert, so a 0-count Ready collapses exactly as before. + renderBoard([], { hasActiveFilters: false, hasMoreActive: true }); + const ready = column("Ready"); + expect(within(ready).getByTestId("ready-collapsed-bar")).toBeInTheDocument(); + }); }); describe("GoalsKanbanView — T#93 column variants", () => { diff --git a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/goals-kanban-view.tsx b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/goals-kanban-view.tsx index c528e8375..327c6756b 100644 --- a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/goals-kanban-view.tsx +++ b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/goals-kanban-view.tsx @@ -877,8 +877,15 @@ function KanbanColumn({ // against trapping the user: keep Ready expanded while its own per-column search box is open, // so filtering it to 0 there never hides the search input with no way back. For Ready, // forceExpand is moot since it is never a valid drop target. + // Also keep Ready expanded while the active set is STILL LOADING under a board filter + // (`hasActiveFilters && hasMore` — for Ready, `hasMore` tracks the active set): under a + // filter the store eager-loads every active page, and a filter-matching Ready goal can sit + // beyond the loaded window, so a transient 0 count mid-load must not collapse Ready to a + // strip and hide it. Once the active set is fully loaded (hasMore=false) a genuine 0 count + // collapses as before. With no filter active this term is false, so desktop is byte-identical. + const readyLoadingUnderFilter = hasActiveFilters && hasMore; const effectiveCollapsed = autoCollapse - ? displayCount === 0 && !isSearchOpen + ? displayCount === 0 && !isSearchOpen && !readyLoadingUnderFilter : collapsed && !forceExpand; // Below lg only the selected lane renders — everything else (including the other two diff --git a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/use-goals-view.test.ts b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/use-goals-view.test.ts index 3ea9e56e8..f5eb1d3c2 100644 --- a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/use-goals-view.test.ts +++ b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/use-goals-view.test.ts @@ -2,7 +2,7 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later import { describe, it, expect, beforeEach, vi } from "vitest"; -import { renderHook, act } from "@testing-library/react"; +import { renderHook, act, waitFor } from "@testing-library/react"; import { useGoalsStore } from "./use-goals-view"; import type { EngineGoalRow } from "./goals-view"; @@ -489,6 +489,71 @@ describe("useGoalsStore — SSR re-seed merges (never resets loaded pages)", () }); }); +describe("useGoalsStore — eager-load under an active filter (board authoritative over the whole active set)", () => { + // A filter-matching goal that sorts BEYOND page 1 (a brand-new goal lands at the end of its + // priority band) — the bug this behaviour exists to fix: invisible in every column, counts 0. + const g1: EngineGoalRow = { ...BASE_GOAL, id: "g1", title: "One", ownerId: "user-1", ownerName: "User One", status: "open" }; + const gBeyond: EngineGoalRow = { ...BASE_GOAL, id: "g-beyond", title: "Beyond", ownerId: "user-1", ownerName: "User One", status: "open" }; + + // Identity-stable initial props (SSR arrays are stable in production) — an inline array + // literal re-fires the re-seed effect every render and loops. See the reconcile note above. + const PAGE1: EngineGoalRow[] = [g1]; + + /** Serves the active set's page 2 (after=c1) containing the beyond-page-1 goal, cursor null. */ + function mockPage2() { + return vi.spyOn(global, "fetch").mockImplementation((input: RequestInfo | URL) => { + const after = new URL(String(input), "http://localhost").searchParams.get("after"); + if (after === "c1") { + return Promise.resolve({ + ok: true, + json: async () => ({ data: [gBeyond], meta: { hasMore: false, nextCursor: null } }), + } as Response); + } + return Promise.resolve({ ok: false, status: 500 } as Response); + }); + } + + it("pages through the whole active set while a facet filter is active, making a beyond-page-1 match visible with a correct count", async () => { + const spy = mockPage2(); + // owner filter active via initialOwnerId → hasActiveFilters true; page 1 has only g1, the + // matching goal sits on page 2 (cursor c1). + const { result } = renderHook(() => useGoalsStore("proj-1", PAGE1, "c1", null, "user-1")); + + // The mount effect fires and pages until the active cursor is exhausted. + await waitFor(() => expect(result.current.active.nextCursor).toBeNull()); + + // The beyond-page-1 goal is now loaded and visible to the Kanban filter — no longer 0. + expect(result.current.active.goals.map((g) => g.id)).toEqual(["g1", "g-beyond"]); + expect(result.current.kanbanGoals.map((g) => g.id)).toContain("g-beyond"); + spy.mockRestore(); + }); + + it("does NOT eager-load when no filter is active (unfiltered path stays paginated — no extra fetches)", async () => { + const spy = vi.spyOn(global, "fetch"); + // No initialOwnerId → owner 'all', no search → filterActive false; a real next cursor exists. + const { result } = renderHook(() => useGoalsStore("proj-1", PAGE1, "c1", null)); + // Flush any pending microtasks the effects might have queued. + await act(async () => { await Promise.resolve(); }); + expect(spy).not.toHaveBeenCalled(); + // The cursor is untouched — pagination is still manual via "Load more". + expect(result.current.active.nextCursor).toBe("c1"); + spy.mockRestore(); + }); + + it("also eager-loads when only a search query is active (fires when search is set, not before)", async () => { + const spy = mockPage2(); + const { result } = renderHook(() => useGoalsStore("proj-1", PAGE1, "c1", null)); + // No filter yet → no eager-load fetch. + expect(spy).not.toHaveBeenCalled(); + + act(() => result.current.setFilter("search", "beyond")); + + await waitFor(() => expect(result.current.active.nextCursor).toBeNull()); + expect(result.current.active.goals.map((g) => g.id)).toEqual(["g1", "g-beyond"]); + spy.mockRestore(); + }); +}); + describe("useGoalsStore — reconcile (atomic in-store refetch of the loaded window)", () => { const g1: EngineGoalRow = { ...BASE_GOAL, id: "g1", title: "One" }; const g2: EngineGoalRow = { ...BASE_GOAL, id: "g2", title: "Two" }; diff --git a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/use-goals-view.ts b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/use-goals-view.ts index 52f2867a8..06b666b0a 100644 --- a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/use-goals-view.ts +++ b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/use-goals-view.ts @@ -321,6 +321,30 @@ export function useGoalsStore( // eslint-disable-next-line react-hooks/exhaustive-deps }, [filters.status, done.nextCursor, done.loading, cancelled.nextCursor, cancelled.loading]); + // While ANY facet filter or a search is active, the Kanban board must be authoritative + // over the ENTIRE non-terminal active set — not just the initially-loaded page(s). The + // filter + fallback counts run purely over `active.goals` (the loaded window), so a + // filter-matching goal that sorts beyond page 1 would be invisible in every column and the + // counts would under-report (a brand-new goal lands at the END of its priority band — + // createdAt ASC — so it's the most likely active goal to fall past page 1). Eagerly page + // through the bounded active set until its cursor is exhausted, one page per pass: + // completing a load advances `active.nextCursor` (and flips `loading` back to false), + // re-running this effect until the cursor hits null. Terminates for that reason; the + // `loading` guard here plus loadMore's own in-flight guard prevent concurrent/duplicate + // fetches. The UNFILTERED path is untouched — with no filter this never fires, so the + // paginated initial load, DB-accurate counts, and manual "Load more" all stay as-is. + // (A future server-side facet-filter + filter-aware counts is the scale follow-up; here we + // only eager-load the bounded active set, and only under an explicit filter.) + const filterActive = computeHasActiveFilters(filters) || Boolean(filters.search); + useEffect(() => { + if (!filterActive) return; + if (active.nextCursor === null || active.nextCursor === "__first__") return; + if (active.loading) return; + void loadMore("active"); + // loadMore is stable for the life of the component (projectId doesn't change mid-session). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterActive, active.nextCursor, active.loading]); + // filteredGoals is used by List, Calendar, and Text views. // "complete" → merge accepted goals from active+done sets, sorted updatedAt DESC. // "cancelled" → cancelled goals from cancelled set, sorted updatedAt DESC.