feat: GitPulse - Git statistics dashboard with Ollama AI summary

This commit is contained in:
2026-04-24 23:02:31 +02:00
parent 280a3534a3
commit d88dc4fd9f
37 changed files with 6101 additions and 0 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
OPENAI_API_KEY=sk-votre-cle-api-ici

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.next/
.env.local
.DS_Store

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

6
next.config.js Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;

3005
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "git-statistics-dashboard",
"version": "1.0.0",
"description": "Dashboard pour les statistiques de commits Git",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.16",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"date-fns": "^3.2.0",
"dotenv": "^16.4.5",
"lucide-react": "^0.344.0",
"next": "^15.1.0",
"openai": "^4.28.0",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.0.0",
"recharts": "^2.12.2",
"simple-git": "^3.22.0",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { readdirSync, statSync, existsSync } from "fs";
import { join, resolve, sep, dirname } from "path";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const dirPath = searchParams.get("path") || "";
try {
const resolved = dirPath ? resolve(dirPath) : resolve("/");
if (!existsSync(resolved)) {
return NextResponse.json({ error: "Dossier introuvable" }, { status: 404 });
}
const stat = statSync(resolved);
if (!stat.isDirectory()) {
return NextResponse.json({ error: "Ce n'est pas un dossier" }, { status: 400 });
}
const entries = readdirSync(resolved, { withFileTypes: true });
const folders = entries
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => {
const full = join(resolved, e.name);
const isGitRepo = existsSync(join(full, ".git"));
return { name: e.name, path: full, isGitRepo };
})
.sort((a, b) => a.name.localeCompare(b.name));
const parent = dirname(resolved);
const isRoot = resolved === sep || resolved.match(/^[A-Z]:\\$/i);
const parentPath = isRoot ? null : parent;
return NextResponse.json({
path: resolved,
parentPath,
isRoot,
folders,
});
} catch (err: any) {
if (err.code === "EACCES" || err.code === "EPERM") {
return NextResponse.json({ error: "Permission refusée" }, { status: 403 });
}
return NextResponse.json({ error: err?.message || "Erreur" }, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
const OLLAMA_URL = process.env.OLLAMA_BASE_URL?.replace(/\/v1$/, "") || "http://localhost:11434";
export async function GET() {
let models: string[] = [];
let detectedModel = process.env.OLLAMA_MODEL || null;
try {
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const data = await res.json();
models = (data.models || []).map((m: any) => m.name);
if (!detectedModel && models.length > 0) {
// Auto-pick: prefer smaller models first
const preferred = ["gemma4", "gemma3", "gemma", "llama3.2", "llama3", "phi", "qwen", "mistral"];
for (const p of preferred) {
const match = models.find((m: string) => m.toLowerCase().includes(p));
if (match) { detectedModel = match; break; }
}
if (!detectedModel) detectedModel = models[0];
}
}
} catch { /* offline */ }
return NextResponse.json({
model: detectedModel || "?",
models,
autoDetected: !process.env.OLLAMA_MODEL,
ollamaUrl: OLLAMA_URL,
});
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const repoPath = searchParams.get("path");
const days = parseInt(searchParams.get("days") || "365");
const maxCommits = parseInt(searchParams.get("maxCommits") || "5000");
if (!repoPath) {
return NextResponse.json({ error: "Path missing" }, { status: 400 });
}
try {
const { analyzeGitRepo } = await import("@/lib/git-analyzer");
const stats = await analyzeGitRepo(repoPath, "main", days, {
maxCommits,
skipAI: true, // AI is done separately for speed
});
return NextResponse.json({ stats });
} catch (error: any) {
return NextResponse.json({ error: error?.message || "Analyse échouée" }, { status: 500 });
}
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { stats, model } = body;
if (!stats || !stats.repoName) {
return NextResponse.json({ error: "Stats manquantes" }, { status: 400 });
}
const { generateAISummary } = await import("@/lib/git-analyzer");
const summary = await generateAISummary(stats, model || undefined);
return NextResponse.json({ summary });
} catch (error: any) {
return NextResponse.json({ error: error?.message || "Erreur" }, { status: 500 });
}
}

80
src/app/globals.css Normal file
View File

@@ -0,0 +1,80 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
}
@layer components {
.scrollbar-thin {
scrollbar-width: thin;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
}

24
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: "Tableau de Bord Statistiques Git",
description: "Visualisez les statistiques de votre dépôt Git",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="fr" suppressHydrationWarning>
<body className={inter.variable}>
<main className="min-h-screen bg-background">{children}</main>
</body>
</html>
);
}

419
src/app/page.tsx Normal file
View File

