diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 46b095d..eeaf298 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -12,6 +12,7 @@ import PocketBase from "pocketbase"; import { buildColumnRecords, assertColumnInvariants } from "../src/lib/columns/seed"; import { SPACING } from "../src/lib/ordering/fractional"; +import { nextCardNumber } from "../src/lib/cards/number"; const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "http://127.0.0.1:8090"; @@ -122,11 +123,38 @@ async function main() { { title: "Delivery note grouping", column: "Done", priority: "low" }, ]; + // Pre-fetch existing numbers so re-seeding keeps the original numbering + // and only fills in the gap when a card is missing. + const existingNumbers = ( + await pb + .collection("cards") + .getFullList({ filter: `project = "${project.id}"`, fields: "number" }) + ).map((c) => Number(c.number) || 0); + let position = SPACING; for (const card of cards) { + const cardNumber = nextCardNumber(existingNumbers); + existingNumbers.push(cardNumber); + await upsert( pb, "cards", + `project = "${project.id}" && title = "${card.title}"`, + { + project: project.id, + column: columns[card.column], + title: card.title, + description: "", + position, + priority: card.priority, + created_by_role: "owner", + accepted: true, + archived: false, + number: cardNumber, + } + ); + position += SPACING; + } `project = "${project.id}" && title = "${card.title}"`, { project: project.id, diff --git a/scripts/setup-remote.ts b/scripts/setup-remote.ts index 1957705..7f564f8 100644 --- a/scripts/setup-remote.ts +++ b/scripts/setup-remote.ts @@ -133,6 +133,93 @@ async function applyRules( console.log(` = rules for ${name}`); } +async function ensureProjectIngestFields(pb: PocketBase): Promise { + const projects = await pb.collections.getOne("projects"); + const have = new Set(projects.fields.map((f: { name: string }) => f.name)); + + const additions = [ + { name: "webhook_secret", type: "text", required: false, max: 80 }, + { name: "last_ingest_at", type: "date", required: false }, + ]; + + const fields = [...(projects.fields as unknown[])]; + let changed = false; + + for (const field of additions) { + if (!have.has(field.name)) { + fields.push({ ...field, presentable: false, hidden: false, system: false }); + changed = true; + } + } + + if (changed) { + await pb.collections.update(projects.id, { fields }); + console.log( + ` + projects fields: ${additions + .map((f) => f.name) + .join(", ")}` + ); + } else { + console.log(" = projects ingest fields already present"); + } +} + +async function ensureCardNumbers(pb: PocketBase): Promise { + const cards = await pb.collections.getOne("cards"); + const have = new Set(cards.fields.map((f: { name: string }) => f.name)); + + if (!have.has("number")) { + const fields = [...(cards.fields as unknown[])]; + fields.push({ + name: "number", + type: "number", + required: false, + onlyInt: true, + presentable: false, + hidden: false, + system: false, + }); + await pb.collections.update(cards.id, { fields }); + console.log(" + cards field: number"); + } else { + console.log(" = cards.number already present"); + } + + // Backfill per project, oldest card first, so numbering matches the order + // the work was created in rather than the order PocketBase returns. + const projects = await pb.collection("projects").getFullList({ fields: "id" }); + for (const project of projects) { + const all = await pb.collection("cards").getFullList({ + filter: `project = "${project.id}"`, + sort: "created", + fields: "id,number", + }); + + let next = 1; + for (const card of all) { + if (typeof card.number === "number" && card.number > 0) { + next = Math.max(next, card.number + 1); + continue; + } + await pb.collection("cards").update(card.id, { number: next }); + next += 1; + } + } + console.log(" = card numbers backfilled"); + + // A unique index makes "never reused" enforceable rather than hoped for. + const cardsCollection = await pb.collections.getOne("cards"); + const indexes = new Set((cardsCollection.indexes as string[]) ?? []); + const unique = "CREATE UNIQUE INDEX idx_cards_project_number ON cards (project, number)"; + if (!indexes.has(unique)) { + indexes.add(unique); + await pb.collections.update(cardsCollection.id, { + indexes: [...indexes], + }); + console.log(" + unique index idx_cards_project_number"); + } +} + async function main(): Promise { const pb = new PocketBase(PB_URL); await authAdmin(pb); @@ -627,38 +714,10 @@ async function main(): Promise { // Add ingest-related fields to projects (only if missing — the projects // collection already exists from earlier migrations). - const projects = await pb.collections.getOne("projects"); - const haveProjectFields = new Set( - projects.fields.map((f: { name: string }) => f.name) - ); - const projectFieldAdditions: Array> = []; - if (!haveProjectFields.has("webhook_secret")) { - projectFieldAdditions.push({ - name: "webhook_secret", - type: "text", - required: false, - max: 80, - }); - } - if (!haveProjectFields.has("last_ingest_at")) { - projectFieldAdditions.push({ - name: "last_ingest_at", - type: "date", - required: false, - }); - } - if (projectFieldAdditions.length > 0) { - await pb.collections.update(projects.id, { - fields: [...(projects.fields as unknown[]), ...projectFieldAdditions], - }); - console.log( - ` + projects fields: ${projectFieldAdditions - .map((f) => (f as { name: string }).name) - .join(", ")}` - ); - } else { - console.log(" = projects ingest fields already present"); - } + await ensureProjectIngestFields(pb); + + // Add the card number field and backfill existing cards. + await ensureCardNumbers(pb); console.log("Done."); } diff --git a/src/app/actions/cards.ts b/src/app/actions/cards.ts index 6ae492c..53f4e48 100644 --- a/src/app/actions/cards.ts +++ b/src/app/actions/cards.ts @@ -6,6 +6,7 @@ import { getServerPb } from "@/lib/pb/server"; import { requireSession } from "@/lib/auth/session"; import { logActivity } from "@/app/actions/activity"; import { positionBetween } from "@/lib/ordering/fractional"; +import { nextCardNumber } from "@/lib/cards/number"; import type { CardRecord, ColumnRecord } from "@/lib/pb/types"; /** diff --git a/src/app/api/client/cards/route.ts b/src/app/api/client/cards/route.ts index 6336746..f04e64b 100644 --- a/src/app/api/client/cards/route.ts +++ b/src/app/api/client/cards/route.ts @@ -89,6 +89,11 @@ export async function POST(request: Request): Promise { }) .catch(() => null); + const numbered = await pb.collection("cards").getFullList({ + filter: `project = "${projectId}"`, + fields: "number", + }); + const card = await pb.collection("cards").create({ project: projectId, column: inbox.id, @@ -100,6 +105,7 @@ export async function POST(request: Request): Promise { created_by_role: "client", accepted: false, archived: false, + number: nextCardNumber(numbered.map((c) => c.number)), }); await pb.collection("activity").create({ diff --git a/src/lib/cards/number.test.ts b/src/lib/cards/number.test.ts new file mode 100644 index 0000000..5eb5c9c --- /dev/null +++ b/src/lib/cards/number.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { nextCardNumber } from "@/lib/cards/number"; + +describe("nextCardNumber", () => { + it("starts at 1 for a project with no cards", () => { + expect(nextCardNumber([])).toBe(1); + }); + + it("returns one past the highest existing number", () => { + expect(nextCardNumber([1, 2, 3])).toBe(4); + }); + + it("does not reuse a number freed by a deletion", () => { + // Reusing 2 would point an old commit's [#2] at a different card. + expect(nextCardNumber([1, 3])).toBe(4); + }); + + it("ignores zero, negatives and non-integers left by bad data", () => { + expect(nextCardNumber([0, -5, 2])).toBe(3); + expect(nextCardNumber([Number.NaN, 4])).toBe(5); + }); + + it("handles an unsorted list", () => { + expect(nextCardNumber([7, 2, 5])).toBe(8); + }); +}); \ No newline at end of file diff --git a/src/lib/cards/number.ts b/src/lib/cards/number.ts new file mode 100644 index 0000000..64841bd --- /dev/null +++ b/src/lib/cards/number.ts @@ -0,0 +1,19 @@ +/** + * Per-project card numbers. + * + * Cards need a short identifier a person can type into a commit message — + * `feat(stock): inline editing [#42]`. PocketBase ids like `2e8izsch98bbc6l` + * cannot serve that purpose. + * + * Numbers are never reused. A freed number would silently repoint an old + * commit's reference at an unrelated card, and git history cannot be corrected + * after the fact. + */ +export function nextCardNumber(existing: number[]): number { + let highest = 0; + for (const value of existing) { + if (!Number.isInteger(value)) continue; + if (value > highest) highest = value; + } + return highest + 1; +} \ No newline at end of file diff --git a/src/lib/pb/types.ts b/src/lib/pb/types.ts index 59cdd0c..82edf0e 100644 --- a/src/lib/pb/types.ts +++ b/src/lib/pb/types.ts @@ -77,6 +77,8 @@ export interface CardRecord extends BaseRecord { start_date: string; scope_rule: string; archived: boolean; + /** Per-project sequential number, for `[#42]` references in commit messages. */ + number: number; } export interface SubtaskRecord extends BaseRecord {