Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"root": true,
"extends": ["next/core-web-vitals"]
}
35 changes: 31 additions & 4 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import TodayFocusHero from "@/components/TodayFocusHero";
import TodayFocusHero from "@/components/TodayFocusHero";
import DashboardHeader from "@/components/DashboardHeader";
import ExportButton from "@/components/ExportButton";
import Link from "next/link";
Expand All @@ -20,9 +20,7 @@ export default async function DashboardPage() {
<div className="min-h-screen bg-[var(--background)] px-4 py-8 text-[var(--foreground)] transition-colors sm:px-6 lg:px-8 max-w-[1600px] mx-auto">
<DashboardHeader />

{/* Quick actions */}
<div className="mt-8 mb-4 flex flex-col sm:flex-row items-center justify-between gap-4">
{/* Left side actions */}
<div className="flex flex-wrap items-center gap-3 w-full sm:w-auto">
<Link
href="/wrapped"
Expand Down Expand Up @@ -86,8 +84,37 @@ export default async function DashboardPage() {
</div>
</section>

<section className="mt-8">
<div className="relative overflow-hidden rounded-xl border border-[var(--border)] bg-gradient-to-r from-emerald-950/20 via-teal-950/10 to-transparent p-5 shadow-lg flex flex-col sm:flex-row justify-between items-center gap-4">
<div className="space-y-1.5 max-w-xl">
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase font-bold text-emerald-400 tracking-wider px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20">
Interactive
</span>
<span className="text-xs text-[var(--muted-foreground)]">
Repo Health
</span>
</div>
<h3 className="text-base font-bold text-[var(--foreground)]">
Repository Health Explorer
</h3>
<p className="text-xs text-[var(--muted-foreground)] leading-relaxed">
Radar charts, score breakdowns, and automated recommendations
for your most active repositories.
</p>
</div>
<Link
href="/dashboard/repo-health"
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-emerald-600 to-teal-600 px-4 py-2 text-xs font-bold text-white shadow-md shadow-emerald-500/20 hover:scale-[1.03] transition-all whitespace-nowrap"
>
Explore Health
<ChevronRight className="h-4 w-4" />
</Link>
</div>
</section>

<CustomizableDashboard />
</div>
</DashboardSSEProvider>
);
}
}
16 changes: 16 additions & 0 deletions src/app/dashboard/repo-health/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { redirect } from "next/navigation";
import RepoHealthExplorer from "@/components/repo-health/RepoHealthExplorer";

export const metadata = {
title: "Repository Health Explorer — DevTrack",
description:
"Interactive health breakdown, radar chart, score analysis, and recommendations for your most active GitHub repositories.",
};

export default async function RepoHealthPage() {
const session = await getServerSession(authOptions);
if (!session) redirect("/");
return <RepoHealthExplorer />;
}
14 changes: 11 additions & 3 deletions src/components/RepoHealthPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,17 @@ export default function RepoHealthPanel({ health, isOpen, onClose }: Props) {
</div>
))}
</div>
<p className="mt-5 text-xs text-[var(--muted-foreground)] border-t border-[var(--border)] pt-4">
Score based on activity in the last 30 days. Updates on page refresh.
</p>
<div className="mt-5 flex items-center justify-between gap-3 border-t border-[var(--border)] pt-4">
<p className="text-xs text-[var(--muted-foreground)]">
Score based on activity in the last 30 days. Updates on page refresh.
</p>
<a
href={`/dashboard/repo-health`}
className="shrink-0 text-xs font-medium text-[var(--accent)] hover:underline underline-offset-2"
>
Full Analysis →
</a>
</div>
</div>
</div>
);
Expand Down
101 changes: 101 additions & 0 deletions src/components/repo-health/RepoHealthBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";

import { memo } from "react";
import type { BreakdownRow } from "@/lib/repo-health-insights";

interface Props {
rows: BreakdownRow[];
}

function ScoreBar({ pct }: { pct: number }) {
const color =
pct >= 70
? "bg-[var(--accent)]"
: pct >= 40
? "bg-[#ca8a04]"
: "bg-[var(--destructive)]";

return (
<div
className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--border)]"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className={`h-full rounded-full transition-all duration-500 ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
);
}