@@ -0,0 +1,419 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { StatsOverview } from "@/components/dashboard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
BarChart2, GitBranch, Loader2, AlertCircle, TrendingUp, Users,
PieChart, Calendar, X, FolderOpen, Search, MessageSquare, FileText,
Settings, Bot, Cpu, Sun, Moon, Sparkles, ChevronRight, RefreshCw
} from "lucide-react";
const COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#06b6d4", "#ec4899", "#f97316", "#84cc16", "#64748b"];
export default function Home() {
const [stats, setStats] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [ollamaStatus, setOllamaStatus] = useState<"checking" | "online" | "offline">("checking");
const [ollamaModel, setOllamaModel] = useState("...");
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [settingsOpen, setSettingsOpen] = useState(false);
const [aiSummary, setAiSummary] = useState<string | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(null);
const [dark, setDark] = useState(false);
const statsRef = useRef<any>(null);
useEffect(() => {
fetch("/api/settings").then(r => r.json()).then(d => {
setOllamaModel(d.model || "?");
setOllamaModels(d.models || []);
if (d.models?.length > 0) setOllamaStatus("online");
}).catch(() => {});
if (window.matchMedia("(prefers-color-scheme: dark)").matches) setDark(true);
}, []);
useEffect(() => { document.documentElement.classList.toggle("dark", dark); }, [dark]);
const analyzeRepo = async (repoPath: string) => {
setLoading(true); setError(null); setAiSummary(null); setSummaryError(null);
try {
const res = await fetch(`/api/stats?path=${encodeURIComponent(repoPath)}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
setStats(data.stats);
statsRef.current = data.stats;
generateSummary(data.stats, ollamaModel !== "?" ? ollamaModel : undefined);
} catch (e: any) {
setError(e?.message || "Erreur");
} finally {
setLoading(false);
}
};
const generateSummary = async (s: any, model?: string) => {
setSummaryLoading(true); setSummaryError(null);
// Strip heavy fields to avoid huge request body
const light = { ...s };
if (light.recentCommits) {
light.recentCommits = light.recentCommits.map((c: any) => ({ ...c, filesList: undefined }));
}
try {
const res = await fetch("/api/summary", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stats: light, model }),
});
const d = await res.json();
if (d.error) { setSummaryError(d.error); setAiSummary(null); }
else setAiSummary(d.summary);
} catch {
setSummaryError("Ollama injoignable. Lancez `ollama serve`.");
} finally {
setSummaryLoading(false);
}
};
return (
<div className={`min-h-screen ${dark ? "dark" : ""} bg-background text-foreground transition-colors`}>
{/* Header */}
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur-lg">
<div className="flex h-14 items-center justify-between px-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<GitBranch className="h-4 w-4 text-primary" />
</div>
<h1 className="text-lg font-bold tracking-tight">GitPulse</h1>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-muted/50 text-xs">
<div className={`w-1.5 h-1.5 rounded-full ${ollamaStatus === "online" ? "bg-green-500 shadow-[0_0_6px_#22c55e]" : "bg-gray-400"}`} />
<button onClick={() => setSettingsOpen(true)} className="hover:text-foreground font-mono">
{ollamaModel}
</button>
</div>
<Button variant="ghost" size="icon" onClick={() => setDark(!dark)}>
{dark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<Button size="sm" onClick={() => setDialogOpen(true)}>+ Nouveau projet</Button>
</div>
</div>
</header>
<AddRepoDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onSubmit={(p) => { setDialogOpen(false); analyzeRepo(p); }} />
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} model={ollamaModel} models={ollamaModels} ollamaStatus={ollamaStatus} onSelectModel={(m) => { setOllamaModel(m); if (statsRef.current) generateSummary(statsRef.current, m); setSettingsOpen(false); }} />
<div className="max-w-7xl mx-auto px-4 py-8">
{loading && (
<div className="flex flex-col items-center justify-center py-40 gap-4">
<div className="relative">
<div className="w-12 h-12 rounded-full border-2 border-primary/30" />
<Loader2 className="h-12 w-12 animate-spin text-primary absolute inset-0" />
</div>
<p className="text-muted-foreground animate-pulse">Analyse en cours...</p>
</div>
)}
{error && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-6">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0" /><div><p className="font-semibold text-destructive">Erreur</p><p className="text-sm text-muted-foreground">{error}</p></div>
<Button variant="outline" size="sm" className="ml-auto" onClick={() => setDialogOpen(true)}>Réessayer</Button>
</CardContent>
</Card>
)}
{!loading && !stats && !error && (
<div className="flex flex-col items-center justify-center py-40 gap-8">
<div className="relative">
<div className="w-20 h-20 rounded-2xl bg-primary/10 flex items-center justify-center">
<BarChart2 className="h-10 w-10 text-primary" />
</div>
<div className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-green-500 flex items-center justify-center animate-pulse">
<Sparkles className="h-3 w-3 text-white" />
</div>
</div>
<div className="text-center max-w-md">
<h2 className="text-3xl font-bold tracking-tight">GitPulse</h2>
<p className="mt-3 text-muted-foreground leading-relaxed">
Visualisation puissante de vos dépôts Git. Statistiques, tendances, contributeurs le tout enrichi par IA.
</p>
</div>
<Button size="lg" className="rounded-full px-8" onClick={() => setDialogOpen(true)}>
<GitBranch className="mr-2 h-4 w-4" />Ajouter un dépôt
</Button>
</div>
)}
{!loading && stats && (
<div className="space-y-8">
{/* Project header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">{stats.repoName || "Projet"}</h2>
<p className="text-sm text-muted-foreground mt-0.5">{stats.repoPath}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => exportCSV(stats)}>📥 CSV</Button>
<Button variant="outline" size="sm" onClick={() => generateSummary(stats, ollamaModel !== "?" ? ollamaModel : undefined)} disabled={summaryLoading}>
<RefreshCw className={`h-3 w-3 mr-1 ${summaryLoading ? "animate-spin" : ""}`} />
IA
</Button>
</div>
</div>
{/* Stats cards */}
<StatsOverview stats={stats} />
{/* AI Summary banner */}
{summaryLoading && (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="flex items-center gap-3 py-4">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<div><p className="font-medium">Résumé IA en cours</p><p className="text-xs text-muted-foreground">Modèle : {ollamaModel}</p></div>
</CardContent>
</Card>
)}
{!summaryLoading && summaryError && (
<Card className="border-amber-500/30 bg-amber-500/5">
<CardContent className="flex items-center gap-3 py-4">
<AlertCircle className="h-5 w-5 text-amber-500" />
<div><p className="font-medium text-amber-600">{summaryError}</p></div>
<Button variant="outline" size="sm" className="ml-auto" onClick={() => generateSummary(stats, ollamaModel !== "?" ? ollamaModel : undefined)}>Réessayer</Button>
</CardContent>
</Card>
)}
{!summaryLoading && !summaryError && aiSummary && (
<Card className="border-primary/20 bg-gradient-to-r from-primary/5 to-transparent">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Analyse IA · {ollamaModel}
</CardTitle>
</CardHeader>
<CardContent><div className="text-sm whitespace-pre-wrap leading-relaxed">{aiSummary}</div></CardContent>
</Card>
)}
{!summaryLoading && !summaryError && !aiSummary && ollamaStatus === "offline" && (
<Card className="border-dashed">
<CardContent className="flex items-center gap-4 py-6 justify-center text-sm text-muted-foreground">
<Cpu className="h-5 w-5" />
<span>Ollama hors ligne <code className="bg-muted px-1 rounded">ollama serve</code> puis <code className="bg-muted px-1 rounded">ollama pull llama3.2</code> pour activer l'IA</span>
</CardContent>
</Card>
)}
{/* Tabs */}
<Tabs defaultValue="commits" className="w-full">
<TabsList>
<TabsTrigger value="commits"><MessageSquare className="mr-2 h-4 w-4" />Commits</TabsTrigger>
<TabsTrigger value="charts"><TrendingUp className="mr-2 h-4 w-4" />Graphiques</TabsTrigger>
<TabsTrigger value="contributors"><Users className="mr-2 h-4 w-4" />Contributeurs</TabsTrigger>
<TabsTrigger value="files"><FileText className="mr-2 h-4 w-4" />Fichiers</TabsTrigger>
<TabsTrigger value="types"><PieChart className="mr-2 h-4 w-4" />Types</TabsTrigger>
</TabsList>
<div className="mt-6">
<TabsContent value="commits">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2"><MessageSquare className="h-5 w-5 text-primary" />Historique ({stats.recentCommits?.length || 0} commits)</CardTitle>
</CardHeader>
<CardContent><CommitSearch commits={stats.recentCommits || []} /></CardContent>
</Card>
</TabsContent>
<TabsContent value="charts">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Calendar className="h-5 w-5 text-primary" />Activité quotidienne</CardTitle></CardHeader>
<CardContent><div className="h-56"><DailyBarChart data={stats.timeSeries?.daily || []} /></div></CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><GitBranch className="h-5 w-5 text-primary" />Heatmap</CardTitle></CardHeader>
<CardContent><HeatmapGrid data={stats.heatmap || []} /></CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader><CardTitle>Par mois</CardTitle></CardHeader>
<CardContent><div className="h-48"><MonthlyBars data={stats.timeSeries?.monthly || []} /></div></CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="contributors">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card><CardHeader><CardTitle>Top contributeurs</CardTitle></CardHeader><CardContent><ContributorBars contributors={stats.contributors || []} /></CardContent></Card>
<Card>
<CardHeader><CardTitle>Détails</CardTitle></CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<StatItem label="Contributeurs" value={stats.contributors?.length || 0} />
<StatItem label="Jours actifs" value={stats.activeDays || 0} />
<StatItem label="Top" value={stats.topContributor?.name || "-"} />
<StatItem label="Commits (top)" value={stats.topContributor?.commits || 0} />
<StatItem label="+ Lignes (top)" value={stats.topContributor?.linesAdded || 0} />
<StatItem label="Fichiers modifiés" value={stats.totalFilesChanged || 0} />
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="files">
<Card>
<CardHeader><CardTitle>Fichiers les plus modifiés</CardTitle></CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full text-sm"><thead><tr className="border-b text-left"><th className="pb-2 font-medium text-muted-foreground">Fichier</th><th className="pb-2 font-medium text-muted-foreground text-right">+</th><th className="pb-2 font-medium text-muted-foreground text-right"></th><th className="pb-2 font-medium text-muted-foreground text-right">Total</th><th className="pb-2 font-medium text-muted-foreground text-right">Commits</th></tr></thead>
<tbody>{(stats.fileChanges || []).slice(0, 25).map((f: any) => (<tr key={f.path} className="border-b last:border-0 hover:bg-muted/50"><td className="py-2 truncate max-w-[300px]" title={f.path}><span className="font-mono text-xs">{f.path}</span></td><td className="py-2 text-right text-green-500 font-mono text-xs">{f.additions.toLocaleString("fr-FR")}</td><td className="py-2 text-right text-red-500 font-mono text-xs">{f.deletions.toLocaleString("fr-FR")}</td><td className="py-2 text-right font-mono text-xs">{f.totalChanges.toLocaleString("fr-FR")}</td><td className="py-2 text-right text-muted-foreground">{f.commits}</td></tr>))}{(stats.fileChanges || []).length === 0 && (<tr><td colSpan={5} className="py-8 text-center text-muted-foreground">Aucune donnée</td></tr>)}</tbody></table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="types">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card><CardHeader><CardTitle>Répartition</CardTitle></CardHeader><CardContent><CommitTypeBars types={stats.commitTypes || []} /></CardContent></Card>
<Card><CardHeader><CardTitle>Distribution</CardTitle></CardHeader><CardContent><div className="h-56 flex items-center"><DonutChart types={stats.commitTypes || []} /></div></CardContent></Card>
</div>
</TabsContent>
</div>
</Tabs>
</div>
)}
</div>
</div>
);
}
// ==================== Settings Dialog ====================
function SettingsDialog({ open, onClose, model, models, ollamaStatus, onSelectModel }: { open: boolean; onClose: () => void; model: string; models: string[]; ollamaStatus: string; onSelectModel: (m: string) => void }) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-sm bg-card rounded-xl border shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b bg-muted/30">
<h2 className="font-semibold flex items-center gap-2"><Cpu className="h-4 w-4 text-primary" />Modèle IA</h2>
<Button variant="ghost" size="icon" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
<div className="p-4 space-y-1 max-h-80 overflow-y-auto">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<div className={`w-1.5 h-1.5 rounded-full ${ollamaStatus === "online" ? "bg-green-500" : "bg-gray-400"}`} />
{ollamaStatus === "online" ? "Ollama connecté" : "Ollama hors ligne"}
</div>
{models.map((m) => (
<button key={m} onClick={() => onSelectModel(m)} className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-left transition-all ${m === model ? "bg-primary/10 border border-primary/30 ring-1 ring-primary/20" : "hover:bg-muted border border-transparent"}`}>
<Bot className="h-4 w-4 flex-shrink-0" />
<span className="font-mono flex-1">{m}</span>
{m === model && <span className="text-[10px] text-primary font-bold">ACTIF</span>}
<ChevronRight className="h-3 w-3 text-muted-foreground" />
</button>
))}
{models.length === 0 && <p className="text-sm text-muted-foreground text-center py-4">Aucun modèle. <code className="bg-muted px-1 rounded">ollama pull llama3.2</code></p>}
</div>
</div>
</div>
);
}
// ==================== AddRepoDialog ====================
function AddRepoDialog({ open, onClose, onSubmit }: { open: boolean; onClose: () => void; onSubmit: (path: string) => void }) {
const [path, setPath] = useState(""); const [browsePath, setBrowsePath] = useState("");
const [folders, setFolders] = useState<{ name: string; path: string; isGitRepo: boolean }[]>([]);
const [browseLoading, setBrowseLoading] = useState(false); const [browseError, setBrowseError] = useState<string | null>(null);
const [parentPath, setParentPath] = useState<string | null>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (open) { setPath(""); setTimeout(() => inputRef.current?.focus(), 100); } }, [open]);
useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === "Escape" && open) onClose(); }; document.addEventListener("keydown", h); return () => document.removeEventListener("keydown", h); }, [open, onClose]);
const fetchBrowse = async (dirPath: string) => { setBrowseLoading(true); setBrowseError(null); try { const res = await fetch(`/api/fs/browse?path=${encodeURIComponent(dirPath)}`); const data = await res.json(); if (data.error) throw new Error(data.error); setBrowsePath(data.path); setParentPath(data.parentPath); setFolders(data.folders.sort((a: any, b: any) => { if (a.isGitRepo && !b.isGitRepo) return -1; if (!a.isGitRepo && b.isGitRepo) return 1; return a.name.localeCompare(b.name); })); } catch (e: any) { setBrowseError(e?.message || "Erreur"); } finally { setBrowseLoading(false); } };
useEffect(() => { if (open) fetchBrowse(path.trim() || "/"); }, [open]);
if (!open) return null;
const gitRepos = folders.filter((f: any) => f.isGitRepo), regularDirs = folders.filter((f: any) => !f.isGitRepo);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-4xl h-[85vh] bg-card rounded-xl border shadow-2xl flex flex-col overflow-hidden">
<div className="flex items-center justify-between p-5 border-b flex-shrink-0">
<h2 className="text-lg font-semibold flex items-center gap-2"><FolderOpen className="h-5 w-5 text-primary" />Sélectionner un dépôt</h2>
<Button variant="ghost" size="icon" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
<div className="px-5 py-3 border-b flex-shrink-0 space-y-2">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground flex-shrink-0">Chemin :</span>
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto">
{parentPath !== null && (
<button type="button" onClick={() => fetchBrowse(parentPath!)} className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-muted hover:bg-muted/80 text-xs font-medium flex-shrink-0 transition-colors">
Retour
</button>
)}
<button type="button" onClick={() => fetchBrowse("/")} className="text-muted-foreground hover:text-foreground flex-shrink-0 font-mono text-xs">/</button>
{browsePath !== "/" && (<><span className="text-muted-foreground"></span><span className="truncate text-muted-foreground text-xs" title={browsePath}>{browsePath.split(/[\/\\]/).slice(1).join(" ")}</span></>)}
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); const t = path.trim(); if (t) onSubmit(t); }} className="flex gap-2">
<input ref={inputRef} type="text" value={path} onChange={(e) => setPath(e.target.value)} placeholder="Collez ou tapez un chemin..." className="flex-1 rounded-md border border-input bg-background px-3 py-1.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" autoComplete="off" />
<Button type="submit" size="sm" disabled={!path.trim()}>Analyser</Button>
</form>
</div><div className="flex-1 overflow-y-auto p-4">{browseLoading && <div className="flex items-center gap-2 py-8 text-sm text-muted-foreground justify-center"><Loader2 className="h-5 w-5 animate-spin" /> Chargement...</div>}{browseError && <div className="text-center py-8"><AlertCircle className="h-8 w-8 text-destructive mx-auto mb-2" /><p className="text-sm text-destructive">{browseError}</p></div>}{!browseLoading && !browseError && folders.length === 0 && (<div className="text-center py-16"><FolderOpen className="h-10 w-10 text-muted-foreground mx-auto mb-3 opacity-30" /><p className="text-sm text-muted-foreground">Dossier vide</p></div>)}{!browseLoading && !browseError && (<div className="space-y-4">{gitRepos.length > 0 && (<div><h3 className="text-xs font-semibold text-green-600 uppercase tracking-wider mb-2 flex items-center gap-1"><GitBranch className="h-3 w-3" />Dépôts Git ({gitRepos.length})</h3><div className="grid grid-cols-1 sm:grid-cols-2 gap-2">{gitRepos.map((f: any) => (<button key={f.path} type="button" onClick={() => { setPath(f.path); onSubmit(f.path); }} className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 dark:bg-green-950/20 hover:bg-green-100 dark:hover:bg-green-950/40 transition-colors text-left group"><GitBranch className="h-5 w-5 text-green-600 flex-shrink-0" /><div className="min-w-0 flex-1"><p className="text-sm font-medium truncate">{f.name}</p><p className="text-[11px] text-green-700/70 dark:text-green-400/70 truncate">{f.path}</p></div><span className="text-xs text-green-600 font-medium opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">Ouvrir </span></button>))}</div></div>)}{regularDirs.length > 0 && (<div>{gitRepos.length > 0 && <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 mt-4">Dossiers ({regularDirs.length})</h3>}<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">{regularDirs.map((f: any) => (<button key={f.path} type="button" onClick={() => { setPath(f.path); fetchBrowse(f.path); }} className="flex items-center gap-2 p-2.5 rounded-lg border hover:bg-muted transition-colors text-left"><FolderOpen className="h-4 w-4 text-muted-foreground flex-shrink-0" /><span className="text-sm truncate">{f.name}</span></button>))}</div></div>)}</div>)}</div></div></div>);
}
// ==================== Commits ====================
function CommitFeed({ commits }: { commits: any[] }) {
if (!commits?.length) return <p className="text-muted-foreground text-sm py-8 text-center">Aucun commit.</p>;
const getType = (msg: string) => { const m = msg.toLowerCase(); if (m.startsWith("feat")) return { l: "feat", c: "bg-emerald-500/10 text-emerald-600 ring-emerald-500/20" }; if (m.startsWith("fix")) return { l: "fix", c: "bg-orange-500/10 text-orange-600 ring-orange-500/20" }; if (m.startsWith("refactor")) return { l: "refactor", c: "bg-purple-500/10 text-purple-600 ring-purple-500/20" }; if (m.startsWith("docs")) return { l: "docs", c: "bg-blue-500/10 text-blue-600 ring-blue-500/20" }; if (m.startsWith("chore")) return { l: "chore", c: "bg-gray-500/10 text-gray-600 ring-gray-500/20" }; if (m.startsWith("perf")) return { l: "perf", c: "bg-red-500/10 text-red-600 ring-red-500/20" }; if (m.startsWith("test")) return { l: "test", c: "bg-yellow-500/10 text-yellow-600 ring-yellow-500/20" }; return { l: "", c: "" }; };
return (<div className="space-y-0.5 max-h-[650px] overflow-y-auto">{commits.map((c, i) => { const t = getType(c.message || ""); return (<div key={c.hash || i} className="flex items-start gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/50 transition-colors border-b border-border/20 last:border-0"><div className="w-2 h-2 rounded-full bg-primary mt-2 flex-shrink-0" /><div className="flex-1 min-w-0"><div className="flex items-center gap-2 flex-wrap">{t.l && <span className={`text-[10px] px-1.5 py-0.5 rounded font-bold uppercase ring-1 ring-inset ${t.c}`}>{t.l}</span>}<span className="text-sm truncate flex-1">{c.message || "(vide)"}</span></div><div className="flex items-center gap-2 mt-1 flex-wrap"><span className="text-xs font-medium">{c.author}</span><span className="text-xs text-muted-foreground">{new Date(c.date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })}</span>{c.insertions > 0 && <span className="text-xs text-green-500 font-mono">+{c.insertions}</span>}{c.deletions > 0 && <span className="text-xs text-red-500 font-mono">-{c.deletions}</span>}{c.files > 0 && <span className="text-xs text-muted-foreground">{c.files}f</span>}</div></div><span className="text-[10px] text-muted-foreground font-mono flex-shrink-0 mt-1">{c.shortHash}</span></div>); })}</div>);
}
function CommitSearch({ commits }: { commits: any[] }) {
const [search, setSearch] = useState(""); const [authorFilter, setAuthorFilter] = useState(""); const [typeFilter, setTypeFilter] = useState("");
const authors = useMemo(() => [...new Set(commits.map((c: any) => c.author))].sort(), [commits]);
const types = useMemo(() => [...new Set(commits.map((c: any) => (c.message || "").split(":")[0]?.split("(")[0] || "other"))].sort(), [commits]);
const filtered = useMemo(() => commits.filter((c: any) => { if (search && !(c.message || "").toLowerCase().includes(search.toLowerCase())) return false; if (authorFilter && c.author !== authorFilter) return false; if (typeFilter && !(c.message || "").toLowerCase().startsWith(typeFilter.toLowerCase())) return false; return true; }), [commits, search, authorFilter, typeFilter]);
return (<div className="space-y-4"><div className="flex flex-wrap gap-2"><div className="relative flex-1 min-w-[200px]"><Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" /><input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Rechercher..." className="w-full pl-9 pr-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" /></div><select value={authorFilter} onChange={(e) => setAuthorFilter(e.target.value)} className="rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">{authors.map((a: string) => <option key={a} value={a === authorFilter ? "" : a}>{a === authorFilter ? "Tous" : a}</option>)}</select><select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} className="rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">{types.map((t: string) => <option key={t} value={t === typeFilter ? "" : t}>{t === typeFilter ? "Tous" : t}</option>)}</select></div><p className="text-xs text-muted-foreground">{filtered.length} commit{filtered.length > 1 ? "s" : ""}</p><CommitFeed commits={filtered.slice(0, 100)} /></div>);
}
// ==================== Charts ====================
function DailyBarChart({ data }: { data: { date: string; count: number }[] }) {
const s = [...data].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()).slice(-60); const max = Math.max(...s.map(d => d.count), 1);
if (!s.length) return <p className="text-muted-foreground text-sm py-8 text-center">Aucune donnée</p>;
return (<div className="flex items-end gap-[1px] h-full w-full min-w-[240px]">{s.map((d, i) => { const h = Math.max((d.count / max) * 100, 3); return <div key={i} className="flex-1 flex flex-col items-center gap-1 group" title={`${d.date}: ${d.count} commits`}><div className="w-full rounded-sm bg-primary/60 group-hover:bg-primary transition-all" style={{ height: `${h}%`, minHeight: "2px" }} />{i % 15 === 0 && <span className="text-[10px] text-muted-foreground">{new Date(d.date).toLocaleDateString("fr-FR", { month: "short", day: "numeric" })}</span>}</div>; })}</div>);
}
function HeatmapGrid({ data }: { data: { date: string; count: number; level: number }[] }) {
if (!data?.length) return <p className="text-muted-foreground text-sm py-8 text-center">Aucune donnée</p>;
const colors = ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e3a", "#1e6c25"]; const weeks: typeof data[] = []; let cw: typeof data = [];
for (const d of data) { cw.push(d); if (cw.length === 7) { weeks.unshift(cw); cw = []; } } if (cw.length > 0) weeks.unshift(cw);
return (<div className="overflow-x-auto pb-2"><div className="flex gap-1">{weeks.map((w, wi) => (<div key={wi} className="flex flex-col gap-1">{w.map((d, di) => (<div key={`${wi}-${di}`} className="w-3 h-3 rounded-sm transition-transform hover:scale-125 cursor-help" style={{ backgroundColor: colors[d.level] || colors[0] }} title={`${d.date}: ${d.count} commits`} />))}</div>))}</div><div className="flex items-center justify-end gap-1 text-[10px] text-muted-foreground mt-2"><span>Moins</span>{colors.map(c => <div key={c} className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />)}<span>Plus</span></div></div>);
}
function MonthlyBars({ data }: { data: { dateLabel: string; count: number }[] }) {
const s = [...data].slice(-12); const max = Math.max(...s.map(d => d.count), 1);
if (!s.length) return <p className="text-muted-foreground text-sm py-8 text-center">Aucune donnée</p>;
return (<div className="flex items-end gap-1 h-full w-full">{s.map((d, i) => { const h = Math.max((d.count / max) * 100, 4); return <div key={i} className="flex-1 flex flex-col items-center gap-1" title={`${d.dateLabel}: ${d.count} commits`}><span className="text-[10px] text-muted-foreground">{d.count || ""}</span><div className="w-full rounded-t-sm bg-primary/60 hover:bg-primary transition-all" style={{ height: `${h}%`, minHeight: "4px" }} /><span className="text-[10px] text-muted-foreground">{d.dateLabel}</span></div>; })}</div>);
}
function ContributorBars({ contributors }: { contributors: { name: string; commits: number }[] }) {
const s = [...contributors].sort((a, b) => b.commits - a.commits).slice(0, 10); const max = Math.max(...s.map(c => c.commits), 1);
if (!s.length) return <p className="text-muted-foreground text-sm py-8 text-center">Aucune donnée</p>;
return (<div className="space-y-3">{s.map((c, i) => (<div key={c.name} className="space-y-1"><div className="flex justify-between text-sm"><span className="font-medium">{c.name}</span><span className="text-muted-foreground">{c.commits}</span></div><div className="w-full bg-muted rounded-full h-3 overflow-hidden"><div className="h-full rounded-full transition-all duration-500" style={{ width: `${(c.commits / max) * 100}%`, backgroundColor: COLORS[i % COLORS.length] }} /></div></div>))}</div>);
}
function CommitTypeBars({ types }: { types: { type: string; percentage: number }[] }) {
const tl: Record<string, string> = { feat: "Nouveau", fix: "Correction", refactor: "Refactorisation", docs: "Docs", style: "Style", chore: "Tâche", perf: "Perf", test: "Test", ci: "CI/CD", build: "Build", revert: "Revert" };
const tc: Record<string, string> = { feat: COLORS[0], fix: COLORS[3], refactor: COLORS[2], docs: COLORS[5], style: COLORS[6], chore: COLORS[9], perf: COLORS[1], test: COLORS[7], ci: COLORS[4], build: COLORS[8], revert: COLORS[9] };
if (!types.length) return <p className="text-muted-foreground text-sm py-8 text-center">Aucune donnée</p>;
return (<div className="space-y-3">{types.filter(t => t.percentage > 0).map(t => (<div key={t.type} className="space-y-1"><div className="flex justify-between text-sm"><span className="font-medium">{tl[t.type] || t.type}</span><span className="text-muted-foreground">{t.percentage}%</span></div><div className="w-full bg-muted rounded-full h-3 overflow-hidden"><div className="h-full rounded-full transition-all" style={{ width: `${t.percentage}%`, backgroundColor: tc[t.type] || COLORS[0] }} /></div></div>))}</div>);
}
function DonutChart({ types }: { types: { type: string; count: number; percentage: number }[] }) {
const f = types.filter(t => t.count > 0); if (!f.length) return <p className="text-muted-foreground text-sm py-8 text-center">Aucune donnée</p>;
const total = f.reduce((s, t) => s + t.count, 0); let cp = 0;
const tl: Record<string, string> = { feat: "New", fix: "Fix", refactor: "Ref", docs: "Doc", style: "Sty", chore: "Chr", perf: "Prf", test: "Tst", ci: "CI", build: "Bld", revert: "Rvt" };
return (<div className="flex items-center gap-4 h-full w-full"><svg viewBox="0 0 100 100" className="w-36 h-36 flex-shrink-0 -rotate-90 drop-shadow-sm">{f.map((t, i) => { const p = (t.count / total) * 100; const s = cp; cp += p; const e = cp; const a1 = (s / 100) * 2 * Math.PI, a2 = (e / 100) * 2 * Math.PI; const x1 = 50 + 40 * Math.cos(a1), y1 = 50 + 40 * Math.sin(a1), x2 = 50 + 40 * Math.cos(a2), y2 = 50 + 40 * Math.sin(a2); return <path key={t.type} d={`M 50 50 L ${x1} ${y1} A 40 40 0 ${p > 50 ? 1 : 0} 1 ${x2} ${y2} Z`} fill={COLORS[i % COLORS.length]} stroke="var(--background)" strokeWidth="1.5" />; })}<circle cx="50" cy="50" r="24" fill="var(--card)" /><text x="50" y="50" textAnchor="middle" dy="0.3em" fontSize="9" fill="currentColor" fontWeight="bold" transform="rotate(90 50 50)">{total}</text></svg><div className="space-y-1.5 text-xs flex-1">{f.slice(0, 7).map((t, i) => (<div key={t.type} className="flex items-center gap-2"><div className="w-3 h-3 rounded-sm flex-shrink-0" style={{ backgroundColor: COLORS[i % COLORS.length] }} /><span className="flex-1">{tl[t.type] || t.type}</span><span className="text-muted-foreground font-mono">{t.percentage}%</span></div>))}</div></div>);
}
function StatItem({ label, value }: { label: string; value: string | number }) {
return (<div className="p-3 rounded-lg bg-muted/50"><p className="text-[11px] text-muted-foreground">{label}</p><p className="text-base font-bold mt-0.5">{typeof value === "number" ? value.toLocaleString("fr-FR") : value}</p></div>);
}
function exportCSV(stats: any) {
const lines: string[] = []; const sep = ";";
lines.push(`GitPulse${sep}${stats.repoName}`, new Date().toLocaleDateString("fr-FR"), "", `Commits${sep}${stats.totalCommits}`, `+Lignes${sep}${stats.totalLinesAdded}`, `-Lignes${sep}${stats.totalLinesDeleted}`, `Jours${sep}${stats.activeDays}`);
for (const c of stats.contributors || []) lines.push(`${c.name}${sep}${c.commits}${sep}${c.linesAdded}${sep}${c.linesDeleted}`);
const blob = new Blob(["\uFEFF" + lines.join("\n")], { type: "text/csv" }); const u = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = u; a.download = `gitpulse-${stats.repoName || "export"}.csv`; a.click(); URL.revokeObjectURL(u);
}

View File

@@ -0,0 +1,93 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
interface DailyData {
date: string;
count: number;
}
interface ActiveDaysProps {
title?: string;
dailyCommits: DailyData[];
className?: string;
}
export function ActiveDaysChart({ title, dailyCommits, className }: ActiveDaysProps) {
const sortedData = [...dailyCommits]
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.slice(-30);
const chartData = sortedData.map((d) => {
const date = new Date(d.date);
const label = `${date.getDate()}/${date.getMonth() + 1}`;
// Calculate 7-day moving average
const idx = sortedData.indexOf(d);
let avg = 0;
if (idx >= 6) {
const slice = sortedData.slice(idx - 6, idx + 1);
avg = parseFloat(
(slice.reduce((sum, c) => sum + c.count, 0) / 6).toFixed(1)
);
}
return { ...d, label, average: idx >= 6 ? avg : 0 };
});
return (
<div className={className}>
<h4 className="text-sm font-semibold mb-4">{title || "Activité des jours"}</h4>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(0,0,0,0.05)" />
<XAxis
dataKey="label"
tick={{ fontSize: 10 }}
stroke="rgba(0,0,0,0.1)"
/>
<YAxis
tick={{ fontSize: 10 }}
stroke="rgba(0,0,0,0.1)"
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "rgba(0,0,0,0.8)",
color: "#fff",
borderRadius: 6,
fontSize: 11,
}}
formatter={(value: number, name: string) => [
`${value} commits`,
name === "average" ? "Moyenne mobile (7j)" : "Commits",
]}
/>
<Line
type="monotone"
dataKey="count"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
fill="rgba(59, 130, 246, 0.1)"
/>
<Line
type="monotone"
dataKey="average"
stroke="#10b981"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -0,0 +1,385 @@
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import type { GitCommit, HeatmapDay } from "@/lib/git-analyzer";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
function getDaysAgo(date: Date): number {
const now = new Date();
const diff = now.getTime() - date.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
interface CommitHistoryProps {
commits: GitCommit[];
days?: number;
startDate?: Date;
}
export function CommitHistory({ commits, days = 365 }: CommitHistoryProps) {
const authorMap = new Map<string, { count: number; percentage: number }>();
for (const c of commits) {
if (!authorMap.has(c.author)) {
authorMap.set(c.author, { count: 0, percentage: 0 });
}
authorMap.get(c.author)!.count += 1;
}
for (const [author, data] of authorMap) {
data.percentage = commits.length > 0 ? Math.round((data.count / commits.length) * 100) : 0;
}
return (
<Card>
<CardHeader>
<CardTitle>Fréquence par contributeur</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{Array.from(authorMap.entries()).map(([author, data]) => (
<div key={author} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium">{author}</span>
<span className="text-muted-foreground">{data.percentage}%</span>
</div>
<Progress value={data.percentage} />
</div>
))}
{commits.length === 0 && (
<p className="text-sm text-muted-foreground">Aucune donnée</p>
)}
</div>
</CardContent>
</Card>
);
}
function getHeatmapColor(level: number): string {
const colors = ["#ebedf0", "#c6e48e", "#7bc96f", "#239a3b", "#196127", "#006031"];
return colors[level] || colors[0];
}
export function ContributionHeatmap({ heatmap, days = 365 }: { heatmap: HeatmapDay[]; days?: number }) {
const weeks = Math.ceil(days / 7);
// Create heatmap grid
const heatmapGrid: { date: string; day: number; count: number; color: string }[][] = [];
for (let week = 0; week < weeks; week++) {
const weekData: { date: string; day: number; count: number; color: string }[] = [];
for (let day = 0; day < 7; day++) {
const date = new Date(Date.now() - (week * 7 + day) * 24 * 60 * 60 * 1000);
const dateStr = date.toISOString().split("T")[0];
const found = heatmap.find(h => h.date === dateStr);
const count = found?.count || 0;
const color = getHeatmapColor(count);
weekData.push({
date: dateStr,
day: date.getDay(),
count,
color,
});
}
heatmapGrid.push(weekData);
}
const dayLabels = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
return (
<Card>
<CardHeader>
<CardTitle>Intensité des contributions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-center gap-1">
{heatmapGrid[heatmapGrid.length - 1]?.map((row, wIdx) => (
<div key={wIdx} className="w-3 h-3 rounded-sm" style={{ backgroundColor: row.color }} />
))}
<span className="text-xs text-muted-foreground ml-2">{days} jours</span>
</div>
<div className="grid grid-cols-7 gap-1">
{dayLabels.map((label, idx) => (
<div key={label} className="text-xs text-center text-muted-foreground">
{label}
</div>
))}
{heatmapGrid.map((week, wIdx) =>
week.map((cell, dIdx) => (
<div
key={wIdx}
style={{
backgroundColor: cell.color,
width: 12,
height: 12,
borderRadius: 2,
}}
title={`${cell.date}: ${cell.count} commits`}
className="transition-transform hover:scale-125 cursor-help"
/>
))
)}
</div>
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
<span>Moins</span>
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: "#ebedf0" }} />
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: "#c6e48e" }} />
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: "#7bc96f" }} />
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: "#239a3b" }} />
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: "#006031" }} />
<span>Plus</span>
</div>
</div>
</CardContent>
</Card>
);
}
function CommitTypeBreakdown({ commitTypes }: { commitTypes: Array<{ type: string; percentage: number }> }) {
const typeColors: Record<string, string> = {
feat: "bg-emerald-500",
fix: "bg-orange-500",
refactor: "bg-purple-500",
docs: "bg-blue-500",
style: "bg-pink-500",
chore: "bg-gray-500",
perf: "bg-red-500",
test: "bg-yellow-500",
ci: "bg-indigo-500",
build: "bg-cyan-500",
revert: "bg-slate-500",
};
const typeLabels: Record<string, string> = {
feat: 'Nouveau',
fix: 'Correction',
refactor: 'Refactorisation',
docs: 'Documentation',
style: 'Style',
chore: 'Tâche',
perf: 'Performance',
test: 'Test',
ci: 'CI/CD',
build: 'Build',
revert: 'Annulation',
};
return (
<Card>
<CardHeader>
<CardTitle>Répartition des commits</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{commitTypes.map(({ type, percentage }) => (
<div key={type} className="flex items-center gap-3">
<div
className={`w-4 h-4 rounded-sm ${typeColors[type] || 'bg-gray-400'}`}
/>
<div className="flex-1">
<div className="flex justify-between text-sm">
<span>{typeLabels[type] || type}</span>
<span className="font-medium">{percentage}%</span>
</div>
<div className="w-full bg-muted rounded-full h-2 mt-1">
<div
className={`h-2 rounded-full ${typeColors[type] || 'bg-gray-400'}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
function ContributionBreakdown({ contributors }: { contributors: any[] }) {
return (
<Card>
<CardHeader>
<CardTitle>Répartition des contributions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-center p-4">
<div className="relative w-32 h-32">
<svg viewBox="0 0 32 32" className="w-full h-full transform -rotate-90">
<circle
cx="16"
cy="16"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="text-muted/10"
/>
{contributors.map((_, idx) => {
const colors = ["#0ea5e9", "#f97316", "#8b5cf6", "#10b981", "#6366f1"];
const total = contributors.reduce((sum, c) => sum + c.commits, 0);
const count = contributors[idx]?.commits || 0;
const percentage = (count / total) * 100;
const dashArray = `${percentage * 0.88} ${100 - percentage * 0.88}`;
const dashOffset = total
? -contributors.slice(0, idx).reduce((sum, c) => sum + (c.commits / total) * 88, 0)
: 0;
return (
<circle
key={idx}
cx="16"
cy="16"
r="14"
fill="none"
stroke={colors[idx % colors.length]}
strokeWidth="4"
strokeDasharray={dashArray}
strokeDashoffset={dashOffset}
/>
);
})}
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<p className="text-xs text-muted-foreground">Total</p>
<p className="text-lg font-bold">{contributors.reduce((sum, c) => sum + c.commits, 0)}</p>
</div>
</div>
</div>
</div>
<div className="space-y-3">
{contributors.map((contributor, idx) => {
const total = contributors.reduce((sum, c) => sum + c.commits, 0);
const percentage = total ? Math.round((contributor.commits / total) * 100) : 0;
const colors = ["#0ea5e9", "#f97316", "#8b5cf6", "#10b981", "#6366f1"];
return (
<div key={idx} className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: colors[idx % colors.length] }}
/>
<div className="flex-1">
<div className="flex justify-between text-sm">
<span className="font-medium">{contributor.name}</span>
<span className="text-muted-foreground">{percentage}%</span>
</div>
<div className="w-full bg-muted rounded-full h-2 mt-1">
<div
className="h-2 rounded-full"
style={{
width: `${percentage}%`,
backgroundColor: colors[idx % colors.length]
}}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
);
}
function CommitTimeline({ dailyCommits }: { dailyCommits: { date: string; count: number }[] }) {
const maxCount = Math.max(...dailyCommits.map(c => c.count), 1);
const today = new Date();
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - 365);
const data = dailyCommits.filter(c => {
const date = new Date(c.date);
return date >= startDate && date <= today;
});
return (
<Card>
<CardHeader>
<CardTitle>Historique des commits</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-end gap-0.5 h-32">
{data.map((point, idx) => {
const height = Math.max((point.count / maxCount) * 100, 5);
const date = new Date(point.date);
const isWeekend = [0, 6].includes(date.getDay());
return (
<div
key={idx}
className="flex-1 flex flex-col items-center gap-1"
title={`${point.date}: ${point.count} commits`}
>
<div
className={`w-full min-w-[2px] rounded-t ${
isWeekend ? 'bg-blue-400' : 'bg-blue-500'
}`}
style={{ height: `${height}%` }}
/>
{idx % 30 === 0 && (
<span className="text-xs text-muted-foreground">
{date.toLocaleDateString('fr-FR', { month: 'short' })}
</span>
)}
</div>
);
})}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{startDate.toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })}</span>
<span>Aujourd'hui</span>
</div>
</div>
</CardContent>
</Card>
);
}
function MostActivePeriod({ mostActive, allTime, lastWeek }: { mostActive: Array<{ type: string; count: number }>; allTime: Array<{ type: string; count: number }>; lastWeek: Array<{ type: string; count: number }> }) {
const getMostActive = (period: Array<{ type: string; count: number }>) => {
const max = Math.max(...period.map(p => p.count));
return period.find(p => p.count === max);
};
return (
<Card>
<CardHeader>
<CardTitle>Périodes les plus actives</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
{[
{ period: 'Aujourd\'hui', data: mostActive },
{ period: 'Cette semaine', data: lastWeek },
{ period: 'Total', data: allTime },
].map(({ period, data }) => {
const most = getMostActive(data);
return (
<div key={period} className="p-3 bg-muted/50 rounded-md">
<p className="text-sm font-medium">{period}</p>
<p className="text-2xl font-bold mt-1">{most?.count || 0}</p>
<p className="text-xs text-muted-foreground">{most?.type || '-'}</p>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,86 @@
interface CommitInfo {
date: string;
count: number;
}
interface CommitTimelineProps {
commits: CommitInfo[];
title?: string;
className?: string;
}
export function CommitTimeline({ commits, title, className }: CommitTimelineProps) {
const maxValue = Math.max(...commits.map(c => c.count), 1);
const minDate = new Date(Math.min(...commits.map(c => new Date(c.date).getTime())));
const maxDate = new Date(Math.max(...commits.map(c => new Date(c.date).getTime())));
const datesUsed = new Set(commits.map(c => c.date));
const today = new Date();
const daysInRange = Math.ceil((today.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24));
// Create the grid of days
const calendarDays: { date: Date; hasCommits?: boolean }[] = [];
const start = new Date(minDate);
start.setDate(start.getDate() - (start.getDay() || 7)); // Start at previous Sunday
for (let i = 0; i < daysInRange + start.getDay(); i++) {
const date = new Date(start.getTime() + i * 24 * 60 * 60 * 1000);
const dateStr = date.toISOString().split('T')[0];
calendarDays.push({
date,
hasCommits: datesUsed.has(dateStr),
});
}
const weeks = Math.ceil(calendarDays.length / 7);
const dayLabels = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'];
return (
<div className={className}>
<h4 className="text-sm font-semibold mb-4">{title || "Historique des commits"}</h4>
<div className="flex gap-1 overflow-x-auto pb-2">
<div className="flex flex-col gap-1 mr-2">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="w-4 h-4 flex items-center justify-center">
<span className="text-[10px] text-muted-foreground">{dayLabels[i]}</span>
</div>
))}
</div>
<div className="flex gap-1">
{Array.from({ length: weeks }).map((_, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-1">
{Array.from({ length: 7 }).map((_, dayIndex) => {
const cellIndex = weekIndex * 7 + dayIndex;
const cell = calendarDays[cellIndex];
if (!cell) return <div key={cellIndex} className="w-3 h-3" />;
return (
<div
key={cellIndex}
className={`w-3 h-3 rounded-sm ${
cell.hasCommits
? 'bg-[#39d353]'
: 'bg-[#ebedf0]'
}`}
title={`${cell.date.toISOString().split("T")[0]}: ${cell.hasCommits ? commits.find((c) => c.date === cell.date.toISOString().split("T")[0])?.count || 0 : 0} commits`}
/>
);
})}
</div>
))}
</div>
</div>
<div className="flex items-center justify-end mt-2 text-xs text-muted-foreground gap-1">
<span>Moins</span>
<div className="w-3 h-3 rounded-sm bg-[#ebedf0]" />
<div className="w-3 h-3 rounded-sm bg-[#9be9a8]" />
<div className="w-3 h-3 rounded-sm bg-[#40c463]" />
<div className="w-3 h-3 rounded-sm bg-[#30a14e]" />
<div className="w-3 h-3 rounded-sm bg-[#216e3a]" />
<span>Plus</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
interface Contributor {
name: string;
email: string;
commits: number;
linesAdded: number;
linesDeleted: number;
}
interface ContributorChartProps {
contributors: Contributor[];
title?: string;
className?: string;
}
export function ContributorChart({ contributors, title, className }: ContributorChartProps) {
const maxValue = Math.max(...contributors.map((c) => c.commits), 1);
const barColors = [
'#0ea5e9',
'#f97316',
'#8b5cf6',
'#10b981',
'#6366f1',
'#ec4899',
'#14b8a6',
'#f59e0b',
'#64748b',
];
const sorted = [...contributors].sort((a, b) => b.commits - a.commits).slice(0, 10);
return (
<div className={className}>
<h4 className="text-sm font-semibold mb-4">{title || "Top Contributeurs"}</h4>
{sorted.map((contributor, index) => {
const percentage = (contributor.commits / maxValue) * 100;
const color = barColors[index % barColors.length];
return (
<div key={index} className="mb-3">
<div className="flex justify-between items-center mb-1">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-xs font-medium">{contributor.name}</span>
</div>
<span className="text-xs text-muted-foreground">
{contributor.commits} commits
</span>
</div>
<div className="w-full bg-muted h-2 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${percentage}%`,
backgroundColor: color,
}}
/>
</div>
</div>
);
})}
{sorted.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
Aucune donnée de contributeur
</p>
)}
</div>
);
}
// Add TypeScript types for props
export interface ContributorData {
name: string;
email: string;
commits: number;
linesAdded: number;
linesDeleted: number;
}

