Files
fusion/plugins/fusion-plugin-even-realities-glasses/src/cards.ts
Fusion 007efcab0b feat(FN-3970): consolidate Even plugin APIs into unified package with webho
Collapsed the Even plugin architecture into a single unified plugin by merging board card routes and replacing the transport stub with a real webhook implementation, removing the separate cards plugin from the workspace. The new unified plugin (`fusion-plugin-even-realities-glasses`) now exports con

Fusion-Task-Id: FN-3970
2026-05-11 06:47:42 -07:00

212 lines
7.2 KiB
TypeScript

import type { Task } from "@fusion/core";
import type { NotificationReason } from "./notifications/types.js";
export type GlassesCardAction = {
type: "start-work" | "request-review" | "quick-capture";
taskId?: string;
label: string;
};
export type GlassesCard = {
id: string;
kind: "task" | "summary";
title: string;
lines: string[];
badge: string;
taskId?: string;
updatedAt?: string;
actions?: GlassesCardAction[];
};
export type BoardSummary = {
counts: Record<string, number>;
updatedAt: string | null;
};
export type CardDeck = {
cards: GlassesCard[];
summary: BoardSummary;
};
const COLUMN_BADGES: Record<string, string> = {
triage: "triage",
todo: "todo",
"in-progress": "in-progress",
"in-review": "in-review",
done: "done",
archived: "archived",
};
const COLUMN_ORDER: Array<Task["column"]> = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
export const DEFAULT_MAX_CHARS_PER_LINE = 24;
export const DEFAULT_MAX_LINES_PER_CARD = 4;
export const DEFAULT_MAX_CARDS_PER_DECK = 8;
const DEFAULT_MAX_TITLE = 80;
export function truncateLine(value: string, maxChars = DEFAULT_MAX_TITLE): string {
const trimmed = value.trim();
if (trimmed.length <= maxChars) return trimmed;
if (maxChars <= 1) return "…";
return `${trimmed.slice(0, Math.max(0, maxChars - 1))}`;
}
export function wrapLines(
value: string,
opts: { maxCharsPerLine?: number; maxLines?: number } = {},
): string[] {
const maxCharsPerLine = Math.max(8, opts.maxCharsPerLine ?? 36);
const maxLines = Math.max(1, opts.maxLines ?? 2);
const words = value.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return [];
const lines: string[] = [];
let current = "";
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (candidate.length > maxCharsPerLine && current) {
lines.push(current);
current = word;
if (lines.length >= maxLines) break;
} else {
current = candidate;
}
}
if (lines.length < maxLines && current) lines.push(current);
if (lines.length > maxLines) return lines.slice(0, maxLines);
if (words.join(" ").length > lines.join(" ").length && lines.length > 0) {
lines[lines.length - 1] = truncateLine(lines[lines.length - 1], maxCharsPerLine);
}
return lines;
}
export function statusBadge(column: Task["column"]): string {
return COLUMN_BADGES[column] ?? "todo";
}
export function formatRelativeAge(updatedAt: string, opts: { now?: () => Date } = {}): string {
const now = opts.now?.() ?? new Date();
const at = new Date(updatedAt);
const diffMs = Math.max(0, now.getTime() - at.getTime());
const mins = Math.floor(diffMs / 60_000);
if (mins < 1) return "updated just now";
if (mins < 60) return `updated ${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `updated ${hours}h ago`;
return `updated ${Math.floor(hours / 24)}d ago`;
}
function boardSummary(tasks: Task[]): BoardSummary {
const counts = Object.fromEntries(COLUMN_ORDER.map((column) => [column, 0]));
let updatedAt: string | null = null;
for (const task of tasks) {
counts[task.column] = (counts[task.column] ?? 0) + 1;
if (!updatedAt || task.updatedAt > updatedAt) updatedAt = task.updatedAt;
}
return { counts, updatedAt };
}
function boardSummaryCardFromCounts(summary: BoardSummary, now: string, maxCharsPerLine: number, maxLines: number): GlassesCard {
const summaryText = `Triage ${summary.counts.triage} Todo ${summary.counts.todo} Doing ${summary.counts["in-progress"]} Review ${summary.counts["in-review"]} Done ${summary.counts.done}`;
return {
id: "summary",
kind: "summary",
title: truncateLine("Fusion board", maxCharsPerLine),
lines: wrapLines(summaryText, { maxCharsPerLine, maxLines }),
badge: statusBadge("todo"),
updatedAt: summary.updatedAt ?? now,
};
}
export function taskToCard(
task: Task,
opts: { maxCharsPerLine?: number; maxLines?: number; now?: () => Date } = {},
): GlassesCard {
const title = typeof task.title === "string" && task.title.trim() ? task.title : task.description;
const maxCharsPerLine = opts.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
return {
id: task.id,
kind: "task",
title: truncateLine(`${task.id.toUpperCase()} ${title}`, maxCharsPerLine),
lines: wrapLines(
`Priority ${task.priority ?? "normal"} Assignee ${task.assignedAgentId ?? task.assigneeUserId ?? "unassigned"} Age ${formatRelativeAge(task.createdAt ?? task.updatedAt, { now: opts.now })}`,
{ maxCharsPerLine, maxLines },
),
badge: statusBadge(task.column),
taskId: task.id,
updatedAt: task.updatedAt,
actions: [
{ type: "start-work", taskId: task.id, label: "Start work" },
{ type: "request-review", taskId: task.id, label: "Request review" },
],
};
}
export function boardToDeck(
tasks: Task[],
opts: { maxCharsPerLine?: number; maxLines?: number; maxCards?: number; now?: string } = {},
): CardDeck {
const maxCharsPerLine = opts.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
const maxCards = opts.maxCards ?? DEFAULT_MAX_CARDS_PER_DECK;
const now = opts.now ?? new Date().toISOString();
const summary = boardSummary(tasks);
const active = tasks
.filter((task) => task.column !== "archived" && task.column !== "done")
.sort((a, b) => (b.updatedAt === a.updatedAt ? b.id.localeCompare(a.id) : b.updatedAt.localeCompare(a.updatedAt)))
.slice(0, Math.max(0, maxCards - 1));
return {
cards: [
boardSummaryCardFromCounts(summary, now, maxCharsPerLine, maxLines),
...active.map((task) => taskToCard(task, { maxCharsPerLine, maxLines, now: () => new Date(now) })),
],
summary,
};
}
export function boardSummaryCard(tasksByColumn: Record<string, number>): GlassesCard {
const ordered = ["triage", "todo", "in-progress", "in-review", "done"];
return {
id: "board-summary",
kind: "summary",
title: "Fusion Board Summary",
lines: ordered.map((column) => `${column}: ${tasksByColumn[column] ?? 0}`),
badge: "todo",
};
}
export function notificationCard(
task: Task,
reason: NotificationReason,
opts: { now?: () => Date; maxCharsPerLine?: number; maxLines?: number } = {},
): GlassesCard {
const baseTitle = typeof task.title === "string" && task.title.trim() ? task.title : task.description;
const reasonTitle =
reason === "entered-column"
? task.column === "in-review"
? "In review"
: "Moved in"
: reason === "new-task"
? "New task"
: reason === "completed"
? "Done"
: "Moved out";
return {
id: `notif:${task.id}:${reason}`,
kind: "task",
title: `${reasonTitle} · ${truncateLine(baseTitle, opts.maxCharsPerLine ?? DEFAULT_MAX_TITLE)}`,
lines: [
`assignee: ${task.assignedAgentId ?? task.assigneeUserId ?? "unassigned"}`,
...wrapLines(formatRelativeAge(task.updatedAt, { now: opts.now }), {
maxCharsPerLine: opts.maxCharsPerLine,
maxLines: opts.maxLines ?? 1,
}),
],
badge: statusBadge(task.column),
taskId: task.id,
updatedAt: task.updatedAt,
};
}