Files
board-viewer/tests/render.test.ts
vitya f6a45f4987 feat(viewer): MSK datetime + done-cutoff expander [v0.3.0]
Closes 2 more UX-round1 fixes (5/6 done; task-numbers remains
design-first cross-repo, awaiting user decision).

ux-full-datetime: formatMskDateTime(iso) → "YYYY-MM-DD HH:MM МСК".
UTC+3 fixed (no-DST since 2011). Tooltip retains full ISO for
copy-paste. Relative age stays in card-meta. Mirrored in board.js
renderCard re-render path.

ux-done-cutoff: source unchanged (render-side cutoff). Done column
sorts by last_commit_iso desc, first 10 visible, rest get
data-overflow="true" (CSS hides). Column bottom gets
"show N more" expander; click toggles col.show-overflow class +
button text "collapse". Non-done columns untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:55:43 +03:00

353 lines
12 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.
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',
md_content: null,
...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');
});
test('clamps future timestamps to "0m" (server clock drift safety)', () => {
// ISO in the future of `now` — can happen with clock drift between
// VDS and Gitea. Should clamp to "0m" rather than throw or surface negative.
expect(formatAge('2026-05-22T13:00:00Z', 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('&lt;script&gt;alert(1)&lt;/script&gt;');
});
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('bundles per-task md_content inside records JSON (drawer reads it without fetch)', () => {
const records = [rec({ slug: 'with-md', md_content: '# heading\n\nbody text' })];
const html = renderBoard(records, { generatedAt: now });
// md is inside the records script tag, JSON-escaped (newlines as \n)
expect(html).toContain('"md_content":"# heading\\n\\nbody text"');
});
test('renders owner as a pill when records span 2+ distinct owners', () => {
const html = renderBoard(
[
rec({ slug: 'x', project_owner: 'alice', project: 'a' }),
rec({ slug: 'y', project_owner: 'bob', project: 'b' }),
],
{ generatedAt: now },
);
expect(html).toMatch(/<span class="badge owner">alice<\/span>/);
expect(html).toMatch(/<span class="badge owner">bob<\/span>/);
});
test('omits owner pill on every card when all records share a single owner', () => {
const html = renderBoard(
[
rec({ slug: 'x', project_owner: 'alice', project: 'a' }),
rec({ slug: 'y', project_owner: 'alice', project: 'b' }),
],
{ generatedAt: now },
);
expect(html).not.toMatch(/<span class="badge owner">/);
});
test('emits a single-owner header summary when whitelist is mono-owner', () => {
const html = renderBoard(
[
rec({ slug: 'x', project_owner: 'alice', project: 'a' }),
rec({ slug: 'y', project_owner: 'alice', project: 'b' }),
],
{ generatedAt: now },
);
expect(html).toMatch(/<span class="owner-summary"[^>]*>Owner: alice<\/span>/);
});
test('omits the owner-summary header when whitelist is multi-owner', () => {
const html = renderBoard(
[
rec({ slug: 'x', project_owner: 'alice', project: 'a' }),
rec({ slug: 'y', project_owner: 'bob', project: 'b' }),
],
{ generatedAt: now },
);
expect(html).not.toContain('owner-summary');
});
test('omits owner pill when project_owner is empty string', () => {
const html = renderBoard(
[
rec({ slug: 'x', project_owner: '' }),
rec({ slug: 'y', project_owner: 'bob' }),
],
{ generatedAt: now },
);
expect(html).not.toMatch(/<span class="badge owner"><\/span>/);
});
test('done column: shows only the 10 most recent by last_commit_iso, others get data-overflow', () => {
// 12 done records, distinct iso descending from 2026-05-22 backwards
const records = Array.from({ length: 12 }, (_, i) =>
rec({
slug: `done-${String(i).padStart(2, '0')}`,
status: 'done',
status_emoji: '🟢',
last_commit_iso: `2026-05-${String(22 - i).padStart(2, '0')}T12:00:00Z`,
}),
);
const html = renderBoard(records, { generatedAt: now });
// most-recent 10 (done-00..done-09) visible, last 2 (done-10, done-11) overflow
const overflowSlugs = [...html.matchAll(
/data-slug="(done-\d+)"[^>]*data-overflow="true"/g,
)].map((m) => m[1]);
expect(overflowSlugs.sort()).toEqual(['done-10', 'done-11']);
// first 10 don't carry data-overflow="true"
for (let i = 0; i < 10; i++) {
expect(html).toMatch(
new RegExp(`data-slug="done-${String(i).padStart(2, '0')}"[^>]*data-overflow="false"`),
);
}
});
test('done column with overflow renders a "show N more" expander button', () => {
const records = Array.from({ length: 12 }, (_, i) =>
rec({
slug: `d-${i}`,
status: 'done',
status_emoji: '🟢',
last_commit_iso: `2026-05-${String(22 - i).padStart(2, '0')}T12:00:00Z`,
}),
);
const html = renderBoard(records, { generatedAt: now });
expect(html).toMatch(
/<button[^>]*class="done-expander"[^>]*>[^<]*show 2 more[^<]*<\/button>/,
);
});
test('done column ≤10 records: no expander button rendered', () => {
const records = Array.from({ length: 8 }, (_, i) =>
rec({
slug: `d-${i}`,
status: 'done',
status_emoji: '🟢',
last_commit_iso: `2026-05-${String(22 - i).padStart(2, '0')}T12:00:00Z`,
}),
);
const html = renderBoard(records, { generatedAt: now });
expect(html).not.toContain('done-expander');
});
test('non-done columns are NOT subject to the cutoff (no overflow attr, no expander)', () => {
const records = Array.from({ length: 12 }, (_, i) =>
rec({
slug: `open-${i}`,
status: 'open',
status_emoji: '⚪',
last_commit_iso: `2026-05-${String(22 - i).padStart(2, '0')}T12:00:00Z`,
}),
);
const html = renderBoard(records, { generatedAt: now });
expect(html).not.toMatch(/data-overflow="true"/);
expect(html).not.toContain('done-expander');
});
test('renders slug as a visible <code class="card-slug"> on every card', () => {
const html = renderBoard(
[rec({ slug: 'board-viewer-pointers', title: 'pointers' })],
{ generatedAt: now },
);
expect(html).toMatch(/<code class="card-slug">board-viewer-pointers<\/code>/);
});
test('renders last_commit_iso as YYYY-MM-DD HH:MM МСК (Moscow time, UTC+3, no DST)', () => {
const html = renderBoard(
[rec({ slug: 'x', last_commit_iso: '2026-05-19T08:30:00Z' })],
{ generatedAt: now },
);
// 08:30 UTC + 3 = 11:30 МСК
expect(html).toMatch(/<span class="date"[^>]*>2026-05-19 11:30 МСК<\/span>/);
// tooltip retains full ISO for copy-paste
expect(html).toContain('title="2026-05-19T08:30:00Z"');
});
test('formats Moscow time correctly across midnight UTC rollover', () => {
const html = renderBoard(
[rec({ slug: 'x', last_commit_iso: '2026-05-19T22:00:00Z' })],
{ generatedAt: now },
);
// 22:00 UTC + 3 = 01:00 МСК next day
expect(html).toMatch(/2026-05-20 01:00 МСК/);
});
test('omits date span when last_commit_iso is null', () => {
const html = renderBoard(
[rec({ slug: 'x', last_commit_iso: null })],
{ generatedAt: now },
);
expect(html).not.toMatch(/<span class="date"/);
});
test('renders 5 status-filter pill buttons in canonical order', () => {
const html = renderBoard([], { generatedAt: now });
const re = /<button[^>]*class="status-filter"[^>]*data-status="([^"]+)"[^>]*>/g;
const matches = [...html.matchAll(re)].map((m) => m[1]);
expect(matches).toEqual(['open', 'in_progress', 'paused', 'blocked', 'done']);
});
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"');
});
});