From 5bbbd0b533b4d3ccf68895f97d1a598d21cc1ce4 Mon Sep 17 00:00:00 2001 From: vitya Date: Fri, 22 May 2026 14:43:29 +0300 Subject: [PATCH] feat(render): renderBoard + formatAge + partition (17 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .tasks/board-viewer-html-render.md | 2 +- src/render.ts | 163 +++++++++++++++++++++++++++++ tests/render.test.ts | 156 +++++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 src/render.ts create mode 100644 tests/render.test.ts diff --git a/.tasks/board-viewer-html-render.md b/.tasks/board-viewer-html-render.md index 393726a..0dc37ba 100644 --- a/.tasks/board-viewer-html-render.md +++ b/.tasks/board-viewer-html-render.md @@ -15,7 +15,7 @@ ## Acceptance criteria -- **5 колонок** по статусам: ⚪ open / 🟡 in-progress / 🟣 paused / 🔴 blocked / 🟢 done. Названия колонок — emoji + слово. +- **5 колонок** по статусам: ⚪ open / 🔴 in-progress / 🟡 paused / 🔵 blocked / 🟢 done. Названия колонок — emoji + слово. - **Карточка:** - title (truncate на 80 chars в default-view), - project badge (e.g. `books`, `board-viewer`), diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..6ed4ece --- /dev/null +++ b/src/render.ts @@ -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): StatusColumn[] { + return COLUMN_ORDER.map(({ status, emoji, label }) => ({ + status, + emoji, + label, + records: records.filter((r) => r.status === status), + })); +} + +export function renderBoard(records: ReadonlyArray, 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 ` + + + + Board Viewer + + + +
+

Tasks

+
+ + + + +
+
+
+${columnsHtml} +
+ + + + + +`; +} + +function renderColumn(col: StatusColumn, now: Date, archiveDays: number): string { + const cards = col.records + .map((r) => renderCard(r, now, archiveDays)) + .join('\n'); + return `
+

${col.emoji} ${escapeHtml(col.label)} ${col.records.length}

+
    +${cards} +
+
`; +} + +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 `
  • +
    ${escapeHtml(truncatedTitle)}
    +
    + ${escapeHtml(r.project)} + ${age === null ? '' : `${escapeHtml(age)}`} +
    +
  • `; +} + +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, '''); +} + +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, '\\u003e') + .replace(/&/g, '\\u0026') + .split(LS).join('\\u2028') + .split(PS).join('\\u2029'); +} diff --git a/tests/render.test.ts b/tests/render.test.ts new file mode 100644 index 0000000..81311d5 --- /dev/null +++ b/tests/render.test.ts @@ -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 { + 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
    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>/.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: '' })], + { generatedAt: now }, + ); + + expect(html).not.toContain('