/**
* Tabular breakdown of the five health-score dimensions.
*
* Each row shows:
* – Metric label
* – Raw signal value (formatted)
* – Earned / max score
* – Progress bar proportional to earned / max
* – Weight contribution to the 100-point total
* – Tooltip with target description
*/
function RepoHealthBreakdown({ rows }: Props) {
return (
<section aria-label="Score breakdown">
<h3 className="mb-3 text-sm font-semibold text-[var(--card-foreground)]">
Score Breakdown
</h3>

<div className="space-y-4">
{rows.map((row) => {
const pct = Math.round((row.earned / row.maxScore) * 100);
return (
<div key={row.label}>
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
{/* Metric name + tooltip */}
<span
className="font-medium text-[var(--card-foreground)] cursor-help"
title={row.tip}
>
{row.label}
</span>

<span className="shrink-0 flex items-center gap-2 tabular-nums text-[var(--muted-foreground)]">
{/* Raw signal value */}
<span
className="rounded bg-[var(--control)] px-1.5 py-0.5 font-mono"
aria-label={`Measured value: ${row.rawValue}`}
>
{row.rawValue}
</span>

{/* Earned / max */}
<span aria-label={`${row.earned} of ${row.maxScore} points`}>
<span className="font-semibold text-[var(--card-foreground)]">
{row.earned}
</span>
<span>/{row.maxScore}</span>
<span className="ml-1 text-[var(--muted-foreground)]/70">
({row.weightPct}%)
</span>
</span>
</span>
</div>

<ScoreBar pct={pct} />
</div>
);
})}
</div>

<p className="mt-3 text-xs text-[var(--muted-foreground)]">
Hover metric names for target thresholds. Total weight: 100 pts.
</p>
</section>
);
}

export default memo(RepoHealthBreakdown);
93 changes: 93 additions & 0 deletions src/components/repo-health/RepoHealthCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use client";

import { memo } from "react";
import type { RepoHealthScore } from "@/types/repo-health";
import { gradeLetter, gradeLabel } from "@/lib/repo-health-insights";

interface Props {
health: RepoHealthScore;
isSelected: boolean;
onClick: () => void;
}

const GRADE_BADGE: Record<string, string> = {
green:
"bg-[var(--accent)]/15 text-[var(--accent)] border-[var(--accent)]/25",
yellow: "bg-[#ca8a04]/15 text-[#ca8a04] border-[#ca8a04]/25",
red: "bg-[var(--destructive)]/15 text-[var(--destructive)] border-[var(--destructive)]/25",
};

const GRADE_RING: Record<string, string> = {
green: "ring-[var(--accent)]",
yellow: "ring-[#ca8a04]",
red: "ring-[var(--destructive)]",
};

/**
* Compact repo card used in the explorer's left-panel repo list.
*
* Displays the repository name, letter grade, numeric score, and a small
* progress bar. Clicking selects the repo and shows the detailed breakdown.
*/
function RepoHealthCard({ health, isSelected, onClick }: Props) {
const shortName = health.repo.split("/")[1] ?? health.repo;
const letter = gradeLetter(health.score);
const label = gradeLabel(health.grade);
const badgeClass = GRADE_BADGE[health.grade] ?? GRADE_BADGE.red;
const ringClass = GRADE_RING[health.grade] ?? GRADE_RING.red;

return (
<button
type="button"
onClick={onClick}
className={[
"w-full rounded-xl border p-3 text-left transition-all duration-150",
"hover:shadow-sm hover:-translate-y-px active:scale-[0.99]",
isSelected
? `border-[var(--accent)] bg-[var(--accent)]/5 ring-1 ${ringClass}`
: "border-[var(--border)] bg-[var(--card)] hover:border-[var(--accent)]/40",
].join(" ")}
aria-pressed={isSelected}
aria-label={`Select ${health.repo} — health score ${health.score}, ${label}`}
>
<div className="flex items-center justify-between gap-2">
{/* Repo name */}
<span className="truncate text-sm font-medium text-[var(--card-foreground)]">
{shortName}
</span>

{/* Grade badge */}
<span
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-bold ${badgeClass}`}
aria-hidden="true"
>
{letter}
</span>
</div>

{/* Owner and numeric score */}
<div className="mt-1 flex items-center justify-between text-xs text-[var(--muted-foreground)]">
<span className="truncate">{health.repo.split("/")[0] ?? ""}</span>
<span className="tabular-nums">{health.score} pts</span>
</div>

{/* Mini score bar */}
<div className="mt-2 h-1 overflow-hidden rounded-full bg-[var(--border)]">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${health.score}%`,
backgroundColor:
health.grade === "green"
? "var(--accent)"
: health.grade === "yellow"
? "#ca8a04"
: "var(--destructive)",
}}
/>
</div>
</button>
);
}

export default memo(RepoHealthCard);
Loading
Loading