feat(render): renderBoard + formatAge + partition (17 tests)
Pure renderer logic for the 5-col kanban view. - formatAge: m/h/d/w/mo/y with calendar-month arithmetic - partitionByStatus: canonical 5-col order - renderBoard: 5 columns, cards with data-slug/data-raw-url/data-archived, HTML-escaped fields, JSON embed escaped via \uXXXX (XSS-safe) - archived flag: done > 14d hidden by default (toggle visible) - align task acceptance emoji set to ⚪🔴🟡🔵🟢 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- **5 колонок** по статусам: ⚪ open / 🟡 in-progress / 🟣 paused / 🔴 blocked / 🟢 done. Названия колонок — emoji + слово.
|
- **5 колонок** по статусам: ⚪ open / 🔴 in-progress / 🟡 paused / 🔵 blocked / 🟢 done. Названия колонок — emoji + слово.
|
||||||
- **Карточка:**
|
- **Карточка:**
|
||||||
- title (truncate на 80 chars в default-view),
|
- title (truncate на 80 chars в default-view),
|
||||||
- project badge (e.g. `books`, `board-viewer`),
|
- project badge (e.g. `books`, `board-viewer`),
|
||||||
|
|||||||
163
src/render.ts
Normal file
163
src/render.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { Status, StatusEmoji } from './parser.ts';
|
||||||
|
import type { TaskRecord } from './reader.ts';
|
||||||
|
|
||||||
|
export interface StatusColumn {
|
||||||
|
status: Status;
|
||||||
|
emoji: StatusEmoji;
|
||||||
|
label: string;
|
||||||
|
records: TaskRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderOptions {
|
||||||
|
generatedAt: Date;
|
||||||
|
archiveAfterDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMN_ORDER: Array<{ status: Status; emoji: StatusEmoji; label: string }> = [
|
||||||
|
{ status: 'open', emoji: '⚪', label: 'open' },
|
||||||
|
{ status: 'in_progress', emoji: '🔴', label: 'in-progress' },
|
||||||
|
{ status: 'paused', emoji: '🟡', label: 'paused' },
|
||||||
|
{ status: 'blocked', emoji: '🔵', label: 'blocked' },
|
||||||
|
{ status: 'done', emoji: '🟢', label: 'done' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TITLE_MAX = 80;
|
||||||
|
const DEFAULT_ARCHIVE_DAYS = 14;
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
|
||||||
|
export function formatAge(iso: string | null, now: Date): string | null {
|
||||||
|
if (iso === null) return null;
|
||||||
|
const then = new Date(iso);
|
||||||
|
const diffMs = Math.max(0, now.getTime() - then.getTime());
|
||||||
|
const minutes = Math.floor(diffMs / 60_000);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
const hours = Math.floor(diffMs / 3_600_000);
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
const days = Math.floor(diffMs / MS_PER_DAY);
|
||||||
|
if (days < 7) return `${days}d`;
|
||||||
|
if (days < 30) return `${Math.floor(days / 7)}w`;
|
||||||
|
const months = calendarMonthsBetween(then, now);
|
||||||
|
if (months < 12) return `${months}mo`;
|
||||||
|
return `${Math.floor(months / 12)}y`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarMonthsBetween(from: Date, to: Date): number {
|
||||||
|
let months =
|
||||||
|
(to.getUTCFullYear() - from.getUTCFullYear()) * 12 +
|
||||||
|
(to.getUTCMonth() - from.getUTCMonth());
|
||||||
|
if (to.getUTCDate() < from.getUTCDate()) months -= 1;
|
||||||
|
return Math.max(0, months);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partitionByStatus(records: ReadonlyArray<TaskRecord>): StatusColumn[] {
|
||||||
|
return COLUMN_ORDER.map(({ status, emoji, label }) => ({
|
||||||
|
status,
|
||||||
|
emoji,
|
||||||
|
label,
|
||||||
|
records: records.filter((r) => r.status === status),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderBoard(records: ReadonlyArray<TaskRecord>, opts: RenderOptions): string {
|
||||||
|
const now = opts.generatedAt;
|
||||||
|
const archiveDays = opts.archiveAfterDays ?? DEFAULT_ARCHIVE_DAYS;
|
||||||
|
const columns = partitionByStatus(records);
|
||||||
|
|
||||||
|
const columnsHtml = columns
|
||||||
|
.map((col) => renderColumn(col, now, archiveDays))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const recordsJson = escapeForScript(JSON.stringify(records));
|
||||||
|
const generated = now.toISOString();
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Board Viewer</title>
|
||||||
|
<link rel="stylesheet" href="static/board.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header data-generated="${attr(generated)}">
|
||||||
|
<h1>Tasks</h1>
|
||||||
|
<div class="controls">
|
||||||
|
<input class="filter" type="search" placeholder="filter slug / title / project" />
|
||||||
|
<label><input type="checkbox" class="toggle-archived"> show archived</label>
|
||||||
|
<label><input type="checkbox" class="toggle-grouping"> group by project</label>
|
||||||
|
<span class="refresh-info"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="board">
|
||||||
|
${columnsHtml}
|
||||||
|
</main>
|
||||||
|
<aside class="drawer" hidden>
|
||||||
|
<button class="drawer-close" type="button">×</button>
|
||||||
|
<div class="drawer-content"></div>
|
||||||
|
</aside>
|
||||||
|
<script id="records" type="application/json">${recordsJson}</script>
|
||||||
|
<script type="module" src="static/board.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderColumn(col: StatusColumn, now: Date, archiveDays: number): string {
|
||||||
|
const cards = col.records
|
||||||
|
.map((r) => renderCard(r, now, archiveDays))
|
||||||
|
.join('\n');
|
||||||
|
return ` <section class="col" data-status="${attr(col.status)}">
|
||||||
|
<h2>${col.emoji} ${escapeHtml(col.label)} <span class="count">${col.records.length}</span></h2>
|
||||||
|
<ul class="cards">
|
||||||
|
${cards}
|
||||||
|
</ul>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(r: TaskRecord, now: Date, archiveDays: number): string {
|
||||||
|
const age = formatAge(r.last_commit_iso, now);
|
||||||
|
const archived = r.status === 'done' && isOlderThanDays(r.last_commit_iso, now, archiveDays);
|
||||||
|
const truncatedTitle = truncate(r.title, TITLE_MAX);
|
||||||
|
return ` <li class="card" data-slug="${attr(r.slug)}" data-raw-url="${attr(r.raw_url)}" data-archived="${archived ? 'true' : 'false'}">
|
||||||
|
<div class="card-title">${escapeHtml(truncatedTitle)}</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="badge project">${escapeHtml(r.project)}</span>
|
||||||
|
${age === null ? '' : `<span class="age">${escapeHtml(age)}</span>`}
|
||||||
|
</div>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOlderThanDays(iso: string | null, now: Date, days: number): boolean {
|
||||||
|
if (iso === null) return false;
|
||||||
|
const diffMs = now.getTime() - new Date(iso).getTime();
|
||||||
|
return diffMs > days * MS_PER_DAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
if (s.length <= max) return s;
|
||||||
|
return s.slice(0, max) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function attr(s: string): string {
|
||||||
|
return escapeHtml(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LS = String.fromCharCode(0x2028);
|
||||||
|
const PS = String.fromCharCode(0x2029);
|
||||||
|
|
||||||
|
function escapeForScript(json: string): string {
|
||||||
|
return json
|
||||||
|
.replace(/</g, '\\u003c')
|
||||||
|
.replace(/>/g, '\\u003e')
|
||||||
|
.replace(/&/g, '\\u0026')
|
||||||
|
.split(LS).join('\\u2028')
|
||||||
|
.split(PS).join('\\u2029');
|
||||||
|
}
|
||||||
156
tests/render.test.ts
Normal file
156
tests/render.test.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import type { TaskRecord } from '../src/reader.ts';
|
||||||
|
import { formatAge, partitionByStatus, renderBoard } from '../src/render.ts';
|
||||||
|
|
||||||
|
function rec(overrides: Partial<TaskRecord>): TaskRecord {
|
||||||
|
return {
|
||||||
|
slug: 'sample',
|
||||||
|
project: 'demo',
|
||||||
|
project_owner: 'o',
|
||||||
|
status: 'open',
|
||||||
|
status_emoji: '⚪',
|
||||||
|
title: 'sample title',
|
||||||
|
where_stopped: null,
|
||||||
|
next_action: null,
|
||||||
|
blocker: null,
|
||||||
|
branch: 'master',
|
||||||
|
last_commit_iso: null,
|
||||||
|
raw_url: 'https://example/raw',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('formatAge', () => {
|
||||||
|
const now = new Date('2026-05-22T12:00:00Z');
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['2026-05-22T11:00:00Z', '1h'],
|
||||||
|
['2026-05-22T11:59:00Z', '1m'],
|
||||||
|
['2026-05-21T12:00:00Z', '1d'],
|
||||||
|
['2026-05-19T12:00:00Z', '3d'],
|
||||||
|
['2026-05-08T12:00:00Z', '2w'],
|
||||||
|
['2026-02-22T12:00:00Z', '3mo'],
|
||||||
|
['2025-05-22T12:00:00Z', '1y'],
|
||||||
|
])('formats %s relative to now as %s', (iso, expected) => {
|
||||||
|
expect(formatAge(iso, now)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when iso is null', () => {
|
||||||
|
expect(formatAge(null, now)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns "0m" for sub-minute differences', () => {
|
||||||
|
expect(formatAge('2026-05-22T11:59:30Z', now)).toBe('0m');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('partitionByStatus', () => {
|
||||||
|
test('groups records into 5 columns in canonical order', () => {
|
||||||
|
const records = [
|
||||||
|
rec({ slug: 'a', status: 'done', status_emoji: '🟢' }),
|
||||||
|
rec({ slug: 'b', status: 'in_progress', status_emoji: '🔴' }),
|
||||||
|
rec({ slug: 'c', status: 'open', status_emoji: '⚪' }),
|
||||||
|
rec({ slug: 'd', status: 'paused', status_emoji: '🟡' }),
|
||||||
|
rec({ slug: 'e', status: 'blocked', status_emoji: '🔵' }),
|
||||||
|
rec({ slug: 'f', status: 'open', status_emoji: '⚪' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const cols = partitionByStatus(records);
|
||||||
|
|
||||||
|
expect(cols.map((c) => c.status)).toEqual([
|
||||||
|
'open',
|
||||||
|
'in_progress',
|
||||||
|
'paused',
|
||||||
|
'blocked',
|
||||||
|
'done',
|
||||||
|
]);
|
||||||
|
expect(cols[0]!.records.map((r) => r.slug)).toEqual(['c', 'f']);
|
||||||
|
expect(cols[4]!.records.map((r) => r.slug)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderBoard', () => {
|
||||||
|
const now = new Date('2026-05-22T12:00:00Z');
|
||||||
|
|
||||||
|
test('includes a <section> per status with canonical emoji label', () => {
|
||||||
|
const html = renderBoard([], { generatedAt: now });
|
||||||
|
|
||||||
|
expect(html).toContain('data-status="open"');
|
||||||
|
expect(html).toContain('data-status="in_progress"');
|
||||||
|
expect(html).toContain('data-status="paused"');
|
||||||
|
expect(html).toContain('data-status="blocked"');
|
||||||
|
expect(html).toContain('data-status="done"');
|
||||||
|
expect(html).toContain('⚪');
|
||||||
|
expect(html).toContain('🔴');
|
||||||
|
expect(html).toContain('🟡');
|
||||||
|
expect(html).toContain('🔵');
|
||||||
|
expect(html).toContain('🟢');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders a card per record with data-slug and data-raw-url', () => {
|
||||||
|
const records = [
|
||||||
|
rec({
|
||||||
|
slug: 'task-a',
|
||||||
|
title: 'first task',
|
||||||
|
raw_url: 'https://git.example/raw/task-a.md',
|
||||||
|
last_commit_iso: '2026-05-19T12:00:00Z',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const html = renderBoard(records, { generatedAt: now });
|
||||||
|
|
||||||
|
expect(html).toContain('data-slug="task-a"');
|
||||||
|
expect(html).toContain('data-raw-url="https://git.example/raw/task-a.md"');
|
||||||
|
expect(html).toContain('first task');
|
||||||
|
expect(html).toContain('3d');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truncates title longer than 80 chars with ellipsis in the card body', () => {
|
||||||
|
const longTitle = 'x'.repeat(100);
|
||||||
|
const html = renderBoard([rec({ title: longTitle })], { generatedAt: now });
|
||||||
|
const cardMatch = /<div class="card-title">([^<]*)<\/div>/.exec(html);
|
||||||
|
|
||||||
|
expect(cardMatch).not.toBeNull();
|
||||||
|
expect(cardMatch![1]).toBe('x'.repeat(80) + '…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapes HTML special chars in user-provided fields', () => {
|
||||||
|
const html = renderBoard(
|
||||||
|
[rec({ slug: 'safe', title: '<script>alert(1)</script>' })],
|
||||||
|
{ generatedAt: now },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).not.toContain('<script>alert(1)');
|
||||||
|
expect(html).toContain('<script>alert(1)</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks done tasks older than 14 days as archived (data-archived="true")', () => {
|
||||||
|
const old = '2026-05-01T12:00:00Z';
|
||||||
|
const fresh = '2026-05-20T12:00:00Z';
|
||||||
|
const html = renderBoard(
|
||||||
|
[
|
||||||
|
rec({ slug: 'old-done', status: 'done', status_emoji: '🟢', last_commit_iso: old }),
|
||||||
|
rec({ slug: 'fresh-done', status: 'done', status_emoji: '🟢', last_commit_iso: fresh }),
|
||||||
|
],
|
||||||
|
{ generatedAt: now },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toMatch(/data-slug="old-done"[^>]*data-archived="true"/);
|
||||||
|
expect(html).toMatch(/data-slug="fresh-done"[^>]*data-archived="false"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('embeds records JSON in <script id="records"> for client-side use', () => {
|
||||||
|
const records = [rec({ slug: 'embed-test' })];
|
||||||
|
|
||||||
|
const html = renderBoard(records, { generatedAt: now });
|
||||||
|
|
||||||
|
expect(html).toMatch(/<script[^>]+id="records"[^>]+type="application\/json"[^>]*>/);
|
||||||
|
expect(html).toContain('"embed-test"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes generation timestamp as data-generated attribute on the header', () => {
|
||||||
|
const html = renderBoard([], { generatedAt: now });
|
||||||
|
|
||||||
|
expect(html).toContain('data-generated="2026-05-22T12:00:00.000Z"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user