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>
353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
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('<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('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"');
|
||
});
|
||
});
|