perf(board): batch signed-in project summary reads

This commit is contained in:
Dave 2026-09-22 20:39:55 +02:00
parent 9778ee09d4
commit ab81f32cdf
2 changed files with 92 additions and 44 deletions

View file

@ -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();
});
});

View file

@ -76,57 +76,45 @@ export async function loadProjectSummaries(): Promise<ProjectSummary[]> {
const pb = await getServerPb();
if (!pb.authStore.isValid) return [];
const projects = await pb.collection("projects").getFullList<ProjectRecord>({
filter: 'status != "archived"',
sort: "name",
});
if (projects.length === 0) return [];
const clients = await pb.collection("clients").getFullList<ClientRecord>();
const [projects, clients, columns, cards] = await Promise.all([
pb.collection("projects").getFullList<ProjectRecord>({
filter: 'status != "archived"',
sort: "name",
}),
pb.collection("clients").getFullList<ClientRecord>(),
pb.collection("columns").getFullList<Pick<ColumnRecord, "id" | "project" | "is_inbox" | "is_done">>({
fields: "id,project,is_inbox,is_done",
}),
pb.collection("cards").getFullList<Pick<CardRecord, "project" | "column">>({
filter: "archived = false",
fields: "project,column",
}),
]);
const clientsById = new Map(clients.map((c) => [c.id, c]));
const summaries: ProjectSummary[] = [];
const summariesByProject = new Map<string, ProjectSummary>();
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<ColumnRecord>(
`project = "${project.id}" && is_inbox = true`
)
.catch(() => null);
const doneColumns = new Set<string>();
const inboxColumns = new Set<string>();
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<ColumnRecord>(
`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<SidebarProjectGroup[]> {
return [...groups.values()].sort((a, b) =>
a.client.name.localeCompare(b.client.name)
);
}
}