From ab81f32cdfda62256063579b168f76546d9cdae8 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 22 Sep 2026 20:39:55 +0200 Subject: [PATCH] perf(board): batch signed-in project summary reads --- src/lib/data/board.test.ts | 60 ++++++++++++++++++++++++++++++ src/lib/data/board.ts | 76 ++++++++++++++++---------------------- 2 files changed, 92 insertions(+), 44 deletions(-) create mode 100644 src/lib/data/board.test.ts diff --git a/src/lib/data/board.test.ts b/src/lib/data/board.test.ts new file mode 100644 index 0000000..decd21f --- /dev/null +++ b/src/lib/data/board.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { loadProjectSummaries } from "@/lib/data/board"; +import { getServerPb } from "@/lib/pb/server"; + +vi.mock("@/lib/pb/server", () => ({ getServerPb: vi.fn() })); + +describe("home project summaries", () => { + beforeEach(() => vi.clearAllMocks()); + + it("counts open and inbox cards across projects with one read per collection", async () => { + const projects = [ + { id: "p1", client: "c1", name: "Alpha" }, + { id: "p2", client: "c1", name: "Beta" }, + ]; + const clients = [{ id: "c1", name: "Client" }]; + const columns = [ + { id: "i1", project: "p1", is_inbox: true, is_done: false }, + { id: "d1", project: "p1", is_inbox: false, is_done: true }, + { id: "i2", project: "p2", is_inbox: true, is_done: false }, + ]; + const cards = [ + { project: "p1", column: "i1" }, + { project: "p1", column: "d1" }, + { project: "p2", column: "i2" }, + { project: "p2", column: "i2" }, + ]; + const getFullList = vi.fn(async (name: string) => ({ projects, clients, columns, cards })[name as "projects" | "clients" | "columns" | "cards"]); + const getFirstListItem = vi.fn(async (filter: string) => { + const projectId = filter.match(/project = "([^"]+)"/)?.[1]; + const column = columns.find((item) => item.project === projectId && (filter.includes("is_inbox") ? item.is_inbox : item.is_done)); + if (!column) throw new Error("No matching column"); + return column; + }); + const getList = vi.fn(async (_page: number, _perPage: number, options: { filter: string }) => ({ + totalItems: options.filter.includes('column = "i1"') ? 1 + : options.filter.includes('column = "i2"') ? 2 + : options.filter.includes('project = "p1"') ? 1 : 2, + })); + const pb = { + authStore: { isValid: true }, + collection: vi.fn((name: string) => ({ + getFullList: () => getFullList(name), + getFirstListItem, + getList, + })), + }; + vi.mocked(getServerPb).mockResolvedValue(pb as never); + + const summaries = await loadProjectSummaries(); + + expect(summaries.map((summary) => ({ id: summary.project.id, open: summary.openCards, inbox: summary.inboxCards }))).toEqual([ + { id: "p1", open: 1, inbox: 1 }, + { id: "p2", open: 2, inbox: 2 }, + ]); + expect(getFullList).toHaveBeenCalledTimes(4); + expect(getFirstListItem).not.toHaveBeenCalled(); + expect(getList).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/data/board.ts b/src/lib/data/board.ts index bbf2dc8..4c06d87 100644 --- a/src/lib/data/board.ts +++ b/src/lib/data/board.ts @@ -76,57 +76,45 @@ export async function loadProjectSummaries(): Promise { const pb = await getServerPb(); if (!pb.authStore.isValid) return []; - const projects = await pb.collection("projects").getFullList({ - filter: 'status != "archived"', - sort: "name", - }); - - if (projects.length === 0) return []; - - const clients = await pb.collection("clients").getFullList(); + const [projects, clients, columns, cards] = await Promise.all([ + pb.collection("projects").getFullList({ + filter: 'status != "archived"', + sort: "name", + }), + pb.collection("clients").getFullList(), + pb.collection("columns").getFullList>({ + fields: "id,project,is_inbox,is_done", + }), + pb.collection("cards").getFullList>({ + filter: "archived = false", + fields: "project,column", + }), + ]); const clientsById = new Map(clients.map((c) => [c.id, c])); - const summaries: ProjectSummary[] = []; + const summariesByProject = new Map(); for (const project of projects) { const client = clientsById.get(project.client); if (!client) continue; + const summary = { project, client, openCards: 0, inboxCards: 0 }; + summaries.push(summary); + summariesByProject.set(project.id, summary); + } - const inbox = await pb - .collection("columns") - .getFirstListItem( - `project = "${project.id}" && is_inbox = true` - ) - .catch(() => null); + const doneColumns = new Set(); + const inboxColumns = new Set(); + for (const column of columns) { + if (!summariesByProject.has(column.project)) continue; + if (column.is_done) doneColumns.add(column.id); + if (column.is_inbox) inboxColumns.add(column.id); + } - const done = await pb - .collection("columns") - .getFirstListItem( - `project = "${project.id}" && is_done = true` - ) - .catch(() => null); - - const openFilter = done - ? `project = "${project.id}" && archived = false && column != "${done.id}"` - : `project = "${project.id}" && archived = false`; - - const open = await pb - .collection("cards") - .getList(1, 1, { filter: openFilter, fields: "id" }); - - const inboxCount = inbox - ? await pb.collection("cards").getList(1, 1, { - filter: `column = "${inbox.id}" && archived = false`, - fields: "id", - }) - : null; - - summaries.push({ - project, - client, - openCards: open.totalItems, - inboxCards: inboxCount?.totalItems ?? 0, - }); + for (const card of cards) { + const summary = summariesByProject.get(card.project); + if (!summary) continue; + if (!doneColumns.has(card.column)) summary.openCards += 1; + if (inboxColumns.has(card.column)) summary.inboxCards += 1; } return summaries; @@ -183,4 +171,4 @@ export async function loadSidebarProjects(): Promise { return [...groups.values()].sort((a, b) => a.client.name.localeCompare(b.client.name) ); -} \ No newline at end of file +}