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>
This commit is contained in:
vitya
2026-05-22 18:55:43 +03:00
parent fdca0b375a
commit f6a45f4987
6 changed files with 212 additions and 28 deletions

View File

@@ -84,11 +84,10 @@ _Updated: 2026-05-22_
---
## [board-viewer-ux-full-datetime] — дата + время в явном формате (не только дата)
**Status:** ready
**Where I stopped:** (not started — 3 design questions: TZ МСК/UTC, format readable/ISO, оставить ли relative «7h». Recommendations внутри `board-viewer-ux-full-datetime.md`.)
**Next action:** TDD mode. Resolve 3 design-вопроса (или принять recommendations: МСК с явным суффиксом, `YYYY-MM-DD HH:MM МСК`, relative сохранить тоненькой строкой). Затем failing test + impl в `src/render.ts`.
## 🟢 [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
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
@@ -113,11 +112,10 @@ _Updated: 2026-05-22_
---
## [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`.
## 🟢 [board-viewer-ux-done-cutoff] — фильтр / cutoff длинного done-tail
**Status:** done
**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).
**Next action:** ops redeploy.
**Branch:** master
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
@@ -136,7 +134,7 @@ _Updated: 2026-05-22_
## 🔵 [board-viewer-ux-round1-review] — review umbrella для 6 UX-fixes round1
**Status:** blocked
**Blocker:** board-viewer-ux-full-datetime, board-viewer-ux-task-numbers, board-viewer-ux-done-cutoff (3/6 round1-fixes еще не сделаны — design-first таски)
**Blocker:** board-viewer-ux-task-numbers (1/6 round1-fixes paused — cross-repo migration требует user-decision)
**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).
**Branch:** master

View File

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

View File

@@ -23,6 +23,7 @@ const COLUMN_ORDER: Array<{ status: Status; emoji: StatusEmoji; label: string }>
const TITLE_MAX = 80;
const DEFAULT_ARCHIVE_DAYS = 14;
const DONE_VISIBLE_LIMIT = 10;
const MS_PER_DAY = 86_400_000;
export function formatAge(iso: string | null, now: Date): string | null {
@@ -131,22 +132,52 @@ function renderColumn(
archiveDays: number,
soloOwner: string | null,
): string {
const cards = col.records
.map((r) => renderCard(r, now, archiveDays, soloOwner))
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');
const expander =
overflowCount > 0
? `\n <button type="button" class="done-expander">show ${overflowCount} more</button>`
: '';
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>
</ul>${expander}
</section>`;
}
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 archived = r.status === 'done' && isOlderThanDays(r.last_commit_iso, now, archiveDays);
@@ -156,9 +187,9 @@ function renderCard(
? `<span class="badge owner">${escapeHtml(r.project_owner)}</span>`
: '';
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>
<code class="card-slug">${escapeHtml(r.slug)}</code>
<div class="card-meta">
@@ -170,8 +201,16 @@ function renderCard(
</li>`;
}
function shortDate(iso: string): string {
return iso.slice(0, 10);
export function formatMskDateTime(iso: string): string {
// 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 {

View File

@@ -210,6 +210,33 @@ body.show-archived .card[data-archived="true"] {
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"] {
display: none;
}

View File

@@ -33,6 +33,7 @@ const STATUS_EMOJI = {
};
const ARCHIVE_DAYS = 14;
const DONE_VISIBLE_LIMIT = 10;
const SOLO_OWNER = (() => {
const owners = new Set();
@@ -79,7 +80,14 @@ function truncate(s, 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 archived = isArchived(rec, now);
const primary = mode === 'by-project'
@@ -89,9 +97,9 @@ function renderCard(rec, now, mode) {
? `<span class="badge owner">${escapeHtml(rec.project_owner)}</span>`
: '';
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>
<code class="card-slug">${escapeHtml(rec.slug)}</code>
<div class="card-meta">
@@ -103,13 +111,32 @@ function renderCard(rec, now, mode) {
</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) {
return STATUS_ORDER.map((status) => {
const inCol = records.filter((r) => r.status === status);
const cards = inCol.map((r) => renderCard(r, now, 'by-status')).join('');
let inCol = records.filter((r) => r.status === status);
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}">
<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>`;
}).join('');
}
@@ -120,7 +147,7 @@ function renderByProject(now) {
const inCol = records
.filter((r) => r.project === project)
.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)}">
<h2>${escapeHtml(project)} <span class="count">${inCol.length}</span></h2>
<ul class="cards">${cards}</ul>
@@ -247,6 +274,15 @@ function renderStatusBlock(rec) {
}
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');
if (card) openDrawer(card);
});

View File

@@ -225,6 +225,77 @@ describe('renderBoard', () => {
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' })],
@@ -234,13 +305,26 @@ describe('renderBoard', () => {
expect(html).toMatch(/<code class="card-slug">board-viewer-pointers<\/code>/);
});
test('renders last_commit_iso as a short YYYY-MM-DD date span', () => {
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 },
);
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', () => {