Files
vfxreview/lib/board-parse.ts
T
twotalesanimation 34af26dfbf 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>
2026-10-07 17:30:40 +02:00

172 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}