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: on:
schedule: schedule:
@ -12,6 +12,7 @@ on:
- main - main
paths-ignore: paths-ignore:
- 'heatmap.svg' - 'heatmap.svg'
- 'team-stats.svg'
- 'README.md' - 'README.md'
jobs: jobs:
@ -27,7 +28,6 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# Nutzt standardmäßig das automatische Gitea-Token oder ein benutzerdefiniertes Secret
token: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }} token: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
- name: ⚙️ .NET 10 SDK einrichten - name: ⚙️ .NET 10 SDK einrichten
@ -49,17 +49,17 @@ jobs:
git config --global user.name "NickiBot[bot]" git config --global user.name "NickiBot[bot]"
git config --global user.email "bot@webcore-consulting.dev" git config --global user.email "bot@webcore-consulting.dev"
# Prüfen, ob sich heatmap.svg oder README.md geändert haben # Prüfen, ob sich SVG-Dateien oder README.md geändert haben
if git status --porcelain | grep -E "(heatmap.svg|README.md)"; then if git status --porcelain | grep -E "(heatmap.svg|team-stats.svg|README.md)"; then
echo "Änderungen an heatmap.svg oder README.md erkannt. Committe..." echo "Änderungen an Grafiken oder README.md erkannt. Committe..."
git add heatmap.svg README.md git add heatmap.svg team-stats.svg README.md
# GANZ WICHTIG: [skip ci] verhindert Endlosschleifen in Gitea Actions! # 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 # Änderungen pushen
git push git push
echo "Profil erfolgreich aktualisiert und gepusht! ✨" echo "Profil & Team-Telemetrie erfolgreich aktualisiert und gepusht! ✨"
else else
echo "Keine Änderungen vorhanden. Alles auf dem neuesten Stand. 👍" echo "Keine Änderungen vorhanden. Alles auf dem neuesten Stand. 👍"
fi fi

140
README.md
View File

