From 06e49cc0fc8041ca435d58b3b33a1fbd54ebef72 Mon Sep 17 00:00:00 2001 From: NickiCloud Date: Wed, 2 Sep 2026 21:09:15 +0200 Subject: [PATCH] feat: add team telemetry dashboard, byte-level language analytics and tabs [skip ci] --- .gitea/workflows/update-profile.yml | 16 +- README.md | 140 ++++++++---- heatmap.svg | 50 +++-- src/ProfileManager/Models/GiteaRepo.cs | 35 ++- src/ProfileManager/Models/Organization.cs | 30 +++ src/ProfileManager/Models/ProfileStats.cs | 32 ++- src/ProfileManager/Program.cs | 114 ++++++---- src/ProfileManager/Services/GiteaApiClient.cs | 201 +++++++++++------- src/ProfileManager/Services/ReadmeInjector.cs | 140 ++++++++---- .../Services/StatsCalculator.cs | 156 +++++++++----- src/ProfileManager/Services/SvgGenerator.cs | 153 +++++++++++-- team-stats.svg | 109 ++++++++++ 12 files changed, 874 insertions(+), 302 deletions(-) create mode 100644 src/ProfileManager/Models/Organization.cs create mode 100644 team-stats.svg diff --git a/.gitea/workflows/update-profile.yml b/.gitea/workflows/update-profile.yml index 85c8f4d..f927d9a 100644 --- a/.gitea/workflows/update-profile.yml +++ b/.gitea/workflows/update-profile.yml @@ -1,4 +1,4 @@ -name: Update Gitea Profile +name: Update Gitea Profile & Team Telemetry on: schedule: @@ -12,6 +12,7 @@ on: - main paths-ignore: - 'heatmap.svg' + - 'team-stats.svg' - 'README.md' jobs: @@ -27,7 +28,6 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - # Nutzt standardmäßig das automatische Gitea-Token oder ein benutzerdefiniertes Secret token: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }} - name: ⚙️ .NET 10 SDK einrichten @@ -49,17 +49,17 @@ jobs: git config --global user.name "NickiBot[bot]" git config --global user.email "bot@webcore-consulting.dev" - # Prüfen, ob sich heatmap.svg oder README.md geändert haben - if git status --porcelain | grep -E "(heatmap.svg|README.md)"; then - echo "Änderungen an heatmap.svg oder README.md erkannt. Committe..." - git add heatmap.svg README.md + # Prüfen, ob sich SVG-Dateien oder README.md geändert haben + if git status --porcelain | grep -E "(heatmap.svg|team-stats.svg|README.md)"; then + echo "Änderungen an Grafiken oder README.md erkannt. Committe..." + git add heatmap.svg team-stats.svg README.md # GANZ WICHTIG: [skip ci] verhindert Endlosschleifen in Gitea Actions! - git commit -m "chore(profile): update activity heatmap & stats [skip ci]" + git commit -m "chore(profile): update personal & team telemetry [skip ci]" # Änderungen pushen git push - echo "Profil erfolgreich aktualisiert und gepusht! ✨" + echo "Profil & Team-Telemetrie erfolgreich aktualisiert und gepusht! ✨" else echo "Keine Änderungen vorhanden. Alles auf dem neuesten Stand. 👍" fi diff --git a/README.md b/README.md index 521502e..87805b7 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ # 🦊 Hey, ich bin Nicki! -### 🎓 Fachabiturient & Leidenschaftlicher C# / .NET Softwareentwickler -*Building high-performance desktop & web applications with a touch of purple fox magic.* +### 🎓 Fachabiturient & Passionierter C# / .NET Software-Architekt +*High-Performance Desktop Apps, moderne Web-Services & kompromissloses Self-Hosting im lila Fuchs-Style.*
@@ -17,63 +17,130 @@ [![Pop!_OS](https://img.shields.io/badge/Pop!_OS-48B9C7?style=for-the-badge&logo=popos&logoColor=white)](https://pop.system76.com/) [![Debian](https://img.shields.io/badge/Debian-A81D33?style=for-the-badge&logo=debian&logoColor=white)](https://www.debian.org/) [![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white)](https://www.docker.com/) -[![Gitea](https://img.shields.io/badge/Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white)](https://about.gitea.com/) +[![WebCore Consulting](https://img.shields.io/badge/WebCore-Consulting-0284c7?style=for-the-badge&logo=buffer&logoColor=white)](https://www.webcore-consulting.de) --- -### 🐾 "Schlau wie ein Fuchs, schnell wie .NET & kompromisslos self-hosted." +### 🐾 "Schlau wie ein Fuchs, skalierbar wie .NET 10 & kompromisslos self-hosted." -## 🧙‍♂️ Über mich & Meine Mission +## 🧙‍♂️ Über mich, Vision & Tech-Stack -Ich bin **Nicki**, angehender Softwareentwickler mit klarem Fokus auf moderne Software-Architektur im .NET-Ökosystem. Wenn ich nicht gerade sauberen, asynchronen C#-Code in **JetBrains Rider auf Pop!_OS** tippe, optimiere ich meine eigene Linux-Server-Infrastruktur oder erkunde virtuelle Welten. +Ich bin **Nicki (Nicklas)**, angehender Softwareentwickler mit fundiertem Fokus auf performante Softwareentwicklung im **C# / .NET 10** Ökosystem. Neben der Anwendungsentwicklung schlägt mein Herz für Linux-Systemadministration, Containerisierung und sichere Netzwerk-Infrastrukturen. -- 💻 **Mein Fokus:** C#, .NET 10, XAML, Cross-Platform UIs mit **Avalonia UI** und reaktive Web-UIs mit **Blazor & MudBlazor**. -- 🐧 **Mein OS & Dev-Setup:** Rider-Enthusiast auf **Pop!_OS (Linux)** – weil Entwicklungs-Power Freiheit braucht. -- 🏰 **Self-Hosting & Homelab:** Eigener Debian-Server mit Docker-Containern: Gitea, Mailcow, Nginx Reverse-Proxy, abgesichert mit UFW & fail2ban. -- 🎮 **Gaming & Abschalten:** Wenn der Build grün ist, findet man mich in **Minecraft** (Redstone & Modpacks), taktischen Matches in **Rainbow Six Siege** oder beim Farmen in **Warframe**. -- 🦊 **Das Fuchs-Branding:** Lila Akzente, Neugier, Agilität und maximale Eleganz in Code & UI. +- 💻 **Fokus:** C#, .NET 10, Cross-Platform Desktop mit **Avalonia UI**, moderne Web-Apps mit **Blazor / MudBlazor** und modulare Microservices. +- 🏢 **Team & Consulting:** Softwareentwickler bei **WebCore Consulting** – Entwicklung von Management-Tools, Kundenportalen und Security-Services. +- 🐧 **Entwicklungsumgebung:** Vollständiger Linux-Workflow mit **JetBrains Rider auf Pop!_OS** – maximale Produktivität ohne Windows-Overhead. +- 🏰 **Self-Hosted Homelab:** Eigener Debian-Rootserver mit Docker-Containern: Gitea & Gitea Actions, Mailcow Enterprise Suite, Nginx Reverse Proxy mit Let's Encrypt, gehärtet durch UFW & fail2ban. +- 🎮 **Gaming Corner:** Wenn der Build grün ist: Redstone-Komplexe in **Minecraft**, taktische Runden in **Rainbow Six Siege** oder Grinden in **Warframe**. +- 🦊 **Branding:** Lila Fuchs (Purple Fox) – steht für Agilität, Eleganz, Scharfsinn und sauberen Code. ---
-## 📊 Meine Gitea Aktivität & Streak - - -Nicki's Gitea Contribution Heatmap +## 📊 Live Aktivitäts- & Performance-Dashboards
---- +
+

🦊 Tab 1: Persönliche Aktivität & Heatmap

-## 🛠️ Ausgewählte Projekte +> *Vollautomatisch generiert über Gitea Actions & .NET 10 – trackt Commits, Streaks und Jahresbeiträge.* -Hier sind ein paar Highlights aus meiner Entwickler-Schmiede: +
+ Nicki's Gitea Contribution Heatmap +
-| Projekt | Stack | Beschreibung | -| :--- | :---: | :--- | -| 📝 **Markdown Editor Pro** | `Avalonia UI` • `C#` | Eleganter, performanter Desktop-Editor mit Live-Rendering, Math-Support und Theme-Engine. | -| 💧 **CloudDrop** | `ASP.NET Core` • `Docker` | Self-hosted File-Sharing Service für schnelle, verschlüsselte Uploads & temporäre Links. | -| 💳 **KasseApp** | `C#` • `SQLite` • `WPF/Avalonia` | Modulare POS- und Kassenabrechnungs-Software mit Offline-Synchronisation für Events. | -| 🥤 **DrinkReminder** | `C#` • `Avalonia UI` | Schicker, unaufdringlicher Desktop- & Mobile-Helfer für gesunde Hydration im Dev-Alltag. | - ---- - -## 🚀 Zuletzt aktualisierte Repositories - - +#### 📌 Zuletzt aktualisierte persönliche Projekte: - 🚀 [**.profile**](https://git.webcore-consulting.dev/NickiCloud/.profile) – 🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10 `C#` • 🕒 *Aktualisiert am 02.09.2026* +
+ +
+ +
+

🏢 Tab 2: Team- & Organisations-Aktivität (WebCore Consulting)

+ +> *Live-Telemetrie aller Team-Repositories, Code-Volumen und Byte-genaue Sprachverteilung.* + +
+ WebCore Consulting Team Statistics +
+ +#### 📈 Byte-genaue Sprachverteilung (Team-Codebase): + +| Sprache | Anteil | Code-Volumen | Verteilung | +| :--- | :---: | :---: | :--- | +| `C#` | **56,2%** | 1,1 MB | `███████████░░░░░░░░░` | +| `HTML` | **40,4%** | 797,4 KB | `████████░░░░░░░░░░░░` | +| `CSS` | **2,3%** | 45,7 KB | `░░░░░░░░░░░░░░░░░░░░` | +| `JavaScript` | **0,4%** | 8,6 KB | `░░░░░░░░░░░░░░░░░░░░` | +| `PowerShell` | **0,4%** | 7,6 KB | `░░░░░░░░░░░░░░░░░░░░` | +| `Shell` | **0,2%** | 3,4 KB | `░░░░░░░░░░░░░░░░░░░░` | + + +#### 🚀 Aktive Team-Repositories: + +- 🏢 [**WebCore/ServerManagement**](https://git.webcore-consulting.dev/WebCore/ServerManagement) 🔒 – Internes Verwaltungs management System + `HTML` • 🕒 *Zuletzt bearbeitet: 02.09.2026* +- 🏢 [**WebCore/USBSafe**](https://git.webcore-consulting.dev/WebCore/USBSafe) 🔒 – WebCore Consulting Projekt + `C#` • 🕒 *Zuletzt bearbeitet: 02.09.2026* +- 🏢 [**WebCore/ServerHub-redesign**](https://git.webcore-consulting.dev/WebCore/ServerHub-redesign) – WebCore Consulting Projekt + `C# / Web` • 🕒 *Zuletzt bearbeitet: 30.08.2026* + + +
+ +--- + +## 🛠️ Ausgewählte Eigenprojekte + +| Projekt | Stack | Beschreibung | +| :--- | :---: | :--- | +| 📝 **Markdown Editor Pro** | `Avalonia UI` • `C#` | Desktop-Editor mit Live-Rendering, Math-Support und Theme-Engine. | +| 💧 **CloudDrop** | `ASP.NET Core` • `Docker` | Self-hosted File-Sharing Service für schnelle Uploads & temporäre Links. | +| 💳 **KasseApp** | `C#` • `SQLite` • `Avalonia` | Modulares Kassensystem mit Offline-Synchronisation für Events & Vereine. | +| 🥤 **DrinkReminder** | `C#` • `Avalonia UI` | Schicker Desktop- & Mobile-Helfer für gesunde Hydration im Entwickleralltag. | + +--- + +## 🛡️ Homelab & Server-Infrastruktur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 🌐 WebCore & HomeLab Architecture │ +│ │ +│ [ Internet / Cloudflare ] │ +│ │ │ +│ ▼ │ +│ [ Debian 12 Host ] ─── [ UFW & fail2ban Security Wall ] │ +│ │ │ +│ ▼ │ +│ [ Nginx Reverse Proxy (SSL/TLS Termination) ] │ +│ │ │ +│ ┌───────┼─────────────────────────┬──────────────┐ │ +│ ▼ ▼ ▼ ▼ │ +│ [Gitea] [Act-Runner] [Mailcow] [Services] │ +│ (Git) (C# / .NET 10 Actions) (E-Mail) (WebCore) │ +└─────────────────────────────────────────────────────────────┘ +``` + +- **Betriebssystem:** Debian GNU/Linux 12 (Bookworm) +- **Container-Runtime:** Docker Engine & Docker Compose +- **Reverse Proxy:** Nginx mit automatischen Let's Encrypt TLS-Zertifikaten +- **Security:** UFW Firewall, fail2ban (SSH & HTTP Brute-Force Schutz) +- **Monitoring & CI/CD:** Gitea Actions mit dediziertem Docker `act_runner` + ---
-### 🎮 Gaming Corner & Fun Facts +### 🎮 Gaming & Fun Corner [![Minecraft](https://img.shields.io/badge/Minecraft-5B8C33?style=for-the-badge&logo=minecraft&logoColor=white)](https://minecraft.net) [![Rainbow Six Siege](https://img.shields.io/badge/Rainbow_Six_Siege-000000?style=for-the-badge&logo=ubisoft&logoColor=white)](https://www.ubisoft.com/game/rainbow-six/siege) @@ -82,13 +149,14 @@ Hier sind ein paar Highlights aus meiner Entwickler-Schmiede: ```csharp var nicki = new Developer { - Drink = "Kühles Spezi oder Mate", - Editor = "JetBrains Rider", - Terminal = "zsh on Pop!_OS", - Motto = "Refactor early, test continuously, deploy autonomously." + Drink = "Kühles Spezi oder Club-Mate", + Workstation = "Pop!_OS on Linux Kernel", + IDE = "JetBrains Rider 2024+", + Team = "WebCore Consulting", + Motto = "Measure twice, refactor once, ship autonomously." }; ``` -Gebaut mit purer C# .NET 10 Power & Gitea Actions • Design im Lila-Fuchs-Style 💜 +Entwickelt mit C# .NET 10 & Gitea Actions • Design im Lila-Fuchs-Style 💜
diff --git a/heatmap.svg b/heatmap.svg index a9ce4df..d30fe62 100644 --- a/heatmap.svg +++ b/heatmap.svg @@ -71,10 +71,10 @@ - 🦊 NickiCloud Gitea Activity + 🦊 NickiCloud Persönliche Aktivität - 23 Commits im letzten Jahr + 24 Commits im letzten Jahr Sep @@ -1173,7 +1173,7 @@ 01.09.2026: 9 Commits - 02.09.2026: 7 Commits + 02.09.2026: 8 Commits 03.09.2026: 0 Commits @@ -1191,31 +1191,51 @@ - 📦 - 23 - Jahres-Beiträge + 📦 + 24 + Jahres-Beiträge - 🔥 - 4 Tage - Aktueller Streak + 🔥 + 4 Tage + Aktueller Streak - - 4 Tage - Längster Streak + + 4 Tage + Längster Streak - - C#: 100,0% + + C#: 57,0% + + + HTML: 39,7% + + + CSS: 2,3% + + + JavaScript: 0,4% + + + PowerShell: 0,4% - C# 100,0% + C# 57,0% + + HTML 39,7% + + CSS 2,3% + + JavaScript 0,4% + + PowerShell 0,4% diff --git a/src/ProfileManager/Models/GiteaRepo.cs b/src/ProfileManager/Models/GiteaRepo.cs index 27617fa..232e7b9 100644 --- a/src/ProfileManager/Models/GiteaRepo.cs +++ b/src/ProfileManager/Models/GiteaRepo.cs @@ -2,14 +2,32 @@ using System.Text.Json.Serialization; namespace ProfileManager.Models; +public sealed class RepoOwner +{ + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("login")] + public string Login { get; set; } = string.Empty; + + [JsonPropertyName("full_name")] + public string FullName { get; set; } = string.Empty; + + [JsonPropertyName("avatar_url")] + public string AvatarUrl { get; set; } = string.Empty; +} + /// -/// DTO representing a repository in Gitea's /api/v1/users/{user}/repos response. +/// DTO representing a repository in Gitea's /api/v1/repos responses. /// public sealed class GiteaRepo { [JsonPropertyName("id")] public long Id { get; set; } + [JsonPropertyName("owner")] + public RepoOwner? Owner { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; @@ -28,15 +46,30 @@ public sealed class GiteaRepo [JsonPropertyName("fork")] public bool Fork { get; set; } + [JsonPropertyName("private")] + public bool Private { get; set; } + [JsonPropertyName("archived")] public bool Archived { get; set; } + [JsonPropertyName("size")] + public long Size { get; set; } + [JsonPropertyName("stars_count")] public int StarsCount { get; set; } [JsonPropertyName("forks_count")] public int ForksCount { get; set; } + [JsonPropertyName("open_issues_count")] + public int OpenIssuesCount { get; set; } + [JsonPropertyName("updated_at")] public DateTime UpdatedAt { get; set; } + + [JsonIgnore] + public Dictionary DetailedLanguages { get; set; } = []; + + public bool IsOrganizationRepo(string personalUsername) => + Owner != null && !string.Equals(Owner.Login, personalUsername, StringComparison.OrdinalIgnoreCase); } diff --git a/src/ProfileManager/Models/Organization.cs b/src/ProfileManager/Models/Organization.cs new file mode 100644 index 0000000..a465a1a --- /dev/null +++ b/src/ProfileManager/Models/Organization.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace ProfileManager.Models; + +public sealed class GiteaOrganization +{ + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("full_name")] + public string FullName { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("website")] + public string Website { get; set; } = string.Empty; + + [JsonPropertyName("location")] + public string Location { get; set; } = string.Empty; + + [JsonPropertyName("avatar_url")] + public string AvatarUrl { get; set; } = string.Empty; +} diff --git a/src/ProfileManager/Models/ProfileStats.cs b/src/ProfileManager/Models/ProfileStats.cs index ce4d580..a70009d 100644 --- a/src/ProfileManager/Models/ProfileStats.cs +++ b/src/ProfileManager/Models/ProfileStats.cs @@ -4,8 +4,17 @@ public sealed class LanguageStat { public required string Name { get; init; } public required double Percentage { get; init; } + public required long Bytes { get; init; } public required int RepoCount { get; init; } public required string Color { get; init; } + + public string FormattedSize => Bytes switch + { + >= 1024 * 1024 => $"{(double)Bytes / (1024 * 1024):F1} MB", + >= 1024 => $"{(double)Bytes / 1024:F1} KB", + > 0 => $"{Bytes} B", + _ => "0 B" + }; } public sealed class DayContribution @@ -17,12 +26,31 @@ public sealed class DayContribution public required int Row { get; init; } // 0..6 (0=Mo..6=So) } +public sealed class TeamStats +{ + public required string OrganizationName { get; init; } + public required string OrganizationLogin { get; init; } + public required string Website { get; init; } + public required int TotalRepos { get; init; } + public required long TotalCodeBytes { get; init; } + public required List Languages { get; init; } + public required List Repos { get; init; } + + public string FormattedTotalSize => TotalCodeBytes switch + { + >= 1024 * 1024 => $"{(double)TotalCodeBytes / (1024 * 1024):F1} MB", + >= 1024 => $"{(double)TotalCodeBytes / 1024:F1} KB", + _ => $"{TotalCodeBytes} B" + }; +} + public sealed class ProfileStats { public required int TotalContributions365 { get; init; } public required int CurrentStreak { get; init; } public required int LongestStreak { get; init; } public required List Days { get; init; } - public required List TopLanguages { get; init; } - public required List LatestRepos { get; init; } + public required List OverallLanguages { get; init; } + public required List PersonalRepos { get; init; } + public required TeamStats? TeamStats { get; init; } } diff --git a/src/ProfileManager/Program.cs b/src/ProfileManager/Program.cs index 1ec04be..925f8eb 100644 --- a/src/ProfileManager/Program.cs +++ b/src/ProfileManager/Program.cs @@ -11,85 +11,96 @@ internal static class Program Console.WriteLine(""" ==================================================== 🦊 NickiCloud Gitea Profile Manager (.NET 10) - Zero-Dependency Autonomous Activity Engine + Enterprise Team & Activity Engine v2.0 ==================================================== """); - // Parse arguments / environment variables var username = GetConfigValue(args, "--username", "GITEA_USERNAME", "NickiCloud"); var apiUrl = GetConfigValue(args, "--api-url", "GITEA_API_URL", "https://git.webcore-consulting.dev/api/v1"); var token = GetConfigValue(args, "--token", "GITEA_TOKEN", string.Empty); var svgPath = GetConfigValue(args, "--output-svg", "OUTPUT_SVG", "heatmap.svg"); + var teamSvgPath = GetConfigValue(args, "--team-svg", "TEAM_SVG", "team-stats.svg"); var readmePath = GetConfigValue(args, "--readme", "README_PATH", "README.md"); - // Resolve paths relative to working directory or repo root - if (!Path.IsPathRooted(svgPath)) - { - svgPath = Path.GetFullPath(Path.Combine(FindRepoRoot(), svgPath)); - } + var repoRoot = FindRepoRoot(); + if (!Path.IsPathRooted(svgPath)) svgPath = Path.GetFullPath(Path.Combine(repoRoot, svgPath)); + if (!Path.IsPathRooted(teamSvgPath)) teamSvgPath = Path.GetFullPath(Path.Combine(repoRoot, teamSvgPath)); + if (!Path.IsPathRooted(readmePath)) readmePath = Path.GetFullPath(Path.Combine(repoRoot, readmePath)); - if (!Path.IsPathRooted(readmePath)) - { - readmePath = Path.GetFullPath(Path.Combine(FindRepoRoot(), readmePath)); - } - - Console.WriteLine($"[Config] Target User : {username}"); - Console.WriteLine($"[Config] API Endpoint: {apiUrl}"); - Console.WriteLine($"[Config] Output SVG : {svgPath}"); - Console.WriteLine($"[Config] Target README: {readmePath}"); + Console.WriteLine($"[Config] Target User : {username}"); + Console.WriteLine($"[Config] API Endpoint : {apiUrl}"); + Console.WriteLine($"[Config] Heatmap SVG : {svgPath}"); + Console.WriteLine($"[Config] Team Stats SVG : {teamSvgPath}"); + Console.WriteLine($"[Config] Target README : {readmePath}"); Console.WriteLine(); try { var client = new GiteaApiClient(apiUrl, string.IsNullOrWhiteSpace(token) ? null : token); - // Step 1: Fetch data in parallel + // Step 1: Parallel retrieval of Heatmap, Accessible Repos, and Organizations var heatmapTask = client.GetHeatmapAsync(username); - var reposTask = client.GetReposAsync(username); + var reposTask = client.GetAllAccessibleReposAsync(username); + var orgsTask = client.GetUserOrganizationsAsync(username); - await Task.WhenAll(heatmapTask, reposTask); + await Task.WhenAll(heatmapTask, reposTask, orgsTask); var heatmap = await heatmapTask; var repos = await reposTask; + var orgs = await orgsTask; - // Step 2: Calculate metrics - Console.WriteLine("[Stats] Calculating streaks, contributions and language metrics..."); - var stats = StatsCalculator.Calculate(heatmap, repos); + // Step 2: Fetch detailed language byte counts per repository + Console.WriteLine("[API] Fetching byte-level language breakdown for all repositories..."); + var langTasks = repos.Select(async repo => + { + var owner = repo.Owner?.Login ?? (repo.FullName.Contains('/') ? repo.FullName.Split('/')[0] : username); + repo.DetailedLanguages = await client.GetRepoLanguagesAsync(owner, repo.Name); + }); + await Task.WhenAll(langTasks); - Console.WriteLine($"[Stats] Total Contributions (365d): {stats.TotalContributions365}"); - Console.WriteLine($"[Stats] Current Streak : {stats.CurrentStreak} days"); - Console.WriteLine($"[Stats] Longest Streak : {stats.LongestStreak} days"); - Console.WriteLine($"[Stats] Top Languages : {string.Join(", ", stats.TopLanguages.Select(l => $"{l.Name} ({l.Percentage:F1}%)"))}"); - Console.WriteLine($"[Stats] Latest Repositories : {string.Join(", ", stats.LatestRepos.Select(r => r.Name))}"); + // Step 3: Calculate multi-tier statistics + Console.WriteLine("[Stats] Calculating Personal, Team & Byte metrics..."); + var stats = StatsCalculator.Calculate(heatmap, repos, orgs, username); + + Console.WriteLine($"[Stats] 365d Contributions: {stats.TotalContributions365}"); + Console.WriteLine($"[Stats] Current Streak : {stats.CurrentStreak} days"); + Console.WriteLine($"[Stats] Longest Streak : {stats.LongestStreak} days"); + Console.WriteLine($"[Stats] Personal Repos : {stats.PersonalRepos.Count}"); + if (stats.TeamStats != null) + { + Console.WriteLine($"[Stats] Team Org : {stats.TeamStats.OrganizationName}"); + Console.WriteLine($"[Stats] Team Repos : {stats.TeamStats.TotalRepos}"); + Console.WriteLine($"[Stats] Team Code Volume : {stats.TeamStats.FormattedTotalSize}"); + Console.WriteLine($"[Stats] Team Top Languages: {string.Join(", ", stats.TeamStats.Languages.Select(l => $"{l.Name}: {l.Percentage:F1}% ({l.FormattedSize})"))}"); + } Console.WriteLine(); - // Step 3: Generate animated SVG - Console.WriteLine($"[SVG] Rendering responsive SVG..."); - var svgContent = SvgGenerator.Generate(stats); - var svgDir = Path.GetDirectoryName(svgPath); - if (!string.IsNullOrEmpty(svgDir) && !Directory.Exists(svgDir)) - { - Directory.CreateDirectory(svgDir); - } var utf8WithoutBom = new UTF8Encoding(false); - await File.WriteAllTextAsync(svgPath, svgContent, utf8WithoutBom); - Console.WriteLine($"[SVG] Saved successfully to: {svgPath}"); - // Step 4: Inject latest repos into README.md + // Step 4: Generate Heatmap SVG + Console.WriteLine($"[SVG] Rendering Heatmap SVG: {svgPath}"); + var heatmapSvg = SvgGenerator.GenerateHeatmap(stats); + EnsureDirectory(svgPath); + await File.WriteAllTextAsync(svgPath, heatmapSvg, utf8WithoutBom); + + // Step 5: Generate Team Stats SVG + if (stats.TeamStats != null) + { + Console.WriteLine($"[SVG] Rendering Team Stats SVG: {teamSvgPath}"); + var teamSvg = SvgGenerator.GenerateTeamStats(stats.TeamStats); + EnsureDirectory(teamSvgPath); + await File.WriteAllTextAsync(teamSvgPath, teamSvg, utf8WithoutBom); + } + + // Step 6: Inject all sections into README.md if (File.Exists(readmePath)) { - Console.WriteLine($"[README] Injecting latest repositories into: {readmePath}"); - ReadmeInjector.InjectLatestRepos(readmePath, stats.LatestRepos); - } - else - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"[README-WARN] Target README not found at {readmePath}. Skipping injection."); - Console.ResetColor(); + Console.WriteLine($"[README] Injecting personal, team & language metrics into: {readmePath}"); + ReadmeInjector.InjectAll(readmePath, stats); } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine("\n✨ Profile update finished successfully!"); + Console.WriteLine("\n✨ Multi-tier profile update completed successfully!"); Console.ResetColor(); return 0; } @@ -103,6 +114,15 @@ internal static class Program } } + private static void EnsureDirectory(string filePath) + { + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + } + private static string GetConfigValue(string[] args, string flag, string envVar, string defaultValue) { for (int i = 0; i < args.Length - 1; i++) diff --git a/src/ProfileManager/Services/GiteaApiClient.cs b/src/ProfileManager/Services/GiteaApiClient.cs index ebcac5c..58a5b46 100644 --- a/src/ProfileManager/Services/GiteaApiClient.cs +++ b/src/ProfileManager/Services/GiteaApiClient.cs @@ -8,6 +8,7 @@ public sealed class GiteaApiClient { private readonly HttpClient _httpClient; private readonly string _baseUrl; + private readonly bool _hasToken; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true @@ -29,12 +30,13 @@ public sealed class GiteaApiClient Timeout = TimeSpan.FromSeconds(15) }; - _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("NickiProfileBot", "1.0")); + _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("NickiProfileBot", "2.0")); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); var token = apiToken ?? Environment.GetEnvironmentVariable("GITEA_TOKEN"); if (!string.IsNullOrWhiteSpace(token)) { + _hasToken = true; _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); } } @@ -53,28 +55,31 @@ public sealed class GiteaApiClient var items = JsonSerializer.Deserialize>(json, JsonOptions); if (items != null && items.Count > 0) { - Console.WriteLine($"[API] Successfully retrieved {items.Count} heatmap data points."); + Console.WriteLine($"[API] Heatmap: {items.Count} entries retrieved."); return items; } } - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"[API-WARN] Heatmap request failed (Status: {response.StatusCode}). Switching to mock fallback."); + Console.WriteLine($"[API-WARN] Heatmap status: {response.StatusCode}. Using fallback."); Console.ResetColor(); } catch (Exception ex) { Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"[API-WARN] Network error connecting to Gitea ({ex.Message}). Using realistic fallback data."); + Console.WriteLine($"[API-WARN] Heatmap error: {ex.Message}. Using fallback."); Console.ResetColor(); } return GenerateMockHeatmap(); } - public async Task> GetReposAsync(string username, CancellationToken cancellationToken = default) + public async Task> GetAllAccessibleReposAsync(string username, CancellationToken cancellationToken = default) { - var url = $"{_baseUrl}/users/{Uri.EscapeDataString(username)}/repos?limit=100&sort=updated"; + // If authenticated, /user/repos retrieves all personal AND team/organization repos + var url = _hasToken + ? $"{_baseUrl}/user/repos?limit=100&sort=updated" + : $"{_baseUrl}/users/{Uri.EscapeDataString(username)}/repos?limit=100&sort=updated"; + Console.WriteLine($"[API] Fetching repositories from: {url}"); try @@ -86,45 +91,109 @@ public sealed class GiteaApiClient var repos = JsonSerializer.Deserialize>(json, JsonOptions); if (repos != null && repos.Count > 0) { - Console.WriteLine($"[API] Successfully retrieved {repos.Count} repositories."); + Console.WriteLine($"[API] Retrieved {repos.Count} total repositories."); return repos; } } - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"[API-WARN] Repositories request failed (Status: {response.StatusCode}). Switching to mock fallback."); + Console.WriteLine($"[API-WARN] Repos request status: {response.StatusCode}. Using fallback."); Console.ResetColor(); } catch (Exception ex) { Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"[API-WARN] Network error connecting to Gitea ({ex.Message}). Using realistic fallback data."); + Console.WriteLine($"[API-WARN] Repos error: {ex.Message}. Using fallback."); Console.ResetColor(); } return GenerateMockRepos(); } + public async Task> GetUserOrganizationsAsync(string username, CancellationToken cancellationToken = default) + { + var url = _hasToken + ? $"{_baseUrl}/user/orgs" + : $"{_baseUrl}/users/{Uri.EscapeDataString(username)}/orgs"; + + Console.WriteLine($"[API] Fetching organizations from: {url}"); + + try + { + using var response = await _httpClient.GetAsync(url, cancellationToken); + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var orgs = JsonSerializer.Deserialize>(json, JsonOptions); + if (orgs != null && orgs.Count > 0) + { + Console.WriteLine($"[API] Retrieved {orgs.Count} organization(s): {string.Join(", ", orgs.Select(o => o.Name))}"); + return orgs; + } + } + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[API-WARN] Orgs error: {ex.Message}."); + Console.ResetColor(); + } + + return + [ + new GiteaOrganization + { + Id = 3, + Name = "WebCore", + Username = "WebCore", + FullName = "WebCore Consulting", + Website = "https://www.webcore-consulting.de", + Location = "NRW" + } + ]; + } + + public async Task> GetRepoLanguagesAsync(string owner, string repoName, CancellationToken cancellationToken = default) + { + var url = $"{_baseUrl}/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repoName)}/languages"; + try + { + using var response = await _httpClient.GetAsync(url, cancellationToken); + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var langs = JsonSerializer.Deserialize>(json, JsonOptions); + if (langs != null) + { + return langs; + } + } + } + catch + { + // Ignore single repo language errors and return empty + } + + return []; + } + private static List GenerateMockHeatmap() { var items = new List(); var now = DateTimeOffset.UtcNow; - var random = new Random(42); // Fixed seed for reproducible fallback + var random = new Random(42); for (int i = 0; i < 365; i++) { var day = now.AddDays(-i); - // Simulate realistic developer habits (more activity on weekdays and active streaks) bool isWeekend = day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; - int chance = isWeekend ? 40 : 75; + int chance = isWeekend ? 35 : 70; if (random.Next(100) < chance) { - int commits = random.Next(1, 14); items.Add(new HeatmapItem { Timestamp = day.ToUnixTimeSeconds(), - Contributions = commits + Contributions = random.Next(1, 12) }); } } @@ -139,69 +208,47 @@ public sealed class GiteaApiClient [ new GiteaRepo { - Name = "Markdown-Editor-Pro", - FullName = "NickiCloud/Markdown-Editor-Pro", - Description = "Moderner, performanter Markdown-Editor mit Live-Vorschau, Syntax-Highlighting & LaTeX-Support auf Avalonia UI.", - HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/Markdown-Editor-Pro", + Name = "USBSafe", + FullName = "WebCore/USBSafe", + Description = "Sicherheits- & Verschlüsselungs-Tool für Wechseldatenträger im Unternehmensnetzwerk.", + HtmlUrl = "https://git.webcore-consulting.dev/WebCore/USBSafe", Language = "C#", - StarsCount = 7, - ForksCount = 1, - UpdatedAt = now.AddHours(-3) - }, - new GiteaRepo - { - Name = "CloudDrop", - FullName = "NickiCloud/CloudDrop", - Description = "Leichtgewichtiger, selbstgehosteter File-Sharing-Dienst mit temporären Download-Links und verschlüsselter Ablage.", - HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/CloudDrop", - Language = "C#", - StarsCount = 5, - ForksCount = 0, - UpdatedAt = now.AddDays(-2) - }, - new GiteaRepo - { - Name = "KasseApp", - FullName = "NickiCloud/KasseApp", - Description = "Intuitive Kassen- und Abrechnungssoftware für Vereine und Events mit Offline-First SQLite-Datenbank.", - HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/KasseApp", - Language = "C#", - StarsCount = 4, - ForksCount = 0, - UpdatedAt = now.AddDays(-5) - }, - new GiteaRepo - { - Name = "DrinkReminder", - FullName = "NickiCloud/DrinkReminder", - Description = "Smarter Tray-Assistent für Desktop & Mobile zur Erinnerung an regelmäßige Flüssigkeitszufuhr.", - HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/DrinkReminder", - Language = "C#", - StarsCount = 3, - ForksCount = 0, - UpdatedAt = now.AddDays(-12) - }, - new GiteaRepo - { - Name = "Selfhosted-Infra", - FullName = "NickiCloud/Selfhosted-Infra", - Description = "Docker-Compose & Konfigurationen für Debian, Gitea, Mailcow, Nginx Reverse Proxy, UFW & Fail2ban.", - HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/Selfhosted-Infra", - Language = "Shell", - StarsCount = 6, - ForksCount = 0, - UpdatedAt = now.AddDays(-18) - }, - new GiteaRepo - { - Name = "WebCore-Portal", - FullName = "NickiCloud/WebCore-Portal", - Description = "Interaktives Service-Portal mit MudBlazor Komponenten und JWT-Authentifizierung.", - HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/WebCore-Portal", - Language = "HTML", StarsCount = 2, - ForksCount = 0, - UpdatedAt = now.AddDays(-25) + UpdatedAt = now.AddHours(-1), + Owner = new RepoOwner { Login = "WebCore", FullName = "WebCore Consulting" } + }, + new GiteaRepo + { + Name = "ServerManagement", + FullName = "WebCore/ServerManagement", + Description = "Internes Verwaltungs- & Monitoring-System für ServerHub mit MudBlazor & ASP.NET Core.", + HtmlUrl = "https://git.webcore-consulting.dev/WebCore/ServerManagement", + Language = "HTML", + StarsCount = 3, + UpdatedAt = now.AddHours(-4), + Owner = new RepoOwner { Login = "WebCore", FullName = "WebCore Consulting" } + }, + new GiteaRepo + { + Name = "ServerHub-redesign", + FullName = "WebCore/ServerHub-redesign", + Description = "Modernes Frontend-Redesign für das zentrale WebCore-Kundenportal.", + HtmlUrl = "https://git.webcore-consulting.dev/WebCore/ServerHub-redesign", + Language = "C#", + StarsCount = 1, + UpdatedAt = now.AddDays(-3), + Owner = new RepoOwner { Login = "WebCore", FullName = "WebCore Consulting" } + }, + new GiteaRepo + { + Name = ".profile", + FullName = "NickiCloud/.profile", + Description = "🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10", + HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/.profile", + Language = "C#", + StarsCount = 1, + UpdatedAt = now, + Owner = new RepoOwner { Login = "NickiCloud", FullName = "Nicklas Voß" } } ]; } diff --git a/src/ProfileManager/Services/ReadmeInjector.cs b/src/ProfileManager/Services/ReadmeInjector.cs index a2b510a..9ff9c66 100644 --- a/src/ProfileManager/Services/ReadmeInjector.cs +++ b/src/ProfileManager/Services/ReadmeInjector.cs @@ -1,15 +1,20 @@ using System.Text; -using System.Text.RegularExpressions; using ProfileManager.Models; namespace ProfileManager.Services; public static class ReadmeInjector { - private const string StartMarker = ""; - private const string EndMarker = ""; + private const string StartReposMarker = ""; + private const string EndReposMarker = ""; - public static bool InjectLatestRepos(string readmePath, List repos) + private const string StartTeamReposMarker = ""; + private const string EndTeamReposMarker = ""; + + private const string StartLangMarker = ""; + private const string EndLangMarker = ""; + + public static bool InjectAll(string readmePath, ProfileStats stats) { if (!File.Exists(readmePath)) { @@ -20,52 +25,97 @@ public static class ReadmeInjector } var content = File.ReadAllText(readmePath, Encoding.UTF8); - int startIndex = content.IndexOf(StartMarker, StringComparison.Ordinal); - int endIndex = content.IndexOf(EndMarker, StringComparison.Ordinal); + + // 1. Inject Personal Repos + content = ReplaceSection(content, StartReposMarker, EndReposMarker, sb => + { + if (stats.PersonalRepos.Count == 0) + { + sb.AppendLine("*Momentan keine öffentlichen persönlichen Repositories vorhanden.*"); + } + else + { + foreach (var repo in stats.PersonalRepos.Take(3)) + { + var desc = string.IsNullOrWhiteSpace(repo.Description) + ? (repo.Name.Equals(".profile", StringComparison.OrdinalIgnoreCase) + ? "🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10" + : "Persönliches Entwicklungsprojekt.") + : repo.Description.Trim(); + + var lang = string.IsNullOrWhiteSpace(repo.Language) + ? (repo.Name.Equals(".profile", StringComparison.OrdinalIgnoreCase) ? "C# / Markdown" : "Code") + : repo.Language; + var stars = repo.StarsCount > 0 ? $" • ⭐ {repo.StarsCount}" : string.Empty; + var updated = repo.UpdatedAt.ToString("dd.MM.yyyy"); + + sb.AppendLine($"- 🚀 [**{repo.Name}**]({repo.HtmlUrl}) – {desc} "); + sb.AppendLine($" `{lang}`{stars} • 🕒 *Aktualisiert am {updated}*"); + } + } + }); + + // 2. Inject Team Repos + content = ReplaceSection(content, StartTeamReposMarker, EndTeamReposMarker, sb => + { + if (stats.TeamStats == null || stats.TeamStats.Repos.Count == 0) + { + sb.AppendLine("*Keine Team-Repositories zugewiesen.*"); + } + else + { + foreach (var repo in stats.TeamStats.Repos.Take(4)) + { + var desc = string.IsNullOrWhiteSpace(repo.Description) + ? "WebCore Consulting Projekt" + : repo.Description.Trim(); + + var lang = string.IsNullOrWhiteSpace(repo.Language) ? "C# / Web" : repo.Language; + var updated = repo.UpdatedAt.ToString("dd.MM.yyyy"); + var isPrivate = repo.Private ? " 🔒" : string.Empty; + + sb.AppendLine($"- 🏢 [**{repo.FullName}**]({repo.HtmlUrl}){isPrivate} – {desc} "); + sb.AppendLine($" `{lang}` • 🕒 *Zuletzt bearbeitet: {updated}*"); + } + } + }); + + // 3. Inject Language Metrics Table / Progress Bars + content = ReplaceSection(content, StartLangMarker, EndLangMarker, sb => + { + var langs = stats.TeamStats?.Languages ?? stats.OverallLanguages; + sb.AppendLine("| Sprache | Anteil | Code-Volumen | Verteilung |"); + sb.AppendLine("| :--- | :---: | :---: | :--- |"); + + foreach (var l in langs.Take(6)) + { + int filledBlocks = (int)Math.Round(l.Percentage / 5.0); + string bar = new string('█', filledBlocks).PadRight(20, '░'); + sb.AppendLine($"| `{l.Name}` | **{l.Percentage:F1}%** | {l.FormattedSize} | `{bar}` |"); + } + }); + + var utf8WithoutBom = new UTF8Encoding(false); + File.WriteAllText(readmePath, content, utf8WithoutBom); + Console.WriteLine($"[README] Successfully updated all dynamic sections in {readmePath}"); + return true; + } + + private static string ReplaceSection(string source, string startMarker, string endMarker, Action builderAction) + { + int startIndex = source.IndexOf(startMarker, StringComparison.Ordinal); + int endIndex = source.IndexOf(endMarker, StringComparison.Ordinal); if (startIndex == -1 || endIndex == -1 || endIndex < startIndex) { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"[README-WARN] Markers '{StartMarker}' and '{EndMarker}' not found in {readmePath}"); - Console.ResetColor(); - return false; + return source; } - var replacement = new StringBuilder(); - replacement.AppendLine(StartMarker); + var sb = new StringBuilder(); + sb.AppendLine(startMarker); + builderAction(sb); + sb.Append(endMarker); - if (repos.Count == 0) - { - replacement.AppendLine("*Momentan keine öffentlichen Repositories verfügbar.*"); - } - else - { - foreach (var repo in repos) - { - var desc = string.IsNullOrWhiteSpace(repo.Description) - ? (repo.Name.Equals(".profile", StringComparison.OrdinalIgnoreCase) - ? "🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10" - : "Persönliches Entwicklungsprojekt.") - : repo.Description.Trim(); - - var lang = string.IsNullOrWhiteSpace(repo.Language) - ? (repo.Name.Equals(".profile", StringComparison.OrdinalIgnoreCase) ? "C# / Markdown" : "Code") - : repo.Language; - var stars = repo.StarsCount > 0 ? $" • ⭐ {repo.StarsCount}" : string.Empty; - var updated = repo.UpdatedAt.ToString("dd.MM.yyyy"); - - replacement.AppendLine($"- 🚀 [**{repo.Name}**]({repo.HtmlUrl}) – {desc} "); - replacement.AppendLine($" `{lang}`{stars} • 🕒 *Aktualisiert am {updated}*"); - } - } - - replacement.Append(EndMarker); - - var newContent = content.Substring(0, startIndex) + replacement + content.Substring(endIndex + EndMarker.Length); - - var utf8WithoutBom = new UTF8Encoding(false); - File.WriteAllText(readmePath, newContent, utf8WithoutBom); - Console.WriteLine($"[README] Successfully injected {repos.Count} repositories into {readmePath}"); - return true; + return source.Substring(0, startIndex) + sb + source.Substring(endIndex + endMarker.Length); } } diff --git a/src/ProfileManager/Services/StatsCalculator.cs b/src/ProfileManager/Services/StatsCalculator.cs index 1bade9d..35338de 100644 --- a/src/ProfileManager/Services/StatsCalculator.cs +++ b/src/ProfileManager/Services/StatsCalculator.cs @@ -4,33 +4,34 @@ namespace ProfileManager.Services; public static class StatsCalculator { - private static readonly Dictionary LanguageColors = new(StringComparer.OrdinalIgnoreCase) + public static readonly Dictionary LanguageColors = new(StringComparer.OrdinalIgnoreCase) { { "C#", "#a855f7" }, - { "C++", "#f34b7d" }, - { "C", "#555555" }, { "HTML", "#e34c26" }, { "CSS", "#563d7c" }, { "JavaScript", "#f1e05a" }, { "TypeScript", "#3178c6" }, - { "Python", "#3572A5" }, + { "PowerShell", "#012456" }, { "Shell", "#89e051" }, { "Bash", "#89e051" }, { "Dockerfile", "#384d54" }, + { "Python", "#3572A5" }, { "Go", "#00ADD8" }, { "Rust", "#dea584" }, { "Vue", "#41b883" }, - { "PHP", "#4F5D95" }, - { "Kotlin", "#A97BFF" }, - { "Java", "#b07219" }, - { "Dart", "#00B4AB" } + { "XAML", "#b53d9e" } }; - public static ProfileStats Calculate(List heatmapItems, List repos, DateOnly? referenceDate = null) + public static ProfileStats Calculate( + List heatmapItems, + List allRepos, + List orgs, + string personalUsername = "NickiCloud", + DateOnly? referenceDate = null) { var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow); - // 1. Group heatmap contributions by DateOnly (UTC) + // 1. Heatmap Contributions Map var dateMap = new Dictionary(); foreach (var item in heatmapItems) { @@ -38,7 +39,7 @@ public static class StatsCalculator dateMap[date] = dateMap.GetValueOrDefault(date, 0) + Math.Max(0, item.Contributions); } - // 2. Build 52x7 Grid ending on Sunday of current week (Monday-based rows 0..6) + // 2. 52x7 Heatmap Grid int dayOffsetFromMonday = ((int)today.DayOfWeek + 6) % 7; var gridEndSunday = today.AddDays(6 - dayOffsetFromMonday); var gridStartMonday = gridEndSunday.AddDays(-(52 * 7 - 1)); @@ -70,13 +71,10 @@ public static class StatsCalculator } } - // 3. Total contributions (last 365 days) + // 3. Totals and Streaks var oneYearAgo = today.AddDays(-365); - int total365 = dateMap - .Where(kv => kv.Key >= oneYearAgo && kv.Key <= today) - .Sum(kv => kv.Value); + int total365 = dateMap.Where(kv => kv.Key >= oneYearAgo && kv.Key <= today).Sum(kv => kv.Value); - // 4. Streaks calculation int currentStreak = 0; if (dateMap.GetValueOrDefault(today, 0) > 0) { @@ -90,7 +88,6 @@ public static class StatsCalculator } else { - // If today has no commit yet, yesterday's streak is still active var check = today.AddDays(-1); while (dateMap.GetValueOrDefault(check, 0) > 0) { @@ -99,7 +96,6 @@ public static class StatsCalculator } } - // Longest Streak across all dates int longestStreak = 0; if (dateMap.Count > 0) { @@ -121,50 +117,41 @@ public static class StatsCalculator } } - // 5. Top 3 Repos (sorted by UpdatedAt desc, exclude forks) - var nonForkRepos = repos.Where(r => !r.Fork).ToList(); - var candidateRepos = nonForkRepos.Count > 0 ? nonForkRepos : repos; - - var latestRepos = candidateRepos + // 4. Split Personal vs. Team Repos + var personalRepos = allRepos + .Where(r => !r.IsOrganizationRepo(personalUsername)) .OrderByDescending(r => r.UpdatedAt) - .Take(3) .ToList(); - // 6. Top Languages calculation - var languageGroups = candidateRepos - .Where(r => !string.IsNullOrWhiteSpace(r.Language)) - .GroupBy(r => r.Language!.Trim()) - .Select(g => new { Name = g.Key, Count = g.Count() }) - .OrderByDescending(x => x.Count) + var teamRepos = allRepos + .Where(r => r.IsOrganizationRepo(personalUsername)) + .OrderByDescending(r => r.UpdatedAt) .ToList(); - int totalLanguageRepos = languageGroups.Sum(x => x.Count); - var topLanguages = new List(); + // 5. Calculate Languages (from Byte Breakdown) + var overallLanguages = AggregateLanguages(allRepos); + var teamLanguages = AggregateLanguages(teamRepos); - if (totalLanguageRepos > 0) + // 6. Build TeamStats + TeamStats? teamStats = null; + if (teamRepos.Count > 0 || orgs.Count > 0) { - foreach (var lang in languageGroups.Take(5)) + var mainOrg = orgs.FirstOrDefault(); + string orgName = mainOrg?.FullName ?? mainOrg?.Name ?? "WebCore Consulting"; + string orgLogin = mainOrg?.Username ?? "WebCore"; + string website = string.IsNullOrWhiteSpace(mainOrg?.Website) ? "https://www.webcore-consulting.de" : mainOrg.Website; + + long totalBytes = teamLanguages.Sum(l => l.Bytes); + teamStats = new TeamStats { - double pct = Math.Round((double)lang.Count / totalLanguageRepos * 100.0, 1); - topLanguages.Add(new LanguageStat - { - Name = lang.Name, - Percentage = pct, - RepoCount = lang.Count, - Color = LanguageColors.GetValueOrDefault(lang.Name, "#c084fc") - }); - } - } - else - { - // Default language representation if repos have no language tags - topLanguages.Add(new LanguageStat - { - Name = "C#", - Percentage = 100.0, - RepoCount = 1, - Color = "#a855f7" - }); + OrganizationName = orgName, + OrganizationLogin = orgLogin, + Website = website, + TotalRepos = teamRepos.Count, + TotalCodeBytes = totalBytes, + Languages = teamLanguages, + Repos = teamRepos + }; } return new ProfileStats @@ -173,8 +160,65 @@ public static class StatsCalculator CurrentStreak = currentStreak, LongestStreak = Math.Max(longestStreak, currentStreak), Days = days, - TopLanguages = topLanguages, - LatestRepos = latestRepos + OverallLanguages = overallLanguages, + PersonalRepos = personalRepos, + TeamStats = teamStats }; } + + private static List AggregateLanguages(List repos) + { + var byteMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + var countMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var repo in repos) + { + if (repo.DetailedLanguages.Count > 0) + { + foreach (var (lang, bytes) in repo.DetailedLanguages) + { + byteMap[lang] = byteMap.GetValueOrDefault(lang, 0L) + bytes; + countMap[lang] = countMap.GetValueOrDefault(lang, 0) + 1; + } + } + else if (!string.IsNullOrWhiteSpace(repo.Language)) + { + // Fallback estimate if no byte details: 100KB per repo + byteMap[repo.Language] = byteMap.GetValueOrDefault(repo.Language, 0L) + 100_000L; + countMap[repo.Language] = countMap.GetValueOrDefault(repo.Language, 0) + 1; + } + } + + long totalBytes = byteMap.Values.Sum(); + var result = new List(); + + if (totalBytes > 0) + { + foreach (var (lang, bytes) in byteMap.OrderByDescending(kv => kv.Value)) + { + double pct = Math.Round((double)bytes / totalBytes * 100.0, 1); + result.Add(new LanguageStat + { + Name = lang, + Percentage = pct, + Bytes = bytes, + RepoCount = countMap.GetValueOrDefault(lang, 1), + Color = LanguageColors.GetValueOrDefault(lang, "#c084fc") + }); + } + } + else + { + result.Add(new LanguageStat + { + Name = "C#", + Percentage = 100.0, + Bytes = 500_000L, + RepoCount = 1, + Color = "#a855f7" + }); + } + + return result; + } } diff --git a/src/ProfileManager/Services/SvgGenerator.cs b/src/ProfileManager/Services/SvgGenerator.cs index 6f2d1f2..7960040 100644 --- a/src/ProfileManager/Services/SvgGenerator.cs +++ b/src/ProfileManager/Services/SvgGenerator.cs @@ -11,7 +11,7 @@ public static class SvgGenerator "Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez" ]; - public static string Generate(ProfileStats stats) + public static string GenerateHeatmap(ProfileStats stats) { var sb = new StringBuilder(16384); @@ -104,9 +104,8 @@ public static class SvgGenerator // Header: Title & Total Commits Badge sb.AppendLine(" "); sb.AppendLine(" "); - sb.AppendLine(" 🦊 NickiCloud Gitea Activity"); + sb.AppendLine(" 🦊 NickiCloud Persönliche Aktivität"); - // Header badge (right aligned) string badgeText = $"{stats.TotalContributions365:N0} Commits im letzten Jahr"; sb.AppendLine($" "); sb.AppendLine($" "); @@ -122,7 +121,6 @@ public static class SvgGenerator if (firstDayOfCol != null && firstDayOfCol.Date.Month != lastMonth) { lastMonth = firstDayOfCol.Date.Month; - // Avoid rendering month if too close to right edge if (col < 50) { int x = gridStartX + col * colStep; @@ -131,7 +129,7 @@ public static class SvgGenerator } } - // Day of Week Labels (Mo, Mi, Fr) + // Weekday Labels (Mo, Mi, Fr) sb.AppendLine(" "); sb.AppendLine($" Mo"); sb.AppendLine($" Mi"); @@ -160,13 +158,8 @@ public static class SvgGenerator int cardWidth = 216; int gap = 14; - // Card 1: Total RenderMetricCard(sb, gridStartX, cardY, cardWidth, cardHeight, "📦", $"{stats.TotalContributions365:N0}", "Jahres-Beiträge"); - - // Card 2: Current Streak RenderMetricCard(sb, gridStartX + cardWidth + gap, cardY, cardWidth, cardHeight, "🔥", $"{stats.CurrentStreak} {(stats.CurrentStreak == 1 ? "Tag" : "Tage")}", "Aktueller Streak"); - - // Card 3: Longest Streak RenderMetricCard(sb, gridStartX + (cardWidth + gap) * 2, cardY, cardWidth, cardHeight, "⚡", $"{stats.LongestStreak} {(stats.LongestStreak == 1 ? "Tag" : "Tage")}", "Längster Streak"); // Top Languages Bar (width = 676px) @@ -175,7 +168,7 @@ public static class SvgGenerator double currentX = gridStartX; const double totalBarWidth = 676.0; - foreach (var lang in stats.TopLanguages) + foreach (var lang in stats.OverallLanguages.Take(5)) { double w = Math.Max(2.0, (lang.Percentage / 100.0) * totalBarWidth); sb.AppendLine($" "); @@ -189,7 +182,7 @@ public static class SvgGenerator sb.AppendLine(" "); sb.AppendLine(" "); int legendX = 0; - foreach (var lang in stats.TopLanguages) + foreach (var lang in stats.OverallLanguages.Take(5)) { sb.AppendLine($" "); string text = $"{lang.Name} {lang.Percentage:F1}%"; @@ -202,13 +195,143 @@ public static class SvgGenerator return sb.ToString(); } + public static string GenerateTeamStats(TeamStats team) + { + var sb = new StringBuilder(16384); + + const int svgWidth = 740; + const int svgHeight = 240; + const int gridStartX = 34; + const double totalBarWidth = 676.0; + + sb.AppendLine(""""""); + sb.AppendLine($""""""); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine($" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + + sb.AppendLine(""" + + """); + + // Background Panel + sb.AppendLine($" "); + + // Header: Team Title & Badge + sb.AppendLine(" "); + sb.AppendLine($" 🏢 {team.OrganizationName} Team & Projekt-Statistiken"); + + string badgeText = $"{team.TotalRepos} Team-Repositories"; + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" {badgeText}"); + sb.AppendLine(" "); + + // Metric Badges (Row 1) + int cardY = 56; + int cardHeight = 44; + int cardWidth = 216; + int gap = 14; + + var topLang = team.Languages.FirstOrDefault(); + string primaryLangStr = topLang != null ? $"{topLang.Name} ({topLang.Percentage:F1}%)" : "C#"; + + RenderMetricCard(sb, gridStartX, cardY, cardWidth, cardHeight, "📁", $"{team.TotalRepos} Repos", "Aktive Team-Projekte"); + RenderMetricCard(sb, gridStartX + cardWidth + gap, cardY, cardWidth, cardHeight, "💾", team.FormattedTotalSize, "Gesamter Team-Code"); + RenderMetricCard(sb, gridStartX + (cardWidth + gap) * 2, cardY, cardWidth, cardHeight, "⚡", primaryLangStr, "Hauptsprache im Team"); + + // Team Languages Bar + sb.AppendLine(" "); + sb.AppendLine(" "); + double currentX = gridStartX; + foreach (var lang in team.Languages) + { + double w = Math.Max(3.0, (lang.Percentage / 100.0) * totalBarWidth); + sb.AppendLine($" "); + sb.AppendLine($" {lang.Name}: {lang.Percentage:F1}% ({lang.FormattedSize})"); + sb.AppendLine(" "); + currentX += w; + } + sb.AppendLine(" "); + + // Team Languages Legend (Detailed with Byte Sizes) + sb.AppendLine(" "); + sb.AppendLine(" "); + int legendX = 0; + int legendY = 0; + int count = 0; + + foreach (var lang in team.Languages.Take(6)) + { + sb.AppendLine($" "); + string text = $"{lang.Name}: {lang.Percentage:F1}% ({lang.FormattedSize})"; + sb.AppendLine($" {text}"); + + count++; + if (count % 3 == 0) + { + legendX = 0; + legendY += 18; + } + else + { + legendX += 225; + } + } + sb.AppendLine(" "); + + // Bottom active repos pills + sb.AppendLine(" "); + sb.AppendLine(" "); + string repoPills = string.Join(" • ", team.Repos.Take(3).Select(r => $"🚀 {r.Name} ({r.Language ?? "Code"})")); + sb.AppendLine($" Team-Repositories: {repoPills}"); + sb.AppendLine(" "); + + sb.AppendLine(""); + return sb.ToString(); + } + private static void RenderMetricCard(StringBuilder sb, int x, int y, int w, int h, string icon, string value, string label) { sb.AppendLine($" "); sb.AppendLine($" "); - sb.AppendLine($" {icon}"); - sb.AppendLine($" {value}"); - sb.AppendLine($" {label}"); + sb.AppendLine($" {icon}"); + sb.AppendLine($" {value}"); + sb.AppendLine($" {label}"); sb.AppendLine(" "); } } diff --git a/team-stats.svg b/team-stats.svg new file mode 100644 index 0000000..470eda2 --- /dev/null +++ b/team-stats.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + 🏢 WebCore Consulting Team & Projekt-Statistiken + + + 3 Team-Repositories + + + + 📁 + 3 Repos + Aktive Team-Projekte + + + + 💾 + 1,9 MB + Gesamter Team-Code + + + + + C# (56,2%) + Hauptsprache im Team + + + + + C#: 56,2% (1,1 MB) + + + HTML: 40,4% (797,4 KB) + + + CSS: 2,3% (45,7 KB) + + + JavaScript: 0,4% (8,6 KB) + + + PowerShell: 0,4% (7,6 KB) + + + Shell: 0,2% (3,4 KB) + + + Dockerfile: 0,1% (2,4 KB) + + + + + + C#: 56,2% (1,1 MB) + + HTML: 40,4% (797,4 KB) + + CSS: 2,3% (45,7 KB) + + JavaScript: 0,4% (8,6 KB) + + PowerShell: 0,4% (7,6 KB) + + Shell: 0,2% (3,4 KB) + + + + Team-Repositories: 🚀 ServerManagement (HTML) • 🚀 USBSafe (C#) • 🚀 ServerHub-redesign () + +