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

@@ -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);
});