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:
twotalesanimation
2026-10-07 17:30:40 +02:00
parent 27d9618995
commit 34af26dfbf
8 changed files with 1331 additions and 0 deletions
+171
View File
@@ -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;
}