feat(board): daily board linked to shots
A /board page that replaces the shared doc used as the day-to-day tracker. One card per active/on-hold project with a status line, dated milestones and to-do lines typed or pasted in the doc's own format: DH2_VFX_027_090 finalise (CHRIS) DH2_VFX_005_010 gfx update @michelle wait: client GFX ADH_GFX_005_020 Shot codes link to the shot (live status + latest version), (NAME)/@name assigns, wait: marks a blocker, headings group lines. Codes that match no shot are flagged, with a relink when there is an obvious near miss. Board lines are their own model, not Tasks: tasks drive shot status and notifications, and a note like "finalise" must not touch either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseBoardLine, parseBoardText, suggestShot } from "./board-parse";
|
||||
|
||||
const shots = [
|
||||
{ id: "s1", shotCode: "DH2_VFX_027_090" },
|
||||
{ id: "s2", shotCode: "DH2_VFX_005_010" },
|
||||
{ id: "s3", shotCode: "DH2_VFX_004_100" },
|
||||
{ id: "s4", shotCode: "UNG_117_023_010", clientCode: "EP17_VFX_003" },
|
||||
];
|
||||
const users = [
|
||||
{ id: "u1", name: "Chris Pinto", email: "chris@example.com" },
|
||||
{ id: "u2", name: "Michelle Pinto", email: "michelle@example.com" },
|
||||
];
|
||||
const ctx = { shots, users };
|
||||
|
||||
describe("parseBoardLine", () => {
|
||||
it("links the shot and assigns from (NAME)", () => {
|
||||
expect(parseBoardLine("DH2_VFX_027_090 finalise (CHRIS)", ctx)).toEqual({
|
||||
text: "finalise",
|
||||
code: "DH2_VFX_027_090",
|
||||
shotId: "s1",
|
||||
assigneeId: "u1",
|
||||
waitingOn: null,
|
||||
group: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches codes case-insensitively and by client alias", () => {
|
||||
expect(parseBoardLine("dh2_vfx_027_090 x", ctx).shotId).toBe("s1");
|
||||
const alias = parseBoardLine("EP17_VFX_003 warp fix", ctx);
|
||||
expect(alias.shotId).toBe("s4");
|
||||
expect(alias.code).toBe("UNG_117_023_010");
|
||||
});
|
||||
|
||||
it("keeps an unknown code as typed for flagging", () => {
|
||||
const r = parseBoardLine("DH2_004_100 not on our tracker?", ctx);
|
||||
expect(r.code).toBe("DH2_004_100");
|
||||
expect(r.shotId).toBeNull();
|
||||
expect(r.text).toBe("not on our tracker?");
|
||||
});
|
||||
|
||||
it("assigns from @name and keeps the rest of a parenthesised group", () => {
|
||||
expect(parseBoardLine("DH2_VFX_005_010 gfx @michelle", ctx).assigneeId).toBe("u2");
|
||||
const r = parseBoardLine("(MICHELLE ROTO) Maagie shot", ctx);
|
||||
expect(r.assigneeId).toBe("u2");
|
||||
expect(r.text).toBe("ROTO Maagie shot");
|
||||
});
|
||||
|
||||
it("leaves parentheses that are not a user alone", () => {
|
||||
const r = parseBoardLine("DH2_VFX_005_010 track (90% done)", ctx);
|
||||
expect(r.assigneeId).toBeNull();
|
||||
expect(r.text).toBe("track (90% done)");
|
||||
});
|
||||
|
||||
it("splits off what the line is waiting on", () => {
|
||||
const r = parseBoardLine(
|
||||
"DH2_VFX_005_010 gfx required for update (MICHELLE) wait: client GFX ADH_GFX_005_020",
|
||||
ctx,
|
||||
);
|
||||
expect(r.shotId).toBe("s2");
|
||||
expect(r.waitingOn).toBe("client GFX ADH_GFX_005_020");
|
||||
expect(r.text).toBe("gfx required for update");
|
||||
expect(parseBoardLine("x waiting on: grade", ctx).waitingOn).toBe("grade");
|
||||
// no colon → prose
|
||||
expect(parseBoardLine("awaiting gfx", ctx).waitingOn).toBeNull();
|
||||
});
|
||||
|
||||
it("takes a note with no shot code", () => {
|
||||
const r = parseBoardLine("- Deposit received", ctx);
|
||||
expect(r).toMatchObject({ text: "Deposit received", code: null, shotId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBoardText", () => {
|
||||
it("groups lines under headings and skips blanks", () => {
|
||||
const items = parseBoardText(
|
||||
"DH2_VFX_027_090 finalise (CHRIS)\n\nWATER DROPS:\nDH2_VFX_005_010 still do (CHRIS)\nDH2_VFX_004_100\n",
|
||||
ctx,
|
||||
);
|
||||
expect(items.map((i) => [i.code, i.group, i.text])).toEqual([
|
||||
["DH2_VFX_027_090", "", "finalise"],
|
||||
["DH2_VFX_005_010", "WATER DROPS", "still do"],
|
||||
["DH2_VFX_004_100", "WATER DROPS", ""],
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats an all-caps line without a code as a heading", () => {
|
||||
const items = parseBoardText("FLAGS\nDH2_004_100 not on our tracker?", ctx);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].group).toBe("FLAGS");
|
||||
});
|
||||
|
||||
it("never treats a single typed line as a heading", () => {
|
||||
expect(parseBoardText("CALL CLIENT", ctx)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestShot", () => {
|
||||
it("suggests the shot with the same prefix and numbers", () => {
|
||||
expect(suggestShot("DH2_004_100", shots)?.id).toBe("s3");
|
||||
});
|
||||
|
||||
it("does not guess between several candidates", () => {
|
||||
const dup = [...shots, { id: "s5", shotCode: "DH2_GFX_004_100" }];
|
||||
expect(suggestShot("DH2_004_100", dup)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Turns lines typed (or pasted) onto the daily board into board items.
|
||||
*
|
||||
* The board replaces a shared doc, so the input is whatever someone would
|
||||
* have typed there:
|
||||
*
|
||||
* WATER DROPS:
|
||||
* DH2_VFX_027_210 still do (CHRIS)
|
||||
* DH2_VFX_005_010 gfx required for update @michelle wait: client GFX ADH_GFX_005_020
|
||||
*
|
||||
* - The first shot-code-shaped token is the line's shot. It is matched against
|
||||
* the project's shot codes and client aliases; a code that matches nothing
|
||||
* is kept as typed so the board can flag it.
|
||||
* - `(NAME)` or `@name` assigns the line when NAME is a user's first or full
|
||||
* name. A parenthesised group can carry more than the name — "(MICHELLE
|
||||
* ROTO)" assigns Michelle and keeps "ROTO" in the text.
|
||||
* - `wait: …` (also `waiting on:`, `blocked by:`) marks what the line is
|
||||
* blocked on. The colon is required so prose like "awaiting gfx" stays text.
|
||||
* - A line with no shot code that ends in ":" or is all capitals is a heading;
|
||||
* the lines after it are grouped under it.
|
||||
*/
|
||||
|
||||
export interface BoardShot {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
clientCode?: string | null;
|
||||
}
|
||||
|
||||
export interface BoardUser {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ParsedBoardLine {
|
||||
text: string;
|
||||
/** The shot code as typed (canonical shotCode when it matched); null = no code on the line */
|
||||
code: string | null;
|
||||
shotId: string | null;
|
||||
assigneeId: string | null;
|
||||
waitingOn: string | null;
|
||||
group: string;
|
||||
}
|
||||
|
||||
// Three or more underscore-separated segments, starting with a letter:
|
||||
// DH2_VFX_027_090, UNG_117_023_010, ADH_GFX_005_020.
|
||||
export const CODE_RE = /\b[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+){2,}\b/;
|
||||
const WAIT_RE = /\b(?:wait(?:ing)?(?:\s+(?:on|for))?|blocked(?:\s+(?:on|by))?)\s*:\s*(.*)$/i;
|
||||
const BULLET_RE = /^(?:[-*•–]|\d+[.)])\s+/;
|
||||
|
||||
export function findShot(code: string, shots: BoardShot[]): BoardShot | null {
|
||||
const c = code.toUpperCase();
|
||||
return (
|
||||
shots.find((s) => s.shotCode.toUpperCase() === c) ??
|
||||
shots.find((s) => s.clientCode?.trim().toUpperCase() === c) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A likely intended shot for a code that matched nothing — same leading
|
||||
* segment and same numeric segments, so "DH2_004_100" suggests
|
||||
* "DH2_VFX_004_100". Only a unique candidate is suggested.
|
||||
*/
|
||||
export function suggestShot(code: string, shots: BoardShot[]): BoardShot | null {
|
||||
const sig = (c: string) => {
|
||||
const parts = c.toUpperCase().split("_");
|
||||
return `${parts[0]}|${parts.filter((p) => /^\d+$/.test(p)).join("_")}`;
|
||||
};
|
||||
const want = sig(code);
|
||||
if (want.endsWith("|")) return null; // no numeric segments to go on
|
||||
const hits = shots.filter((s) => sig(s.shotCode) === want);
|
||||
return hits.length === 1 ? hits[0] : null;
|
||||
}
|
||||
|
||||
function userNames(u: BoardUser): string[] {
|
||||
const names = [u.email.split("@")[0]];
|
||||
if (u.name) {
|
||||
names.push(u.name);
|
||||
names.push(u.name.split(/\s+/)[0]);
|
||||
}
|
||||
return names.map((n) => n.toLowerCase());
|
||||
}
|
||||
|
||||
function findUser(word: string, users: BoardUser[]): BoardUser | null {
|
||||
const w = word.toLowerCase();
|
||||
const hits = users.filter((u) => userNames(u).includes(w));
|
||||
return hits.length === 1 ? hits[0] : null;
|
||||
}
|
||||
|
||||
function isHeading(line: string): boolean {
|
||||
if (CODE_RE.test(line)) return false;
|
||||
if (line.endsWith(":")) return true;
|
||||
const letters = line.replace(/[^A-Za-z]/g, "");
|
||||
return letters.length >= 3 && letters === letters.toUpperCase();
|
||||
}
|
||||
|
||||
export function parseBoardLine(
|
||||
raw: string,
|
||||
ctx: { shots: BoardShot[]; users: BoardUser[] },
|
||||
group = "",
|
||||
): ParsedBoardLine {
|
||||
let line = raw.trim().replace(BULLET_RE, "");
|
||||
|
||||
let waitingOn: string | null = null;
|
||||
const wait = WAIT_RE.exec(line);
|
||||
if (wait) {
|
||||
waitingOn = wait[1].trim() || null;
|
||||
line = line.slice(0, wait.index);
|
||||
}
|
||||
|
||||
let code: string | null = null;
|
||||
let shotId: string | null = null;
|
||||
const codeMatch = CODE_RE.exec(line);
|
||||
if (codeMatch) {
|
||||
const shot = findShot(codeMatch[0], ctx.shots);
|
||||
code = shot?.shotCode ?? codeMatch[0].toUpperCase();
|
||||
shotId = shot?.id ?? null;
|
||||
line = line.slice(0, codeMatch.index) + line.slice(codeMatch.index + codeMatch[0].length);
|
||||
}
|
||||
|
||||
let assigneeId: string | null = null;
|
||||
// (NAME …) — first word a user's name; the rest of the group stays as text
|
||||
line = line.replace(/\(([^)]*)\)/g, (whole, inner: string) => {
|
||||
if (assigneeId) return whole;
|
||||
const words = inner.trim().split(/\s+/);
|
||||
// Full name first ("Chris Pinto"), then first word
|
||||
const full = findUser(words.slice(0, 2).join(" "), ctx.users);
|
||||
const user = full ?? findUser(words[0] ?? "", ctx.users);
|
||||
if (!user) return whole;
|
||||
assigneeId = user.id;
|
||||
const rest = words.slice(full ? 2 : 1).join(" ");
|
||||
return rest ? ` ${rest} ` : " ";
|
||||
});
|
||||
line = line.replace(/@([A-Za-z][\w.-]*)/g, (whole, name: string) => {
|
||||
if (assigneeId) return whole;
|
||||
const user = findUser(name, ctx.users);
|
||||
if (!user) return whole;
|
||||
assigneeId = user.id;
|
||||
return " ";
|
||||
});
|
||||
|
||||
const text = line
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^[\s:\-–,.]+|[\s:\-–,]+$/g, "")
|
||||
.trim();
|
||||
|
||||
return { text, code, shotId, assigneeId, waitingOn, group };
|
||||
}
|
||||
|
||||
/** Every non-blank line of `input` as a board item, headings applied as groups. */
|
||||
export function parseBoardText(
|
||||
input: string,
|
||||
ctx: { shots: BoardShot[]; users: BoardUser[]; group?: string },
|
||||
): ParsedBoardLine[] {
|
||||
const out: ParsedBoardLine[] = [];
|
||||
let group = ctx.group ?? "";
|
||||
const lines = input.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(BULLET_RE, "");
|
||||
// A single line is always an item — someone typing "Deposit received"
|
||||
// into the quick-add box means a note, not a heading.
|
||||
if (lines.length > 1 && isHeading(line)) {
|
||||
group = line.replace(/:$/, "").trim();
|
||||
continue;
|
||||
}
|
||||
const parsed = parseBoardLine(line, ctx, group);
|
||||
if (parsed.text || parsed.code || parsed.waitingOn) out.push(parsed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user