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>
299 lines
9.8 KiB
JavaScript
299 lines
9.8 KiB
JavaScript
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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`);
|