diff --git a/.tasks/STATUS.md b/.tasks/STATUS.md index d44ff05..9d7faf9 100644 --- a/.tasks/STATUS.md +++ b/.tasks/STATUS.md @@ -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 @@ -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 ``. 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 @@ -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 diff --git a/package.json b/package.json index 55cce08..e859c55 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/render.ts b/src/render.ts index e709c46..4532080 100644 --- a/src/render.ts +++ b/src/render.ts @@ -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 = 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 ` + : ''; + return `

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

+ ${expander}
`; } +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( ? `${escapeHtml(r.project_owner)}` : ''; const dateSpan = r.last_commit_iso - ? `${escapeHtml(shortDate(r.last_commit_iso))}` + ? `${escapeHtml(formatMskDateTime(r.last_commit_iso))}` : ''; - return `
  • + return `
  • ${escapeHtml(truncatedTitle)}
    ${escapeHtml(r.slug)}
    @@ -170,8 +201,16 @@ function renderCard(
  • `; } -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 { diff --git a/static/board.css b/static/board.css index ab67884..7dc1bc3 100644 --- a/static/board.css +++ b/static/board.css @@ -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; } diff --git a/static/board.js b/static/board.js index 105a817..b5228e1 100644 --- a/static/board.js +++ b/static/board.js @@ -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) { ? `${escapeHtml(rec.project_owner)}` : ''; const dateSpan = rec.last_commit_iso - ? `${escapeHtml(rec.last_commit_iso.slice(0, 10))}` + ? `${escapeHtml(formatMskDateTime(rec.last_commit_iso))}` : ''; - return `
  • + return `
  • ${escapeHtml(truncate(rec.title, 80))}
    ${escapeHtml(rec.slug)}
    @@ -103,13 +111,32 @@ function renderCard(rec, now, mode) {
  • `; } +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 + ? `` + : ''; return `

    ${STATUS_EMOJI[status]} ${STATUS_LABEL[status]} ${inCol.length}

    - + ${expander}
    `; }).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 `

    ${escapeHtml(project)} ${inCol.length}

    @@ -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); }); diff --git a/tests/render.test.ts b/tests/render.test.ts index 763bec4..24a8d99 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -225,6 +225,77 @@ describe('renderBoard', () => { expect(html).not.toMatch(/<\/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( + /]*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 on every card', () => { const html = renderBoard( [rec({ slug: 'board-viewer-pointers', title: 'pointers' })], @@ -234,13 +305,26 @@ describe('renderBoard', () => { expect(html).toMatch(/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(/]*>2026-05-19<\/span>/); + // 08:30 UTC + 3 = 11:30 МСК + expect(html).toMatch(/]*>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', () => {