Files
board-viewer/static/board.js
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

299 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { marked } from 'https://esm.sh/marked@14';
const recordsEl = document.getElementById('records');
const records = recordsEl ? JSON.parse(recordsEl.textContent || '[]') : [];
const filterInput = document.querySelector('.filter');
const toggleArchived = document.querySelector('.toggle-archived');
const toggleGrouping = document.querySelector('.toggle-grouping');
const drawer = document.querySelector('.drawer');
const drawerContent = drawer?.querySelector('.drawer-content');
const drawerClose = drawer?.querySelector('.drawer-close');
const refreshInfo = document.querySelector('.refresh-info');
const header = document.querySelector('header');
const board = document.querySelector('main.board');
const generatedAt = header?.dataset.generated ? new Date(header.dataset.generated) : null;
const NEXT_TICK_MIN = 5;
const STATUS_ORDER = ['open', 'in_progress', 'paused', 'blocked', 'done'];
const STATUS_LABEL = {
open: 'open',
in_progress: 'in-progress',
paused: 'paused',
blocked: 'blocked',
done: 'done',
};
const STATUS_EMOJI = {
open: '⚪',
in_progress: '🔴',
paused: '🟡',
blocked: '🔵',
done: '🟢',
};
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) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatAge(iso, now) {
if (!iso) return null;
const then = new Date(iso);
const diff = Math.max(0, now.getTime() - then.getTime());
const m = Math.floor(diff / 60_000);
if (m < 60) return `${m}m`;
const h = Math.floor(diff / 3_600_000);
if (h < 24) return `${h}h`;
const d = Math.floor(diff / 86_400_000);
if (d < 7) return `${d}d`;
if (d < 30) return `${Math.floor(d / 7)}w`;
let mo =
(now.getUTCFullYear() - then.getUTCFullYear()) * 12 +
(now.getUTCMonth() - then.getUTCMonth());
if (now.getUTCDate() < then.getUTCDate()) mo -= 1;
mo = Math.max(0, mo);
if (mo < 12) return `${mo}mo`;
return `${Math.floor(mo / 12)}y`;
}
function isArchived(rec, now) {
if (rec.status !== 'done' || !rec.last_commit_iso) return false;
const ms = now.getTime() - new Date(rec.last_commit_iso).getTime();
return ms > ARCHIVE_DAYS * 86_400_000;
}
function truncate(s, max) {
return s.length <= max ? s : s.slice(0, max) + '…';
}
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'
? `<span class="badge status">${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}</span>`
: `<span class="badge project">${escapeHtml(rec.project)}</span>`;
const ownerPill = rec.project_owner && SOLO_OWNER === null
? `<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(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'}" 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">
${primary}
${ownerPill}
${dateSpan}
${age === null ? '' : `<span class="age">${escapeHtml(age)}</span>`}
</div>
</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) => {
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>${expander}
</section>`;
}).join('');
}
function renderByProject(now) {
const projects = [...new Set(records.map((r) => r.project))].sort();
return projects.map((project) => {
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', 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>
</section>`;
}).join('');
}
function rerender() {
if (!board) return;
const now = new Date();
const byProject = toggleGrouping?.checked;
board.innerHTML = byProject ? renderByProject(now) : renderByStatus(now);
if (filterInput) applyFilter(filterInput.value);
}
function relativeMin(ms) {
return Math.max(0, Math.floor(ms / 60000));
}
function updateRefreshInfo() {
if (!refreshInfo || !generatedAt) return;
const ageMin = relativeMin(Date.now() - generatedAt.getTime());
const nextMin = Math.max(0, NEXT_TICK_MIN - ageMin);
refreshInfo.textContent = `refreshed ${ageMin}m ago · next in ${nextMin}m`;
}
updateRefreshInfo();
setInterval(updateRefreshInfo, 30_000);
const recordsBySlug = new Map(records.map((r) => [r.slug, r]));
const activeStatuses = new Set();
function applyFilter(query) {
const q = query.trim().toLowerCase();
document.querySelectorAll('.card').forEach((card) => {
const slug = card.dataset.slug || '';
const rec = recordsBySlug.get(slug);
if (!rec) {
card.dataset.hidden = 'true';
return;
}
const textHay = [
rec.slug,
rec.title,
rec.project,
rec.project_owner ?? '',
rec.status,
]
.join(' ')
.toLowerCase();
const textMatch = q === '' || textHay.includes(q);
const statusMatch = activeStatuses.size === 0 || activeStatuses.has(rec.status);
card.dataset.hidden = textMatch && statusMatch ? 'false' : 'true';
});
}
filterInput?.addEventListener('input', (e) => {
applyFilter(e.target.value);
});
document.querySelectorAll('.status-filter').forEach((btn) => {
btn.addEventListener('click', () => {
const s = btn.dataset.status;
if (!s) return;
if (activeStatuses.has(s)) {
activeStatuses.delete(s);
btn.classList.remove('active');
} else {
activeStatuses.add(s);
btn.classList.add('active');
}
applyFilter(filterInput?.value || '');
});
});
toggleArchived?.addEventListener('change', (e) => {
document.body.classList.toggle('show-archived', e.target.checked);
});
toggleGrouping?.addEventListener('change', () => {
document.body.classList.toggle('group-by-project', toggleGrouping.checked);
rerender();
});
function openDrawer(card) {
if (!drawer || !drawerContent) return;
const slug = card.dataset.slug || '';
const rec = recordsBySlug.get(slug);
drawer.hidden = false;
if (!rec) {
drawerContent.innerHTML = `<h2>${escapeHtml(slug)}</h2><p>record not found.</p>`;
return;
}
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) => {
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);
});
drawerClose?.addEventListener('click', () => {
if (drawer) drawer.hidden = true;
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && drawer) drawer.hidden = true;
});
console.log(`board-viewer: ${records.length} records loaded`);