diff --git a/server/src/__tests__/company-work-batch.test.ts b/server/src/__tests__/company-work-batch.test.ts new file mode 100644 index 00000000000..9fe1abc5a1d --- /dev/null +++ b/server/src/__tests__/company-work-batch.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { takeCompanyWorkBatch } from "../services/company-work-batch.js"; + +describe("company work batch", () => { + it("shares capacity while preserving the owner's order within each company", () => { + const entries = [ + { companyId: "a", id: "a-oldest" }, { companyId: "a", id: "a-next" }, + { companyId: "a", id: "a-later" }, { companyId: "b", id: "b-due" }, + ]; + expect(takeCompanyWorkBatch(entries, 3)).toEqual({ + entries: [entries[0], entries[3], entries[1]], lastCompany: "a", + }); + }); + it("rotates between batches when more companies are due than fit in one batch", () => { + const entries = ["a", "b", "c", "d"].flatMap(companyId => [ + { companyId, id: `${companyId}-first` }, { companyId, id: `${companyId}-second` }, + ]); + const first = takeCompanyWorkBatch(entries, 2); + const remaining = entries.filter(entry => !first.entries.includes(entry)); + const second = takeCompanyWorkBatch(remaining, 2, first.lastCompany); + expect([...first.entries, ...second.entries].map(entry => entry.companyId)).toEqual(["a", "b", "c", "d"]); + expect(takeCompanyWorkBatch(remaining, 1, "z").entries[0].companyId).toBe("a"); + }); + it("finishes an empty or exhausted batch without inventing work", () => { + expect(takeCompanyWorkBatch([], 25, "gone")).toEqual({ entries: [], lastCompany: "gone" }); + const entry = { companyId: "only" }; + expect(takeCompanyWorkBatch([entry], 25).entries).toEqual([entry]); + }); +}); diff --git a/server/src/services/company-work-batch.ts b/server/src/services/company-work-batch.ts new file mode 100644 index 00000000000..f83d2dcd5a5 --- /dev/null +++ b/server/src/services/company-work-batch.ts @@ -0,0 +1,28 @@ +export function takeCompanyWorkBatch( + ordered: readonly T[], + limit: number, + afterCompany: string | null = null, +): { entries: T[]; lastCompany: string | null } { + const grouped = new Map(); + for (const entry of ordered) { + const group = grouped.get(entry.companyId); + if (group) group.push(entry); else grouped.set(entry.companyId, [entry]); + } + const companies = [...grouped.keys()].sort(); + const next = afterCompany === null ? 0 : companies.findIndex(id => id > afterCompany); + const start = next < 0 ? 0 : next; + const rotation = [...companies.slice(start), ...companies.slice(0, start)]; + const entries: T[] = []; + let lastCompany = afterCompany; + for (let round = 0; entries.length < limit; round++) { + let added = false; + for (const companyId of rotation) { + const entry = grouped.get(companyId)![round]; + if (!entry) continue; + entries.push(entry); lastCompany = companyId; added = true; + if (entries.length === limit) break; + } + if (!added) break; + } + return { entries, lastCompany }; +}