View File

@@ -0,0 +1,41 @@
interface CommitsByDateProps {
commits: Array<{ date: string; count: number }>;
className?: string;
}
export function CommitsByDateChart({ commits, className }: CommitsByDateProps) {
const sortedCommits = [...commits].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
const maxValue = Math.max(...sortedCommits.map(c => c.count), 1);
return (
<div className={className}>
<h4 className="text-sm font-semibold mb-4">Commits par date</h4>
<div className="flex items-end gap-2 h-40">
{sortedCommits.map((commit, idx) => {
const height = (commit.count / maxValue) * 100;
const date = new Date(commit.date);
const label = date.toLocaleDateString("fr-FR", { day: "2-digit", month: "short" });
return (
<div key={idx} className="flex-1 flex flex-col items-center gap-1">
<span className="text-xs text-muted-foreground">
{commit.count > 0 ? commit.count : ''}
</span>
<div
className="w-full bg-emerald-500 rounded-t-sm"
style={{
height: `${height}%`,
minHeight: commit.count > 0 ? '24px' : '0'
}}
title={`${label}: ${commit.count} commits`}
/>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
type CommitsByDate = { date: string; count: number };
type CommitsByDayOfWeek = { day: number; count: number };
type CommitsByMonth = { month: number; count: number };
type CommitsByHourOfDay = { hour: number; count: number };
type FilesByType = { type: string; count: number };
function formatDateFr(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
}
function formatHourFr(hour: number): string {
return `${hour.toString().padStart(2, "0")}h00`;
}
interface CommitsByDateProps {
commits: CommitsByDate[];
className?: string;
}
export function CommitsByDateChart({ commits, className }: CommitsByDateProps) {
const sortedCommits = [...commits].sort(
(a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
);
const maxValue = Math.max(...sortedCommits.map(c => c.count), 1);
const labels = sortedCommits.map((c) => formatDateFr(c.date));
return { commits };
}
interface CommitsByDayOfWeekProps {
commits: CommitsByDayOfWeek[];
className?: string;
}
export function CommitsByDayOfWeekChart({ commits, className }: CommitsByDayOfWeekProps) {
const maxValue = Math.max(...commits.map(p => p.count), 1);
const days = [
'Dimanche',
'Lundi',
'Mardi',
'Mercredi',
'Jeudi',
'Vendredi',
'Samedi'
];
const data = days.map((day, idx) => {
const found = commits.find(p => p.day === idx);
return {
day: day,
count: found ? found.count : 0,
percentage: found ? Math.round((found.count / maxValue) * 100) : 0
};
}).sort((a, b) => b.count - a.count);
return (
<div className={className}>
<h4 className="text-sm font-semibold mb-4">Jours de la semaine</h4>
<div className="space-y-2">
{data.map((item, idx) => (
<div key={item.day} className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: getDayColor(idx) }}
/>
<span className="text-xs w-12">{item.day.substring(0, 3)}</span>
<div className="flex-1 bg-muted rounded-full h-2">
<div
className="h-full rounded-full"
style={{
width: `${item.percentage}%`,
backgroundColor: getDayColor(idx)
}}
/>
</div>
<span className="text-xs w-8 text-right text-muted-foreground">{item.count}</span>
</div>
))}
</div>
</div>
);
}
function getDayColor(idx: number) {
const colors = ['#8b5cf6', '#3b82f6', '#06b6d4', '#10b981', '#f59e0b', '#f97316', '#ef4444'];
return colors[idx];
}
interface CommitsByMonthProps {
commits: CommitsByMonth[];
className?: string;
}
export function CommitsByMonthChart({ commits, className }: CommitsByMonthProps) {
const months = [
'Jan', 'Fev', 'Mar', 'Avr', 'Mai', 'Juin',
'Juil', 'Aou', 'Sep', 'Oct', 'Nov', 'Dec'
];
const data = months.map((month, idx) => {
const found = commits.find(p => p.month === idx);
return {
month,
count: found ? found.count : 0
};
});
return (
<div className={className}>
<h4 className="text-sm font-semibold mb-4">Mois de l'année</h4>
<div className="flex items-end gap-2 h-40">
{data.map((item, idx) => {
const maxValue = Math.max(...data.map(d => d.count), 1);
const height = (item.count / maxValue) * 100;
return (
<div key={item.month} className="flex-1 flex flex-col items-center gap-1">
<span className="text-xs text-muted-foreground">
{item.count > 0 ? item.count : ''}
</span>
<div
className="w-full bg-blue-500 rounded-t-sm"
style={{ height: `${height}%`, minHeight: item.count > 0 ? '24px' : '0' }}
/>
<span className="text-xs text-muted-foreground">{item.month}</span>
</div>
);
})}
</div>
</div>
);
}
interface CommitsByHourOfDayProps {
commits: CommitsByHourOfDay[];
className?: string;
}
export function CommitsByHourOfDayChart({ commits, className }: CommitsByHourOfDayProps) {
const maxValue = Math.max(...commits.map(p => p.count), 1);
const hours = Array.from({ length: 24 }, (_, i) => {
const found = commits.find(p => p.hour === i);
return {
hour: i,
label: formatHourFr(i),
count: found ? found.count : 0,
percentage: found ? (found.count / maxValue) * 100 : 0
};
});
return (
<div className={className}>
<h4 className="text-sm font-semibold mb-4">Heures de la journée</h4>
<div className="flex items-end h-40 gap-1">
{hours.map((item) => (
<div key={item.hour} className="flex-1 flex flex-col items-center gap-1 min-w-[2px]">
<div
className="w-full bg-purple-500 rounded-t-sm"
style={{ height: `${item.percentage > 0 ? Math.max(item.percentage * 0.8, 5) : 0}%` }}
/>
</div>
))}
</div>
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
<span>00h00</span>
<span>12h00</span>
<span>23h59</span>
</div>
</div>
);
}
interface FilesByTypeProps {
files: FilesByType[];
className?: string;
}
export function FilesByTypeChart({ files, className }: FilesByTypeProps) {
const colors = {
src: '#3b82f6',
css: '#8b5cf6',
html: '#f59e0b',
json: '#64748b',
md: '#10b981',
ts: '#06b6d4',
test: '#ec4899',
config: '#92400e',
git: '#6b7280',
asset: '#f97316',
other: '#a3a3a3',
};
const sorted = [...files].sort((a, b) => b.count - a.count);
const maxCount = sorted[0]?.count || 1;
return (
<div className={className}>
<h4 className="text-sm font-semibold mb-4">Types de fichiers modifiés</h4>
<div className="space-y-2">
{sorted.map((file, idx) => {
const percentage = (file.count / maxCount) * 100;
const ext = file.type.toLowerCase();
const color = colors[ext as keyof typeof colors] || '#a3a3a3';
return (
<div key={ext} className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: color }}
/>
<span className="text-xs uppercase w-12 font-mono">.{ext}</span>
<div className="flex-1 bg-muted rounded-full h-2">
<div
className="h-full rounded-full"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-xs w-8 text-right text-muted-foreground">{file.count}</span>
</div>
);
})}
</div>
</div>
);
}
interface ActiveDaysProps {
activeDays: number;
totalDays: number;
className?: string;
}
export function ActiveDaysChart({ activeDays, totalDays, className }: ActiveDaysProps) {
const percentage = totalDays > 0 ? Math.round((activeDays / totalDays) * 100) : 0;
const color =
percentage > 60 ? 'text-green-500' :
percentage > 30 ? 'text-yellow-500' :
'text-red-500';
return (
<div className={className}>
<h4 className="text-sm font-semibold mb-4">Jours actifs</h4>
<div className="flex items-center gap-4">
<div className="text-center">
<div className={`text-3xl font-bold ${color}`}>{activeDays}</div>
<div className="text-xs text-muted-foreground mt-1">jours actifs</div>
</div>
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">
{percentage}% des {totalDays} jours
</div>
<div className="w-full bg-muted rounded-full h-3">
<div
className={`h-full rounded-full transition-all ${
percentage > 60 ? 'bg-green-500' :
percentage > 30 ? 'bg-yellow-500' :
'bg-red-500'
}`}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="text-xs text-muted-foreground mt-1">
taux d'activité
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export { RepoManager, StatsOverview, CommitHistory } from "./repo-manager";
export { ActiveDaysChart } from "./active-days-chart";
export { CommitTimeline } from "./commit-timeline";
export { ContributorChart } from "./contributor-chart";
export { CommitsByDateChart } from "./git-commit-stats";
export { CommitsByDayOfWeekChart, CommitsByMonthChart, CommitsByHourOfDayChart, FilesByTypeChart } from "./git-diff";
export { ContributionHeatmap } from "./commit-history";

View File

@@ -0,0 +1,164 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Plus, X, Folder, FolderOpen, Search, Trash2, Clock, GitGraph, GitBranch, FileText } from "lucide-react";
import Image from "next/image";
export function RepoManager({
repos,
onAdd,
onRemove,
}: {
repos: { path: string; name: string }[];
onAdd: (repo: { path: string; name: string }) => void;
onRemove: (path: string) => void;
}) {
return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<GitBranch className="w-5 h-5 text-primary" />
Dépôts Git
</h3>
<div className="space-y-3">
{repos.map((repo) => (
<div
key={repo.path}
className="flex items-center justify-between p-3 bg-muted/50 rounded-md border border-border"
>
<div className="flex items-center gap-3">
<Folder className="w-4 h-4 text-primary" />
<div>
<p className="font-medium text-sm">{repo.name}</p>
<p className="text-xs text-muted-foreground truncate max-w-[200px]">{repo.path}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onRemove(repo.path)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full border-dashed"
>
<Plus className="w-4 h-4 mr-2" />
Ajouter un dépôt
</Button>
</div>
</Card>
);
}
export function StatsOverview({ stats }: { stats: any }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Commits"
value={stats.totalCommits}
format="number"
icon={<GitGraph className="w-5 h-5 text-primary" />}
color="blue"
/>
<StatCard
title="Lignes ajoutées"
value={stats.totalLinesAdded}
format="number"
icon={<Plus className="w-5 h-5 text-green-500" />}
color="green"
/>
<StatCard
title="Lignes supprimées"
value={stats.totalLinesDeleted}
format="number"
icon={<X className="w-5 h-5 text-red-500" />}
color="red"
/>
<StatCard
title="Jours actifs"
value={stats.activeDays}
format="number"
icon={<Clock className="w-5 h-5 text-yellow-500" />}
color="yellow"
/>
</div>
);
}
function StatCard({
title,
value,
icon,
color,
format,
}: {
title: string;
value: number | string;
icon: React.ReactNode;
color: string;
format: "number" | "percent";
}) {
const colorMap: Record<string, string> = {
blue: "bg-blue-50 text-blue-600 border-blue-200",
green: "bg-green-50 text-green-600 border-green-200",
red: "bg-red-50 text-red-600 border-red-200",
yellow: "bg-yellow-50 text-yellow-600 border-yellow-200",
};
const displayed = format === "number"
? Number(value).toLocaleString("fr-FR")
: `${value}%`;
return (
<Card className={`p-4 border-l-4 ${colorMap[color].split(" ").slice(2).join(" ")}`}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{title}</p>
<p className="text-2xl font-bold mt-1">{displayed}</p>
</div>
<div className={`p-2 rounded-lg ${colorMap[color]}`}>
{icon}
</div>
</div>
</Card>
);
}
export function CommitHistory({ commits }: { commits: any[] }) {
return (
<div className="space-y-3">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
Historique des commits (derniers 10)
</h3>
<div className="space-y-2">
{commits.slice(0, 10).map((commit, idx) => (
<div
key={commit.hash}
className="flex items-center gap-3 p-3 bg-card rounded-md border border-border hover:bg-muted/50 transition-colors"
>
<div className="w-2 h-2 rounded-full bg-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{commit.msg}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
<span>{commit.author}</span>
<span></span>
<span>{commit.date}</span>
<span></span>
<span>+{commit.add} -{commit.del}</span>
</div>
</div>
<span className="text-xs text-muted-foreground flex-shrink-0">
{commit.hash.substring(0, 7)}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Accordion = ({
children,
className,
}: React.ComponentProps<"div">) => {
return <div className={cn("w-full", className)}>{children}</div>;
};
const AccordionItem = ({
children,
className,
}: React.ComponentProps<"div">) => {
return <div className={cn("border-b", className)}>{children}</div>;
};
const AccordionTrigger = ({
children,
className,
onClick,
}: React.ComponentProps<"button">) => {
return (
<button
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
onClick={onClick}
type="button"
>
{children}
</button>
);
};
const AccordionContent = ({
children,
className,
}: React.ComponentProps<"div">) => {
return (
<div className={cn("pb-4 pt-0 text-sm", className)}>{children}</div>
);
};
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,34 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { variant?: "default" | "destructive" }
>(({ className, variant = "default", ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
variant === "destructive" &&
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
className
)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertDescription };

View File

@@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,78 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { value: number }
>(({ className, value = 0, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<div
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</div>
));
Progress.displayName = "Progress";
export { Progress };

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface SelectProps
extends React.SelectHTMLAttributes<HTMLSelectElement> {}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => {
return (
<select
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
>
{children}
</select>
);
}
);
Select.displayName = "Select";
export { Select };

View File

@@ -0,0 +1,16 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,80 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
export { Table, TableHeader, TableRow, TableHead, TableBody, TableCell };

View File

@@ -0,0 +1,95 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface TabsContextType {
value: string;
onValueChange: (value: string) => void;
}
const TabsContext = React.createContext<TabsContextType | null>(null);
function useTabs() {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error("Tabs components must be used within <Tabs>");
return ctx;
}
function Tabs({
defaultValue,
className,
children,
}: {
defaultValue: string;
className?: string;
children: React.ReactNode;
}) {
const [value, setValue] = React.useState(defaultValue);
return (
<TabsContext.Provider value={{ value, onValueChange: setValue }}>
<div className={cn("space-y-2", className)}>{children}</div>
</TabsContext.Provider>
);
}
function TabsList({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
>
{children}
</div>
);
}
function TabsTrigger({
value,
className,
children,
}: {
value: string;
className?: string;
children: React.ReactNode;
}) {
const { value: activeValue, onValueChange } = useTabs();
const isActive = value === activeValue;
return (
<button
type="button"
onClick={() => onValueChange(value)}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isActive && "bg-background text-foreground shadow-sm",
className
)}
>
{children}
</button>
);
}
function TabsContent({
value,
className,
children,
}: {
value: string;
className?: string;
children: React.ReactNode;
}) {
const { value: activeValue } = useTabs();
if (value !== activeValue) return null;
return <div className={cn("ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className)}>{children}</div>;
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface TooltipProps {
content: string;
children: React.ReactNode;
className?: string;
}
export function Tooltip({ content, children, className }: TooltipProps) {
const [show, setShow] = React.useState(false);
return (
<div
className={cn("relative inline-block", className)}
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
>
{children}
{show && (
<div className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs text-white bg-gray-800 rounded whitespace-nowrap">
{content}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800" />
</div>
)}
</div>
);
}

666
src/lib/git-analyzer.ts Normal file
View File

@@ -0,0 +1,666 @@
import { simpleGit } from "simple-git";
import OpenAI from "openai";
const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434/v1";
const OLLAMA_API_URL = "http://localhost:11434/api";
export async function detectOllamaModel(): Promise<string | null> {
// Priority: env var > auto-detect > null
if (process.env.OLLAMA_MODEL) return process.env.OLLAMA_MODEL;
try {
const res = await fetch(`${OLLAMA_API_URL}/tags`, { signal: AbortSignal.timeout(3000) });
if (!res.ok) return null;
const data = await res.json();
const models: string[] = (data.models || []).map((m: any) => m.name);
if (models.length === 0) return null;
// Prefer smaller/faster models first
const preferred = ["gemma4", "gemma3", "gemma", "llama3.2", "llama3", "phi", "qwen", "mistral"];
for (const p of preferred) {
const match = models.find((m) => m.includes(p));
if (match) return match;
}
return models[0];
} catch {
return null;
}
}
export interface GitCommit {
hash: string;
shortHash: string;
author: string;
email: string;
date: Date;
message: string;
files: number;
insertions: number;
deletions: number;
filesList: { path: string; additions: number; deletions: number }[];
}
export interface RepoStats {
repoPath: string;
repoName: string;
totalCommits: number;
totalLinesAdded: number;
totalLinesDeleted: number;
totalFilesChanged: number;
activeDays: number;
contributors: AuthorStats[];
topContributor: AuthorStats;
mostActivePeriod: { type: string; count: number };
commitTypes: CommitTypeStats[];
timeSeries: TimeSeriesData;
heatmap: HeatmapDay[];
fileChanges: FileChangeStats[];
summary: string;
recentCommits: GitCommit[];
}
export interface AuthorStats {
name: string;
email: string;
commits: number;
linesAdded: number;
linesDeleted: number;
daysActive: number;
}
export interface CommitTypeStats {
type: string;
count: number;
percentage: number;
}
export interface TimeSeriesData {
daily: TimePoint[];
weekly: TimePoint[];
monthly: TimePoint[];
}
export interface TimePoint {
date: string;
dateLabel: string;
count: number;
linesAdded: number;
linesDeleted: number;
}
export interface HeatmapDay {
date: string;
day: number;
count: number;
level: number;
}
export interface FileChangeStats {
path: string;
additions: number;
deletions: number;
totalChanges: number;
commits: number;
}
export interface ComparisonData {
repos: RepoStats[];
labels: string[];
commitComparison: { repo: string; commits: number; linesAdded: number; linesDeleted: number }[];
contributorComparison: { name: string; repos: string[] }[];
}
const KNOWN_COMMIT_TYPES = ["feat", "fix", "refactor", "docs", "style", "chore", "perf", "test", "ci", "build", "revert"] as const;
type KnownCommitType = (typeof KNOWN_COMMIT_TYPES)[number];
const COMMIT_TYPE_LABELS: Record<KnownCommitType, string> = {
feat: "Nouveau",
fix: "Correction",
refactor: "Refactorisation",
docs: "Documentation",
style: "Style",
chore: "Tâche",
perf: "Performance",
test: "Test",
ci: "CI/CD",
build: "Build",
revert: "Annulation",
};
const DAYS_FR = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
const MONTHS_FR = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
function analyzeCommitType(msg: string): KnownCommitType {
const lower = msg.toLowerCase().trim();
for (const type of KNOWN_COMMIT_TYPES) {
if (lower.startsWith(`${type}:`) || lower.startsWith(`${type}(`) || lower.startsWith(`[${type}]`)) {
return type;
}
}
for (const type of KNOWN_COMMIT_TYPES) {
if (lower.includes(`${type}:`) || lower.includes(`${type} `)) {
return type;
}
}
return "chore";
}
export function getHeatmapLevel(count: number): number {
if (count === 0) return 0;
if (count <= 3) return 1;
if (count <= 6) return 2;
if (count <= 10) return 3;
if (count <= 20) return 4;
return 5;
}
export function formatHeatmapColor(level: number): string {
const colors = ["#ebedf0", "#c6e48e", "#7bc96f", "#239a3b", "#196127", "#006031"];
return colors[level] || colors[0];
}
export function getContributionsColor(level: number): string {
const colors = ["hsl(var(--muted))", "#9BE9A8", "#40C463", "#30A14E", "#216E3A", "#1E6C25"];
return colors[level] || colors[0];
}
export function formatDateFr(date: Date): string {
const d = date.getDate();
const m = MONTHS_FR[date.getMonth()];
const y = date.getFullYear();
return `${d} ${m} ${y}`;
}
export function formatShortDateFr(date: Date): string {
const d = date.getDate();
const m = MONTHS_FR[date.getMonth()];
return `${d} ${m}`;
}
export function formatNumber(n: number): string {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return n.toString();
}
export function getDaysAgo(date: Date): number {
const now = new Date();
const diff = now.getTime() - date.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
export function getRelevantDates(
commits: GitCommit[],
days: number = 365,
startDate?: Date
) {
const start = startDate
? new Date(startDate)
: new Date();
start.setDate(start.getDate() - days);
start.setHours(0, 0, 0, 0);
return commits.filter((c) => c.date >= start);
}
export async function analyzeGitRepo(
repoPath: string,
branch: string = "main",
days: number = 365,
options: { apiKey?: string; maxCommits?: number; skipAI?: boolean } = {}
): Promise<RepoStats> {
const git = simpleGit(repoPath);
await git.checkIsRepo();
const repo = await simpleGit(repoPath).revparse(["--show-toplevel"]);
const repoName = Array.isArray(repo) ? repo[repo.length - 1] : repo;
// Use git raw with --numstat to get per-file additions/deletions
const maxCount = options.maxCommits ?? 5000;
const gitLogRaw = await git.raw([
"log",
"--numstat",
"-n", String(maxCount),
"--format=%H|||%h|||%an|||%ae|||%aI|||%s",
]);
// Parse: each commit block starts with hash|||... followed by numstat file lines
const relevantCommits: GitCommit[] = [];
const blocks = gitLogRaw.trim().split(/\n(?=[0-9a-f]{40}\|\|\|)/);
for (const block of blocks) {
const lines = block.trim().split("\n");
const header = lines[0];
const parts = header.split("|||");
if (parts.length < 6) continue;
const hash = parts[0];
const shortHash = parts[1];
const author = parts[2];
const email = parts[3];
const date = new Date(parts[4]);
const message = parts[5] || "";
// Parse numstat lines: "additions\tsuppressions\tpath"
let totalAdditions = 0;
let totalDeletions = 0;
const filesList: { path: string; additions: number; deletions: number }[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// numstat format: "additions\tdeletions\tpath" (additions/deletions can be "-" for binary)
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
if (m) {
const adds = m[1] === "-" ? 0 : parseInt(m[1]);
const dels = m[2] === "-" ? 0 : parseInt(m[2]);
const path = m[3];
totalAdditions += adds;
totalDeletions += dels;
filesList.push({ path, additions: adds, deletions: dels });
}
}
const filesChanged = filesList.length;
// Apply date filter if needed
if (days < 365) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
if (date < cutoff) continue;
}
relevantCommits.push({
hash,
shortHash,
author,
email,
date,
message,
files: filesChanged,
insertions: totalAdditions,
deletions: totalDeletions,
filesList,
});
}
const totalCommits = relevantCommits.length;
const totalLinesAdded = relevantCommits.reduce((s, c) => s + c.insertions, 0);
const totalLinesDeleted = relevantCommits.reduce((s, c) => s + c.deletions, 0);
const totalFilesChanged = relevantCommits.reduce((s, c) => s + c.files, 0);
const activeDaysSet = new Set(relevantCommits.map((c) => c.date.toDateString()));
const activeDays = activeDaysSet.size;
const authorMap = new Map<string, AuthorStats>();
const authorDays = new Map<string, Set<string>>();
for (const c of relevantCommits) {
const key = `${c.author}<${c.email}>`;
if (!authorMap.has(key)) {
authorMap.set(key, { name: c.author, email: c.email, commits: 0, linesAdded: 0, linesDeleted: 0, daysActive: 0 });
}
const authorStat = authorMap.get(key)!;
authorStat.commits += 1;
authorStat.linesAdded += c.insertions;
authorStat.linesDeleted += c.deletions;
if (!authorDays.has(key)) authorDays.set(key, new Set());
authorDays.get(key)!.add(c.date.toDateString());
}
for (const [key, days] of authorDays) {
authorMap.get(key)!.daysActive = days.size;
}
const contributors = Array.from(authorMap.values())
.sort((a, b) => b.commits - a.commits);
const topContributor = contributors[0] || { name: "Aucun", email: "", commits: 0, linesAdded: 0, linesDeleted: 0, daysActive: 0 };
const today = new Date();
today.setHours(23, 59, 59, 999);
const dayStart = new Date(today);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(today);
dayEnd.setHours(23, 59, 59, 999);
const weekStart = new Date(today);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
monthStart.setHours(0, 0, 0, 0);
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999);
const commitByDay: Record<string, GitCommit[]> = {};
const commitByWeek: Record<string, GitCommit[]> = {};
const commitByMonth: Record<string, GitCommit[]> = {};
for (const c of relevantCommits) {
const ds = c.date.toISOString().split("T")[0];
if (!commitByDay[ds]) commitByDay[ds] = [];
commitByDay[ds].push(c);
const ws = weekStart.toISOString();
if (c.date >= weekStart && c.date <= weekEnd) {
const wkIdx = Math.floor((c.date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000));
const wkKey = `${weekStart.getFullYear()}-S${String(wkIdx + 1).padStart(2, "0")}`;
if (!commitByWeek[wkKey]) commitByWeek[wkKey] = [];
commitByWeek[wkKey].push(c);
}
const ms = `${c.date.getFullYear()}-${String(c.date.getMonth() + 1).padStart(2, "0")}`;
if (!commitByMonth[ms]) commitByMonth[ms] = [];
commitByMonth[ms].push(c);
}
const todayDayCount = commitByDay[dayStart.toISOString().split("T")[0]]?.length ?? 0;
const weekDayCount = commitByWeek[`${weekStart.getFullYear()}-S${String(Math.floor((today.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000) + 1)).padStart(2, "0")}`]?.length ?? 0;
const monthDayCount = commitByMonth[`${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`]?.length ?? 0;
let mostActivePeriod = { type: "Aujourd'hui", count: todayDayCount };
if (weekDayCount > mostActivePeriod.count) mostActivePeriod = { type: "Cette semaine", count: weekDayCount };
if (monthDayCount > mostActivePeriod.count) mostActivePeriod = { type: "Ce mois", count: monthDayCount };
const daily: TimePoint[] = [];
const now = Date.now();
for (let i = 0; i < days; i++) {
const date = new Date(now - i * 24 * 60 * 60 * 1000);
const key = date.toISOString().split("T")[0];
const count = commitByDay[key]?.length ?? 0;
const ins = commitByDay[key]?.reduce((s, c) => s + c.insertions, 0) ?? 0;
const dels = commitByDay[key]?.reduce((s, c) => s + c.deletions, 0) ?? 0;
daily.push({
date: key,
dateLabel: formatShortDateFr(date),
count,
linesAdded: ins,
linesDeleted: dels,
});
}
for (let i = 0; i < 20; i++) {
const weekStartCalc = new Date(now - i * 7 * 24 * 60 * 60 * 1000);
weekStartCalc.setHours(0, 0, 0, 0);
const weekEndCalc = new Date(weekStartCalc);
weekEndCalc.setDate(weekEndCalc.getDate() + 6);
weekEndCalc.setHours(23, 59, 59, 999);
const weekCommits = relevantCommits.filter((c) => c.date >= weekStartCalc && c.date <= weekEndCalc);
if (weekCommits.length > 0) {
const wkIdx = i + 1;
const wkKey = `${weekStartCalc.getFullYear()}-S${String(Math.ceil((weekStartCalc.getTime() - new Date(weekStartCalc.getFullYear(), 0, 1).getTime()) / (7 * 24 * 60 * 60 * 1000) + 1)).padStart(2, "0")}`;
daily.push({
date: weekStartCalc.toISOString().split("T")[0],
dateLabel: `S${wkIdx} (${formatShortDateFr(weekStartCalc)})`,
count: weekCommits.length,
linesAdded: weekCommits.reduce((s, c) => s + c.insertions, 0),
linesDeleted: weekCommits.reduce((s, c) => s + c.deletions, 0),
});
}
}
const weekly = Object.entries(commitByWeek)
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, commits]) => ({
date: label,
dateLabel: label,
count: commits.length,
linesAdded: commits.reduce((s, c) => s + c.insertions, 0),
linesDeleted: commits.reduce((s, c) => s + c.deletions, 0),
}));
const monthly = Object.entries(commitByMonth)
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, commits]) => {
const [year, monthStr] = label.split("-");
const monthNum = parseInt(monthStr) - 1;
return {
date: label,
dateLabel: `${MONTHS_FR[monthNum]} ${year}`,
count: commits.length,
linesAdded: commits.reduce((s, c) => s + c.insertions, 0),
linesDeleted: commits.reduce((s, c) => s + c.deletions, 0),
};
});
const commitTypeMap = new Map<KnownCommitType, number>();
for (const type of KNOWN_COMMIT_TYPES) commitTypeMap.set(type, 0);
for (const c of relevantCommits) {
const t = analyzeCommitType(c.message);
commitTypeMap.set(t, (commitTypeMap.get(t) || 0) + 1);
}
const commitTypes: CommitTypeStats[] = [];
for (const [type, count] of commitTypeMap.entries()) {
commitTypes.push({
type,
count,
percentage: totalCommits > 0 ? Math.round((count / totalCommits) * 100) : 0,
});
}
commitTypes.sort((a, b) => b.count - a.count);
const heatmap: HeatmapDay[] = [];
for (let i = 0; i < days; i++) {
const date = new Date(now - i * 24 * 60 * 60 * 1000);
const ds = date.toISOString().split("T")[0];
const dayCommits = commitByDay[ds] || [];
const count = dayCommits.length;
const level = getHeatmapLevel(count);
heatmap.push({
date: ds,
day: date.getDay(),
count,
level,
});
}
const fileMap = new Map<string, { additions: number; deletions: number; commits: Set<string> }>();
for (const c of relevantCommits) {
for (const f of c.filesList) {
const key = f.path;
if (!fileMap.has(key)) fileMap.set(key, { additions: 0, deletions: 0, commits: new Set() });
const entry = fileMap.get(key)!;
entry.additions += f.additions;
entry.deletions += f.deletions;
entry.commits.add(c.hash);
}
}
const fileChanges: FileChangeStats[] = Array.from(fileMap.entries())
.map(([path, data]) => ({
path,
additions: data.additions,
deletions: data.deletions,
totalChanges: data.additions + data.deletions,
commits: data.commits.size,
}))
.sort((a, b) => b.totalChanges - a.totalChanges)
.slice(0, 50);
let summary = "";
const daysRange = days;
const avgCommitsPerDay = totalCommits / Math.max(daysRange, 1);
const avgFilesPerCommit = totalFilesChanged / Math.max(totalCommits, 1);
summary += `Le projet "${repoName}" présente ${totalCommits} commits sur les derniers ${daysRange} jours.\n\n`;
summary += `- ${contributors.length} contributeur(s) actif(s)\n`;
summary += `- ${totalLinesAdded} lignes ajoutées (+), ${totalLinesDeleted} lignes supprimées (-)\n`;
summary += `- ${totalFilesChanged} fichiers modifiés\n`;
summary += `- ${activeDays} jours actifs\n`;
summary += `- Moyenne : ${avgCommitsPerDay.toFixed(1)} commits/jour\n`;
summary += `- ${contributors.length > 0 ? topContributor.name : "N/A"} est le contributeur principal\n\n`;
const topType = commitTypes[0];
if (topType) {
summary += `Type de commit le plus fréquent : "${COMMIT_TYPE_LABELS[topType.type as KnownCommitType] || topType.type}" (${topType.count}, ${topType.percentage}%)\n`;
}
const maxActiveWeeks: Array<{ week: number; count: number }> = [];
for (let w = 0; w < 52; w++) {
const wkStart = new Date(now - w * 7 * 24 * 60 * 60 * 1000);
wkStart.setHours(0, 0, 0, 0);
const wkEnd = new Date(wkStart);
wkEnd.setDate(wkEnd.getDate() + 6);
wkEnd.setHours(23, 59, 59, 999);
const count = relevantCommits.filter((c) => c.date >= wkStart && c.date <= wkEnd).length;
if (count > 0) maxActiveWeeks.push({ week: w + 1, count });
}
const topWeek = maxActiveWeeks.sort((a, b) => b.count - a.count)[0];
if (topWeek) {
summary += `Semaine la plus active : S${String(topWeek.week).padStart(2, "0")} (${topWeek.count} commits)\n`;
}
if (avgFilesPerCommit > 3) {
summary += `Les commits sont larges en moyenne (${avgFilesPerCommit.toFixed(1)} fichiers/commit). Il pourrait être utile de découper les modifications.\n`;
}
if (contributors.length > 1) {
const topContribs = contributors.slice(0, 3).map((c) => c.name).join(", ");
summary += `Top contributeurs : ${topContribs}\n`;
}
if (totalCommits === 0) {
summary += "Aucun commit trouvé dans la période sélectionnée.";
}
return {
repoPath,
repoName: repoName.replace(/\/$/, ""),
totalCommits,
totalLinesAdded,
totalLinesDeleted,
totalFilesChanged,
activeDays,
contributors,
topContributor,
mostActivePeriod,
commitTypes,
timeSeries: { daily: daily.reverse(), weekly, monthly },
heatmap,
fileChanges,
summary,
recentCommits: relevantCommits.slice(0, 200),
};
}
export function compareRepos(
repos: Array<{ path: string; name: string }>,
days = 365,
options = {}
): Promise<ComparisonData> {
return Promise.all(
repos.map((repo) =>
analyzeGitRepo(repo.path, undefined, days, options).catch(() => null)
)
).then((results): ComparisonData => {
const validRepos = results.filter((r) => r !== null) as RepoStats[];
return {
repos: validRepos,
labels: repos.map((r) => r.name),
commitComparison: validRepos.map((r) => ({
repo: r.repoName,
commits: r.totalCommits,
linesAdded: r.totalLinesAdded,
linesDeleted: r.totalLinesDeleted,
})),
contributorComparison: [],
};
});
}
export function exportToCSV(stats: RepoStats, filename: string = "git-stats"): string {
const lines: string[] = [];
const sep = ";";
const pushHeader = (headers: string[]) => lines.push(headers.join(sep));
const pushRow = (row: string[]) => lines.push(row.join(sep));
lines.push(`Statistiques Git${sep}${stats.repoName.toUpperCase()}`);
lines.push(`Date${sep}${new Date().toLocaleDateString("fr-FR")}`);
lines.push(``);
lines.push("INDICATEUR VALEUR");
lines.push(`Total commits${sep}${stats.totalCommits}`);
lines.push(`Lignes ajoutées${sep}${stats.totalLinesAdded}`);
lines.push(`Lignes supprimées${sep}${stats.totalLinesDeleted}`);
lines.push(`Fichiers modifiés${sep}${stats.totalFilesChanged}`);
lines.push(`Jours actifs${sep}${stats.activeDays}`);
lines.push(``);
lines.push("CONTRIBUTEURS");
pushHeader(["Nom", "Email", "Commits", "Lignes ajoutées", "Lignes supprimées", "Jours actifs"]);
for (const c of stats.contributors) {
pushRow([c.name, c.email, String(c.commits), String(c.linesAdded), String(c.linesDeleted), String(c.daysActive)]);
}
lines.push(``);
lines.push("TYPES DE COMMITS");
pushHeader(["Type", "Nom français", "Nombre", "Pourcentage"]);
for (const t of stats.commitTypes) {
pushRow([t.type, COMMIT_TYPE_LABELS[t.type as KnownCommitType] || t.type, String(t.count), `${t.percentage}%`]);
}
lines.push(``);
lines.push("MODIFICATIONS DE FICHIERS");
pushHeader(["Fichier", "Ajouts", "Suppressions", "Total", "Commits"]);
for (const f of stats.fileChanges.slice(0, 30)) {
pushRow([f.path, String(f.additions), String(f.deletions), String(f.totalChanges), String(f.commits)]);
}
const content = lines.join("\n");
const bom = "\uFEFF";
return bom + content;
}
// ==================== AI Summary (async, separate from analysis) ====================
export async function generateAISummary(stats: RepoStats, model?: string): Promise<string> {
const selectedModel = model || (await detectOllamaModel());
if (!selectedModel) return "Aucun modèle Ollama disponible. Lancez `ollama pull llama3.2`.";
try {
const openai = new OpenAI({ baseURL: OLLAMA_BASE_URL, apiKey: "ollama" });
// Build context from recent commits
const commitSamples = (stats.recentCommits || []).slice(0, 30)
.map((c) => `[${c.author}] ${c.message}`)
.join("\n");
const prompt = `Tu es un expert en analyse de projets Git. Analyse ce dépôt et donne un résumé en français (5-8 phrases max) :
**Projet** : ${stats.repoName}
**Période** : ${stats.activeDays} jours actifs, ${stats.totalCommits} commits
**Statistiques clés** :
- ${stats.totalLinesAdded} lignes ajoutées / ${stats.totalLinesDeleted} supprimées
- ${stats.contributors.length} contributeur(s) : ${stats.contributors.slice(0, 5).map((c) => c.name).join(", ")}
- Types de commits : ${stats.commitTypes.filter((t) => t.count > 0).map((t) => `${t.type} (${t.percentage}%)`).join(", ")}
- Fichiers modifiés : ${stats.totalFilesChanged}
**Derniers commits** :
${commitSamples}
Réponds avec ce format :
1. 📊 **Activité** : tendance générale
2. 👥 **Équipe** : qui fait quoi
3. 🔧 **Code** : types de changements principaux
4. 💡 **Conseil** : une suggestion pertinente`;
const response = await openai.chat.completions.create({
model: selectedModel,
messages: [{ role: "user" as const, content: prompt }],
max_tokens: 500,
temperature: 0.7,
});
return response.choices[0]?.message?.content?.trim() || "Pas de résumé généré.";
} catch (error: any) {
return `Erreur Ollama (${selectedModel}) : ${error?.message || "inconnue"}. Vérifiez \`ollama pull ${selectedModel}\`.`;
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

63
tailwind.config.js Normal file
View File

@@ -0,0 +1,63 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
};

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}