Skip to content

Commit 25dd726

Browse files
committed
feat: dashboard project quick-launch grid
1 parent 1dcf8d1 commit 25dd726

2 files changed

Lines changed: 340 additions & 67 deletions

File tree

client/dashboard.js

Lines changed: 220 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Dashboard {
88
this.isVisible = false;
99
this.quickLinks = null;
1010
this._escHandler = null;
11+
this._projectLaunchInFlight = false;
1112
}
1213

1314
async show() {
@@ -467,7 +468,7 @@ class Dashboard {
467468
.replace(/</g, '&lt;')
468469
.replace(/>/g, '&gt;');
469470

470-
const renderAdvice = async ({ force = false } = {}) => {
471+
const renderAdvice = async ({ force = false } = {}) => {
471472
if (!adviceEl) return;
472473
adviceEl.textContent = 'Loading…';
473474

@@ -526,13 +527,22 @@ class Dashboard {
526527
});
527528
};
528529

529-
try {
530-
const [statusRes, telemetryRes, projectsRes, readinessRes] = await Promise.all([
531-
showStatus ? fetch('/api/process/status?mode=mine').catch(() => null) : Promise.resolve(null),
532-
showTelemetry ? fetch('/api/process/telemetry').catch(() => null) : Promise.resolve(null),
533-
showProjects ? fetch('/api/process/projects?mode=mine').catch(() => null) : Promise.resolve(null),
534-
showReadiness ? fetch('/api/process/readiness/templates').catch(() => null) : Promise.resolve(null)
535-
]);
530+
try {
531+
const projectsBoardPromise = (showProjects && this.orchestrator?.getProjectsBoard)
532+
? this.orchestrator.getProjectsBoard({ force: false }).catch(() => null)
533+
: Promise.resolve(null);
534+
const scannedReposPromise = (showProjects && this.orchestrator?.getScannedRepos)
535+
? this.orchestrator.getScannedRepos({ force: false }).catch(() => [])
536+
: Promise.resolve([]);
537+
538+
const [statusRes, telemetryRes, projectsRes, readinessRes, projectsBoardData, scannedRepos] = await Promise.all([
539+
showStatus ? fetch('/api/process/status?mode=mine').catch(() => null) : Promise.resolve(null),
540+
showTelemetry ? fetch('/api/process/telemetry').catch(() => null) : Promise.resolve(null),
541+
showProjects ? fetch('/api/process/projects?mode=mine').catch(() => null) : Promise.resolve(null),
542+
showReadiness ? fetch('/api/process/readiness/templates').catch(() => null) : Promise.resolve(null),
543+
projectsBoardPromise,
544+
scannedReposPromise
545+
]);
536546

537547
if (showStatus && statusEl) {
538548
const data = statusRes ? await statusRes.json().catch(() => ({})) : {};
@@ -571,68 +581,211 @@ class Dashboard {
571581
}
572582
}
573583

574-
if (showProjects && projectsEl) {
575-
const data = projectsRes ? await projectsRes.json().catch(() => ({})) : {};
576-
if (projectsRes && projectsRes.ok) {
577-
const totals = data?.totals || {};
578-
const repos = Array.isArray(data?.repos) ? data.repos : [];
579-
const top = repos.slice(0, 6);
580-
581-
const pickWorstRisk = (counts) => {
582-
const c = counts && typeof counts === 'object' ? counts : {};
583-
if (Number(c.critical || 0) > 0) return 'critical';
584-
if (Number(c.high || 0) > 0) return 'high';
585-
if (Number(c.medium || 0) > 0) return 'medium';
586-
if (Number(c.low || 0) > 0) return 'low';
587-
return '';
588-
};
584+
if (showProjects && projectsEl) {
585+
const data = projectsRes ? await projectsRes.json().catch(() => ({})) : {};
586+
const projectsBoard = projectsBoardData?.board && typeof projectsBoardData.board === 'object' ? projectsBoardData.board : null;
587+
const scanned = Array.isArray(scannedRepos) ? scannedRepos : [];
589588

590-
const riskChip = (risk) => {
591-
const r = String(risk || '').trim().toLowerCase();
592-
if (!r) return '';
593-
const cls = (r === 'critical' || r === 'high') ? 'level-warn' : '';
594-
return `<span class="process-chip ${cls}">${escapeHtml(r)}</span>`;
595-
};
589+
const normalizeKey = (value) => (this.orchestrator?.normalizeProjectsBoardProjectKey?.(value) ?? String(value || '').trim().replace(/\\/g, '/'));
596590

597-
projectsEl.innerHTML = `
598-
<div>Repos <strong>${Number(totals?.repos ?? top.length ?? 0)}</strong> • Open PRs <strong>${Number(totals?.prsOpen ?? 0)}</strong></div>
599-
<div>Unreviewed <strong>${Number(totals?.prsUnreviewed ?? 0)}</strong> • Needs fix <strong>${Number(totals?.prsNeedsFix ?? 0)}</strong></div>
600-
<div style="margin-top:8px; display:flex; flex-direction:column; gap:6px;">
601-
${top.length ? top.map((r) => {
602-
const repo = String(r?.repo || '').trim();
603-
const open = Number(r?.prsOpen ?? 0);
604-
const unrev = Number(r?.prsUnreviewed ?? 0);
605-
const avgReview = r?.telemetry?.avgReviewSeconds ? `${Math.round(Number(r.telemetry.avgReviewSeconds))}s` : '—';
606-
const worstRisk = pickWorstRisk(r?.riskCounts);
607-
return `
608-
<button class="btn-secondary" type="button" data-open-repo="${escapeHtml(repo)}" title="Open PRs filtered to ${escapeHtml(repo)}" style="width:100%; display:flex; justify-content:space-between; align-items:center; gap:10px;">
609-
<span style="min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(repo)} (${open} open, ${unrev} unrev)</span>
610-
<span style="display:flex; align-items:center; gap:8px; flex-shrink:0;">
611-
${worstRisk ? riskChip(worstRisk) : ''}
612-
<span style="opacity:0.8;">${escapeHtml(avgReview)}</span>
613-
</span>
614-
</button>
615-
`;
616-
}).join('') : `<div style="opacity:0.8;">No PRs found.</div>`}
617-
</div>
618-
`;
591+
const boardHtml = (() => {
592+
if (!projectsBoard || scanned.length === 0) return '';
619593

620-
projectsEl.querySelectorAll('[data-open-repo]').forEach((btn) => {
621-
btn.addEventListener('click', () => {
622-
const repo = btn.getAttribute('data-open-repo') || '';
623-
if (!repo) return;
624-
try {
625-
localStorage.setItem('prs-panel-repo', repo);
626-
} catch {}
627-
try {
628-
this.orchestrator?.showPRsPanel?.();
629-
} catch {}
630-
});
631-
});
632-
} else {
633-
projectsEl.textContent = 'Failed to load.';
634-
}
635-
}
594+
const repoByKey = new Map();
595+
for (const repo of scanned) {
596+
const key = normalizeKey(repo?.relativePath);
597+
if (!key) continue;
598+
if (!repoByKey.has(key)) repoByKey.set(key, repo);
599+
}
600+
if (!repoByKey.size) return '';
601+
602+
const getOrderIndex = (columnId) => {
603+
const raw = projectsBoard?.orderByColumn && typeof projectsBoard.orderByColumn === 'object'
604+
? projectsBoard.orderByColumn[columnId]
605+
: null;
606+
const order = Array.isArray(raw) ? raw : [];
607+
const index = new Map();
608+
order.forEach((k, i) => {
609+
const key = normalizeKey(k);
610+
if (!key || index.has(key)) return;
611+
index.set(key, i);
612+
});
613+
return index;
614+
};
615+
616+
const collect = (columnId) => {
617+
const out = [];
618+
for (const [key, repo] of repoByKey.entries()) {
619+
const col = this.orchestrator?.getProjectsBoardColumnForProjectKey?.(key, projectsBoardData) || 'backlog';
620+
if (col === columnId) out.push({ key, repo });
621+
}
622+
const index = getOrderIndex(columnId);
623+
out.sort((a, b) => {
624+
const aRank = index.has(a.key) ? index.get(a.key) : Number.POSITIVE_INFINITY;
625+
const bRank = index.has(b.key) ? index.get(b.key) : Number.POSITIVE_INFINITY;
626+
if (aRank !== bRank) return aRank - bRank;
627+
return String(a.repo?.name || '').localeCompare(String(b.repo?.name || ''));
628+
});
629+
return out;
630+
};
631+
632+
const shipNext = collect('next');
633+
const active = collect('active');
634+
const total = shipNext.length + active.length;
635+
if (total === 0) return '';
636+
637+
const tagMap = projectsBoard?.tagsByProjectKey && typeof projectsBoard.tagsByProjectKey === 'object'
638+
? projectsBoard.tagsByProjectKey
639+
: {};
640+
641+
const renderTile = (item) => {
642+
const icon = this.orchestrator?.getProjectIcon?.(item?.repo?.type) || '📁';
643+
const name = String(item?.repo?.name || item?.key || '').trim();
644+
const key = normalizeKey(item?.key);
645+
const category = String(item?.repo?.category || '').trim();
646+
const type = String(item?.repo?.type || '').trim();
647+
const subtitle = category ? `${category}${key}` : key;
648+
const isLive = !!tagMap[key]?.live;
649+
return `
650+
<button type="button"
651+
class="dashboard-project-tile ${isLive ? 'is-live' : ''}"
652+
data-dashboard-start-project="${escapeHtml(key)}"
653+
data-project-type="${escapeHtml(type)}"
654+
title="Start worktree: ${escapeHtml(name)}">
655+
<span class="dashboard-project-tile-icon">${escapeHtml(icon)}</span>
656+
<span class="dashboard-project-tile-text">
657+
<span class="dashboard-project-tile-name">${escapeHtml(name)}</span>
658+
<span class="dashboard-project-tile-subtitle">${escapeHtml(subtitle)}</span>
659+
</span>
660+
${isLive ? `<span class="dashboard-project-tile-live" title="Live">★</span>` : ''}
661+
</button>
662+
`;
663+
};
664+
665+
const renderGroup = (label, list) => {
666+
if (!list.length) return '';
667+
return `
668+
<div class="dashboard-project-group">
669+
<div class="dashboard-project-group-title">${escapeHtml(label)} <span class="dashboard-project-group-count">${list.length}</span></div>
670+
<div class="dashboard-project-grid">
671+
${list.map(renderTile).join('')}
672+
</div>
673+
</div>
674+
`;
675+
};
676+
677+
return `
678+
<div class="dashboard-projects-board">
679+
${renderGroup('Ship Next', shipNext)}
680+
${renderGroup('Active', active)}
681+
</div>
682+
`;
683+
})();
684+
685+
const prSummaryHtml = (() => {
686+
if (!(projectsRes && projectsRes.ok)) {
687+
return `<div style="opacity:0.85;">Failed to load PR summary.</div>`;
688+
}
689+
690+
const totals = data?.totals || {};
691+
const repos = Array.isArray(data?.repos) ? data.repos : [];
692+
const top = repos.slice(0, 6);
693+
694+
const pickWorstRisk = (counts) => {
695+
const c = counts && typeof counts === 'object' ? counts : {};
696+
if (Number(c.critical || 0) > 0) return 'critical';
697+
if (Number(c.high || 0) > 0) return 'high';
698+
if (Number(c.medium || 0) > 0) return 'medium';
699+
if (Number(c.low || 0) > 0) return 'low';
700+
return '';
701+
};
702+
703+
const riskChip = (risk) => {
704+
const r = String(risk || '').trim().toLowerCase();
705+
if (!r) return '';
706+
const cls = (r === 'critical' || r === 'high') ? 'level-warn' : '';
707+
return `<span class="process-chip ${cls}">${escapeHtml(r)}</span>`;
708+
};
709+
710+
return `
711+
<div>Repos <strong>${Number(totals?.repos ?? top.length ?? 0)}</strong> • Open PRs <strong>${Number(totals?.prsOpen ?? 0)}</strong></div>
712+
<div>Unreviewed <strong>${Number(totals?.prsUnreviewed ?? 0)}</strong> • Needs fix <strong>${Number(totals?.prsNeedsFix ?? 0)}</strong></div>
713+
<div style="margin-top:8px; display:flex; flex-direction:column; gap:6px;">
714+
${top.length ? top.map((r) => {
715+
const repo = String(r?.repo || '').trim();
716+
const open = Number(r?.prsOpen ?? 0);
717+
const unrev = Number(r?.prsUnreviewed ?? 0);
718+
const avgReview = r?.telemetry?.avgReviewSeconds ? `${Math.round(Number(r.telemetry.avgReviewSeconds))}s` : '—';
719+
const worstRisk = pickWorstRisk(r?.riskCounts);
720+
return `
721+
<button class="btn-secondary" type="button" data-open-repo="${escapeHtml(repo)}" title="Open PRs filtered to ${escapeHtml(repo)}" style="width:100%; display:flex; justify-content:space-between; align-items:center; gap:10px;">
722+
<span style="min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(repo)} (${open} open, ${unrev} unrev)</span>
723+
<span style="display:flex; align-items:center; gap:8px; flex-shrink:0;">
724+
${worstRisk ? riskChip(worstRisk) : ''}
725+
<span style="opacity:0.8;">${escapeHtml(avgReview)}</span>
726+
</span>
727+
</button>
728+
`;
729+
}).join('') : `<div style="opacity:0.8;">No PRs found.</div>`}
730+
</div>
731+
`;
732+
})();
733+
734+
projectsEl.innerHTML = `${boardHtml}${prSummaryHtml}`;
735+
736+
projectsEl.querySelectorAll('[data-dashboard-start-project]').forEach((btn) => {
737+
btn.addEventListener('click', async () => {
738+
if (this._projectLaunchInFlight) return;
739+
const key = String(btn.getAttribute('data-dashboard-start-project') || '').trim();
740+
if (!key) return;
741+
this._projectLaunchInFlight = true;
742+
btn.disabled = true;
743+
try {
744+
const currentId = String(this.orchestrator?.currentWorkspace?.id || '').trim();
745+
const workspaces = Array.isArray(this.workspaces) ? this.workspaces : [];
746+
const pickRecent = () => {
747+
if (currentId) return currentId;
748+
let best = null;
749+
let bestTime = 0;
750+
for (const ws of workspaces) {
751+
const t = ws?.lastAccess ? new Date(ws.lastAccess).getTime() : 0;
752+
if (!best || t > bestTime) {
753+
best = ws;
754+
bestTime = t;
755+
}
756+
}
757+
return String(best?.id || '').trim();
758+
};
759+
760+
const targetId = pickRecent();
761+
try { this.orchestrator?.hideDashboard?.(); } catch {}
762+
if (targetId && targetId !== currentId) {
763+
this.orchestrator?.switchToWorkspace?.(targetId);
764+
await this.orchestrator?.waitForWorkspaceActive?.(targetId).catch(() => false);
765+
}
766+
await this.orchestrator?.startProjectWorktreeFromBoardKey?.(key);
767+
} catch {
768+
this.orchestrator?.showToast?.('Failed to start worktree', 'error');
769+
} finally {
770+
btn.disabled = false;
771+
this._projectLaunchInFlight = false;
772+
}
773+
});
774+
});
775+
776+
projectsEl.querySelectorAll('[data-open-repo]').forEach((btn) => {
777+
btn.addEventListener('click', () => {
778+
const repo = btn.getAttribute('data-open-repo') || '';
779+
if (!repo) return;
780+
try {
781+
localStorage.setItem('prs-panel-repo', repo);
782+
} catch {}
783+
try {
784+
this.orchestrator?.showPRsPanel?.();
785+
} catch {}
786+
});
787+
});
788+
}
636789

637790
if (showReadiness && readinessEl) {
638791
const data = readinessRes ? await readinessRes.json().catch(() => ({})) : {};

0 commit comments

Comments
 (0)