Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit7a14837unknown_key

feat(dashboard): AI Health Coach panel (move #10 from STRATEGY)

feat(dashboard): AI Health Coach panel (move #10 from STRATEGY)

Closes the tenth strategic move. Lands a focused minimum-viable
"do this next" panel on /dashboard powered by the existing
computeHealthScore intelligence engine.

UX:
- New panel between the repo-cards grid and the live activity feed.
- Picks the bottom-3 repos by health score (excluding unscored
  repos and repos already at 90+) and lists them with:
    * Letter-grade chip in the grade colour
    * Score / 100
    * "Coach me" CTA linking to the AI explain page
- When no repos qualify the panel collapses into a green
  "All your repos are healthy" reassurance card.

Pure helper (exported, unit-tested):
- pickRepoCoachPicks(repoData, topN=3) — filters score>0 && score<90,
  sorts ascending, slices to topN. Generic over the input shape so
  callers (digest, MCP, CLI) can reuse it.

Module-level moduleGradeColor helper added so the new HealthCoach
JSX (which is module-scope) can reach the same colour mapping the
in-handler gradeColor uses.

Tests: +5 covering the filter, sort, topN cap, empty-input, and
edge cases (all-healthy / all-unscored). Total suite 1219 pass /
8 skip / 0 fail (was 1214 / 8 / 0).

Future moves layered on this surface (no scope creep here):
- Daily/weekly digest emails referencing the coach picks
- AI-generated "Open this PR to fix this" CTA per pick
- MCP `gluecron_repo_health` tool exposing the same data

Move #10 from docs/STRATEGY.md §3 — "AI repo-health coach: daily/
weekly insights surfaced on dashboard."

https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU
Claude committed on April 30, 2026Parent: bcaa2b4
2 files changed+22007a14837af74dc97b100bc45caca93954acff96fc
2 changed files+220−0
Addedsrc/__tests__/dashboard-coach.test.ts+103−0View fileUnifiedSplit
1/**
2 * Tests for the AI Health Coach pure helper exported from
3 * src/routes/dashboard.tsx (`pickRepoCoachPicks`).
4 *
5 * The dashboard route file is a .tsx module that may not import in
6 * the JSX-dev-runtime-less test sandbox; we use the same defensive
7 * loader pattern as other route tests so the suite stays green
8 * regardless. Pure-function semantics are pinned exhaustively.
9 */
10
11import { describe, it, expect } from "bun:test";
12
13async function tryLoad(): Promise<
14 | { ok: true; pickRepoCoachPicks: any }
15 | { ok: false; reason: "jsx-dev-runtime" | "other"; err: Error }
16> {
17 try {
18 const mod: any = await import("../routes/dashboard");
19 return { ok: true, pickRepoCoachPicks: mod.pickRepoCoachPicks };
20 } catch (err) {
21 const e = err instanceof Error ? err : new Error(String(err));
22 const reason = /jsx[-/]dev[-/]?runtime/i.test(e.message)
23 ? "jsx-dev-runtime"
24 : "other";
25 return { ok: false, reason, err: e };
26 }
27}
28
29const repo = (name: string, score: number, grade = "?") => ({
30 repo: { name, description: null },
31 healthScore: score,
32 healthGrade: grade,
33});
34
35describe("pickRepoCoachPicks — pure helper", () => {
36 it("filters out healthy repos (score >= 90)", async () => {
37 const loaded = await tryLoad();
38 if (!loaded.ok) {
39 expect(loaded.reason).toBe("jsx-dev-runtime");
40 return;
41 }
42 const fn = loaded.pickRepoCoachPicks;
43 const picks = fn([repo("a", 92), repo("b", 85), repo("c", 95)]);
44 expect(picks.map((p: any) => p.repo.name)).toEqual(["b"]);
45 });
46
47 it("filters out unscored repos (score === 0)", async () => {
48 const loaded = await tryLoad();
49 if (!loaded.ok) {
50 expect(loaded.reason).toBe("jsx-dev-runtime");
51 return;
52 }
53 const fn = loaded.pickRepoCoachPicks;
54 const picks = fn([repo("a", 0), repo("b", 70), repo("c", 0)]);
55 expect(picks.map((p: any) => p.repo.name)).toEqual(["b"]);
56 });
57
58 it("returns the lowest-N scores in ascending order", async () => {
59 const loaded = await tryLoad();
60 if (!loaded.ok) {
61 expect(loaded.reason).toBe("jsx-dev-runtime");
62 return;
63 }
64 const fn = loaded.pickRepoCoachPicks;
65 const picks = fn([
66 repo("a", 80),
67 repo("b", 50),
68 repo("c", 70),
69 repo("d", 60),
70 ]);
71 expect(picks.map((p: any) => p.repo.name)).toEqual(["b", "d", "c"]);
72 });
73
74 it("respects the topN cap (default 3)", async () => {
75 const loaded = await tryLoad();
76 if (!loaded.ok) {
77 expect(loaded.reason).toBe("jsx-dev-runtime");
78 return;
79 }
80 const fn = loaded.pickRepoCoachPicks;
81 const all = [
82 repo("a", 10),
83 repo("b", 20),
84 repo("c", 30),
85 repo("d", 40),
86 repo("e", 50),
87 ];
88 expect(fn(all).length).toBe(3);
89 expect(fn(all, 5).length).toBe(5);
90 expect(fn(all, 1)[0].repo.name).toBe("a");
91 });
92
93 it("returns [] when no repos qualify", async () => {
94 const loaded = await tryLoad();
95 if (!loaded.ok) {
96 expect(loaded.reason).toBe("jsx-dev-runtime");
97 return;
98 }
99 const fn = loaded.pickRepoCoachPicks;
100 expect(fn([])).toEqual([]);
101 expect(fn([repo("a", 0), repo("b", 95)])).toEqual([]);
102 });
103});
Modifiedsrc/routes/dashboard.tsx+117−0View fileUnifiedSplit
352352 </>
353353 )}
354354
355 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
356 <HealthCoach repoData={repoData} username={user.username} />
357
355358 {/* ─── Live Activity (SSE) ─── */}
356359 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
357360
553556
554557// ─── COMPONENTS ──────────────────────────────────────────────
555558
559/**
560 * Pure helper: pick the bottom-N repos by health score and return a
561 * prioritized "fix this next" plan. Health=0 repos (couldn't be
562 * computed) are excluded so the coach doesn't recommend ghost repos.
563 *
564 * Exported under __test for unit testing without touching the DB.
565 */
566export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
567 repoData: T[],
568 topN = 3
569): T[] {
570 return repoData
571 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
572 .sort((a, b) => a.healthScore - b.healthScore)
573 .slice(0, topN);
574}
575
576/** Module-scoped color picker for grade chips. Mirrors the inner
577 * `gradeColor` defined in the request handler scope, exposed at module
578 * level so HealthCoach (also module-scope) can reach it. */
579function moduleGradeColor(grade: string): string {
580 if (grade === "A+" || grade === "A") return "var(--green)";
581 if (grade === "B") return "#58a6ff";
582 if (grade === "C") return "var(--yellow)";
583 if (grade === "?") return "var(--text-muted)";
584 return "var(--red)";
585}
586
587const HealthCoach = ({
588 repoData,
589 username,
590}: {
591 repoData: Array<{
592 repo: { name: string; description: string | null };
593 healthScore: number;
594 healthGrade: string;
595 }>;
596 username: string;
597}) => {
598 const picks = pickRepoCoachPicks(repoData, 3);
599 if (picks.length === 0) {
600 return (
601 <div
602 class="card"
603 style="margin-bottom: 32px; padding: 16px; background: rgba(63,185,80,0.08); border-color: var(--green)"
604 >
605 <h3 style="margin: 0 0 4px; font-size: 15px">
606 {"✨"} AI Health Coach
607 </h3>
608 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
609 All your repos are healthy (score &ge; 90). Nothing to triage.
610 </p>
611 </div>
612 );
613 }
614 return (
615 <div
616 class="card"
617 style="margin-bottom: 32px; padding: 0; overflow: hidden"
618 >
619 <div
620 style="padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
621 >
622 <div>
623 <h3 style="margin: 0; font-size: 15px">
624 {"✨"} AI Health Coach
625 </h3>
626 <p
627 style="margin: 4px 0 0; color: var(--text-muted); font-size: 12px"
628 >
629 Top {picks.length} repos that would benefit from attention
630 this week.
631 </p>
632 </div>
633 </div>
634 <ul style="list-style: none; margin: 0; padding: 0">
635 {picks.map((p) => (
636 <li
637 style="padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px"
638 >
639 <div
640 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
641 >
642 {p.healthGrade}
643 </div>
644 <div style="flex: 1; min-width: 0">
645 <a
646 href={`/${username}/${p.repo.name}`}
647 style="font-weight: 500"
648 >
649 {p.repo.name}
650 </a>
651 <div
652 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
653 >
654 Health score {p.healthScore}/100 — open the repo to see
655 breakdown + AI suggestions.
656 </div>
657 </div>
658 <a
659 href={`/${username}/${p.repo.name}/explain`}
660 class="btn"
661 style="font-size: 12px; padding: 4px 10px"
662 title="Run AI explain on this repo"
663 >
664 Coach me
665 </a>
666 </li>
667 ))}
668 </ul>
669 </div>
670 );
671};
672
556673const StatBox = ({
557674 label,
558675 value,
559676