@ -2,8 +2,8 @@
# 🦊 Hey, ich bin Nicki! # 🦊 Hey, ich bin Nicki!
### 🎓 Fachabiturient & Leidenschaftlicher C# / .NET Softwareentwickler ### 🎓 Fachabiturient & Passionierter C# / .NET Software-Architekt
*Building high-performance desktop & web applications with a touch of purple fox magic.* *High-Performance Desktop Apps, moderne Web-Services & kompromissloses Self-Hosting im lila Fuchs-Style.*
<br/> <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/) [![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/) [![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/) [![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> </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**. - 💻 **Fokus:** C#, .NET 10, Cross-Platform Desktop mit **Avalonia UI**, moderne Web-Apps mit **Blazor / MudBlazor** und modulare Microservices.
- 🐧 **Mein OS & Dev-Setup:** Rider-Enthusiast auf **Pop!_OS (Linux)** weil Entwicklungs-Power Freiheit braucht. - 🏢 **Team & Consulting:** Softwareentwickler bei **WebCore Consulting** Entwicklung von Management-Tools, Kundenportalen und Security-Services.
- 🏰 **Self-Hosting & Homelab:** Eigener Debian-Server mit Docker-Containern: Gitea, Mailcow, Nginx Reverse-Proxy, abgesichert mit UFW & fail2ban. - 🐧 **Entwicklungsumgebung:** Vollständiger Linux-Workflow mit **JetBrains Rider auf Pop!_OS** maximale Produktivität ohne Windows-Overhead.
- 🎮 **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**. - 🏰 **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.
- 🦊 **Das Fuchs-Branding:** Lila Akzente, Neugier, Agilität und maximale Eleganz in Code & UI. - 🎮 **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"> <div align="center">
## 📊 Meine Gitea Aktivität & Streak ## 📊 Live Aktivitäts- & Performance-Dashboards
<!-- Die Heatmap wird vollautomatisch per Gitea Action mit .NET 10 generiert -->
<img src="heatmap.svg" alt="Nicki's Gitea Contribution Heatmap" width="100%" />
</div> </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 | #### 📌 Zuletzt aktualisierte persönliche Projekte:
| :--- | :---: | :--- |
| 📝 **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 -->
<!-- START_LATEST_REPOS --> <!-- START_LATEST_REPOS -->
- 🚀 [**.profile**](https://git.webcore-consulting.dev/NickiCloud/.profile) 🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10 - 🚀 [**.profile**](https://git.webcore-consulting.dev/NickiCloud/.profile) 🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10
`C#` • 🕒 *Aktualisiert am 02.09.2026* `C#` • 🕒 *Aktualisiert am 02.09.2026*
<!-- END_LATEST_REPOS --> <!-- 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"> <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) [![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) [![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 ```csharp
var nicki = new Developer var nicki = new Developer
{ {
Drink = "Kühles Spezi oder Mate", Drink = "Kühles Spezi oder Club-Mate",
Editor = "JetBrains Rider", Workstation = "Pop!_OS on Linux Kernel",
Terminal = "zsh on Pop!_OS", IDE = "JetBrains Rider 2024+",
Motto = "Refactor early, test continuously, deploy autonomously." 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> </div>

View File

@ -71,10 +71,10 @@
<rect class="bg-panel" x="1" y="1" width="738" height="263" /> <rect class="bg-panel" x="1" y="1" width="738" height="263" />
<g transform="translate(20, 20)"> <g transform="translate(20, 20)">
<!-- Fox Icon & Title --> <!-- 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" /> <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)" /> <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> </g>
<!-- Month Labels --> <!-- Month Labels -->
<text class="lbl-month" x="34" y="54">Sep</text> <text class="lbl-month" x="34" y="54">Sep</text>
@ -1173,7 +1173,7 @@
<title>01.09.2026: 9 Commits</title> <title>01.09.2026: 9 Commits</title>
</rect> </rect>
<rect class="cell lvl-3" x="697" y="90" width="10" height="10" rx="2" style="animation-delay: 612ms;"> <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>
<rect class="cell lvl-0" x="697" y="103" width="10" height="10" rx="2" style="animation-delay: 612ms;"> <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> <title>03.09.2026: 0 Commits</title>
@ -1191,31 +1191,51 @@
<!-- Stat Badges --> <!-- Stat Badges -->
<g transform="translate(34, 166)"> <g transform="translate(34, 166)">
<rect width="216" height="36" rx="8" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" /> <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 x="12" y="27" font-size="16">📦</text>
<text class="stat-val" x="38" y="17">23</text> <text class="stat-val" x="40" y="20">24</text>
<text class="stat-lbl" x="38" y="29">Jahres-Beiträge</text> <text class="stat-lbl" x="40" y="33">Jahres-Beiträge</text>
</g> </g>
<g transform="translate(264, 166)"> <g transform="translate(264, 166)">
<rect width="216" height="36" rx="8" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" /> <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 x="12" y="27" font-size="16">🔥</text>
<text class="stat-val" x="38" y="17">4 Tage</text> <text class="stat-val" x="40" y="20">4 Tage</text>
<text class="stat-lbl" x="38" y="29">Aktueller Streak</text> <text class="stat-lbl" x="40" y="33">Aktueller Streak</text>
</g> </g>
<g transform="translate(494, 166)"> <g transform="translate(494, 166)">
<rect width="216" height="36" rx="8" fill="var(--metric-bg)" stroke="var(--metric-border)" stroke-width="1" /> <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 x="12" y="27" font-size="16"></text>
<text class="stat-val" x="38" y="17">4 Tage</text> <text class="stat-val" x="40" y="20">4 Tage</text>
<text class="stat-lbl" x="38" y="29">Längster Streak</text> <text class="stat-lbl" x="40" y="33">Längster Streak</text>
</g> </g>
<!-- Top Languages Bar --> <!-- Top Languages Bar -->
<g clip-path="url(#langBarClip)"> <g clip-path="url(#langBarClip)">
<rect x="34.0" y="215" width="676.0" height="8" fill="#a855f7"> <rect x="34.0" y="215" width="385.3" height="8" fill="#a855f7">
<title>C#: 100,0%</title> <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> </rect>
</g> </g>
<!-- Language Legend --> <!-- Language Legend -->
<g transform="translate(34, 238)"> <g transform="translate(34, 238)">
<circle cx="4" cy="4" r="4" fill="#a855f7" /> <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> </g>
</svg> </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; 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> /// <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> /// </summary>
public sealed class GiteaRepo public sealed class GiteaRepo
{ {
[JsonPropertyName("id")] [JsonPropertyName("id")]
public long Id { get; set; } public long Id { get; set; }
[JsonPropertyName("owner")]
public RepoOwner? Owner { get; set; }
[JsonPropertyName("name")] [JsonPropertyName("name")]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
@ -28,15 +46,30 @@ public sealed class GiteaRepo
[JsonPropertyName("fork")] [JsonPropertyName("fork")]
public bool Fork { get; set; } public bool Fork { get; set; }
[JsonPropertyName("private")]
public bool Private { get; set; }
[JsonPropertyName("archived")] [JsonPropertyName("archived")]
public bool Archived { get; set; } public bool Archived { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonPropertyName("stars_count")] [JsonPropertyName("stars_count")]
public int StarsCount { get; set; } public int StarsCount { get; set; }
[JsonPropertyName("forks_count")] [JsonPropertyName("forks_count")]
public int ForksCount { get; set; } public int ForksCount { get; set; }
[JsonPropertyName("open_issues_count")]
public int OpenIssuesCount { get; set; }
[JsonPropertyName("updated_at")] [JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; } 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 string Name { get; init; }
public required double Percentage { get; init; } public required double Percentage { get; init; }
public required long Bytes { get; init; }
public required int RepoCount { get; init; } public required int RepoCount { get; init; }
public required string Color { 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 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 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 sealed class ProfileStats
{ {
public required int TotalContributions365 { get; init; } public required int TotalContributions365 { get; init; }
public required int CurrentStreak { get; init; } public required int CurrentStreak { get; init; }
public required int LongestStreak { get; init; } public required int LongestStreak { get; init; }
public required List<DayContribution> Days { get; init; } public required List<DayContribution> Days { get; init; }
public required List<LanguageStat> TopLanguages { get; init; } public required List<LanguageStat> OverallLanguages { get; init; }
public required List<GiteaRepo> LatestRepos { get; init; } public required List<GiteaRepo> PersonalRepos { get; init; }
public required TeamStats? TeamStats { get; init; }
} }

View File

@ -11,85 +11,96 @@ internal static class Program
Console.WriteLine(""" Console.WriteLine("""
==================================================== ====================================================
🦊 NickiCloud Gitea Profile Manager (.NET 10) 🦊 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 username = GetConfigValue(args, "--username", "GITEA_USERNAME", "NickiCloud");
var apiUrl = GetConfigValue(args, "--api-url", "GITEA_API_URL", "https://git.webcore-consulting.dev/api/v1"); 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 token = GetConfigValue(args, "--token", "GITEA_TOKEN", string.Empty);
var svgPath = GetConfigValue(args, "--output-svg", "OUTPUT_SVG", "heatmap.svg"); 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"); var readmePath = GetConfigValue(args, "--readme", "README_PATH", "README.md");
// Resolve paths relative to working directory or repo root var repoRoot = FindRepoRoot();
if (!Path.IsPathRooted(svgPath)) if (!Path.IsPathRooted(svgPath)) svgPath = Path.GetFullPath(Path.Combine(repoRoot, svgPath));
{ if (!Path.IsPathRooted(teamSvgPath)) teamSvgPath = Path.GetFullPath(Path.Combine(repoRoot, teamSvgPath));
svgPath = Path.GetFullPath(Path.Combine(FindRepoRoot(), svgPath)); if (!Path.IsPathRooted(readmePath)) readmePath = Path.GetFullPath(Path.Combine(repoRoot, readmePath));
}
if (!Path.IsPathRooted(readmePath)) Console.WriteLine($"[Config] Target User : {username}");
{ Console.WriteLine($"[Config] API Endpoint : {apiUrl}");
readmePath = Path.GetFullPath(Path.Combine(FindRepoRoot(), readmePath)); Console.WriteLine($"[Config] Heatmap SVG : {svgPath}");
} Console.WriteLine($"[Config] Team Stats SVG : {teamSvgPath}");
Console.WriteLine($"[Config] Target README : {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(); Console.WriteLine();
try try
{ {
var client = new GiteaApiClient(apiUrl, string.IsNullOrWhiteSpace(token) ? null : token); 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 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 heatmap = await heatmapTask;
var repos = await reposTask; var repos = await reposTask;
var orgs = await orgsTask;
// Step 2: Calculate metrics // Step 2: Fetch detailed language byte counts per repository
Console.WriteLine("[Stats] Calculating streaks, contributions and language metrics..."); Console.WriteLine("[API] Fetching byte-level language breakdown for all repositories...");
var stats = StatsCalculator.Calculate(heatmap, repos); 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] Current Streak : {stats.CurrentStreak} days"); Console.WriteLine("[Stats] Calculating Personal, Team & Byte metrics...");
Console.WriteLine($"[Stats] Longest Streak : {stats.LongestStreak} days"); var stats = StatsCalculator.Calculate(heatmap, repos, orgs, username);
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] 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(); 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); 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)) if (File.Exists(readmePath))
{ {
Console.WriteLine($"[README] Injecting latest repositories into: {readmePath}"); Console.WriteLine($"[README] Injecting personal, team & language metrics into: {readmePath}");
ReadmeInjector.InjectLatestRepos(readmePath, stats.LatestRepos); ReadmeInjector.InjectAll(readmePath, stats);
}
else
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[README-WARN] Target README not found at {readmePath}. Skipping injection.");
Console.ResetColor();
} }
Console.ForegroundColor = ConsoleColor.Green; Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("\n✨ Profile update finished successfully!"); Console.WriteLine("\n✨ Multi-tier profile update completed successfully!");
Console.ResetColor(); Console.ResetColor();
return 0; 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) private static string GetConfigValue(string[] args, string flag, string envVar, string defaultValue)
{ {
for (int i = 0; i < args.Length - 1; i++) 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 HttpClient _httpClient;
private readonly string _baseUrl; private readonly string _baseUrl;
private readonly bool _hasToken;
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
@ -29,12 +30,13 @@ public sealed class GiteaApiClient
Timeout = TimeSpan.FromSeconds(15) 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")); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = apiToken ?? Environment.GetEnvironmentVariable("GITEA_TOKEN"); var token = apiToken ?? Environment.GetEnvironmentVariable("GITEA_TOKEN");
if (!string.IsNullOrWhiteSpace(token)) if (!string.IsNullOrWhiteSpace(token))
{ {
_hasToken = true;
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
} }
} }
@ -53,28 +55,31 @@ public sealed class GiteaApiClient
var items = JsonSerializer.Deserialize<List<HeatmapItem>>(json, JsonOptions); var items = JsonSerializer.Deserialize<List<HeatmapItem>>(json, JsonOptions);
if (items != null && items.Count > 0) 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; return items;
} }
} }
Console.ForegroundColor = ConsoleColor.Yellow; 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(); Console.ResetColor();
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.ForegroundColor = ConsoleColor.Yellow; 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(); Console.ResetColor();
} }
return GenerateMockHeatmap(); 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}"); Console.WriteLine($"[API] Fetching repositories from: {url}");
try try
@ -86,45 +91,109 @@ public sealed class GiteaApiClient
var repos = JsonSerializer.Deserialize<List<GiteaRepo>>(json, JsonOptions); var repos = JsonSerializer.Deserialize<List<GiteaRepo>>(json, JsonOptions);
if (repos != null && repos.Count > 0) if (repos != null && repos.Count > 0)
{ {
Console.WriteLine($"[API] Successfully retrieved {repos.Count} repositories."); Console.WriteLine($"[API] Retrieved {repos.Count} total repositories.");
return repos; return repos;
} }
} }
Console.ForegroundColor = ConsoleColor.Yellow; 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(); Console.ResetColor();
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.ForegroundColor = ConsoleColor.Yellow; 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(); Console.ResetColor();
} }
return GenerateMockRepos(); 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() private static List<HeatmapItem> GenerateMockHeatmap()
{ {
var items = new List<HeatmapItem>(); var items = new List<HeatmapItem>();
var now = DateTimeOffset.UtcNow; 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++) for (int i = 0; i < 365; i++)
{ {
var day = now.AddDays(-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; 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) if (random.Next(100) < chance)
{ {
int commits = random.Next(1, 14);
items.Add(new HeatmapItem items.Add(new HeatmapItem
{ {
Timestamp = day.ToUnixTimeSeconds(), Timestamp = day.ToUnixTimeSeconds(),
Contributions = commits Contributions = random.Next(1, 12)
}); });
} }
} }
@ -139,69 +208,47 @@ public sealed class GiteaApiClient
[ [
new GiteaRepo new GiteaRepo
{ {
Name = "Markdown-Editor-Pro", Name = "USBSafe",
FullName = "NickiCloud/Markdown-Editor-Pro", FullName = "WebCore/USBSafe",
Description = "Moderner, performanter Markdown-Editor mit Live-Vorschau, Syntax-Highlighting & LaTeX-Support auf Avalonia UI.", Description = "Sicherheits- & Verschlüsselungs-Tool für Wechseldatenträger im Unternehmensnetzwerk.",
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/Markdown-Editor-Pro", HtmlUrl = "https://git.webcore-consulting.dev/WebCore/USBSafe",
Language = "C#", 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, StarsCount = 2,
ForksCount = 0, UpdatedAt = now.AddHours(-1),
UpdatedAt = now.AddDays(-25) 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;
using System.Text.RegularExpressions;
using ProfileManager.Models; using ProfileManager.Models;
namespace ProfileManager.Services; namespace ProfileManager.Services;
public static class ReadmeInjector public static class ReadmeInjector
{ {
private const string StartMarker = "<!-- START_LATEST_REPOS -->"; private const string StartReposMarker = "<!-- START_LATEST_REPOS -->";
private const string EndMarker = "<!-- END_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)) if (!File.Exists(readmePath))
{ {
@ -20,52 +25,97 @@ public static class ReadmeInjector
} }
var content = File.ReadAllText(readmePath, Encoding.UTF8); 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<StringBuilder> builderAction)
{
int startIndex = source.IndexOf(startMarker, StringComparison.Ordinal);
int endIndex = source.IndexOf(endMarker, StringComparison.Ordinal);
if (startIndex == -1 || endIndex == -1 || endIndex < startIndex) if (startIndex == -1 || endIndex == -1 || endIndex < startIndex)
{ {
Console.ForegroundColor = ConsoleColor.Yellow; return source;
Console.WriteLine($"[README-WARN] Markers '{StartMarker}' and '{EndMarker}' not found in {readmePath}");
Console.ResetColor();
return false;
} }
var replacement = new StringBuilder(); var sb = new StringBuilder();
replacement.AppendLine(StartMarker); sb.AppendLine(startMarker);
builderAction(sb);
sb.Append(endMarker);
if (repos.Count == 0) return source.Substring(0, startIndex) + sb + source.Substring(endIndex + endMarker.Length);
{
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;
} }
} }

View File

@ -4,33 +4,34 @@ namespace ProfileManager.Services;
public static class StatsCalculator 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#", "#a855f7" },
{ "C++", "#f34b7d" },
{ "C", "#555555" },
{ "HTML", "#e34c26" }, { "HTML", "#e34c26" },
{ "CSS", "#563d7c" }, { "CSS", "#563d7c" },
{ "JavaScript", "#f1e05a" }, { "JavaScript", "#f1e05a" },
{ "TypeScript", "#3178c6" }, { "TypeScript", "#3178c6" },
{ "Python", "#3572A5" }, { "PowerShell", "#012456" },
{ "Shell", "#89e051" }, { "Shell", "#89e051" },
{ "Bash", "#89e051" }, { "Bash", "#89e051" },
{ "Dockerfile", "#384d54" }, { "Dockerfile", "#384d54" },
{ "Python", "#3572A5" },
{ "Go", "#00ADD8" }, { "Go", "#00ADD8" },
{ "Rust", "#dea584" }, { "Rust", "#dea584" },
{ "Vue", "#41b883" }, { "Vue", "#41b883" },
{ "PHP", "#4F5D95" }, { "XAML", "#b53d9e" }
{ "Kotlin", "#A97BFF" },
{ "Java", "#b07219" },
{ "Dart", "#00B4AB" }
}; };
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); var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
// 1. Group heatmap contributions by DateOnly (UTC) // 1. Heatmap Contributions Map
var dateMap = new Dictionary<DateOnly, int>(); var dateMap = new Dictionary<DateOnly, int>();
foreach (var item in heatmapItems) foreach (var item in heatmapItems)
{ {
@ -38,7 +39,7 @@ public static class StatsCalculator
dateMap[date] = dateMap.GetValueOrDefault(date, 0) + Math.Max(0, item.Contributions); 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; int dayOffsetFromMonday = ((int)today.DayOfWeek + 6) % 7;
var gridEndSunday = today.AddDays(6 - dayOffsetFromMonday); var gridEndSunday = today.AddDays(6 - dayOffsetFromMonday);
var gridStartMonday = gridEndSunday.AddDays(-(52 * 7 - 1)); 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); var oneYearAgo = today.AddDays(-365);
int total365 = dateMap int total365 = dateMap.Where(kv => kv.Key >= oneYearAgo && kv.Key <= today).Sum(kv => kv.Value);
.Where(kv => kv.Key >= oneYearAgo && kv.Key <= today)
.Sum(kv => kv.Value);
// 4. Streaks calculation
int currentStreak = 0; int currentStreak = 0;
if (dateMap.GetValueOrDefault(today, 0) > 0) if (dateMap.GetValueOrDefault(today, 0) > 0)
{ {
@ -90,7 +88,6 @@ public static class StatsCalculator
} }
else else
{ {
// If today has no commit yet, yesterday's streak is still active
var check = today.AddDays(-1); var check = today.AddDays(-1);
while (dateMap.GetValueOrDefault(check, 0) > 0) while (dateMap.GetValueOrDefault(check, 0) > 0)
{ {
@ -99,7 +96,6 @@ public static class StatsCalculator
} }
} }
// Longest Streak across all dates
int longestStreak = 0; int longestStreak = 0;
if (dateMap.Count > 0) if (dateMap.Count > 0)
{ {
@ -121,50 +117,41 @@ public static class StatsCalculator
} }
} }
// 5. Top 3 Repos (sorted by UpdatedAt desc, exclude forks) // 4. Split Personal vs. Team Repos
var nonForkRepos = repos.Where(r => !r.Fork).ToList(); var personalRepos = allRepos
var candidateRepos = nonForkRepos.Count > 0 ? nonForkRepos : repos; .Where(r => !r.IsOrganizationRepo(personalUsername))
var latestRepos = candidateRepos
.OrderByDescending(r => r.UpdatedAt) .OrderByDescending(r => r.UpdatedAt)
.Take(3)
.ToList(); .ToList();
// 6. Top Languages calculation var teamRepos = allRepos
var languageGroups = candidateRepos .Where(r => r.IsOrganizationRepo(personalUsername))
.Where(r => !string.IsNullOrWhiteSpace(r.Language)) .OrderByDescending(r => r.UpdatedAt)
.GroupBy(r => r.Language!.Trim())
.Select(g => new { Name = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count)
.ToList(); .ToList();
int totalLanguageRepos = languageGroups.Sum(x => x.Count); // 5. Calculate Languages (from Byte Breakdown)
var topLanguages = new List<LanguageStat>(); 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); OrganizationName = orgName,
topLanguages.Add(new LanguageStat OrganizationLogin = orgLogin,
{ Website = website,
Name = lang.Name, TotalRepos = teamRepos.Count,
Percentage = pct, TotalCodeBytes = totalBytes,
RepoCount = lang.Count, Languages = teamLanguages,
Color = LanguageColors.GetValueOrDefault(lang.Name, "#c084fc") Repos = teamRepos
}); };
}
}
else
{
// Default language representation if repos have no language tags
topLanguages.Add(new LanguageStat
{
Name = "C#",
Percentage = 100.0,
RepoCount = 1,
Color = "#a855f7"
});
} }
return new ProfileStats return new ProfileStats
@ -173,8 +160,65 @@ public static class StatsCalculator
CurrentStreak = currentStreak, CurrentStreak = currentStreak,
LongestStreak = Math.Max(longestStreak, currentStreak), LongestStreak = Math.Max(longestStreak, currentStreak),
Days = days, Days = days,
TopLanguages = topLanguages, OverallLanguages = overallLanguages,
LatestRepos = latestRepos 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" "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); var sb = new StringBuilder(16384);
@ -104,9 +104,8 @@ public static class SvgGenerator
// Header: Title & Total Commits Badge // Header: Title & Total Commits Badge
sb.AppendLine(" <g transform=\"translate(20, 20)\">"); sb.AppendLine(" <g transform=\"translate(20, 20)\">");
sb.AppendLine(" <!-- Fox Icon & Title -->"); 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"; 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($" <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)\" />"); 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) if (firstDayOfCol != null && firstDayOfCol.Date.Month != lastMonth)
{ {
lastMonth = firstDayOfCol.Date.Month; lastMonth = firstDayOfCol.Date.Month;
// Avoid rendering month if too close to right edge
if (col < 50) if (col < 50)
{ {
int x = gridStartX + col * colStep; 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(" <!-- 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 + 8}\">Mo</text>");
sb.AppendLine($" <text class=\"lbl-day\" x=\"15\" y=\"{gridStartY + 2 * colStep + 8}\">Mi</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 cardWidth = 216;
int gap = 14; int gap = 14;
// Card 1: Total
RenderMetricCard(sb, gridStartX, cardY, cardWidth, cardHeight, "📦", $"{stats.TotalContributions365:N0}", "Jahres-Beiträge"); 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"); 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"); RenderMetricCard(sb, gridStartX + (cardWidth + gap) * 2, cardY, cardWidth, cardHeight, "⚡", $"{stats.LongestStreak} {(stats.LongestStreak == 1 ? "Tag" : "Tage")}", "Längster Streak");
// Top Languages Bar (width = 676px) // Top Languages Bar (width = 676px)
@ -175,7 +168,7 @@ public static class SvgGenerator
double currentX = gridStartX; double currentX = gridStartX;
const double totalBarWidth = 676.0; 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); 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}\">"); 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(" <!-- Language Legend -->");
sb.AppendLine(" <g transform=\"translate(34, 238)\">"); sb.AppendLine(" <g transform=\"translate(34, 238)\">");
int legendX = 0; 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}\" />"); sb.AppendLine($" <circle cx=\"{legendX + 4}\" cy=\"4\" r=\"4\" fill=\"{lang.Color}\" />");
string text = $"{lang.Name} {lang.Percentage:F1}%"; string text = $"{lang.Name} {lang.Percentage:F1}%";
@ -202,13 +195,143 @@ public static class SvgGenerator
return sb.ToString(); 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) 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($" <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($" <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 x=\"12\" y=\"27\" font-size=\"16\">{icon}</text>");
sb.AppendLine($" <text class=\"stat-val\" x=\"38\" y=\"17\">{value}</text>"); sb.AppendLine($" <text class=\"stat-val\" x=\"40\" y=\"20\">{value}</text>");
sb.AppendLine($" <text class=\"stat-lbl\" x=\"38\" y=\"29\">{label}</text>"); sb.AppendLine($" <text class=\"stat-lbl\" x=\"40\" y=\"33\">{label}</text>");
sb.AppendLine(" </g>"); 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