feat: add team telemetry dashboard, byte-level language analytics and tabs [skip ci]

This commit is contained in:
NickiCloud 2026-09-02 21:09:15 +02:00
parent 6f898088f2
commit 06e49cc0fc
12 changed files with 874 additions and 302 deletions

View File

@ -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

140
README.md
View File

@ -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.*
<br/>
@ -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."
</div>
## 🧙‍♂️ Ü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.
---
<div align="center">
## 📊 Meine Gitea Aktivität & Streak
<!-- Die Heatmap wird vollautomatisch per Gitea Action mit .NET 10 generiert -->
<img src="heatmap.svg" alt="Nicki's Gitea Contribution Heatmap" width="100%" />
## 📊 Live Aktivitäts- & Performance-Dashboards
</div>
---
<details open>
<summary><h3>🦊 Tab 1: Persönliche Aktivität &amp; Heatmap</h3></summary>
## 🛠️ 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:
<div align="center">
<img src="heatmap.svg" alt="Nicki's Gitea Contribution Heatmap" width="100%" />
</div>
| 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
<!-- Dieser Bereich wird dynamisch von der C# .NET 10 Konsolen-App aktualisiert -->
#### 📌 Zuletzt aktualisierte persönliche Projekte:
<!-- START_LATEST_REPOS -->
- 🚀 [**.profile**](https://git.webcore-consulting.dev/NickiCloud/.profile) 🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10
`C#` • 🕒 *Aktualisiert am 02.09.2026*
<!-- END_LATEST_REPOS -->
</details>
<br/>
<details open>
<summary><h3>🏢 Tab 2: Team- &amp; Organisations-Aktivität (WebCore Consulting)</h3></summary>
> *Live-Telemetrie aller Team-Repositories, Code-Volumen und Byte-genaue Sprachverteilung.*
<div align="center">
<img src="team-stats.svg" alt="WebCore Consulting Team Statistics" width="100%" />
</div>
#### 📈 Byte-genaue Sprachverteilung (Team-Codebase):
<!-- START_LANGUAGE_METRICS -->
| 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 | `░░░░░░░░░░░░░░░░░░░░` |
<!-- END_LANGUAGE_METRICS -->
#### 🚀 Aktive Team-Repositories:
<!-- START_TEAM_REPOS -->
- 🏢 [**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*
<!-- END_TEAM_REPOS -->
</details>
---
## 🛠️ 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`
---
<div align="center">
### 🎮 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."
};
```
<sub>Gebaut mit purer C# .NET 10 Power & Gitea Actions • Design im Lila-Fuchs-Style 💜</sub>
<sub>Entwickelt mit C# .NET 10 & Gitea Actions • Design im Lila-Fuchs-Style 💜</sub>
</div>

View File

@ -71,10 +71,10 @@
<rect class="bg-panel" x="1" y="1" width="738" height="263" />
<g transform="translate(20, 20)">
<!-- Fox Icon & Title -->
<text class="title" x="0" y="14">🦊 NickiCloud <tspan font-weight="400" fill="var(--accent)"></tspan> <tspan font-weight="500" font-size="12" fill="var(--text-sub)">Gitea Activity</tspan></text>
<text class="title" x="0" y="14">🦊 NickiCloud <tspan font-weight="400" fill="var(--accent)"></tspan> <tspan font-weight="500" font-size="12" fill="var(--text-sub)">Persönliche Aktivität</tspan></text>
<rect x="510" y="0" width="190" height="22" rx="11" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" />
<circle cx="522" cy="11" r="4" fill="var(--accent)" />
<text class="subtitle" x="532" y="15" font-weight="600">23 Commits im letzten Jahr</text>
<text class="subtitle" x="532" y="15" font-weight="600">24 Commits im letzten Jahr</text>
</g>
<!-- Month Labels -->
<text class="lbl-month" x="34" y="54">Sep</text>
@ -1173,7 +1173,7 @@
<title>01.09.2026: 9 Commits</title>
</rect>
<rect class="cell lvl-3" x="697" y="90" width="10" height="10" rx="2" style="animation-delay: 612ms;">
<title>02.09.2026: 7 Commits</title>
<title>02.09.2026: 8 Commits</title>
</rect>
<rect class="cell lvl-0" x="697" y="103" width="10" height="10" rx="2" style="animation-delay: 612ms;">
<title>03.09.2026: 0 Commits</title>
@ -1191,31 +1191,51 @@
<!-- Stat Badges -->
<g transform="translate(34, 166)">
<rect width="216" height="36" rx="8" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" />
<text x="12" y="23" font-size="16">📦</text>
<text class="stat-val" x="38" y="17">23</text>
<text class="stat-lbl" x="38" y="29">Jahres-Beiträge</text>
<text x="12" y="27" font-size="16">📦</text>
<text class="stat-val" x="40" y="20">24</text>
<text class="stat-lbl" x="40" y="33">Jahres-Beiträge</text>
</g>
<g transform="translate(264, 166)">
<rect width="216" height="36" rx="8" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" />
<text x="12" y="23" font-size="16">🔥</text>
<text class="stat-val" x="38" y="17">4 Tage</text>
<text class="stat-lbl" x="38" y="29">Aktueller Streak</text>
<text x="12" y="27" font-size="16">🔥</text>
<text class="stat-val" x="40" y="20">4 Tage</text>
<text class="stat-lbl" x="40" y="33">Aktueller Streak</text>
</g>
<g transform="translate(494, 166)">
<rect width="216" height="36" rx="8" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" />
<text x="12" y="23" font-size="16"></text>
<text class="stat-val" x="38" y="17">4 Tage</text>
<text class="stat-lbl" x="38" y="29">Längster Streak</text>
<text x="12" y="27" font-size="16"></text>
<text class="stat-val" x="40" y="20">4 Tage</text>
<text class="stat-lbl" x="40" y="33">Längster Streak</text>
</g>
<!-- Top Languages Bar -->
<g clip-path="url(#langBarClip)">
<rect x="34.0" y="215" width="676.0" height="8" fill="#a855f7">
<title>C#: 100,0%</title>
<rect x="34.0" y="215" width="385.3" height="8" fill="#a855f7">
<title>C#: 57,0%</title>
</rect>
<rect x="419.3" y="215" width="268.4" height="8" fill="#e34c26">
<title>HTML: 39,7%</title>
</rect>
<rect x="687.7" y="215" width="15.5" height="8" fill="#563d7c">
<title>CSS: 2,3%</title>
</rect>
<rect x="703.2" y="215" width="2.7" height="8" fill="#f1e05a">
<title>JavaScript: 0,4%</title>
</rect>
<rect x="705.9" y="215" width="2.7" height="8" fill="#012456">
<title>PowerShell: 0,4%</title>
</rect>
</g>
<!-- Language Legend -->
<g transform="translate(34, 238)">
<circle cx="4" cy="4" r="4" fill="#a855f7" />
<text class="lang-lbl" x="13" y="7">C# 100,0%</text>
<text class="lang-lbl" x="13" y="7">C# 57,0%</text>
<circle cx="89" cy="4" r="4" fill="#e34c26" />
<text class="lang-lbl" x="98" y="7">HTML 39,7%</text>
<circle cx="188" cy="4" r="4" fill="#563d7c" />
<text class="lang-lbl" x="197" y="7">CSS 2,3%</text>
<circle cx="273" cy="4" r="4" fill="#f1e05a" />
<text class="lang-lbl" x="282" y="7">JavaScript 0,4%</text>
<circle cx="407" cy="4" r="4" fill="#012456" />
<text class="lang-lbl" x="416" y="7">PowerShell 0,4%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -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;
}
/// <summary>
/// DTO representing a repository in Gitea's /api/v1/users/{user}/repos response.
/// DTO representing a repository in Gitea's /api/v1/repos responses.
/// </summary>
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<string, long> DetailedLanguages { get; set; } = [];
public bool IsOrganizationRepo(string personalUsername) =>
Owner != null && !string.Equals(Owner.Login, personalUsername, StringComparison.OrdinalIgnoreCase);
}

View File

@ -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;
}

View File

@ -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<LanguageStat> Languages { get; init; }
public required List<GiteaRepo> 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<DayContribution> Days { get; init; }
public required List<LanguageStat> TopLanguages { get; init; }
public required List<GiteaRepo> LatestRepos { get; init; }
public required List<LanguageStat> OverallLanguages { get; init; }
public required List<GiteaRepo> PersonalRepos { get; init; }
public required TeamStats? TeamStats { get; init; }
}

View File

@ -11,31 +11,26 @@ 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));
}
if (!Path.IsPathRooted(readmePath))
{
readmePath = Path.GetFullPath(Path.Combine(FindRepoRoot(), readmePath));
}
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));
Console.WriteLine($"[Config] Target User : {username}");
Console.WriteLine($"[Config] API Endpoint : {apiUrl}");
Console.WriteLine($"[Config] Output SVG : {svgPath}");
Console.WriteLine($"[Config] Heatmap SVG : {svgPath}");
Console.WriteLine($"[Config] Team Stats SVG : {teamSvgPath}");
Console.WriteLine($"[Config] Target README : {readmePath}");
Console.WriteLine();
@ -43,53 +38,69 @@ internal static class Program
{
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}");
// 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] 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))}");
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++)

View File

@ -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<List<HeatmapItem>>(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<List<GiteaRepo>> GetReposAsync(string username, CancellationToken cancellationToken = default)
public async Task<List<GiteaRepo>> 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<List<GiteaRepo>>(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<List<GiteaOrganization>> 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<List<GiteaOrganization>>(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<Dictionary<string, long>> 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<Dictionary<string, long>>(json, JsonOptions);
if (langs != null)
{
return langs;
}
}
}
catch
{
// Ignore single repo language errors and return empty
}
return [];
}
private static List<HeatmapItem> GenerateMockHeatmap()
{
var items = new List<HeatmapItem>();
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ß" }
}
];
}

View File

@ -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 = "<!-- START_LATEST_REPOS -->";
private const string EndMarker = "<!-- END_LATEST_REPOS -->";
private const string StartReposMarker = "<!-- START_LATEST_REPOS -->";
private const string EndReposMarker = "<!-- END_LATEST_REPOS -->";
public static bool InjectLatestRepos(string readmePath, List<GiteaRepo> repos)
private const string StartTeamReposMarker = "<!-- START_TEAM_REPOS -->";
private const string EndTeamReposMarker = "<!-- END_TEAM_REPOS -->";
private const string StartLangMarker = "<!-- START_LANGUAGE_METRICS -->";
private const string EndLangMarker = "<!-- END_LANGUAGE_METRICS -->";
public static bool InjectAll(string readmePath, ProfileStats stats)
{
if (!File.Exists(readmePath))
{
@ -20,27 +25,17 @@ 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);
if (startIndex == -1 || endIndex == -1 || endIndex < startIndex)
// 1. Inject Personal Repos
content = ReplaceSection(content, StartReposMarker, EndReposMarker, sb =>
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[README-WARN] Markers '{StartMarker}' and '{EndMarker}' not found in {readmePath}");
Console.ResetColor();
return false;
}
var replacement = new StringBuilder();
replacement.AppendLine(StartMarker);
if (repos.Count == 0)
if (stats.PersonalRepos.Count == 0)
{
replacement.AppendLine("*Momentan keine öffentlichen Repositories verfügbar.*");
sb.AppendLine("*Momentan keine öffentlichen persönlichen Repositories vorhanden.*");
}
else
{
foreach (var repo in repos)
foreach (var repo in stats.PersonalRepos.Take(3))
{
var desc = string.IsNullOrWhiteSpace(repo.Description)
? (repo.Name.Equals(".profile", StringComparison.OrdinalIgnoreCase)
@ -54,18 +49,73 @@ public static class ReadmeInjector
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}*");
sb.AppendLine($"- 🚀 [**{repo.Name}**]({repo.HtmlUrl}) {desc} ");
sb.AppendLine($" `{lang}`{stars} • 🕒 *Aktualisiert am {updated}*");
}
}
});
replacement.Append(EndMarker);
// 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 newContent = content.Substring(0, startIndex) + replacement + content.Substring(endIndex + EndMarker.Length);
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, newContent, utf8WithoutBom);
Console.WriteLine($"[README] Successfully injected {repos.Count} repositories into {readmePath}");
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<StringBuilder> builderAction)
{
int startIndex = source.IndexOf(startMarker, StringComparison.Ordinal);
int endIndex = source.IndexOf(endMarker, StringComparison.Ordinal);
if (startIndex == -1 || endIndex == -1 || endIndex < startIndex)
{
return source;
}
var sb = new StringBuilder();
sb.AppendLine(startMarker);
builderAction(sb);
sb.Append(endMarker);
return source.Substring(0, startIndex) + sb + source.Substring(endIndex + endMarker.Length);
}
}

View File

@ -4,33 +4,34 @@ namespace ProfileManager.Services;
public static class StatsCalculator
{
private static readonly Dictionary<string, string> LanguageColors = new(StringComparer.OrdinalIgnoreCase)
public static readonly Dictionary<string, string> 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<HeatmapItem> heatmapItems, List<GiteaRepo> repos, DateOnly? referenceDate = null)
public static ProfileStats Calculate(
List<HeatmapItem> heatmapItems,
List<GiteaRepo> allRepos,
List<GiteaOrganization> 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<DateOnly, int>();
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<LanguageStat>();
// 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<LanguageStat> AggregateLanguages(List<GiteaRepo> repos)
{
var byteMap = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
var countMap = new Dictionary<string, int>(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<LanguageStat>();
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;
}
}

View File

@ -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(" <g transform=\"translate(20, 20)\">");
sb.AppendLine(" <!-- Fox Icon & Title -->");
sb.AppendLine(" <text class=\"title\" x=\"0\" y=\"14\">🦊 NickiCloud <tspan font-weight=\"400\" fill=\"var(--accent)\">•</tspan> <tspan font-weight=\"500\" font-size=\"12\" fill=\"var(--text-sub)\">Gitea Activity</tspan></text>");
sb.AppendLine(" <text class=\"title\" x=\"0\" y=\"14\">🦊 NickiCloud <tspan font-weight=\"400\" fill=\"var(--accent)\">•</tspan> <tspan font-weight=\"500\" font-size=\"12\" fill=\"var(--text-sub)\">Persönliche Aktivität</tspan></text>");
// Header badge (right aligned)
string badgeText = $"{stats.TotalContributions365:N0} Commits im letzten Jahr";
sb.AppendLine($" <rect x=\"510\" y=\"0\" width=\"190\" height=\"22\" rx=\"11\" fill=\"var(--metric-bg)\" stroke=\"var(--metric-border)\" stroke-width=\"1\" />");
sb.AppendLine($" <circle cx=\"522\" cy=\"11\" r=\"4\" fill=\"var(--accent)\" />");
@ -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(" <!-- Weekday Labels -->");
sb.AppendLine($" <text class=\"lbl-day\" x=\"15\" y=\"{gridStartY + 8}\">Mo</text>");
sb.AppendLine($" <text class=\"lbl-day\" x=\"15\" y=\"{gridStartY + 2 * colStep + 8}\">Mi</text>");
@ -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($" <rect x=\"{currentX.ToString("F1", CultureInfo.InvariantCulture)}\" y=\"215\" width=\"{w.ToString("F1", CultureInfo.InvariantCulture)}\" height=\"8\" fill=\"{lang.Color}\">");
@ -189,7 +182,7 @@ public static class SvgGenerator
sb.AppendLine(" <!-- Language Legend -->");
sb.AppendLine(" <g transform=\"translate(34, 238)\">");
int legendX = 0;
foreach (var lang in stats.TopLanguages)
foreach (var lang in stats.OverallLanguages.Take(5))
{
sb.AppendLine($" <circle cx=\"{legendX + 4}\" cy=\"4\" r=\"4\" fill=\"{lang.Color}\" />");
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("""<?xml version="1.0" encoding="utf-8"?>""");
sb.AppendLine($"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {svgWidth} {svgHeight}" width="100%" height="100%">""");
sb.AppendLine(" <defs>");
sb.AppendLine(" <clipPath id=\"teamLangBarClip\">");
sb.AppendLine($" <rect x=\"{gridStartX}\" y=\"125\" width=\"{totalBarWidth}\" height=\"12\" rx=\"6\" />");
sb.AppendLine(" </clipPath>");
sb.AppendLine(" </defs>");
sb.AppendLine("""
<style>
:root {
--bg: #11111b;
--card: #181825;
--border: #313244;
--text-main: #cdd6f4;
--text-sub: #a6adc8;
--text-muted: #6c7086;
--accent: #89b4fa;
--metric-bg: #1e1e2e;
--metric-border: #313244;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--text-main: #0f172a;
--text-sub: #334155;
--text-muted: #64748b;
--accent: #2563eb;
--metric-bg: #f1f5f9;
--metric-border: #cbd5e1;
}
}
.bg-panel { fill: var(--bg); stroke: var(--border); stroke-width: 1.2px; rx: 12px; }
.title { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; fill: var(--text-main); }
.subtitle { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 11px; fill: var(--text-sub); }
.stat-val { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 700; fill: var(--text-main); }
.stat-lbl { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 10px; fill: var(--text-sub); }
.lang-lbl { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 10px; font-weight: 500; fill: var(--text-sub); }
.project-tag { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 10px; fill: var(--text-muted); }
</style>
""");
// Background Panel
sb.AppendLine($" <rect class=\"bg-panel\" x=\"1\" y=\"1\" width=\"{svgWidth - 2}\" height=\"{svgHeight - 2}\" />");
// Header: Team Title & Badge
sb.AppendLine(" <g transform=\"translate(24, 20)\">");
sb.AppendLine($" <text class=\"title\" x=\"0\" y=\"14\">🏢 {team.OrganizationName} <tspan font-weight=\"400\" fill=\"var(--accent)\">•</tspan> <tspan font-weight=\"500\" font-size=\"12\" fill=\"var(--text-sub)\">Team &amp; Projekt-Statistiken</tspan></text>");
string badgeText = $"{team.TotalRepos} Team-Repositories";
sb.AppendLine($" <rect x=\"500\" y=\"0\" width=\"185\" height=\"22\" rx=\"11\" fill=\"var(--metric-bg)\" stroke=\"var(--metric-border)\" stroke-width=\"1\" />");
sb.AppendLine($" <circle cx=\"512\" cy=\"11\" r=\"4\" fill=\"#a855f7\" />");
sb.AppendLine($" <text class=\"subtitle\" x=\"522\" y=\"15\" font-weight=\"600\">{badgeText}</text>");
sb.AppendLine(" </g>");
// 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(" <!-- Team Languages Progress Bar -->");
sb.AppendLine(" <g clip-path=\"url(#teamLangBarClip)\">");
double currentX = gridStartX;
foreach (var lang in team.Languages)
{
double w = Math.Max(3.0, (lang.Percentage / 100.0) * totalBarWidth);
sb.AppendLine($" <rect x=\"{currentX.ToString("F1", CultureInfo.InvariantCulture)}\" y=\"125\" width=\"{w.ToString("F1", CultureInfo.InvariantCulture)}\" height=\"12\" fill=\"{lang.Color}\">");
sb.AppendLine($" <title>{lang.Name}: {lang.Percentage:F1}% ({lang.FormattedSize})</title>");
sb.AppendLine(" </rect>");
currentX += w;
}
sb.AppendLine(" </g>");
// Team Languages Legend (Detailed with Byte Sizes)
sb.AppendLine(" <!-- Detailed Language Legend -->");
sb.AppendLine(" <g transform=\"translate(34, 158)\">");
int legendX = 0;
int legendY = 0;
int count = 0;
foreach (var lang in team.Languages.Take(6))
{
sb.AppendLine($" <circle cx=\"{legendX + 4}\" cy=\"{legendY + 4}\" r=\"4\" fill=\"{lang.Color}\" />");
string text = $"{lang.Name}: {lang.Percentage:F1}% ({lang.FormattedSize})";
sb.AppendLine($" <text class=\"lang-lbl\" x=\"{legendX + 13}\" y=\"{legendY + 7}\">{text}</text>");
count++;
if (count % 3 == 0)
{
legendX = 0;
legendY += 18;
}
else
{
legendX += 225;
}
}
sb.AppendLine(" </g>");
// Bottom active repos pills
sb.AppendLine(" <!-- Active Team Repositories Footer -->");
sb.AppendLine(" <g transform=\"translate(34, 215)\">");
string repoPills = string.Join(" • ", team.Repos.Take(3).Select(r => $"🚀 {r.Name} ({r.Language ?? "Code"})"));
sb.AppendLine($" <text class=\"project-tag\" x=\"0\" y=\"0\">Team-Repositories: {repoPills}</text>");
sb.AppendLine(" </g>");
sb.AppendLine("</svg>");
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($" <g transform=\"translate({x}, {y})\">");
sb.AppendLine($" <rect width=\"{w}\" height=\"{h}\" rx=\"8\" fill=\"var(--metric-bg)\" stroke=\"var(--metric-border)\" stroke-width=\"1\" />");
sb.AppendLine($" <text x=\"12\" y=\"23\" font-size=\"16\">{icon}</text>");
sb.AppendLine($" <text class=\"stat-val\" x=\"38\" y=\"17\">{value}</text>");
sb.AppendLine($" <text class=\"stat-lbl\" x=\"38\" y=\"29\">{label}</text>");
sb.AppendLine($" <text x=\"12\" y=\"27\" font-size=\"16\">{icon}</text>");
sb.AppendLine($" <text class=\"stat-val\" x=\"40\" y=\"20\">{value}</text>");
sb.AppendLine($" <text class=\"stat-lbl\" x=\"40\" y=\"33\">{label}</text>");
sb.AppendLine(" </g>");
}
}

109
team-stats.svg Normal file
View File

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 740 240" width="100%" height="100%">
<defs>
<clipPath id="teamLangBarClip">
<rect x="34" y="125" width="676" height="12" rx="6" />
</clipPath>
</defs>
<style>
:root {
--bg: #11111b;
--card: #181825;
--border: #313244;
--text-main: #cdd6f4;
--text-sub: #a6adc8;
--text-muted: #6c7086;
--accent: #89b4fa;
--metric-bg: #1e1e2e;
--metric-border: #313244;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--text-main: #0f172a;
--text-sub: #334155;
--text-muted: #64748b;
--accent: #2563eb;
--metric-bg: #f1f5f9;
--metric-border: #cbd5e1;
}
}
.bg-panel { fill: var(--bg); stroke: var(--border); stroke-width: 1.2px; rx: 12px; }
.title { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; fill: var(--text-main); }
.subtitle { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 11px; fill: var(--text-sub); }
.stat-val { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 700; fill: var(--text-main); }
.stat-lbl { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 10px; fill: var(--text-sub); }
.lang-lbl { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 10px; font-weight: 500; fill: var(--text-sub); }
.project-tag { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 10px; fill: var(--text-muted); }
</style>
<rect class="bg-panel" x="1" y="1" width="738" height="238" />
<g transform="translate(24, 20)">
<text class="title" x="0" y="14">🏢 WebCore Consulting <tspan font-weight="400" fill="var(--accent)"></tspan> <tspan font-weight="500" font-size="12" fill="var(--text-sub)">Team &amp; Projekt-Statistiken</tspan></text>
<rect x="500" y="0" width="185" height="22" rx="11" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" />
<circle cx="512" cy="11" r="4" fill="#a855f7" />
<text class="subtitle" x="522" y="15" font-weight="600">3 Team-Repositories</text>
</g>
<g transform="translate(34, 56)">
<rect width="216" height="44" rx="8" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" />
<text x="12" y="27" font-size="16">📁</text>
<text class="stat-val" x="40" y="20">3 Repos</text>
<text class="stat-lbl" x="40" y="33">Aktive Team-Projekte</text>
</g>
<g transform="translate(264, 56)">
<rect width="216" height="44" rx="8" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" />
<text x="12" y="27" font-size="16">💾</text>
<text class="stat-val" x="40" y="20">1,9 MB</text>
<text class="stat-lbl" x="40" y="33">Gesamter Team-Code</text>
</g>
<g transform="translate(494, 56)">
<rect width="216" height="44" rx="8" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" />
<text x="12" y="27" font-size="16"></text>
<text class="stat-val" x="40" y="20">C# (56,2%)</text>
<text class="stat-lbl" x="40" y="33">Hauptsprache im Team</text>
</g>
<!-- Team Languages Progress Bar -->
<g clip-path="url(#teamLangBarClip)">
<rect x="34.0" y="125" width="379.9" height="12" fill="#a855f7">
<title>C#: 56,2% (1,1 MB)</title>
</rect>
<rect x="413.9" y="125" width="273.1" height="12" fill="#e34c26">
<title>HTML: 40,4% (797,4 KB)</title>
</rect>
<rect x="687.0" y="125" width="15.5" height="12" fill="#563d7c">
<title>CSS: 2,3% (45,7 KB)</title>
</rect>
<rect x="702.6" y="125" width="3.0" height="12" fill="#f1e05a">
<title>JavaScript: 0,4% (8,6 KB)</title>
</rect>
<rect x="705.6" y="125" width="3.0" height="12" fill="#012456">
<title>PowerShell: 0,4% (7,6 KB)</title>
</rect>
<rect x="708.6" y="125" width="3.0" height="12" fill="#89e051">
<title>Shell: 0,2% (3,4 KB)</title>
</rect>
<rect x="711.6" y="125" width="3.0" height="12" fill="#384d54">
<title>Dockerfile: 0,1% (2,4 KB)</title>
</rect>
</g>
<!-- Detailed Language Legend -->
<g transform="translate(34, 158)">
<circle cx="4" cy="4" r="4" fill="#a855f7" />
<text class="lang-lbl" x="13" y="7">C#: 56,2% (1,1 MB)</text>
<circle cx="229" cy="4" r="4" fill="#e34c26" />
<text class="lang-lbl" x="238" y="7">HTML: 40,4% (797,4 KB)</text>
<circle cx="454" cy="4" r="4" fill="#563d7c" />
<text class="lang-lbl" x="463" y="7">CSS: 2,3% (45,7 KB)</text>
<circle cx="4" cy="22" r="4" fill="#f1e05a" />
<text class="lang-lbl" x="13" y="25">JavaScript: 0,4% (8,6 KB)</text>
<circle cx="229" cy="22" r="4" fill="#012456" />
<text class="lang-lbl" x="238" y="25">PowerShell: 0,4% (7,6 KB)</text>
<circle cx="454" cy="22" r="4" fill="#89e051" />
<text class="lang-lbl" x="463" y="25">Shell: 0,2% (3,4 KB)</text>
</g>
<!-- Active Team Repositories Footer -->
<g transform="translate(34, 215)">
<text class="project-tag" x="0" y="0">Team-Repositories: 🚀 ServerManagement (HTML) • 🚀 USBSafe (C#) • 🚀 ServerHub-redesign ()</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB