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:
@@ -84,11 +84,10 @@ _Updated: 2026-05-22_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚪ [board-viewer-ux-full-datetime] — дата + время в явном формате (не только дата)
|
## 🟢 [board-viewer-ux-full-datetime] — дата + время в явном формате (не только дата)
|
||||||
|
**Status:** done
|
||||||
**Status:** ready
|
**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).
|
||||||
**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
|
**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 -->
|
||||||
|
|
||||||
@@ -113,11 +112,10 @@ _Updated: 2026-05-22_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚪ [board-viewer-ux-done-cutoff] — фильтр / cutoff длинного done-tail
|
## 🟢 [board-viewer-ux-done-cutoff] — фильтр / cutoff длинного done-tail
|
||||||
|
**Status:** done
|
||||||
**Status:** ready (design-first)
|
**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 — 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:** ops redeploy.
|
||||||
**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 -->
|
||||||
|
|
||||||
@@ -136,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-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 импл-таск 🟢)
|
**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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "board-viewer",
|
"name": "board-viewer",
|
||||||
"version": "0.2.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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -131,22 +132,52 @@ function renderColumn(
|
|||||||
archiveDays: number,
|
archiveDays: number,
|
||||||
soloOwner: string | null,
|
soloOwner: string | null,
|
||||||
): string {
|
): string {
|
||||||
const cards = col.records
|
let cardRecords: ReadonlyArray<TaskRecord> = col.records;
|
||||||
.map((r) => renderCard(r, now, archiveDays, soloOwner))
|
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 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(
|
function renderCard(
|
||||||
r: TaskRecord,
|
r: TaskRecord,
|
||||||
now: Date,
|
now: Date,
|
||||||
archiveDays: number,
|
archiveDays: number,
|
||||||
soloOwner: string | null,
|
soloOwner: string | null,
|
||||||
|
overflow: boolean,
|
||||||
): string {
|
): 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);
|
||||||
@@ -156,9 +187,9 @@ function renderCard(
|
|||||||
? `<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>
|
<code class="card-slug">${escapeHtml(r.slug)}</code>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
@@ -170,8 +201,16 @@ function renderCard(
|
|||||||
</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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const STATUS_EMOJI = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ARCHIVE_DAYS = 14;
|
const ARCHIVE_DAYS = 14;
|
||||||
|
const DONE_VISIBLE_LIMIT = 10;
|
||||||
|
|
||||||
const SOLO_OWNER = (() => {
|
const SOLO_OWNER = (() => {
|
||||||
const owners = new Set();
|
const owners = new Set();
|
||||||
@@ -79,7 +80,14 @@ 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'
|
||||||
@@ -89,9 +97,9 @@ function renderCard(rec, now, mode) {
|
|||||||
? `<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>
|
<code class="card-slug">${escapeHtml(rec.slug)}</code>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
@@ -103,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('');
|
||||||
}
|
}
|
||||||
@@ -120,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>
|
||||||
@@ -247,6 +274,15 @@ function renderStatusBlock(rec) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -225,6 +225,77 @@ describe('renderBoard', () => {
|
|||||||
expect(html).not.toMatch(/<span class="badge owner"><\/span>/);
|
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', () => {
|
test('renders slug as a visible <code class="card-slug"> on every card', () => {
|
||||||
const html = renderBoard(
|
const html = renderBoard(
|
||||||
[rec({ slug: 'board-viewer-pointers', title: 'pointers' })],
|
[rec({ slug: 'board-viewer-pointers', title: 'pointers' })],
|
||||||
@@ -234,13 +305,26 @@ describe('renderBoard', () => {
|
|||||||
expect(html).toMatch(/<code class="card-slug">board-viewer-pointers<\/code>/);
|
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(
|
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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user