Compare commits

..

3 Commits

Author SHA1 Message Date
vitya
4e3f964a25 chore: pause task-numbers [board-viewer-ux-task-numbers]
User defers cross-repo migration to a separate session per Rule 1+2
(touching 5 whitelisted repos' STATUS.md sits outside the autonomous
scope of this branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:27:24 +03:00
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
vitya
fdca0b375a feat(viewer): bundle md + slug + conditional owner pill [v0.2.0]
Closes 3 of 6 UX-round1 fixes in one TDD cycle (49→56 tests).

drawer-bundle-md: TaskRecord.md_content fetched in build-time;
drawer reads recordsBySlug instead of fetch — 0 runtime hits to
git.kzntsv.site/raw (private repos 401'd browser fetches without token).
Fallback dl-block when md=null.

ux-show-slug: <code class="card-slug"> rendered under card-title in
both render.ts and board.js renderCard (re-render path).

ux-owner-conditional: distinctOwner(records) pre-scan. Single owner →
pill omitted on cards, header gets <span class="owner-summary">
Owner: X</span>. Multi-owner → pill restored, no header summary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:51:12 +03:00
8 changed files with 477 additions and 94 deletions

View File

@@ -66,61 +66,56 @@ _Updated: 2026-05-22_
--- ---
## [board-viewer-drawer-bundle-md] — fix `Failed to fetch` в drawer: bundle md-content в build-time ## 🟢 [board-viewer-drawer-bundle-md] — fix `Failed to fetch` в drawer: bundle md-content в build-time
**Status:** done
**Where I stopped:** TaskRecord.md_content bundled в build-time через `client.getFile(.tasks/<slug>.md)`. Drawer читает `recordsBySlug.get(slug).md_content` вместо fetch — 0 runtime запросов к git.kzntsv.site/raw. Fallback на status-block dl когда md=null. 3 новых reader-теста + 1 render-тест.
**Next action:** ops redeploy на VDS (см. .admin/[board-viewer-redeploy-ux-round1]).
**Branch:** master
<!-- closed-by: OpeItcLoc03 / 2026-05-22 / note: code-complete (52→56 tests pass, typecheck clean). Live smoke (board.kzntsv.site после redeploy + DevTools network 0 git.kzntsv.site/raw hits) — ops-side. -->
**Status:** ready (critical bug) ---
**Where I stopped:** (not started — diagnose grounded: приватные репо + `fetch(rawUrl, {credentials:'omit'})` без auth → Gitea 401/302→login → TypeError. Решение — bundle per-task .md в build-time в JSON/inline script, drawer читает локально.)
**Next action:** см. `board-viewer-drawer-bundle-md.md`. TDD mode. Сначала reader test: `TaskRecord.md_content?: string`. Затем render embeds JSON-bundle. Затем `static/board.js openDrawer` читает local instead of fetch. E2E smoke в browser-driver: 0 запросов на git.kzntsv.site/raw при клике. ## 🟢 [board-viewer-ux-show-slug] — показать slug на карточке (сейчас только в data-attr)
**Status:** done
**Where I stopped:** `<code class="card-slug">{slug}</code>` рендерится в обеих ветках (`render.ts` + `static/board.js`). CSS — monospace, dim, под `card-title`. 1 новый render-тест.
**Next action:** ops redeploy.
**Branch:** master
<!-- closed-by: OpeItcLoc03 / 2026-05-22 / note: 56 tests pass, typecheck clean. Visual smoke — после redeploy. -->
---
## 🟢 [board-viewer-ux-full-datetime] — дата + время в явном формате (не только дата)
**Status:** done
**Where I stopped:** `formatMskDateTime` (UTC+3, no-DST) в `render.ts` + `static/board.js`. Карточка показывает `YYYY-MM-DD HH:MM МСК`. Tooltip с full ISO. Relative age оставлен в card-meta (тоненькая строка). 2 новых теста (basic + midnight rollover).
**Next action:** ops redeploy.
**Branch:** master **Branch:** master
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 --> <!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
--- ---
## [board-viewer-ux-show-slug] — показать slug на карточке (сейчас только в data-attr) ## 🟢 [board-viewer-ux-owner-conditional] — скрывать owner-pill когда все таски одного owner'а
**Status:** done
**Where I stopped:** `distinctOwner(records)` pre-scan: единственный owner → pill убирается со всех карточек, в header добавляется `<span class="owner-summary">Owner: X</span>`; 2+ owners → pill возвращается на карточки. Поведение симметрично в `render.ts` и `static/board.js` (через `SOLO_OWNER` constant). 4 новых render-теста (mono / multi / mono-summary / multi-omits-summary).
**Next action:** ops redeploy + verify auth.toml whitelist на VDS.
**Branch:** master
<!-- closed-by: OpeItcLoc03 / 2026-05-22 / note: 56 tests pass, typecheck clean. -->
**Status:** ready ---
**Where I stopped:** (not started)
**Next action:** TDD mode. Failing test в `tests/render.test.ts`: ожидать `<code class="card-slug">...</code>` в output. Impl в `src/render.ts` + CSS в `static/board.css`. Smoke: slug читается на каждой карточке. ## 🟡 [board-viewer-ux-task-numbers] — shorthand numbering (BV-12) на карточке
**Status:** paused (cross-repo migration outside autonomous scope)
**Where I stopped:** (paused 2026-05-22 — user choice: defer до отдельной сессии. 4 design-вопроса остаются: scope per-repo/global, source-of-truth `<!-- num: ... -->` vs auto-derive, format BV-12 vs T-001, ретроактивно или нет. Recommendations в `board-viewer-ux-task-numbers.md` — все 4 имеют preferred answer.)
**Next action:** Возобновить в отдельной user-session. Decision-tree: либо «board-viewer-impl-only» (TDD parser+render+badge, ретроактивная нумерация — отдельная chore-таска), либо «full cross-repo» (impl + 5 whitelisted repos retro-numbering, отдельные commit'ы per-repo).
**Branch:** master **Branch:** master
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 --> <!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
--- ---
## [board-viewer-ux-full-datetime] — дата + время в явном формате (не только дата) ## 🟢 [board-viewer-ux-done-cutoff] — фильтр / cutoff длинного done-tail
**Status:** done
**Status:** ready **Where I stopped:** source неизменен (✓). Render-side cutoff: done column sort'ится by `last_commit_iso desc`, первые 10 видимы, rest `data-overflow="true"` (CSS hides), внизу column'а button `<button class="done-expander">show N more</button>`. Click → toggle `col.show-overflow` + button text `collapse`. Non-done колонки не trozhуются. 4 новых render-теста (overflow attr / expander / no-expander / non-done).
**Where I stopped:** (not started — 3 design questions: TZ МСК/UTC, format readable/ISO, оставить ли relative «7h». Recommendations внутри `board-viewer-ux-full-datetime.md`.) **Next action:** ops redeploy.
**Next action:** TDD mode. Resolve 3 design-вопроса (или принять recommendations: МСК с явным суффиксом, `YYYY-MM-DD HH:MM МСК`, relative сохранить тоненькой строкой). Затем failing test + impl в `src/render.ts`.
**Branch:** master
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
---
## ⚪ [board-viewer-ux-owner-conditional] — скрывать owner-pill когда все таски одного owner'а
**Status:** ready
**Where I stopped:** (not started — текущий VDS whitelist single-owner `OpeItcLoc03/*` → pill дублируется visual noise. Рендерить условно: single-owner → шапка «Owner: X (N repos)», без pill; multi-owner → pill на карточках.)
**Next action:** TDD mode. Два теста (single/multi owner fixtures). Impl в `src/render.ts`. Связанный ops-audit в `.admin/[board-viewer-redeploy-ux-round1]`: verify `auth.toml board_viewer_repos` (по memory `books` = `victor/books`, не `OpeItcLoc03/books`).
**Branch:** master
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
---
## ⚪ [board-viewer-ux-task-numbers] — shorthand numbering (BV-12) на карточке
**Status:** ready (design-first)
**Where I stopped:** (not started — 4 design-вопроса: scope per-repo/global, source-of-truth `<!-- num: ... -->` в STATUS.md vs auto-derive, format BV-12 vs T-001, ретроактивно или нет. Recommendations в `board-viewer-ux-task-numbers.md`.)
**Next action:** Resolve 4 design-вопроса (single user-session). Если рекомендации принимаются — TDD impl: parser извлекает `num` из block-comment, render показывает badge. Ретроспективно — отдельный proход «chore: number existing tasks». Если возникает дилемма — `.workshop/.brainstorm/board-viewer-task-numbers.md`.
**Branch:** master
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
---
## ⚪ [board-viewer-ux-done-cutoff] — фильтр / cutoff длинного done-tail
**Status:** ready (design-first)
**Where I stopped:** (not started — 4 design-вопроса: что считать «archive-нужно» (все 🟢 или N дней+), source неизменен (recommend) vs физический archive, default hide vs show-all, тип cutoff (last-N / date / combined). Recommendations в `board-viewer-ux-done-cutoff.md`.)
**Next action:** Resolve design (single user-session или brainstorm). Recommendations: source-неизменен, render-side cutoff = «hide done by default + toggle + last-10 fallback при show». TDD impl: render с N done>cutoff показывает только N + «N more» expander. Если дилемма — `.workshop/.brainstorm/board-viewer-done-cutoff.md`.
**Branch:** master **Branch:** master
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 --> <!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
@@ -139,7 +134,7 @@ _Updated: 2026-05-22_
## 🔵 [board-viewer-ux-round1-review] — review umbrella для 6 UX-fixes round1 ## 🔵 [board-viewer-ux-round1-review] — review umbrella для 6 UX-fixes round1
**Status:** blocked **Status:** blocked
**Blocker:** board-viewer-drawer-bundle-md, board-viewer-ux-show-slug, board-viewer-ux-full-datetime, board-viewer-ux-owner-conditional, board-viewer-ux-task-numbers, board-viewer-ux-done-cutoff **Blocker:** board-viewer-ux-task-numbers (1/6 round1-fixes paused — cross-repo migration требует user-decision)
**Where I stopped:** (not started — открывать когда все 6 импл-таск 🟢) **Where I stopped:** (not started — открывать когда все 6 импл-таск 🟢)
**Next action:** Independent review subagent'ом (clean context, не имплементер) — TDD-immutability (тесты не переписаны под impl), output-correctness (smoke на live board.kzntsv.site после redeploy), regression check (49 существующих тестов остаются pass). **Next action:** Independent review subagent'ом (clean context, не имплементер) — TDD-immutability (тесты не переписаны под impl), output-correctness (smoke на live board.kzntsv.site после redeploy), regression check (49 существующих тестов остаются pass).
**Branch:** master **Branch:** master

View File

@@ -1,6 +1,6 @@
{ {
"name": "board-viewer", "name": "board-viewer",
"version": "0.1.0", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Read-only HTML kanban viewer over Gitea API", "description": "Read-only HTML kanban viewer over Gitea API",

View File

@@ -19,6 +19,7 @@ export interface TaskRecord {
branch: string | null; branch: string | null;
last_commit_iso: string | null; last_commit_iso: string | null;
raw_url: string; raw_url: string;
md_content: string | null;
} }
export async function readBoard( export async function readBoard(
@@ -32,15 +33,19 @@ export async function readBoard(
if (status === null) continue; if (status === null) continue;
const blocks = parseStatus(status); const blocks = parseStatus(status);
// Parallel fan-out for per-task commit-iso fetches. Preserves block order // Parallel fan-out: per-task .md content + per-task last-commit-iso.
// because Promise.all resolves arrays positionally. // Both arrays preserve block order via positional Promise.all resolution.
const isoList = await Promise.all( // md_content is bundled in build-time to avoid runtime browser fetches
blocks.map((b) => client.getLatestCommitIso(owner, repo, `.tasks/${b.slug}.md`)), // against private Gitea repos (browser has no token; raw URLs 401 → drawer "Failed to fetch").
); const taskPaths = blocks.map((b) => `.tasks/${b.slug}.md`);
const [mdList, isoList] = await Promise.all([
Promise.all(taskPaths.map((p) => client.getFile(owner, repo, p))),
Promise.all(taskPaths.map((p) => client.getLatestCommitIso(owner, repo, p))),
]);
for (let i = 0; i < blocks.length; i++) { for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]!; const block = blocks[i]!;
const taskPath = `.tasks/${block.slug}.md`; const taskPath = taskPaths[i]!;
records.push({ records.push({
slug: block.slug, slug: block.slug,
project: repo, project: repo,
@@ -54,6 +59,7 @@ export async function readBoard(
branch: block.branch, branch: block.branch,
last_commit_iso: isoList[i]!, last_commit_iso: isoList[i]!,
raw_url: client.rawUrl(owner, repo, taskPath), raw_url: client.rawUrl(owner, repo, taskPath),
md_content: mdList[i]!,
}); });
} }
} }

View File

@@ -23,6 +23,7 @@ const COLUMN_ORDER: Array<{ status: Status; emoji: StatusEmoji; label: string }>
const TITLE_MAX = 80; const TITLE_MAX = 80;
const DEFAULT_ARCHIVE_DAYS = 14; const DEFAULT_ARCHIVE_DAYS = 14;
const DONE_VISIBLE_LIMIT = 10;
const MS_PER_DAY = 86_400_000; const MS_PER_DAY = 86_400_000;
export function formatAge(iso: string | null, now: Date): string | null { export function formatAge(iso: string | null, now: Date): string | null {
@@ -64,9 +65,10 @@ export function renderBoard(records: ReadonlyArray<TaskRecord>, opts: RenderOpti
const now = opts.generatedAt; const now = opts.generatedAt;
const archiveDays = opts.archiveAfterDays ?? DEFAULT_ARCHIVE_DAYS; const archiveDays = opts.archiveAfterDays ?? DEFAULT_ARCHIVE_DAYS;
const columns = partitionByStatus(records); const columns = partitionByStatus(records);
const soloOwner = distinctOwner(records);
const columnsHtml = columns const columnsHtml = columns
.map((col) => renderColumn(col, now, archiveDays)) .map((col) => renderColumn(col, now, archiveDays, soloOwner))
.join('\n'); .join('\n');
const recordsJson = escapeForScript(JSON.stringify(records)); const recordsJson = escapeForScript(JSON.stringify(records));
@@ -77,6 +79,10 @@ export function renderBoard(records: ReadonlyArray<TaskRecord>, opts: RenderOpti
`<button type="button" class="status-filter" data-status="${attr(c.status)}">${c.emoji} ${escapeHtml(c.label)}</button>`, `<button type="button" class="status-filter" data-status="${attr(c.status)}">${c.emoji} ${escapeHtml(c.label)}</button>`,
).join('\n '); ).join('\n ');
const ownerSummary = soloOwner
? `<span class="owner-summary" data-owner="${attr(soloOwner)}">Owner: ${escapeHtml(soloOwner)}</span>`
: '';
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -84,9 +90,10 @@ export function renderBoard(records: ReadonlyArray<TaskRecord>, opts: RenderOpti
<title>Board Viewer</title> <title>Board Viewer</title>
<link rel="stylesheet" href="static/board.css"> <link rel="stylesheet" href="static/board.css">
</head> </head>
<body> <body${soloOwner ? ' class="solo-owner"' : ''}>
<header data-generated="${attr(generated)}"> <header data-generated="${attr(generated)}"${soloOwner ? ` data-solo-owner="${attr(soloOwner)}"` : ''}>
<h1>Tasks</h1> <h1>Tasks</h1>
${ownerSummary}
<div class="controls"> <div class="controls">
<input class="filter" type="search" placeholder="filter slug / title / project / owner" /> <input class="filter" type="search" placeholder="filter slug / title / project / owner" />
<label><input type="checkbox" class="toggle-archived"> show archived</label> <label><input type="checkbox" class="toggle-archived"> show archived</label>
@@ -111,30 +118,80 @@ ${columnsHtml}
`; `;
} }
function renderColumn(col: StatusColumn, now: Date, archiveDays: number): string { function distinctOwner(records: ReadonlyArray<TaskRecord>): string | null {
const cards = col.records const owners = new Set<string>();
.map((r) => renderCard(r, now, archiveDays)) for (const r of records) {
if (r.project_owner) owners.add(r.project_owner);
}
return owners.size === 1 ? [...owners][0]! : null;
}
function renderColumn(
col: StatusColumn,
now: Date,
archiveDays: number,
soloOwner: string | null,
): string {
let cardRecords: ReadonlyArray<TaskRecord> = col.records;
let overflowCount = 0;
let overflowStartIdx = -1;
if (col.status === 'done') {
const sorted = [...col.records].sort(compareByIsoDesc);
cardRecords = sorted;
if (sorted.length > DONE_VISIBLE_LIMIT) {
overflowStartIdx = DONE_VISIBLE_LIMIT;
overflowCount = sorted.length - DONE_VISIBLE_LIMIT;
}
}
const cards = cardRecords
.map((r, i) =>
renderCard(r, now, archiveDays, soloOwner, overflowStartIdx !== -1 && i >= overflowStartIdx),
)
.join('\n'); .join('\n');
const expander =
overflowCount > 0
? `\n <button type="button" class="done-expander">show ${overflowCount} more</button>`
: '';
return ` <section class="col" data-status="${attr(col.status)}"> return ` <section class="col" data-status="${attr(col.status)}">
<h2>${col.emoji} ${escapeHtml(col.label)} <span class="count">${col.records.length}</span></h2> <h2>${col.emoji} ${escapeHtml(col.label)} <span class="count">${col.records.length}</span></h2>
<ul class="cards"> <ul class="cards">
${cards} ${cards}
</ul> </ul>${expander}
</section>`; </section>`;
} }
function renderCard(r: TaskRecord, now: Date, archiveDays: number): string { function compareByIsoDesc(a: TaskRecord, b: TaskRecord): number {
// Null iso treated as oldest. Descending: recent first.
if (a.last_commit_iso === null && b.last_commit_iso === null) return 0;
if (a.last_commit_iso === null) return 1;
if (b.last_commit_iso === null) return -1;
return b.last_commit_iso.localeCompare(a.last_commit_iso);
}
function renderCard(
r: TaskRecord,
now: Date,
archiveDays: number,
soloOwner: string | null,
overflow: boolean,
): string {
const age = formatAge(r.last_commit_iso, now); const age = formatAge(r.last_commit_iso, now);
const archived = r.status === 'done' && isOlderThanDays(r.last_commit_iso, now, archiveDays); const archived = r.status === 'done' && isOlderThanDays(r.last_commit_iso, now, archiveDays);
const truncatedTitle = truncate(r.title, TITLE_MAX); const truncatedTitle = truncate(r.title, TITLE_MAX);
const ownerPill = r.project_owner const ownerPill =
r.project_owner && soloOwner === null
? `<span class="badge owner">${escapeHtml(r.project_owner)}</span>` ? `<span class="badge owner">${escapeHtml(r.project_owner)}</span>`
: ''; : '';
const dateSpan = r.last_commit_iso const dateSpan = r.last_commit_iso
? `<span class="date" title="${attr(r.last_commit_iso)}">${escapeHtml(shortDate(r.last_commit_iso))}</span>` ? `<span class="date" title="${attr(r.last_commit_iso)}">${escapeHtml(formatMskDateTime(r.last_commit_iso))}</span>`
: ''; : '';
return ` <li class="card" data-slug="${attr(r.slug)}" data-raw-url="${attr(r.raw_url)}" data-archived="${archived ? 'true' : 'false'}"> return ` <li class="card" data-slug="${attr(r.slug)}" data-raw-url="${attr(r.raw_url)}" data-archived="${archived ? 'true' : 'false'}" data-overflow="${overflow ? 'true' : 'false'}">
<div class="card-title">${escapeHtml(truncatedTitle)}</div> <div class="card-title">${escapeHtml(truncatedTitle)}</div>
<code class="card-slug">${escapeHtml(r.slug)}</code>
<div class="card-meta"> <div class="card-meta">
<span class="badge project">${escapeHtml(r.project)}</span> <span class="badge project">${escapeHtml(r.project)}</span>
${ownerPill} ${ownerPill}
@@ -144,8 +201,16 @@ function renderCard(r: TaskRecord, now: Date, archiveDays: number): string {
</li>`; </li>`;
} }
function shortDate(iso: string): string { export function formatMskDateTime(iso: string): string {
return iso.slice(0, 10); // Moscow time: UTC+3, no DST since 2011. Compute by shifting epoch ms.
const utc = new Date(iso);
const msk = new Date(utc.getTime() + 3 * 60 * 60 * 1000);
const y = msk.getUTCFullYear();
const mo = String(msk.getUTCMonth() + 1).padStart(2, '0');
const d = String(msk.getUTCDate()).padStart(2, '0');
const h = String(msk.getUTCHours()).padStart(2, '0');
const mi = String(msk.getUTCMinutes()).padStart(2, '0');
return `${y}-${mo}-${d} ${h}:${mi} МСК`;
} }
function isOlderThanDays(iso: string | null, now: Date, days: number): boolean { function isOlderThanDays(iso: string | null, now: Date, days: number): boolean {

View File

@@ -210,6 +210,33 @@ body.show-archived .card[data-archived="true"] {
display: block; display: block;
} }
.card[data-overflow="true"] {
display: none;
}
.col.show-overflow .card[data-overflow="true"] {
display: block;
opacity: 0.7;
}
.done-expander {
align-self: flex-start;
margin-top: 0.4rem;
font: inherit;
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
background: transparent;
border: 1px dashed var(--border);
border-radius: var(--radius);
color: var(--muted);
cursor: pointer;
}
.done-expander:hover {
border-style: solid;
border-color: var(--muted);
}
.card[data-hidden="true"] { .card[data-hidden="true"] {
display: none; display: none;
} }
@@ -287,3 +314,44 @@ aside.drawer[hidden] {
color: var(--muted); color: var(--muted);
font-size: 0.7rem; font-size: 0.7rem;
} }
.card-slug {
display: block;
font: 0.7rem/1.3 ui-monospace, SFMono-Regular, Consolas, monospace;
color: var(--muted);
margin-bottom: 0.35rem;
word-break: break-all;
opacity: 0.85;
}
.owner-summary {
font-size: 0.8rem;
color: var(--muted);
border: 1px solid var(--border);
padding: 0.1rem 0.55rem;
border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.drawer-empty {
color: var(--muted);
font-style: italic;
}
.status-block {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.3rem 0.8rem;
font-size: 0.85rem;
margin: 0;
}
.status-block dt {
color: var(--muted);
font-weight: 500;
}
.status-block dd {
margin: 0;
word-break: break-word;
}

View File

@@ -33,6 +33,13 @@ const STATUS_EMOJI = {
}; };
const ARCHIVE_DAYS = 14; const ARCHIVE_DAYS = 14;
const DONE_VISIBLE_LIMIT = 10;
const SOLO_OWNER = (() => {
const owners = new Set();
for (const r of records) if (r.project_owner) owners.add(r.project_owner);
return owners.size === 1 ? [...owners][0] : null;
})();
function escapeHtml(s) { function escapeHtml(s) {
return String(s) return String(s)
@@ -73,20 +80,28 @@ function truncate(s, max) {
return s.length <= max ? s : s.slice(0, max) + '…'; return s.length <= max ? s : s.slice(0, max) + '…';
} }
function renderCard(rec, now, mode) { function formatMskDateTime(iso) {
const utc = new Date(iso);
const msk = new Date(utc.getTime() + 3 * 60 * 60 * 1000);
const pad = (n) => String(n).padStart(2, '0');
return `${msk.getUTCFullYear()}-${pad(msk.getUTCMonth() + 1)}-${pad(msk.getUTCDate())} ${pad(msk.getUTCHours())}:${pad(msk.getUTCMinutes())} МСК`;
}
function renderCard(rec, now, mode, overflow) {
const age = formatAge(rec.last_commit_iso, now); const age = formatAge(rec.last_commit_iso, now);
const archived = isArchived(rec, now); const archived = isArchived(rec, now);
const primary = mode === 'by-project' const primary = mode === 'by-project'
? `<span class="badge status">${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}</span>` ? `<span class="badge status">${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}</span>`
: `<span class="badge project">${escapeHtml(rec.project)}</span>`; : `<span class="badge project">${escapeHtml(rec.project)}</span>`;
const ownerPill = rec.project_owner const ownerPill = rec.project_owner && SOLO_OWNER === null
? `<span class="badge owner">${escapeHtml(rec.project_owner)}</span>` ? `<span class="badge owner">${escapeHtml(rec.project_owner)}</span>`
: ''; : '';
const dateSpan = rec.last_commit_iso const dateSpan = rec.last_commit_iso
? `<span class="date" title="${escapeHtml(rec.last_commit_iso)}">${escapeHtml(rec.last_commit_iso.slice(0, 10))}</span>` ? `<span class="date" title="${escapeHtml(rec.last_commit_iso)}">${escapeHtml(formatMskDateTime(rec.last_commit_iso))}</span>`
: ''; : '';
return `<li class="card" data-slug="${escapeHtml(rec.slug)}" data-raw-url="${escapeHtml(rec.raw_url)}" data-archived="${archived ? 'true' : 'false'}"> return `<li class="card" data-slug="${escapeHtml(rec.slug)}" data-raw-url="${escapeHtml(rec.raw_url)}" data-archived="${archived ? 'true' : 'false'}" data-overflow="${overflow ? 'true' : 'false'}">
<div class="card-title">${escapeHtml(truncate(rec.title, 80))}</div> <div class="card-title">${escapeHtml(truncate(rec.title, 80))}</div>
<code class="card-slug">${escapeHtml(rec.slug)}</code>
<div class="card-meta"> <div class="card-meta">
${primary} ${primary}
${ownerPill} ${ownerPill}
@@ -96,13 +111,32 @@ function renderCard(rec, now, mode) {
</li>`; </li>`;
} }
function compareByIsoDesc(a, b) {
if (!a.last_commit_iso && !b.last_commit_iso) return 0;
if (!a.last_commit_iso) return 1;
if (!b.last_commit_iso) return -1;
return b.last_commit_iso.localeCompare(a.last_commit_iso);
}
function renderByStatus(now) { function renderByStatus(now) {
return STATUS_ORDER.map((status) => { return STATUS_ORDER.map((status) => {
const inCol = records.filter((r) => r.status === status); let inCol = records.filter((r) => r.status === status);
const cards = inCol.map((r) => renderCard(r, now, 'by-status')).join(''); let overflowStart = -1;
if (status === 'done') {
inCol = [...inCol].sort(compareByIsoDesc);
if (inCol.length > DONE_VISIBLE_LIMIT) overflowStart = DONE_VISIBLE_LIMIT;
}
const cards = inCol
.map((r, i) => renderCard(r, now, 'by-status', overflowStart !== -1 && i >= overflowStart))
.join('');
const overflowCount = overflowStart === -1 ? 0 : inCol.length - overflowStart;
const expander =
overflowCount > 0
? `<button type="button" class="done-expander">show ${overflowCount} more</button>`
: '';
return `<section class="col" data-status="${status}"> return `<section class="col" data-status="${status}">
<h2>${STATUS_EMOJI[status]} ${STATUS_LABEL[status]} <span class="count">${inCol.length}</span></h2> <h2>${STATUS_EMOJI[status]} ${STATUS_LABEL[status]} <span class="count">${inCol.length}</span></h2>
<ul class="cards">${cards}</ul> <ul class="cards">${cards}</ul>${expander}
</section>`; </section>`;
}).join(''); }).join('');
} }
@@ -113,7 +147,7 @@ function renderByProject(now) {
const inCol = records const inCol = records
.filter((r) => r.project === project) .filter((r) => r.project === project)
.sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)); .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status));
const cards = inCol.map((r) => renderCard(r, now, 'by-project')).join(''); const cards = inCol.map((r) => renderCard(r, now, 'by-project', false)).join('');
return `<section class="col" data-project="${escapeHtml(project)}"> return `<section class="col" data-project="${escapeHtml(project)}">
<h2>${escapeHtml(project)} <span class="count">${inCol.length}</span></h2> <h2>${escapeHtml(project)} <span class="count">${inCol.length}</span></h2>
<ul class="cards">${cards}</ul> <ul class="cards">${cards}</ul>
@@ -197,24 +231,58 @@ toggleGrouping?.addEventListener('change', () => {
rerender(); rerender();
}); });
async function openDrawer(card) { function openDrawer(card) {
if (!drawer || !drawerContent) return; if (!drawer || !drawerContent) return;
const rawUrl = card.dataset.rawUrl; const slug = card.dataset.slug || '';
const slug = card.dataset.slug; const rec = recordsBySlug.get(slug);
if (!rawUrl) return;
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2><p>loading…</p>`;
drawer.hidden = false; drawer.hidden = false;
try { if (!rec) {
const res = await fetch(rawUrl, { credentials: 'omit' }); drawerContent.innerHTML = `<h2>${escapeHtml(slug)}</h2><p>record not found.</p>`;
if (!res.ok) throw new Error(`HTTP ${res.status}`); return;
const md = await res.text();
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2>` + marked.parse(md);
} catch (err) {
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2><p>Failed to load: ${escapeHtml(String(err))}</p>`;
} }
const md = rec.md_content;
if (md === null || md === undefined) {
drawerContent.innerHTML =
`<h2>${escapeHtml(slug)}</h2>` +
`<p class="drawer-empty">No per-task <code>.md</code> file in repo — status block only.</p>` +
renderStatusBlock(rec);
return;
}
drawerContent.innerHTML = `<h2>${escapeHtml(slug)}</h2>` + marked.parse(md);
}
function renderStatusBlock(rec) {
const rows = [
['Status', `${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}`],
['Title', rec.title],
['Where stopped', rec.where_stopped],
['Next action', rec.next_action],
['Blocker', rec.blocker],
['Branch', rec.branch],
];
return (
'<dl class="status-block">' +
rows
.filter(([, v]) => v !== null && v !== undefined && v !== '')
.map(
([k, v]) =>
`<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`,
)
.join('') +
'</dl>'
);
} }
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
const expander = e.target.closest?.('.done-expander');
if (expander) {
const col = expander.closest('.col');
if (!col) return;
if (!expander.dataset.original) expander.dataset.original = expander.textContent;
const expanded = col.classList.toggle('show-overflow');
expander.textContent = expanded ? 'collapse' : expander.dataset.original;
return;
}
const card = e.target.closest?.('.card'); const card = e.target.closest?.('.card');
if (card) openDrawer(card); if (card) openDrawer(card);
}); });

View File

@@ -26,7 +26,11 @@ _Updated: 2026-05-22_
describe('readBoard', () => { describe('readBoard', () => {
test('merges parser output with project metadata and Gitea last-commit + raw URL', async () => { test('merges parser output with project metadata and Gitea last-commit + raw URL', async () => {
const client = fakeClient({ const client = fakeClient({
getFile: vi.fn().mockResolvedValue(STATUS_MD_ONE), getFile: vi.fn(async (_o: string, _r: string, path: string) => {
if (path === '.tasks/STATUS.md') return STATUS_MD_ONE;
if (path === '.tasks/task-a.md') return '# task-a\n\ndeep context body.';
return null;
}),
getLatestCommitIso: vi.fn().mockResolvedValue('2026-05-22T09:00:00Z'), getLatestCommitIso: vi.fn().mockResolvedValue('2026-05-22T09:00:00Z'),
rawUrl: vi.fn().mockReturnValue( rawUrl: vi.fn().mockReturnValue(
'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md', 'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md',
@@ -50,9 +54,40 @@ describe('readBoard', () => {
last_commit_iso: '2026-05-22T09:00:00Z', last_commit_iso: '2026-05-22T09:00:00Z',
raw_url: raw_url:
'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md', 'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md',
md_content: '# task-a\n\ndeep context body.',
}); });
}); });
test('per-task .md missing (404) → md_content is null, record still emitted', async () => {
const client = fakeClient({
getFile: vi.fn(async (_o: string, _r: string, path: string) => {
if (path === '.tasks/STATUS.md') return STATUS_MD_ONE;
return null;
}),
getLatestCommitIso: vi.fn().mockResolvedValue(null),
});
const records = await readBoard(client, [{ owner: 'o', repo: 'r' }]);
expect(records).toHaveLength(1);
expect(records[0]!.md_content).toBeNull();
});
test('fetches per-task .md alongside STATUS.md (.tasks/<slug>.md path)', async () => {
const getFile = vi.fn(async (_o: string, _r: string, path: string) => {
if (path === '.tasks/STATUS.md') return STATUS_MD_ONE;
return 'body';
});
const client = fakeClient({
getFile,
getLatestCommitIso: vi.fn().mockResolvedValue(null),
});
await readBoard(client, [{ owner: 'o', repo: 'r' }]);
expect(getFile).toHaveBeenCalledWith('o', 'r', '.tasks/task-a.md');
});
test('skips repo silently when STATUS.md is missing (returns no records for that repo)', async () => { test('skips repo silently when STATUS.md is missing (returns no records for that repo)', async () => {
const client = fakeClient({ const client = fakeClient({
getFile: vi.fn().mockResolvedValue(null), // 404 getFile: vi.fn().mockResolvedValue(null), // 404

View File

@@ -16,6 +16,7 @@ function rec(overrides: Partial<TaskRecord>): TaskRecord {
branch: 'master', branch: 'master',
last_commit_iso: null, last_commit_iso: null,
raw_url: 'https://example/raw', raw_url: 'https://example/raw',
md_content: null,
...overrides, ...overrides,
}; };
} }
@@ -154,31 +155,176 @@ describe('renderBoard', () => {
expect(html).toContain('"embed-test"'); expect(html).toContain('"embed-test"');
}); });
test('renders owner as a pill when project_owner is non-empty', () => { 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( const html = renderBoard(
[rec({ slug: 'x', project_owner: 'OpeItcLoc03' })], [
rec({ slug: 'x', project_owner: 'alice', project: 'a' }),
rec({ slug: 'y', project_owner: 'bob', project: 'b' }),
],
{ generatedAt: now }, { generatedAt: now },
); );
expect(html).toMatch(/<span class="badge owner">OpeItcLoc03<\/span>/); 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', () => { test('omits owner pill when project_owner is empty string', () => {
const html = renderBoard( const html = renderBoard(
[rec({ slug: 'x', project_owner: '' })], [
rec({ slug: 'x', project_owner: '' }),
rec({ slug: 'y', project_owner: 'bob' }),
],
{ generatedAt: now }, { generatedAt: now },
); );
expect(html).not.toContain('badge owner'); expect(html).not.toMatch(/<span class="badge owner"><\/span>/);
}); });
test('renders last_commit_iso as a short YYYY-MM-DD date 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( const html = renderBoard(
[rec({ slug: 'x', last_commit_iso: '2026-05-19T08:30:00Z' })], [rec({ slug: 'x', last_commit_iso: '2026-05-19T08:30:00Z' })],
{ generatedAt: now }, { generatedAt: now },
); );
expect(html).toMatch(/<span class="date"[^>]*>2026-05-19<\/span>/); // 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', () => { test('omits date span when last_commit_iso is null', () => {