feat: GitPulse - Git statistics dashboard with Ollama AI summary
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
OPENAI_API_KEY=sk-votre-cle-api-ici
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal 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
6
next.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
3005
package-lock.json
generated
Normal file
3005
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
47
src/app/api/fs/browse/route.ts
Normal file
47
src/app/api/fs/browse/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/api/settings/route.ts
Normal file
32
src/app/api/settings/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
24
src/app/api/stats/route.ts
Normal file
24
src/app/api/stats/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/api/summary/route.ts
Normal file
19
src/app/api/summary/route.ts
Normal 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
80
src/app/globals.css
Normal 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
24
src/app/layout.tsx
Normal 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
419
src/app/page.tsx
Normal 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);
|
||||||
|
}
|
||||||
93
src/components/dashboard/active-days-chart.tsx
Normal file
93
src/components/dashboard/active-days-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
385
src/components/dashboard/commit-history.tsx
Normal file
385
src/components/dashboard/commit-history.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/dashboard/commit-timeline.tsx
Normal file
86
src/components/dashboard/commit-timeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/dashboard/contributor-chart.tsx
Normal file
80
src/components/dashboard/contributor-chart.tsx
Normal 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;
|
||||||
|
}
|
||||||
41
src/components/dashboard/git-commit-stats.tsx
Normal file
41
src/components/dashboard/git-commit-stats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
src/components/dashboard/git-diff.tsx
Normal file
271
src/components/dashboard/git-diff.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/dashboard/index.ts
Normal file
7
src/components/dashboard/index.ts
Normal 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";
|
||||||
164
src/components/dashboard/repo-manager.tsx
Normal file
164
src/components/dashboard/repo-manager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/ui/accordion.tsx
Normal file
46
src/components/ui/accordion.tsx
Normal 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 };
|
||||||
34
src/components/ui/alert.tsx
Normal file
34
src/components/ui/alert.tsx
Normal 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 };
|
||||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal 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 };
|
||||||
78
src/components/ui/card.tsx
Normal file
78
src/components/ui/card.tsx
Normal 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 };
|
||||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal 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 };
|
||||||
24
src/components/ui/progress.tsx
Normal file
24
src/components/ui/progress.tsx
Normal 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 };
|
||||||
25
src/components/ui/select.tsx
Normal file
25
src/components/ui/select.tsx
Normal 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 };
|
||||||
16
src/components/ui/skeleton.tsx
Normal file
16
src/components/ui/skeleton.tsx
Normal 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 };
|
||||||
80
src/components/ui/table.tsx
Normal file
80
src/components/ui/table.tsx
Normal 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 };
|
||||||
95
src/components/ui/tabs.tsx
Normal file
95
src/components/ui/tabs.tsx
Normal 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 };
|
||||||
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal 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
666
src/lib/git-analyzer.ts
Normal 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
6
src/lib/utils.ts
Normal 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
63
tailwind.config.js
Normal 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
21
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user