9 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 72e5eb43c4 test: README-Hilfen abdecken, Doku, Version 1.4.0
CI / pruefen (push) Has been cancelled
Elf Tests: welche Dateinamen als README gelten, dass Markdown Vorrang hat,
dass an einer Zeilengrenze gekuerzt wird und dass base64 mit Umlauten und
Zeilenumbruechen richtig ankommt.

Im Browser gegen vercel/swr geprueft, dazu ein erfundenes README fuer die
relativen Links, die swr nicht hergibt: aufgeloeste Pfade, unveraenderte
absolute Links, Anker bleiben Anker, kein img im Dokument und kein a im a.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:42 +02:00
florian.wildeandClaude Opus 5 e97bd43b23 feat(ui): README auf der Projektseite anzeigen
Lange READMEs sind eingeklappt, ein Knopf zeigt alles. Kurze stehen gleich
vollstaendig da - ein Knopf, der nichts freigibt, waere nur Ballast.

Relative Links werden gegen das Verzeichnis der Datei im Repository
aufgeloest. Ohne das liefen sie ins Leere, denn im README steht "docs/x.md"
und nicht die volle Adresse.

Bilder werden nicht geladen, sondern als anklickbare Marke gezeigt. Die
Content-Security-Policy erlaubt img-src nur von der eigenen Instanz; sie
dafuer zu oeffnen hiesse, jedem fremden README das Nachladen beliebiger
Bilder zu gestatten - also auch Zaehlpixeln. Fuer Abzeichen und
Bildschirmfotos, in einer Vorschau ohnehin Beiwerk, ist das kein guter
Tausch.

Der Umbau passiert im Baum und nicht ueber eine Komponente, weil es auf den
Elternknoten ankommt: "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel,
und ein a in einem a ist ungueltiges HTML. Der Browser zieht es beim Parsen
auseinander - der erste Versuch scheiterte prompt an einem
Hydrationsfehler. Innerhalb eines Links bleibt jetzt nur der Text stehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:42 +02:00
florian.wildeandClaude Opus 5 f8e6c66876 feat(git): README des Repositories holen
Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
Projekt eigentlich ging.

GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet - ein Aufruf
genuegt. GitLab und Gitea kennen keinen; dort wird erst das
Wurzelverzeichnis aufgelistet und dann die gefundene Datei geholt. Erkannt
werden README.md und die ueblichen Varianten, Gross- und Kleinschreibung
egal; Markdown hat Vorrang, weil nur das wirklich dargestellt werden kann.

Geholt wird hoechstens einmal am Tag, wie der Kalender, und mit eigenem
Zeitstempel: schlaegt das eine fehl, soll das andere nicht mit blockiert
werden.

Bei 60 000 Zeichen wird abgeschnitten, aber an einer Zeilengrenze - sonst
bliebe womoeglich ein halber Codeblock stehen. Dekodiert wird ueber Buffer
und nicht ueber atob, sonst kaemen Umlaute als Kauderwelsch heraus.

Kein README ist eine Auskunft, kein Fehlschlag: der Zeitstempel wird dann
trotzdem gesetzt, damit nicht jeder Abgleich erneut sucht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:18 +02:00
florian.wildeandClaude Opus 5 01884f4747 test: wiederkehrende Aufgaben abdecken, Doku, Version 1.3.0
CI / pruefen (push) Has been cancelled
Sieben Tests fuer die Terminlogik: Rechnen vom Faelligkeitsdatum,
Vorruecken bei langem Liegenbleiben, der Monatsuebergang ueber kurze
Monate, und dass die Schleife auch bei einem Datum von 1970 endet.

Dazu ein Ende-zu-Ende-Test ueber beide Wege nach ERLEDIGT und den Nachweis,
dass ein erneutes Speichern keine zweite Fassung erzeugt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:40 +02:00
florian.wildeandClaude Opus 5 04a07bd36c fix(ui): Kontrast gedaempfter Texte auf WCAG AA bringen
Eine Durchsicht mit axe-core ueber zehn Seitenzustaende - Dialoge einzeln,
sie stehen geschlossen nicht im DOM - fand 26 Verstoesse, alle derselben
Art: slate-500 kommt auf dem sehr dunklen Untergrund nur auf 3,8 zu 1,
slate-600 sogar auf 2,5. Verlangt sind 4,5 fuer normalen Text, und beide
Stufen werden reichlich fuer Hinweise und Nebenangaben benutzt.

Statt an hundert Stellen die Klasse zu tauschen, sind die beiden Stufen im
Theme aufgehellt. Der Abstand zwischen ihnen bleibt sichtbar, der Rest des
Entwurfs unangetastet. Geprueft gegen alle drei vorkommenden Untergruende;
der schlechteste Wert zaehlt.

Damit stimmt auch, was FEATURES.md ueber Barrierefreiheit behauptet - bisher
stand die Behauptung ungeprueft da.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:40 +02:00
florian.wildeandClaude Opus 5 a3093dc68a feat(tasks): wiederkehrende Aufgaben
Taeglich, woechentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe
nach ERLEDIGT, entsteht die naechste von selbst. Die erledigte bleibt
stehen - sonst ginge die Historie verloren.

Gerechnet wird vom Faelligkeitsdatum, nicht vom Abhaken: eine woechentliche
Aufgabe bleibt auf ihrem Montag, auch wenn sie einmal erst am Mittwoch
erledigt wurde. Lange Liegengebliebenes rutscht so weit vor, bis der Termin
in der Zukunft liegt; eine Aufgabe, die schon bei ihrer Entstehung
ueberfaellig ist, hilft niemandem.

Beim Monatsintervall bleibt der Tag im Monat erhalten. setMonth allein
rollt ueber: der 31. Januar wuerde zum 3. Maerz statt zum 28. Februar.

Ausgeloest wird an beiden Stellen, an denen eine Aufgabe erledigt werden
kann - im Dialog und durch Ziehen ins Brett. Nur der Uebergang zaehlt: eine
bereits erledigte Aufgabe erneut zu speichern erzeugt keine zweite Fassung.

Bewusst grob gehalten. Feinere Regeln wie "jeden zweiten Dienstag im Monat"
braucht ein Projektdashboard nicht, und sie waeren ohne Kalenderansicht
kaum nachvollziehbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:39 +02:00
florian.wildeandClaude Opus 5 52cfb075e3 test: Heatmap und Vorlagen abdecken, Doku, Version 1.2.0
CI / pruefen (push) Has been cancelled
Elf Tests fuer die Seitenlogik des Kalenders: Deckel, Abbruch bei
unvollstaendiger Seite, Commits mit Datum in der Zukunft, und dass der
Zeitraum beim aeltesten bekannten Commit beginnt statt vor einem Jahr.

Dazu ein Ende-zu-Ende-Test fuer Vorlagen: sichern, anwenden, Datentrennung
gegen ein zweites Konto, und dass die Vorlage das Loeschen ihres
Ursprungsprojekts ueberlebt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:38 +02:00
florian.wildeandClaude Opus 5 57c50cfb7f feat(projects): Projektvorlagen
Ein Projekt, das sich bewaehrt hat, laesst sich im Projektkopf als Vorlage
sichern: Status, Prioritaet, Farbe, Tags, Notizen und offene Aufgaben. Beim
Anlegen steht sie oben im Dialog zur Auswahl.

Benutzereigen statt fest eingebaut. Welche Tags und welche Startaufgaben
sinnvoll sind, weiss nur, wer damit arbeitet - eine mitgelieferte Liste
waere geraten.

Zwei Dinge gehen bewusst nicht mit. Erledigte Aufgaben nicht: eine Vorlage
beschreibt, was zu tun ist, nicht was einmal getan wurde. Und Repositories
nicht: eine Vorlage, die jedes neue Projekt an dasselbe Repository haengt,
waere eine Falle.

Die Stammdaten uebernimmt der Dialog sichtbar in die Felder, damit man sie
vor dem Speichern noch aendern kann; bereits Getipptes bleibt stehen.
Notizen und Aufgaben legt erst der Server an - im Dialog liessen sie sich
nicht darstellen, und wer die Vorlage wieder abwaehlt, soll sie nicht
mitgeschleppt bekommen.

templateId gilt nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus
einer Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:38 +02:00
florian.wildeandClaude Opus 5 ca6181b58f feat(git): Commit-Heatmap ueber ein Jahr
Ein Feld je Tag auf der Projektseite, Wochen als Spalten, mit Farbskala und
Datum im Tooltip.

Woher die Zahlen kommen, unterscheidet sich je Anbieter. GitHub fuehrt die
Statistik selbst und liefert 52 Wochen mit Tageswerten in einem einzigen
Aufruf. GitLab und Gitea kennen nichts Vergleichbares; dort werden Commits
seitenweise geholt, gedeckelt auf fuenf Seiten - genug fuer die allermeisten
Repositories und wenig genug, um kein fremdes Kontingent zu sprengen.

Gezeichnet wird nur der Zeitraum, fuer den wirklich Daten vorliegen. Reichen
die Seiten nur ein paar Monate zurueck, faengt die Heatmap dort an und sagt
es dazu. Ein leeres Feld wuerde sonst behaupten, an dem Tag sei nicht
gearbeitet worden, obwohl darueber nichts bekannt ist - genau diesen Fehler
hatte die Verlaufslinie schon einmal.

Geholt wird hoechstens einmal am Tag und getrennt vom uebrigen Abgleich: an
einem Jahresueberblick aendert sich von Stunde zu Stunde nichts Sichtbares,
der Abgleich laeuft aber im Zweifel stuendlich.

Der Kalender benutzt getJson statt getJsonSafe. Waere ein Rate-Limit als
"keine Daten" durchgegangen, haette der gesetzte Zeitstempel die Heatmap
einen ganzen Tag lang unterdrueckt. Jetzt bleibt er bei einem Fehlschlag
ungesetzt, und der naechste Abgleich versucht es erneut - das deckt auch den
Fall ab, dass GitHub die Statistik gerade erst berechnet und mit 202
antwortet.

Nachgewiesen an vercel/swr: 49 Commits an 33 Tagen ueber ein volles Jahr aus
der Statistik, und mit kurzer Historie 90 statt 365 Felder samt Hinweis auf
den Deckel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:19 +02:00
45 changed files with 2587 additions and 48 deletions
+7
View File
@@ -102,6 +102,13 @@ Bei Änderungen an der Oberfläche **hinsehen**. Chrome ist installiert, ein
Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots. Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots.
Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen. Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen.
**Barrierefreiheit misst man, statt sie zu behaupten.** `axe-core` liegt als
Entwicklungsabhängigkeit bereit; ein Skript hängt es per `addScriptTag` in
die laufende Seite und ruft `axe.run` mit den WCAG-Tags auf. Jeder
Dialogzustand ist eine eigene Prüfung — geschlossene Dialoge stehen nicht im
DOM. Der Kontrast gedämpfter Texte ist der Punkt, der hier schon einmal
gerissen wurde; die aufgehellten Stufen stehen in `globals.css` im `@theme`.
Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`), Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`),
muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein
JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden
+80 -4
View File
@@ -138,6 +138,24 @@ Bedienung über Tastatur: Pfeile wählen, `Enter` öffnet, `Esc` schließt. Die
wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden
wäre Verschwendung. wäre Verschwendung.
### Projektvorlagen
Ein Projekt, das sich bewährt hat, lässt sich im Projektkopf über **Als Vorlage sichern**
festhalten: Status, Priorität, Farbe, Tags, Notizen und offene Aufgaben. Beim Anlegen eines
neuen Projekts steht die Vorlage oben im Dialog zur Auswahl.
Bewusst benutzereigen statt fest eingebaut — welche Tags und welche Startaufgaben sinnvoll
sind, weiß nur, wer damit arbeitet. Eine mitgelieferte Liste wäre geraten.
- **Erledigte Aufgaben kommen nicht mit.** Eine Vorlage beschreibt, was zu tun ist, nicht
was einmal getan wurde.
- **Repositories gehen nicht mit.** Eine Vorlage, die jedes neue Projekt an dasselbe
Repository hängt, wäre eine Falle.
- Die Stammdaten füllt der Dialog **sichtbar** in die Felder, damit sie vor dem Speichern
noch änderbar sind; bereits Getipptes wird nicht überschrieben. Notizen und Aufgaben legt
erst der Server an — im Dialog ließen sie sich nicht darstellen.
- Vorlagen überleben das Löschen ihres Ursprungsprojekts.
### Mehrfachauswahl ### Mehrfachauswahl
Ein Kästchen auf jeder Karte — es erscheint erst beim Überfahren, damit nicht auf jeder Ein Kästchen auf jeder Karte — es erscheint erst beim Überfahren, damit nicht auf jeder
@@ -189,6 +207,23 @@ Blockiert · Erledigt**.
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen - Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
wieder gelöscht. wieder gelöscht.
### Wiederkehrende Aufgaben
Täglich, wöchentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe nach
**Erledigt** — im Dialog oder durch Ziehen —, entsteht die nächste von selbst. Die
erledigte bleibt stehen, sonst ginge die Historie verloren.
- **Gerechnet wird vom Fälligkeitsdatum, nicht vom Abhaken.** Eine wöchentliche Aufgabe
bleibt auf ihrem Montag, auch wenn sie einmal erst am Mittwoch erledigt wurde.
- Lange Liegengebliebenes rutscht so weit vor, bis der Termin in der Zukunft liegt — eine
Aufgabe, die schon bei ihrer Entstehung überfällig ist, hilft niemandem.
- Beim Monatsintervall bleibt der Tag im Monat erhalten. Der 31. Januar wird zum
28. Februar, nicht zum 3. März.
- Nur der **Übergang** nach Erledigt löst aus. Eine bereits erledigte Aufgabe erneut zu
speichern erzeugt keine zweite Fassung.
- Bewusst grob gehalten: feinere Regeln („jeden zweiten Dienstag im Monat") braucht ein
Projektdashboard nicht und wären ohne Kalenderansicht kaum nachvollziehbar.
### Aus Git-Issues übernehmen ### Aus Git-Issues übernehmen
Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel, Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel,
@@ -264,6 +299,29 @@ App und Infrastruktur. Sie gehören trotzdem in **eine** Karte.
Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das
Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste. Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste.
### 4.0a README-Vorschau
Das README des führenden Repositories steht auf der Projektseite — man muss nicht ins Git
wechseln, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging. Lange
READMEs sind eingeklappt, ein Knopf zeigt alles.
- Geholt wird **höchstens einmal am Tag**, wie die Heatmap. GitHub hat einen eigenen
Endpunkt, der jede Schreibweise findet; bei GitLab und Gitea braucht es zwei Aufrufe
(Wurzelverzeichnis auflisten, dann die Datei holen).
- Erkannt werden `README.md` und die üblichen Varianten, Groß- und Kleinschreibung egal.
Markdown hat Vorrang — nur das lässt sich wirklich darstellen.
- Bei 60 000 Zeichen wird an einer **Zeilengrenze** abgeschnitten, damit kein halber
Codeblock stehen bleibt.
- **Relative Links** werden gegen das Verzeichnis der Datei im Repository aufgelöst, sonst
liefen sie ins Leere.
- **Bilder werden nicht geladen.** Die Content-Security-Policy erlaubt `img-src` nur von
der eigenen Instanz; sie dafür zu öffnen hieße, jedem fremden README das Nachladen
beliebiger Bilder zu gestatten — also auch Zählpixeln. Für Abzeichen und
Bildschirmfotos, die in einer Vorschau ohnehin Beiwerk sind, ist das kein guter Tausch.
Stattdessen steht ein anklickbarer Verweis mit dem Alternativtext.
- Der Inhalt stammt aus einem fremden Repository und läuft deshalb durch dieselbe
Bereinigung wie Notizen (`rehype-sanitize`).
### 4.1 Unterstützte Systeme ### 4.1 Unterstützte Systeme
| System | API | Authentifizierung | Besonderheit | | System | API | Authentifizierung | Besonderheit |
@@ -301,6 +359,7 @@ Firmen-Gitea nebeneinander.
| Tags | bis zu 25, mit SHA, Nachricht und Datum | | Tags | bis zu 25, mit SHA, Nachricht und Datum |
| Commits | die letzten 15 zur Anzeige; geholt werden 100 für die Aktivitätskurve | | Commits | die letzten 15 zur Anzeige; geholt werden 100 für die Aktivitätskurve |
| **Commit-Aktivität** | Balkenreihe der letzten Tage, auf Karte und Projektseite | | **Commit-Aktivität** | Balkenreihe der letzten Tage, auf Karte und Projektseite |
| **Commit-Heatmap** | Ein Feld je Tag über bis zu ein Jahr, auf der Projektseite |
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung | | Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| Sprachen | Verteilung als Balken mit Prozentwerten | | Sprachen | Verteilung als Balken mit Prozentwerten |
@@ -327,9 +386,19 @@ bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
| Einstellung | Bedeutung | | Einstellung | Bedeutung |
| --- | --- | | --- | --- |
| Abstand | 15 Minuten bis täglich | | Abstand | 15 Minuten bis täglich |
| Projekte je Durchlauf | 1–100, Voreinstellung 25 | | Repositories je Durchlauf | 1–100, Voreinstellung 25 |
| Fertige und archivierte auslassen | Dort ändert sich selten noch etwas | | Fertige und archivierte auslassen | Dort ändert sich selten noch etwas |
Gezählt und begrenzt wird je **Repository**, nicht je Projekt: sonst zöge ein Projekt mit
fünf Repos fünfmal so viele Anfragen nach sich, wie sein Platz im Kontingent hergibt.
Die Daten für die **Heatmap** kommen getrennt und höchstens einmal am Tag — an einem
Jahresüberblick ändert sich von Stunde zu Stunde nichts Sichtbares. Bei GitHub genügt dafür
ein Aufruf der Jahresstatistik; GitLab und Gitea kennen keine, dort werden bis zu fünf
Seiten Commits geholt. Reicht das nicht ein Jahr zurück, **beginnt die Heatmap beim
ältesten bekannten Commit** und sagt es dazu — leere Felder für Zeiträume zu zeichnen, über
die nichts bekannt ist, wäre eine Lüge.
Wie der Durchlauf arbeitet: Wie der Durchlauf arbeitet:
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob - Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
@@ -473,8 +542,14 @@ letzten Zustellung stehen an der Karte.
- **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand, - **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand,
animierte Fortschrittsbalken. animierte Fortschrittsbalken.
- **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten). - **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten).
- **Barrierearm**: durchgehende ARIA-Labels, Escape schließt Dialoge, sichtbarer - **Barrierefrei nach WCAG 2.1 AA**, und zwar geprüft statt behauptet: axe-core läuft über
Fokusring, `prefers-reduced-motion` schaltet Animationen ab. zehn Seitenzustände einschließlich der Dialoge und meldet keine Verstöße. Durchgehende
ARIA-Labels, Escape schließt Dialoge, sichtbarer Fokusring, `prefers-reduced-motion`
schaltet Animationen ab.
Bei der ersten Durchsicht fielen 26 Kontrastverstöße auf: gedämpfter Text erreichte auf
dem sehr dunklen Untergrund nur 3,8 statt 4,5 zu 1. Statt an hundert Stellen die Klasse
zu tauschen, sind die beiden gedämpften Stufen im Theme aufgehellt — der Abstand
zwischen ihnen bleibt sichtbar.
- **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone - **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen. Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
- **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy - **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy
@@ -503,7 +578,8 @@ letzten Zustellung stehen an der Karte.
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) | | `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Besitzer | | `Project` | Projektstammdaten, Status, Fortschritt, Besitzer |
| `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) | | `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels und Issue-Herkunft | | `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels, Wiederholung und Issue-Herkunft |
| `ProjectTemplate` | Vorlagen je Konto: Stammdaten, Tags, Notizen und Aufgaben |
| `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt | | `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt |
| `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis | | `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) | | `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
+5
View File
@@ -341,6 +341,9 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` | `/api/projects/export` | Alle eigenen Projekte als JSON | | `GET` | `/api/projects/export` | Alle eigenen Projekte als JSON |
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) | | `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau | | `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
| `GET` `POST` | `/api/templates` | Projektvorlagen auflisten / anlegen |
| `PATCH` `DELETE` | `/api/templates/:id` | Vorlage ändern / löschen |
| `POST` | `/api/templates/from-project` | Aus einem Projekt eine Vorlage machen |
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) | | `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
| `GET` | `/api/search?q=` | Volltextsuche über die eigenen Notizen | | `GET` | `/api/search?q=` | Volltextsuche über die eigenen Notizen |
| `GET` `POST` | `/api/notifications` | Benachrichtigungsziele | | `GET` `POST` | `/api/notifications` | Benachrichtigungsziele |
@@ -348,6 +351,8 @@ ausschließlich Daten des angemeldeten Kontos.
| `POST` | `/api/notifications/:id/test` | Testnachricht senden | | `POST` | `/api/notifications/:id/test` | Testnachricht senden |
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen | | `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `POST` | `/api/projects/:id/sync` | Alle Repositories des Projekts abgleichen | | `POST` | `/api/projects/:id/sync` | Alle Repositories des Projekts abgleichen |
| `PATCH` | `/api/projects/reorder` | Neue Reihenfolge im Brett |
| `POST` | `/api/projects/bulk` | Status, Tags oder Löschen für mehrere |
| `GET` | `/api/projects/:id/activity` | Verlauf des Projekts (`?limit=`) | | `GET` | `/api/projects/:id/activity` | Verlauf des Projekts (`?limit=`) |
| `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts | | `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts |
| `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen | | `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen |
+29 -13
View File
@@ -20,51 +20,64 @@ erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
--- ---
## 2 · Danach ### 1.2 Läuft es noch? `M`
### 2.1 Commit-Aktivität als Heatmap `S` Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt.
Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft.
Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz
greift dabei wie bei den Git-Aufrufen.
Die Sparkline gibt es auf Karte und Projektseite. Eine Heatmap über ein Jahr ### 1.3 Persönliche API-Token `M`
wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine
Seite Commits, also Paginierung oder die Statistik-Endpunkte. Derzeit geht die API nur mit Sitzungscookie. Ein Token je Konto würde
`curl`-Aufrufe und Handy-Shortcuts erlauben — Idee aus dem Terminal anlegen,
ohne den Browser zu öffnen. Nur der Hash in der Datenbank, wie bei den
Sitzungen.
--- ---
## 3 · Größere Brocken ## 2 · Größere Brocken
### 3.1 Passwort-Zurücksetzen per E-Mail `M` ### 2.1 Passwort-Zurücksetzen per E-Mail `M`
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb. und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
### 3.2 Mehrsprachigkeit `L` ### 2.2 Mehrsprachigkeit `L`
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
Voraussetzung, falls du das Projekt veröffentlichen willst. Voraussetzung, falls du das Projekt veröffentlichen willst.
--- ---
## 4 · Kleinigkeiten ## 3 · Kleinigkeiten
| Idee | Aufwand | Nutzen | | Idee | Aufwand | Nutzen |
| --- | --- | --- | | --- | --- | --- |
| Wochenrückblick | `S` | „Was ist diese Woche passiert" über alle Projekte, aus dem Aktivitätsverlauf |
| Globale Zeitleiste | `S` | Derselbe Verlauf, aber projektübergreifend als eigene Seite |
| Suche über Aufgaben | `S` | Die Volltextsuche deckt bisher nur Notizen ab |
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste | | Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" | | Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt |
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen | | Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel | | Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` | | Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat | | Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
--- ---
## 5 · Bewusst nicht ## 4 · Bewusst nicht
Damit klar ist, was **nicht** kommt und warum: Damit klar ist, was **nicht** kommt und warum:
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und - **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten. sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
- **Veraltete Abhängigkeiten prüfen.** Klingt verlockend, aber Renovate und
Dependabot machen das besser, und es wären viele Aufrufe an fremde
Registries — für eine Zahl, die im Repo selbst schon steht.
- **Anhänge an Notizen.** Bräuchte Dateispeicher und damit eine zweite Sorge
bei jeder Sicherung. Ein Link ins Git tut es auch.
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine - **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
Übersicht, keine Entwicklungsumgebung. Übersicht, keine Entwicklungsumgebung.
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle. - **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
@@ -94,6 +107,9 @@ Damit klar ist, was **nicht** kommt und warum:
| **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ | | **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ |
| **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ | | **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ |
| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ | | **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
| **v1.2.0** Commit-Heatmap über ein Jahr, Projektvorlagen | ✓ |
| **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ |
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
+158
View File
@@ -374,6 +374,164 @@ test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) =
expect((await geloescht.json()).changed).toBe(3); expect((await geloescht.json()).changed).toBe(3);
}); });
test("Wiederkehrende Aufgaben legen die naechste Fassung an", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Wiederkehrend" } })).json()
).project;
const woechentlich = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, {
data: {
title: "Abhaengigkeiten pruefen",
dueDate: "2026-07-06T12:00:00",
recurrence: "WEEKLY",
labels: ["wartung"],
},
})
).json()
).task;
// Ein Zwischenschritt loest noch nichts aus.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { status: "DOING" } });
let alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(1);
// Erst der Uebergang nach ERLEDIGT.
const erledigt = await request.patch(`/api/tasks/${woechentlich.id}`, {
data: { status: "DONE" },
});
expect((await erledigt.json()).next).not.toBeNull();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
const folge = alle.find((task: { id: string }) => task.id !== woechentlich.id);
expect(folge.status).toBe("TODO");
expect(folge.title).toBe("Abhaengigkeiten pruefen");
expect(folge.labels).toEqual(["wartung"]);
expect(folge.recurrence).toBe("WEEKLY");
expect(folge.recurredFrom).toBe(woechentlich.id);
// Der Termin liegt in der Zukunft und auf demselben Wochentag.
expect(new Date(folge.dueDate).getTime()).toBeGreaterThan(Date.now());
expect(new Date(folge.dueDate).getUTCDay()).toBe(1);
// Erneut speichern erzeugt keine dritte.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { title: "Umbenannt" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
// Der zweite Weg: durch Ziehen in die Spalte ERLEDIGT.
const gezogen = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { DONE: [folge.id] } },
});
expect(gezogen.ok()).toBeTruthy();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(3);
expect(alle.filter((task: { status: string }) => task.status === "TODO")).toHaveLength(1);
// Eine einmalige Aufgabe bleibt einmalig.
const einmalig = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, { data: { title: "Nur einmal" } })
).json()
).task;
await request.patch(`/api/tasks/${einmalig.id}`, { data: { status: "DONE" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(4);
});
test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Projekt herrichten, das als Vorlage taugt.
const quelle = (
await (
await request.post("/api/projects", {
data: { name: "Vorlagenquelle", status: "PLANNING", priority: 3, tags: ["web", "docker"] },
})
).json()
).project;
await request.post(`/api/projects/${quelle.id}/notes`, {
data: { content: "Checkliste vor dem Start." },
});
const erledigt = (
await (
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Schon fertig" } })
).json()
).task;
await request.patch(`/api/tasks/${erledigt.id}`, { data: { status: "DONE" } });
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Repo anlegen" } });
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "CI einrichten" } });
const gesichert = await request.post("/api/templates/from-project", {
data: { projectId: quelle.id, name: "Web-App", description: "Mit Docker und CI" },
});
expect(gesichert.status()).toBe(201);
const vorlage = (await gesichert.json()).template;
expect(vorlage.tags).toEqual(["web", "docker"]);
expect(vorlage.status).toBe("PLANNING");
expect(vorlage.notes).toHaveLength(1);
// Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu tun ist.
expect(vorlage.tasks).toHaveLength(2);
expect(vorlage.tasks.map((task: { title: string }) => task.title)).not.toContain("Schon fertig");
// Derselbe Name ein zweites Mal wird abgewiesen.
const doppelt = await request.post("/api/templates/from-project", {
data: { projectId: quelle.id, name: "Web-App" },
});
expect(doppelt.status()).toBe(409);
// Anwenden: Notizen und Aufgaben entstehen mit dem Projekt.
const neu = (
await (
await request.post("/api/projects", {
data: { name: "Aus Vorlage", status: "IDEA", templateId: vorlage.id },
})
).json()
).project;
const geladen = (await (await request.get(`/api/projects/${neu.id}`)).json()).project;
expect(geladen.notes).toHaveLength(1);
expect(geladen.tasks).toHaveLength(2);
// Die Reihenfolge der Vorlage bleibt erhalten.
expect(geladen.tasks.map((task: { title: string }) => task.title)).toEqual([
"Repo anlegen",
"CI einrichten",
]);
// Der ausdrueckliche Status gewinnt gegen den der Vorlage.
expect(geladen.status).toBe("IDEA");
// Der Verlauf haelt die Herkunft fest.
const verlauf = (await (await request.get(`/api/projects/${neu.id}/activity`)).json()).activities;
expect(verlauf.some((entry: { summary: string }) => entry.summary.includes("Web-App"))).toBe(true);
// Eine fremde Vorlage laesst sich nicht unterschieben.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/templates")).json()).templates).toHaveLength(0);
const untergeschoben = await fremd.post("/api/projects", {
data: { name: "Klau", templateId: vorlage.id },
});
expect(untergeschoben.status()).toBe(404);
expect((await fremd.delete(`/api/templates/${vorlage.id}`)).status()).toBe(404);
await fremd.dispose();
// Und die Vorlage ueberlebt das Loeschen ihres Ursprungsprojekts.
await request.delete(`/api/projects/${quelle.id}`);
const nochDa = (await (await request.get("/api/templates")).json()).templates;
expect(nochDa).toHaveLength(1);
expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy();
});
test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => { test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
await login(request, ADMIN); await login(request, ADMIN);
+13 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "1.0.0", "version": "1.2.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -34,6 +34,7 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
@@ -2536,6 +2537,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/axe-core": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
"integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/bail": { "node_modules/bail": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.1.0", "version": "1.4.0",
"private": true, "private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": { "scripts": {
@@ -56,6 +56,7 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "commitCalendar" JSONB NOT NULL DEFAULT '{}',
ADD COLUMN "commitCalendarAt" TIMESTAMP(3);
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "ProjectTemplate" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"summary" TEXT,
"projectDescription" TEXT,
"status" "ProjectStatus" NOT NULL DEFAULT 'IDEA',
"priority" INTEGER NOT NULL DEFAULT 2,
"accent" TEXT NOT NULL DEFAULT 'violet',
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"progressFromTasks" BOOLEAN NOT NULL DEFAULT false,
"notes" JSONB NOT NULL DEFAULT '[]',
"tasks" JSONB NOT NULL DEFAULT '[]',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProjectTemplate_ownerId_idx" ON "ProjectTemplate"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectTemplate_ownerId_name_key" ON "ProjectTemplate"("ownerId", "name");
-- AddForeignKey
ALTER TABLE "ProjectTemplate" ADD CONSTRAINT "ProjectTemplate_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "Recurrence" AS ENUM ('DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "recurredFrom" TEXT,
ADD COLUMN "recurrence" "Recurrence";
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "readme" TEXT,
ADD COLUMN "readmeAt" TIMESTAMP(3),
ADD COLUMN "readmeBaseUrl" TEXT,
ADD COLUMN "readmePath" TEXT;
+72
View File
@@ -71,6 +71,16 @@ enum ActivityKind {
SYNC_FAILED SYNC_FAILED
} }
/// Wie oft sich eine Aufgabe wiederholt. Bewusst grob: feinere Regeln
/// (jeden zweiten Dienstag im Monat) braucht ein Projektdashboard nicht,
/// und sie waeren ohne Kalenderansicht kaum nachvollziehbar.
enum Recurrence {
DAILY
WEEKLY
BIWEEKLY
MONTHLY
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts. /// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus { enum TaskStatus {
TODO TODO
@@ -127,6 +137,7 @@ model User {
activities Activity[] activities Activity[]
recoveryCodes RecoveryCode[] recoveryCodes RecoveryCode[]
pendingLogins PendingLogin[] pendingLogins PendingLogin[]
templates ProjectTemplate[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -449,6 +460,15 @@ model Task {
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist. /// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime? doneAt DateTime?
/// Wiederholt sich die Aufgabe? Wandert sie nach ERLEDIGT, entsteht die
/// naechste Fassung mit fortgeschriebenem Faelligkeitsdatum. Die erledigte
/// bleibt stehen – sonst ginge die Historie verloren.
recurrence Recurrence?
/// Aus welcher Aufgabe diese hervorgegangen ist. Nur zur Nachvollziehbarkeit;
/// bewusst ohne Fremdschluessel, damit das Loeschen der Vorgaengerin die
/// Nachfolgerin nicht mitnimmt.
recurredFrom String?
/// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link /// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link
/// merken, damit dasselbe Issue nicht zweimal uebernommen wird. /// merken, damit dasselbe Issue nicht zweimal uebernommen wird.
sourceIssue Int? sourceIssue Int?
@@ -466,6 +486,41 @@ model Task {
@@index([projectId, status, position]) @@index([projectId, status, position])
} }
/// Eine Vorlage fuer neue Projekte: Stammdaten, Tags, Notizen und Aufgaben.
///
/// Bewusst benutzereigen und nicht fest eingebaut. Welche Tags und welche
/// Startaufgaben sinnvoll sind, weiss nur, wer damit arbeitet – eine
/// mitgelieferte Liste waere geraten.
model ProjectTemplate {
id String @id @default(cuid())
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
name String
/// Wozu die Vorlage gut ist – erscheint in der Auswahl.
description String?
// ── Was die Vorlage in das neue Projekt schreibt ───────────
summary String?
projectDescription String?
status ProjectStatus @default(IDEA)
priority Int @default(2)
accent String @default("violet")
tags String[] @default([])
progressFromTasks Boolean @default(false)
/// [{ title, content, pinned }]
notes Json @default("[]")
/// [{ title, description, status, labels }]
tasks Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, name])
@@index([ownerId])
}
/// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert. /// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert.
model Activity { model Activity {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -550,6 +605,23 @@ model RepoSnapshot {
/// Commits je Tag der letzten 30 Tage, aeltester zuerst. /// Commits je Tag der letzten 30 Tage, aeltester zuerst.
commitActivity Json @default("[]") commitActivity Json @default("[]")
/// Commits je Tag ueber bis zu ein Jahr – die Heatmap.
/// { from, to, days: { "2026-07-26": 3 }, source, truncated }
/// Getrennt vom uebrigen Stand, weil das Holen zusaetzliche Aufrufe kostet
/// und deshalb hoechstens einmal am Tag geschieht.
commitCalendar Json @default("{}")
commitCalendarAt DateTime?
/// Das README des Repositories, roh als Markdown. Wie der Kalender
/// hoechstens einmal am Tag geholt – es aendert sich selten, und der
/// Abgleich laeuft im Zweifel stuendlich.
readme String?
/// Dateiname, wie er im Repository steht ("README.md", "readme.rst" …).
readmePath String?
/// Wogegen relative Links im README aufzuloesen sind.
readmeBaseUrl String?
readmeAt DateTime?
// ── Letzter CI-Lauf auf dem Default-Branch ───────────────── // ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled /// success | failure | running | pending | cancelled
ciState String? ciState String?
@@ -3,7 +3,7 @@ import type { Prisma, TaskStatus } from "@prisma/client";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject, refreshDerivedProgress } from "@/lib/tasks"; import { ownedProject, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
import { formatZodError, taskReorderSchema } from "@/lib/validation"; import { formatZodError, taskReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -52,11 +52,14 @@ export async function PATCH(request: Request, { params }: Params) {
const now = new Date(); const now = new Date();
const updates: Prisma.PrismaPromise<unknown>[] = []; const updates: Prisma.PrismaPromise<unknown>[] = [];
/** Wer eben erst nach ERLEDIGT gewandert ist – nicht, wer dort schon lag. */
const frischErledigt: string[] = [];
for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) { for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) {
ids.forEach((taskId, index) => { ids.forEach((taskId, index) => {
if (!ownedIds.has(taskId)) return; if (!ownedIds.has(taskId)) return;
const before = previousStatus.get(taskId); const before = previousStatus.get(taskId);
if (status === "DONE" && before !== "DONE") frischErledigt.push(taskId);
updates.push( updates.push(
prisma.task.update({ prisma.task.update({
where: { id: taskId }, where: { id: taskId },
@@ -77,6 +80,14 @@ export async function PATCH(request: Request, { params }: Params) {
if (updates.length > 0) { if (updates.length > 0) {
await prisma.$transaction(updates); await prisma.$transaction(updates);
// Wiederkehrende Aufgaben, die eben nach ERLEDIGT gezogen wurden,
// bekommen ihre naechste Fassung. Erst nach der Transaktion: vorher
// stuende doneAt noch nicht, aus dem der Folgetermin gerechnet wird.
for (const taskId of frischErledigt) {
await spawnRecurrence(taskId);
}
await refreshDerivedProgress(id); await refreshDerivedProgress(id);
} }
+2 -1
View File
@@ -46,7 +46,7 @@ export async function POST(request: Request, { params }: Params) {
); );
} }
const { title, description, status, dueDate, labels } = parsed.data; const { title, description, status, dueDate, labels, recurrence } = parsed.data;
const task = await prisma.task.create({ const task = await prisma.task.create({
data: { data: {
@@ -58,6 +58,7 @@ export async function POST(request: Request, { params }: Params) {
position: await nextPosition(id, status), position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null, dueDate: dueDate ? new Date(dueDate) : null,
labels, labels,
recurrence: recurrence ?? null,
doneAt: status === "DONE" ? new Date() : null, doneAt: status === "DONE" ? new Date() : null,
}, },
}); });
+64 -3
View File
@@ -74,6 +74,20 @@ export async function POST(request: Request) {
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404); if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
} }
// Aus der Vorlage kommen nur Notizen und Aufgaben. Die Stammdaten hat die
// Oberflaeche schon in die Felder uebernommen, damit man sie vor dem
// Speichern noch sieht und aendern kann.
const vorlage = data.templateId
? await prisma.projectTemplate.findFirst({
where: { id: data.templateId, ownerId: auth.user.id },
select: { name: true, notes: true, tasks: true },
})
: null;
if (data.templateId && !vorlage) return jsonError("Vorlage nicht gefunden.", 404);
const notes = templateNotes(vorlage?.notes);
const tasks = templateTasks(vorlage?.tasks);
const angelegt = await prisma.project.create({ const angelegt = await prisma.project.create({
data: { data: {
ownerId: auth.user.id, ownerId: auth.user.id,
@@ -87,8 +101,11 @@ export async function POST(request: Request) {
accent: data.accent, accent: data.accent,
tags: data.tags, tags: data.tags,
favorite: data.favorite, favorite: data.favorite,
progressFromTasks: data.progressFromTasks,
// Hinten an die Statusspalte des Bretts. // Hinten an die Statusspalte des Bretts.
position: await nextProjectPosition(auth.user.id, data.status), position: await nextProjectPosition(auth.user.id, data.status),
notes: { create: notes },
tasks: { create: tasks },
}, },
select: { id: true }, select: { id: true },
}); });
@@ -111,12 +128,56 @@ export async function POST(request: Request) {
projectId: project.id, projectId: project.id,
userId: auth.user.id, userId: auth.user.id,
kind: "PROJECT_CREATED", kind: "PROJECT_CREATED",
summary: summary: vorlage
data.status === "IDEA" ? `Aus Vorlage „${vorlage.name}“ angelegt`
: data.status === "IDEA"
? "Als Idee angelegt" ? "Als Idee angelegt"
: `Angelegt mit Status „${statusOf(data.status).label}“`, : `Angelegt mit Status „${statusOf(data.status).label}“`,
meta: { status: data.status }, meta: {
status: data.status,
...(vorlage ? { template: vorlage.name, notes: notes.length, tasks: tasks.length } : {}),
},
}); });
return NextResponse.json({ project }, { status: 201 }); return NextResponse.json({ project }, { status: 201 });
} }
/**
* Die Vorlage kommt als JSON aus der Datenbank und ist damit fuer TypeScript
* unbekannt. Hier wird sie auf das Noetige zurechtgeschnitten – lieber einen
* Eintrag auslassen als am Anlegen scheitern.
*/
function templateNotes(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
.filter((entry) => typeof entry.content === "string" && entry.content.trim().length > 0)
.slice(0, 50)
.map((entry) => ({
title: typeof entry.title === "string" ? entry.title : null,
content: entry.content as string,
pinned: entry.pinned === true,
}));
}
function templateTasks(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
.filter((entry) => typeof entry.title === "string" && entry.title.trim().length > 0)
.slice(0, 100)
.map((entry, index) => ({
title: entry.title as string,
description: typeof entry.description === "string" ? entry.description : null,
status: (["TODO", "DOING", "BLOCKED"] as const).includes(
entry.status as "TODO" | "DOING" | "BLOCKED",
)
? (entry.status as "TODO" | "DOING" | "BLOCKED")
: ("TODO" as const),
// Reihenfolge wie in der Vorlage.
position: index,
labels: Array.isArray(entry.labels)
? entry.labels.filter((label): label is string => typeof label === "string").slice(0, 8)
: [],
}));
}
+21 -4
View File
@@ -4,7 +4,12 @@ import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
import { TASK_STATUS_META, nextPosition, refreshDerivedProgress } from "@/lib/tasks"; import {
TASK_STATUS_META,
nextPosition,
refreshDerivedProgress,
spawnRecurrence,
} from "@/lib/tasks";
import { formatZodError, taskUpdateSchema } from "@/lib/validation"; import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -44,6 +49,7 @@ export async function PATCH(request: Request, { params }: Params) {
if (data.title !== undefined) update.title = data.title; if (data.title !== undefined) update.title = data.title;
if (data.description !== undefined) update.description = data.description; if (data.description !== undefined) update.description = data.description;
if (data.labels !== undefined) update.labels = data.labels; if (data.labels !== undefined) update.labels = data.labels;
if (data.recurrence !== undefined) update.recurrence = data.recurrence;
if (data.dueDate !== undefined) { if (data.dueDate !== undefined) {
update.dueDate = data.dueDate ? new Date(data.dueDate) : null; update.dueDate = data.dueDate ? new Date(data.dueDate) : null;
} }
@@ -57,6 +63,15 @@ export async function PATCH(request: Request, { params }: Params) {
} }
const task = await prisma.task.update({ where: { id }, data: update }); const task = await prisma.task.update({ where: { id }, data: update });
// Genau beim Uebergang nach ERLEDIGT entsteht die naechste Fassung. Ein
// erneutes Speichern einer schon erledigten Aufgabe darf keine zweite
// erzeugen – deshalb haengt es an update.status und nicht an data.status.
const folge =
update.status === "DONE" && existing.status !== "DONE"
? await spawnRecurrence(id)
: null;
await refreshDerivedProgress(existing.projectId); await refreshDerivedProgress(existing.projectId);
// Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder // Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder
@@ -68,12 +83,14 @@ export async function PATCH(request: Request, { params }: Params) {
projectId: existing.projectId, projectId: existing.projectId,
userId: auth.user.id, userId: auth.user.id,
kind: "TASK_MOVED", kind: "TASK_MOVED",
summary: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`, summary: folge
meta: { taskId: id, from: existing.status, to: nach }, ? `Aufgabe „${shorten(task.title, 40)}“ erledigt, nächste angelegt`
: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
meta: { taskId: id, from: existing.status, to: nach, ...(folge ? { next: folge.id } : {}) },
}); });
} }
return NextResponse.json({ task }); return NextResponse.json({ task, next: folge });
} }
export async function DELETE(_request: Request, { params }: Params) { export async function DELETE(_request: Request, { params }: Params) {
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, templateUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await prisma.projectTemplate.findFirst({
where: { id, ownerId: auth.user.id },
select: { id: true },
});
if (!existing) return jsonError("Vorlage nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = templateUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
if (data.name) {
const doppelt = await prisma.projectTemplate.findFirst({
where: { ownerId: auth.user.id, name: data.name, id: { not: id } },
select: { id: true },
});
if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409);
}
const template = await prisma.projectTemplate.update({ where: { id }, data });
return NextResponse.json({ template });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// deleteMany mit Besitzerbedingung: trifft fremde Vorlagen gar nicht erst.
const result = await prisma.projectTemplate.deleteMany({
where: { id, ownerId: auth.user.id },
});
if (result.count === 0) return jsonError("Vorlage nicht gefunden", 404);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, templateFromProjectSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Aus einem vorhandenen Projekt eine Vorlage machen.
*
* Das ist der Weg, auf dem Vorlagen ueblicherweise entstehen: man richtet ein
* Projekt so ein, wie es passt, und haelt das dann fest. Eine Vorlage von
* Grund auf zu tippen kann man auch – ueber POST /api/templates.
*
* Nicht mitgenommen wird alles Projektbezogene: Name, Slug, Fortschritt,
* Favorit und vor allem die Repositories. Eine Vorlage, die jedes neue
* Projekt an dasselbe Repository haengt, waere eine Falle.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = templateFromProjectSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { projectId, name, description, withNotes, withTasks } = parsed.data;
const project = await prisma.project.findFirst({
where: { id: projectId, ownerId: auth.user.id },
include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
},
});
if (!project) return jsonError("Projekt nicht gefunden", 404);
const doppelt = await prisma.projectTemplate.findFirst({
where: { ownerId: auth.user.id, name },
select: { id: true },
});
if (doppelt) return jsonError(`Eine Vorlage „${name}“ gibt es bereits.`, 409);
const template = await prisma.projectTemplate.create({
data: {
ownerId: auth.user.id,
name,
description: description ?? null,
summary: project.summary,
projectDescription: project.description,
status: project.status,
priority: project.priority,
accent: project.accent,
tags: project.tags,
progressFromTasks: project.progressFromTasks,
notes: withNotes
? project.notes.map((note) => ({
title: note.title,
content: note.content,
pinned: note.pinned,
}))
: [],
// Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu
// tun ist, nicht was einmal getan wurde.
tasks: withTasks
? project.tasks
.filter((task) => task.status !== "DONE")
.map((task) => ({
title: task.title,
description: task.description,
status: task.status,
labels: task.labels,
}))
: [],
},
});
return NextResponse.json({ template }, { status: 201 });
}
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, templateCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/** Hoechstzahl je Konto. Wer mehr braucht, hat keine Vorlagen mehr, sondern Projekte. */
const MAX_TEMPLATES = 30;
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const templates = await prisma.projectTemplate.findMany({
where: { ownerId: auth.user.id },
orderBy: { name: "asc" },
});
return NextResponse.json({ templates });
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = templateCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const vorhanden = await prisma.projectTemplate.count({ where: { ownerId: auth.user.id } });
if (vorhanden >= MAX_TEMPLATES) {
return jsonError(`Mehr als ${MAX_TEMPLATES} Vorlagen sind nicht vorgesehen.`, 409);
}
const data = parsed.data;
// Der Name ist je Konto eindeutig; ein Duplikat soll eine verstaendliche
// Meldung geben statt eines Datenbankfehlers.
const doppelt = await prisma.projectTemplate.findFirst({
where: { ownerId: auth.user.id, name: data.name },
select: { id: true },
});
if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409);
const template = await prisma.projectTemplate.create({
data: {
ownerId: auth.user.id,
name: data.name,
description: data.description ?? null,
summary: data.summary ?? null,
projectDescription: data.projectDescription ?? null,
status: data.status,
priority: data.priority,
accent: data.accent,
tags: data.tags,
progressFromTasks: data.progressFromTasks,
notes: data.notes,
tasks: data.tasks,
},
});
return NextResponse.json({ template }, { status: 201 });
}
+24
View File
@@ -124,6 +124,30 @@
} }
.md a:hover { color: #ddd6fe; } .md a:hover { color: #ddd6fe; }
/* Bilder werden nicht geladen, sondern als Marke gezeigt – siehe die
Begruendung in Markdown.tsx. Damit sie nicht wie Fliesstext aussieht,
bekommt sie einen Rahmen und ein Symbol davor. */
.md .md-image {
display: inline-flex;
align-items: center;
gap: 0.3em;
border: 1px dashed rgba(255, 255, 255, 0.16);
border-radius: 0.4rem;
padding: 0.05em 0.45em;
font-size: 0.85em;
color: #8a98b0;
text-decoration: none;
vertical-align: baseline;
}
.md .md-image::before {
content: "▣";
opacity: 0.6;
}
.md a.md-image:hover {
color: #c4b5fd;
border-color: rgba(196, 181, 253, 0.4);
}
.md strong { color: #f1f5f9; font-weight: 600; } .md strong { color: #f1f5f9; font-weight: 600; }
.md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; } .md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; }
+16
View File
@@ -16,6 +16,22 @@
--color-void-soft: #0a0c1a; --color-void-soft: #0a0c1a;
--color-haze: #12142b; --color-haze: #12142b;
/* ── Gedaempfte Textfarben ──────────────────────────────────
Tailwinds slate-500 und slate-600 kommen auf diesem sehr dunklen
Untergrund nur auf 3,8 bzw. 2,5 zu 1. WCAG AA verlangt 4,5 fuer
normalen Text, und beide werden hier reichlich fuer Hinweise und
Nebenangaben benutzt – axe-core hat das an 26 Stellen beanstandet.
Statt an hundert Stellen die Klasse zu tauschen, werden die beiden
Stufen hier aufgehellt. Der Abstand zwischen ihnen bleibt sichtbar,
der Rest des Entwurfs unangetastet.
Geprueft gegen die drei vorkommenden Untergruende #0b0d1c (Seite),
#0e0f17 (.glass) und #14141d (.popover) – der schlechteste Wert
zaehlt. */
--color-slate-500: #8a98b0; /* 6,3 : 1 */
--color-slate-600: #78879f; /* 5,0 : 1 */
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif; "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, --font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
+26
View File
@@ -5,6 +5,7 @@ import { ActivityFeed } from "@/components/ActivityFeed";
import { GitPanel } from "@/components/GitPanel"; import { GitPanel } from "@/components/GitPanel";
import { Markdown } from "@/components/Markdown"; import { Markdown } from "@/components/Markdown";
import { NotesPanel } from "@/components/NotesPanel"; import { NotesPanel } from "@/components/NotesPanel";
import { ReadmePanel } from "@/components/ReadmePanel";
import { ProjectHeader } from "@/components/ProjectHeader"; import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard"; import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard"; import { requireUserPage } from "@/lib/auth/guard";
@@ -54,6 +55,20 @@ export default async function ProjectPage({ params }: PageProps) {
const detail = project as unknown as ProjectDetail; const detail = project as unknown as ProjectDetail;
const fuehrend = detail.repositories[0]?.snapshot;
const readme = fuehrend?.readme
? {
content: fuehrend.readme,
path: fuehrend.readmePath,
baseUrl: fuehrend.readmeBaseUrl,
// Die Basis endet auf dem Verzeichnis; angehaengt ergibt das die Datei.
fileUrl:
fuehrend.readmeBaseUrl && fuehrend.readmePath
? `${fuehrend.readmeBaseUrl}${fuehrend.readmePath.split("/").pop()}`
: (fuehrend.htmlUrl ?? null),
}
: null;
return ( return (
<div className="space-y-6 pt-4"> <div className="space-y-6 pt-4">
<ProjectHeader <ProjectHeader
@@ -83,6 +98,17 @@ export default async function ProjectPage({ params }: PageProps) {
)} )}
</section> </section>
{/* Das README des fuehrenden Repositories – bei mehreren waere eine
zweite Vorschau untereinander mehr Laenge als Nutzen. */}
{readme ? (
<ReadmePanel
content={readme.content}
path={readme.path}
baseUrl={readme.baseUrl}
fileUrl={readme.fileUrl}
/>
) : null}
<NotesPanel projectId={detail.id} notes={detail.notes} /> <NotesPanel projectId={detail.id} notes={detail.notes} />
</div> </div>
+6 -1
View File
@@ -24,10 +24,11 @@ import {
Unlink, Unlink,
} from "lucide-react"; } from "lucide-react";
import { CiBadge } from "@/components/CiBadge"; import { CiBadge } from "@/components/CiBadge";
import { Heatmap } from "@/components/Heatmap";
import { RepositoryDialog } from "@/components/RepositoryDialog"; import { RepositoryDialog } from "@/components/RepositoryDialog";
import { Sparkline } from "@/components/Sparkline"; import { Sparkline } from "@/components/Sparkline";
import { repoTitle } from "@/lib/git/meta"; import { repoTitle } from "@/lib/git/meta";
import { asArray, asRecord } from "@/types"; import { asArray, asCalendar, asRecord } from "@/types";
import type { import type {
BranchInfo, BranchInfo,
CommitInfo, CommitInfo,
@@ -109,6 +110,7 @@ export function GitPanel({
const pulls = asArray<TicketInfo>(snapshot?.pullRequests); const pulls = asArray<TicketInfo>(snapshot?.pullRequests);
const issues = asArray<TicketInfo>(snapshot?.issues); const issues = asArray<TicketInfo>(snapshot?.issues);
const activity = asArray<number>(snapshot?.commitActivity); const activity = asArray<number>(snapshot?.commitActivity);
const calendar = asCalendar(snapshot?.commitCalendar);
/** Ein einzelnes Repository oder – ohne Argument – alle. */ /** Ein einzelnes Repository oder – ohne Argument – alle. */
async function sync(nurDieses?: string) { async function sync(nurDieses?: string) {
@@ -416,6 +418,9 @@ export function GitPanel({
</div> </div>
) : null} ) : null}
{/* Der Jahresueberblick, sobald er einmal geholt wurde. */}
{calendar ? <Heatmap calendar={calendar} /> : null}
{/* Sprachen */} {/* Sprachen */}
{languageTotal > 0 ? ( {languageTotal > 0 ? (
<div className="glass p-4"> <div className="glass p-4">
+184
View File
@@ -0,0 +1,184 @@
import { CalendarRange } from "lucide-react";
import type { CommitCalendar } from "@/lib/git/calendar";
import { formatDate } from "@/lib/utils";
/**
* Commits je Tag als Kalender – ein Feld pro Tag, Wochen als Spalten.
*
* Wie die Verlaufslinie ohne Diagrammbibliothek: es sind Quadrate in einem
* Raster, dafuer braucht es keine Achsen und keine Legende ausser der
* Farbskala.
*
* Gezeichnet wird nur der Zeitraum, fuer den wirklich Daten vorliegen.
* Wo die Seiten des Anbieters nur ein paar Monate zurueckreichen, faengt die
* Heatmap dort an – ein leeres Feld wuerde sonst behaupten, an dem Tag sei
* nicht gearbeitet worden, obwohl darueber nichts bekannt ist.
*/
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const MONATE = [
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
];
/** Fuenf Stufen, damit ein einzelner Ausreisser nicht alles andere platt macht. */
function stufe(anzahl: number, hoch: number): number {
if (anzahl <= 0) return 0;
if (hoch <= 1) return 4;
const anteil = anzahl / hoch;
if (anteil > 0.75) return 4;
if (anteil > 0.5) return 3;
if (anteil > 0.25) return 2;
return 1;
}
const FARBEN = [
"fill-white/6",
"fill-violet-500/25",
"fill-violet-500/45",
"fill-violet-400/70",
"fill-violet-300",
];
/** Montag der Woche, in der dieser Tag liegt (UTC). */
function wochenanfang(tag: Date): Date {
const kopie = new Date(tag);
// getUTCDay: 0 = Sonntag. Die Woche soll am Montag beginnen.
const versatz = (kopie.getUTCDay() + 6) % 7;
kopie.setUTCDate(kopie.getUTCDate() - versatz);
kopie.setUTCHours(0, 0, 0, 0);
return kopie;
}
export function Heatmap({ calendar }: { calendar: CommitCalendar }) {
const von = new Date(`${calendar.from}T00:00:00.000Z`);
const bis = new Date(`${calendar.to}T00:00:00.000Z`);
if (Number.isNaN(von.getTime()) || Number.isNaN(bis.getTime()) || von > bis) return null;
const start = wochenanfang(von);
const wochen: Array<Array<{ key: string; anzahl: number; ausserhalb: boolean }>> = [];
let hoechstwert = 0;
for (const wert of Object.values(calendar.days)) {
if (wert > hoechstwert) hoechstwert = wert;
}
const zeiger = new Date(start);
while (zeiger <= bis) {
const woche: Array<{ key: string; anzahl: number; ausserhalb: boolean }> = [];
for (let tag = 0; tag < 7; tag += 1) {
const key = zeiger.toISOString().slice(0, 10);
// Vor dem ersten bekannten Tag und nach heute wird nichts gezeichnet.
const ausserhalb = zeiger < von || zeiger > bis;
woche.push({ key, anzahl: calendar.days[key] ?? 0, ausserhalb });
zeiger.setUTCDate(zeiger.getUTCDate() + 1);
}
wochen.push(woche);
}
const gesamt = Object.values(calendar.days).reduce((sum, wert) => sum + wert, 0);
const tageMitCommits = Object.keys(calendar.days).length;
// Masse in SVG-Einheiten; die Breite ergibt sich aus der Zahl der Wochen.
const feld = 11;
const luecke = 3;
const kopf = 14;
const spalte = feld + luecke;
const breite = wochen.length * spalte - luecke;
const hoehe = kopf + 7 * spalte - luecke;
// Monatsbeschriftung an der Woche, in der ein neuer Monat beginnt.
const monate: Array<{ x: number; label: string }> = [];
let letzterMonat = -1;
wochen.forEach((woche, index) => {
const ersterTag = new Date(`${woche[0].key}T00:00:00.000Z`);
const monat = ersterTag.getUTCMonth();
if (monat !== letzterMonat) {
letzterMonat = monat;
monate.push({ x: index * spalte, label: MONATE[monat] });
}
});
return (
<div className="glass p-4">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<CalendarRange className="h-3 w-3" />
Commits je Tag
</p>
<p className="text-[11px] text-slate-500">
{gesamt} an {tageMitCommits} {tageMitCommits === 1 ? "Tag" : "Tagen"} ·{" "}
{/* Der Zeitraum gehoert dazu: er ist nicht immer ein Jahr. */}
seit {formatDate(calendar.from)}
{calendar.truncated ? " (Älteres nicht abgerufen)" : ""}
</p>
</div>
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${breite + 22} ${hoehe}`}
width={breite + 22}
height={hoehe}
className="max-w-full"
role="img"
aria-label={`Commits je Tag seit ${formatDate(calendar.from)}: ${gesamt} an ${tageMitCommits} Tagen`}
>
{monate.map((monat, index) => (
<text
key={`${monat.label}-${index}`}
x={monat.x + 22}
y={9}
className="fill-slate-500 text-[8px]"
>
{monat.label}
</text>
))}
{/* Nur jeder zweite Wochentag, sonst steht es zu eng. */}
{WOCHENTAGE.map((tag, index) =>
index % 2 === 1 ? (
<text
key={tag}
x={0}
y={kopf + index * spalte + feld - 2}
className="fill-slate-600 text-[7px]"
>
{tag}
</text>
) : null,
)}
{wochen.map((woche, x) =>
woche.map((tag, y) =>
tag.ausserhalb ? null : (
<rect
key={tag.key}
x={x * spalte + 22}
y={kopf + y * spalte}
width={feld}
height={feld}
rx={2}
className={FARBEN[stufe(tag.anzahl, hoechstwert)]}
>
<title>
{`${formatDate(tag.key)}: ${tag.anzahl} ${tag.anzahl === 1 ? "Commit" : "Commits"}`}
</title>
</rect>
),
),
)}
</svg>
</div>
<div className="mt-2 flex items-center justify-end gap-1.5 text-[10px] text-slate-600">
<span>weniger</span>
{FARBEN.map((farbe, index) => (
<svg key={index} width={9} height={9} aria-hidden>
<rect width={9} height={9} rx={2} className={farbe} />
</svg>
))}
<span>mehr</span>
</div>
</div>
);
}
+94 -3
View File
@@ -24,12 +24,93 @@ const schema = {
}, },
}; };
/** Loest relative Verweise gegen die Basis auf; laesst absolute in Ruhe. */
function absolut(href: string | undefined, base: string | null | undefined): string | undefined {
if (!href || !base) return href;
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("#")) {
return href;
}
try {
return new URL(href, base).toString();
} catch {
return href;
}
}
type HastNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: HastNode[];
value?: string;
};
/**
* Ersetzt Bilder durch eine anklickbare Marke – siehe die Begruendung
* unten am `.md-image`-Stil.
*
* Das passiert im Baum und nicht ueber eine Komponente, weil es auf den
* Elternknoten ankommt: `[![Abzeichen](bild)](ziel)` ist in READMEs die
* Regel, und ein `<a>` in einem `<a>` ist ungueltiges HTML. Der Browser
* zieht es beim Parsen auseinander, was zu einem Hydrationsfehler fuehrt –
* genau daran ist es beim ersten Versuch gescheitert. Innerhalb eines Links
* bleibt deshalb nur der Text stehen; der umgebende Link ist ohnehin das,
* was man anklicken will.
*/
function bilderAlsMarke(base: string | null | undefined) {
return () => (tree: HastNode) => {
const gehe = (knoten: HastNode, inLink: boolean) => {
const kinder = knoten.children;
if (!kinder) return;
for (let index = 0; index < kinder.length; index += 1) {
const kind = kinder[index];
if (kind.type === "element" && kind.tagName === "img") {
const quelle = typeof kind.properties?.src === "string" ? kind.properties.src : undefined;
const alt = typeof kind.properties?.alt === "string" ? kind.properties.alt.trim() : "";
const text: HastNode = { type: "text", value: alt || "Bild" };
const ziel = absolut(quelle, base);
kinder[index] =
inLink || !ziel
? { type: "element", tagName: "span", properties: { className: ["md-image"] }, children: [text] }
: {
type: "element",
tagName: "a",
properties: {
className: ["md-image"],
href: ziel,
title: ziel,
target: "_blank",
rel: "noreferrer noopener nofollow",
},
children: [text],
};
continue;
}
gehe(kind, inLink || (kind.type === "element" && kind.tagName === "a"));
}
};
gehe(tree, false);
};
}
export function Markdown({ export function Markdown({
children, children,
className, className,
baseUrl,
}: { }: {
children: string | null | undefined; children: string | null | undefined;
className?: string; className?: string;
/**
* Wogegen relative Verweise aufzuloesen sind. Gesetzt beim README aus
* einem Repository – dort zeigen Links auf Dateien daneben, die ohne
* Basis ins Leere liefen.
*/
baseUrl?: string | null;
}) { }) {
if (!children?.trim()) return null; if (!children?.trim()) return null;
@@ -37,12 +118,22 @@ export function Markdown({
<div className={cn("md", className)}> <div className={cn("md", className)}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeSanitize, schema]]} // Erst bereinigen, dann umbauen: die Marke soll nicht selbst durch
// die Bereinigung laufen und dabei ihre Klassen verlieren.
rehypePlugins={[[rehypeSanitize, schema], bilderAlsMarke(baseUrl)]}
components={{ components={{
// Links aus fremdem Inhalt immer in neuem Tab und ohne // Links aus fremdem Inhalt immer in neuem Tab und ohne
// Zugriff auf das oeffnende Fenster. // Zugriff auf das oeffnende Fenster.
a: ({ href, children: content }) => ( // className muss durchgereicht werden, sonst verlieren die
<a href={href} target="_blank" rel="noreferrer noopener nofollow"> // Bildmarken aus dem Umbau oben ihre Gestalt.
a: ({ href, className: klasse, title, children: content }) => (
<a
href={absolut(href, baseUrl)}
className={klasse}
title={title}
target="_blank"
rel="noreferrer noopener nofollow"
>
{content} {content}
</a> </a>
), ),
+140 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2, RefreshCw, Search } from "lucide-react"; import { Loader2, RefreshCw, Search, Trash2 } from "lucide-react";
import type { ProjectStatus } from "@prisma/client"; import type { ProjectStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status"; import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status";
@@ -17,6 +17,33 @@ interface ProjectDialogProps {
project?: ProjectListItem | null; project?: ProjectListItem | null;
} }
/** Eine Vorlage, so wie sie /api/templates liefert. */
export interface ProjectTemplateEntry {
id: string;
name: string;
description: string | null;
summary: string | null;
projectDescription: string | null;
status: ProjectStatus;
priority: number;
accent: string;
tags: string[];
progressFromTasks: boolean;
notes: unknown[];
tasks: unknown[];
}
/** "3 Notizen, 5 Aufgaben" – oder was davon zutrifft. */
function beschreibeVorlage(vorlage: ProjectTemplateEntry): string {
const teile: string[] = [];
const notizen = Array.isArray(vorlage.notes) ? vorlage.notes.length : 0;
const aufgaben = Array.isArray(vorlage.tasks) ? vorlage.tasks.length : 0;
if (notizen > 0) teile.push(`${notizen} ${notizen === 1 ? "Notiz" : "Notizen"}`);
if (aufgaben > 0) teile.push(`${aufgaben} ${aufgaben === 1 ? "Aufgabe" : "Aufgaben"}`);
if (vorlage.tags.length > 0) teile.push(vorlage.tags.map((tag) => `#${tag}`).join(" "));
return teile.length > 0 ? teile.join(" · ") : "nur Stammdaten";
}
interface FormState { interface FormState {
name: string; name: string;
summary: string; summary: string;
@@ -33,6 +60,8 @@ interface FormState {
repoName: string; repoName: string;
autoSync: boolean; autoSync: boolean;
progressFromTasks: boolean; progressFromTasks: boolean;
/** Nur beim Anlegen: aus welcher Vorlage Notizen und Aufgaben kommen. */
templateId: string;
} }
const EMPTY: FormState = { const EMPTY: FormState = {
@@ -51,6 +80,7 @@ const EMPTY: FormState = {
repoName: "", repoName: "",
autoSync: true, autoSync: true,
progressFromTasks: false, progressFromTasks: false,
templateId: "",
}; };
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -60,14 +90,72 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const isEdit = Boolean(project);
const [templates, setTemplates] = useState<ProjectTemplateEntry[]>([]);
const gewaehlteVorlage = templates.find((entry) => entry.id === form.templateId) ?? null;
/**
* Die Vorlagen werden erst geladen, wenn der Dialog aufgeht – und nur zum
* Anlegen. Beim Bearbeiten waeren sie nur Ballast.
*/
useEffect(() => {
if (!open || isEdit) return;
let abgebrochen = false;
fetch("/api/templates")
.then((response) => (response.ok ? response.json() : { templates: [] }))
.then((payload) => {
if (!abgebrochen) setTemplates(payload.templates ?? []);
})
.catch(() => undefined);
return () => {
abgebrochen = true;
};
}, [open, isEdit]);
/**
* Uebernimmt die Stammdaten sichtbar in die Felder. Notizen und Aufgaben
* bleiben der Serverseite ueberlassen: sie liessen sich hier nicht
* darstellen, und wer die Vorlage wieder abwaehlt, soll sie auch nicht
* mitgeschleppt bekommen.
*
* Bereits Getipptes wird nicht ueberschrieben – nur leere Felder werden
* gefuellt.
*/
function applyTemplate(templateId: string) {
const vorlage = templates.find((entry) => entry.id === templateId);
if (!vorlage) {
update("templateId", "");
return;
}
setForm((previous) => ({
...previous,
templateId,
name: previous.name || vorlage.name,
summary: previous.summary || (vorlage.summary ?? ""),
description: previous.description || (vorlage.projectDescription ?? ""),
status: vorlage.status,
priority: vorlage.priority,
accent: vorlage.accent,
tags: previous.tags || vorlage.tags.join(", "),
progressFromTasks: vorlage.progressFromTasks,
}));
}
async function removeTemplate(vorlage: ProjectTemplateEntry) {
if (!window.confirm(`Vorlage „${vorlage.name}“ loeschen?`)) return;
await fetch(`/api/templates/${vorlage.id}`, { method: "DELETE" }).catch(() => undefined);
setTemplates((current) => current.filter((entry) => entry.id !== vorlage.id));
update("templateId", "");
}
const [repoPickerOpen, setRepoPickerOpen] = useState(false); const [repoPickerOpen, setRepoPickerOpen] = useState(false);
const [repos, setRepos] = useState<RepoListEntry[]>([]); const [repos, setRepos] = useState<RepoListEntry[]>([]);
const [reposLoading, setReposLoading] = useState(false); const [reposLoading, setReposLoading] = useState(false);
const [repoFilter, setRepoFilter] = useState(""); const [repoFilter, setRepoFilter] = useState("");
const [repoError, setRepoError] = useState<string | null>(null); const [repoError, setRepoError] = useState<string | null>(null);
const isEdit = Boolean(project);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setError(null); setError(null);
@@ -96,6 +184,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoName: project.repositories[0]?.repoName ?? "", repoName: project.repositories[0]?.repoName ?? "",
autoSync: project.repositories[0]?.autoSync ?? true, autoSync: project.repositories[0]?.autoSync ?? true,
progressFromTasks: project.progressFromTasks, progressFromTasks: project.progressFromTasks,
templateId: "",
} }
: EMPTY, : EMPTY,
); );
@@ -165,6 +254,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoName: form.repoName, repoName: form.repoName,
autoSync: form.autoSync, autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks, progressFromTasks: form.progressFromTasks,
// Nur beim Anlegen; beim Bearbeiten kennt das Schema das Feld nicht.
...(isEdit ? {} : { templateId: form.templateId || null }),
}; };
try { try {
@@ -203,6 +294,52 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
} }
> >
<form onSubmit={submit} className="space-y-5"> <form onSubmit={submit} className="space-y-5">
{/* Nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus einer
Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln. */}
{!isEdit && templates.length > 0 ? (
<div className="glass-soft p-4">
<label className="field-label" htmlFor="project-template">
Aus Vorlage
</label>
<div className="flex flex-wrap items-center gap-2">
<select
id="project-template"
className="field w-auto min-w-[14rem] flex-1"
value={form.templateId}
onChange={(event) => applyTemplate(event.target.value)}
>
<option value="">– ohne Vorlage –</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
{gewaehlteVorlage ? (
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs text-rose-200"
onClick={() => removeTemplate(gewaehlteVorlage)}
title="Diese Vorlage loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{gewaehlteVorlage ? (
<p className="mt-1.5 text-xs text-slate-500">
{gewaehlteVorlage.description ? `${gewaehlteVorlage.description} · ` : ""}
{beschreibeVorlage(gewaehlteVorlage)}
</p>
) : (
<p className="mt-1.5 text-xs text-slate-500">
Vorlagen entstehen aus einem bestehenden Projekt – im Projektkopf unter
„Als Vorlage sichern“.
</p>
)}
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="field-label" htmlFor="project-name"> <label className="field-label" htmlFor="project-name">
+21
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
ArrowLeft, ArrowLeft,
BookmarkPlus,
CalendarDays, CalendarDays,
CheckSquare, CheckSquare,
Download, Download,
@@ -17,6 +18,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { ProjectDialog } from "@/components/ProjectDialog"; import { ProjectDialog } from "@/components/ProjectDialog";
import { SaveTemplateDialog } from "@/components/SaveTemplateDialog";
import { StatusSelect } from "@/components/StatusSelect"; import { StatusSelect } from "@/components/StatusSelect";
import { Popover } from "@/components/ui/Popover"; import { Popover } from "@/components/ui/Popover";
import { repoTitle } from "@/lib/git/meta"; import { repoTitle } from "@/lib/git/meta";
@@ -36,6 +38,7 @@ export function ProjectHeader({
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [templateOpen, setTemplateOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const exportButtonRef = useRef<HTMLButtonElement>(null); const exportButtonRef = useRef<HTMLButtonElement>(null);
const [favorite, setFavorite] = useState(project.favorite); const [favorite, setFavorite] = useState(project.favorite);
@@ -179,6 +182,14 @@ export function ProjectHeader({
> >
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
</button> </button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setTemplateOpen(true)}
title="Als Vorlage sichern"
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
<button <button
type="button" type="button"
className="btn btn-ghost px-3 py-1.5 text-xs" className="btn btn-ghost px-3 py-1.5 text-xs"
@@ -284,6 +295,16 @@ export function ProjectHeader({
connections={connections} connections={connections}
project={project} project={project}
/> />
<SaveTemplateDialog
open={templateOpen}
onClose={() => setTemplateOpen(false)}
projectId={project.id}
projectName={project.name}
noteCount={project.notes.length}
// Erledigte kommen nicht mit – hier steht, was wirklich uebernommen wird.
taskCount={project.tasks.filter((task) => task.status !== "DONE").length}
/>
</header> </header>
); );
} }
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { BookOpen, ChevronDown, ExternalLink } from "lucide-react";
import { Markdown } from "@/components/Markdown";
import { cn } from "@/lib/utils";
/**
* Das README des verknuepften Repositories.
*
* Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
* Projekt eigentlich ging. Nicht zum Lesen ganzer Handbuecher gedacht:
* eingeklappt zeigt es die ersten Zeilen, ausgeklappt alles.
*/
export function ReadmePanel({
content,
path,
baseUrl,
fileUrl,
}: {
content: string;
path: string | null;
baseUrl: string | null;
/** Direktlink auf die Datei im Git-System. */
fileUrl: string | null;
}) {
const [offen, setOffen] = useState(false);
// Kurze READMEs vollstaendig zu zeigen ist einfacher, als sie mit einem
// Knopf zu versehen, der nichts freigibt.
const kurz = content.length < 900;
return (
<section className="glass p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<BookOpen className="h-4 w-4 text-cyan-300" />
{path ?? "README"}
</h2>
{fileUrl ? (
<a
href={fileUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-violet-300"
>
Im Git öffnen
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<div className="relative">
<div
className={cn(
"overflow-hidden transition-[max-height] duration-300",
!kurz && !offen && "max-h-72",
)}
>
<Markdown baseUrl={baseUrl}>{content}</Markdown>
</div>
{/* Verlauf ueber den abgeschnittenen Rand, damit erkennbar ist, dass
es weitergeht. Nur wenn wirklich etwas verdeckt wird. */}
{!kurz && !offen ? (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#0b0d1c] to-transparent"
/>
) : null}
</div>
{!kurz ? (
<button
type="button"
onClick={() => setOffen((wert) => !wert)}
className="btn btn-ghost mt-2 w-full text-xs"
aria-expanded={offen}
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", offen && "rotate-180")} />
{offen ? "Weniger" : "Ganzes README anzeigen"}
</button>
) : null}
</section>
);
}
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useEffect, useState } from "react";
import { BookmarkPlus, Check, Loader2 } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
/**
* Aus dem offenen Projekt eine Vorlage machen.
*
* Bewusst hier und nicht in einer eigenen Verwaltungsseite: Vorlagen
* entstehen aus einem Projekt, das sich bewaehrt hat. Sie von Grund auf zu
* tippen kann man ueber die API, taeglich braucht das niemand.
*/
export function SaveTemplateDialog({
open,
onClose,
projectId,
projectName,
noteCount,
taskCount,
}: {
open: boolean;
onClose: () => void;
projectId: string;
projectName: string;
noteCount: number;
taskCount: number;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [withNotes, setWithNotes] = useState(true);
const [withTasks, setWithTasks] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
if (!open) return;
setName(projectName);
setDescription("");
setWithNotes(true);
setWithTasks(true);
setError(null);
setDone(false);
}, [open, projectName]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
try {
const response = await fetch("/api/templates/from-project", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, name, description, withNotes, withTasks }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
setDone(true);
// Kurz stehen lassen, damit die Bestaetigung ankommt.
setTimeout(onClose, 900);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title="Als Vorlage sichern"
description="Neue Projekte starten damit mit denselben Tags, Notizen und Aufgaben."
>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="field-label" htmlFor="template-name">
Name der Vorlage *
</label>
<input
id="template-name"
className="field"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={80}
required
autoFocus
/>
</div>
<div>
<label className="field-label" htmlFor="template-description">
Wofür ist sie gut? (optional)
</label>
<input
id="template-description"
className="field"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="z. B. Kleine Web-App mit Docker und CI"
maxLength={280}
/>
</div>
<div className="space-y-2">
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={withNotes}
onChange={(event) => setWithNotes(event.target.checked)}
disabled={noteCount === 0}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Notizen übernehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
{noteCount === 0
? "Dieses Projekt hat keine Notizen."
: `${noteCount} ${noteCount === 1 ? "Notiz" : "Notizen"}`}
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={withTasks}
onChange={(event) => setWithTasks(event.target.checked)}
disabled={taskCount === 0}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Aufgaben übernehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
{taskCount === 0
? "Dieses Projekt hat keine offenen Aufgaben."
: `${taskCount} offene ${taskCount === 1 ? "Aufgabe" : "Aufgaben"} — erledigte kommen nicht mit`}
</span>
</span>
</label>
</div>
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-xs text-slate-500">
Repositories gehen <strong>nicht</strong> mit. Eine Vorlage, die jedes neue Projekt
an dasselbe Repository hängt, wäre eine Falle.
</p>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving || done}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : done ? (
<Check className="h-4 w-4" />
) : (
<BookmarkPlus className="h-4 w-4" />
)}
{done ? "Gesichert" : "Sichern"}
</button>
</div>
</form>
</Modal>
);
}
+13 -2
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { AlignLeft, CalendarClock, GripVertical } from "lucide-react"; import { AlignLeft, CalendarClock, GripVertical, Repeat } from "lucide-react";
import type { Task } from "@prisma/client"; import type { Task } from "@prisma/client";
import { RECURRENCE_META } from "@/lib/tasks";
import { cn, formatDate } from "@/lib/utils"; import { cn, formatDate } from "@/lib/utils";
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */ /** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
@@ -69,12 +70,22 @@ export function TaskCard({
{task.title} {task.title}
</p> </p>
{(task.description || task.dueDate || task.labels.length > 0) && ( {(task.description || task.dueDate || task.recurrence || task.labels.length > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]"> <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{task.description ? ( {task.description ? (
<AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" /> <AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" />
) : null} ) : null}
{task.recurrence ? (
<span
className="inline-flex items-center gap-1 text-slate-500"
title={`Wiederholt sich: ${RECURRENCE_META[task.recurrence].label}`}
>
<Repeat className="h-2.5 w-2.5" />
{RECURRENCE_META[task.recurrence].short}
</span>
) : null}
{task.dueDate ? ( {task.dueDate ? (
<span className={cn("inline-flex items-center gap-1", due?.className)}> <span className={cn("inline-flex items-center gap-1", due?.className)}>
<CalendarClock className="h-2.5 w-2.5" /> <CalendarClock className="h-2.5 w-2.5" />
+31 -2
View File
@@ -3,9 +3,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react"; import { Loader2, Trash2 } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client"; import type { Recurrence, Task, TaskStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn, parseTags } from "@/lib/utils"; import { cn, parseTags } from "@/lib/utils";
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */ /** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
@@ -36,6 +36,7 @@ export function TaskDialog({
const [status, setStatus] = useState<TaskStatus>(initialStatus); const [status, setStatus] = useState<TaskStatus>(initialStatus);
const [dueDate, setDueDate] = useState(""); const [dueDate, setDueDate] = useState("");
const [labels, setLabels] = useState(""); const [labels, setLabels] = useState("");
const [recurrence, setRecurrence] = useState<string>("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -48,6 +49,7 @@ export function TaskDialog({
setStatus(task?.status ?? initialStatus); setStatus(task?.status ?? initialStatus);
setDueDate(toDateInput(task?.dueDate ?? null)); setDueDate(toDateInput(task?.dueDate ?? null));
setLabels(task?.labels.join(", ") ?? ""); setLabels(task?.labels.join(", ") ?? "");
setRecurrence(task?.recurrence ?? "");
setError(null); setError(null);
}, [open, task, initialStatus]); }, [open, task, initialStatus]);
@@ -64,6 +66,7 @@ export function TaskDialog({
// Datum nicht auf den Vortag ziehen. // Datum nicht auf den Vortag ziehen.
dueDate: dueDate ? `${dueDate}T12:00:00` : null, dueDate: dueDate ? `${dueDate}T12:00:00` : null,
labels: parseTags(labels), labels: parseTags(labels),
recurrence: recurrence || null,
}; };
try { try {
@@ -194,6 +197,32 @@ export function TaskDialog({
placeholder="bug, ui" placeholder="bug, ui"
/> />
</div> </div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="task-recurrence">
Wiederholung
</label>
<select
id="task-recurrence"
className="field"
value={recurrence}
onChange={(event) => setRecurrence(event.target.value)}
>
<option value="">– einmalig –</option>
{(Object.keys(RECURRENCE_META) as Recurrence[]).map((key) => (
<option key={key} value={key}>
{RECURRENCE_META[key].label}
</option>
))}
</select>
{recurrence ? (
<p className="mt-1 text-xs text-slate-500">
Sobald die Aufgabe nach „Erledigt“ wandert, entsteht die nächste. Gerechnet
wird vom Fälligkeitsdatum, nicht vom Abhaken — eine wöchentliche Aufgabe
bleibt also auf ihrem Wochentag.
</p>
) : null}
</div>
</div> </div>
{error ? ( {error ? (
+91
View File
@@ -41,6 +41,97 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
}; };
export const CHANGELOG: Release[] = [ export const CHANGELOG: Release[] = [
{
version: "1.4.0",
date: "2026-07-26",
headline: "Worum ging es hier nochmal?",
changes: [
{
kind: "neu",
text:
"README-Vorschau auf der Projektseite. Erspart den Wechsel ins Git, nur um zu " +
"sehen, worum ein halbvergessenes Projekt eigentlich ging.",
},
{
kind: "besser",
text:
"Relative Links im README zeigen dorthin, wo sie hingehören — aufgelöst gegen " +
"das Verzeichnis der Datei im Repository.",
},
{
kind: "besser",
text:
"Bilder im README werden bewusst nicht geladen, sondern als anklickbarer " +
"Verweis gezeigt. Die Content-Security-Policy dafür zu öffnen hieße, jedem " +
"fremden README das Nachladen beliebiger Bilder zu erlauben — auch Zählpixeln.",
},
],
},
{
version: "1.3.0",
date: "2026-07-26",
headline: "Was sich wiederholt, und was besser lesbar ist.",
changes: [
{
kind: "neu",
text:
"Wiederkehrende Aufgaben: täglich, wöchentlich, alle zwei Wochen oder monatlich. " +
"Wandert die Aufgabe nach „Erledigt“, entsteht die nächste von selbst.",
},
{
kind: "behoben",
text:
"Gedämpfter Text erreichte auf dem dunklen Untergrund nur 3,8 statt der " +
"geforderten 4,5 zu 1 — an 26 Stellen. Beide Stufen sind jetzt aufgehellt, " +
"der Abstand zwischen ihnen bleibt sichtbar.",
},
{
kind: "besser",
text:
"Die Barrierefreiheit ist mit axe-core über zehn Seitenzustände geprüft, " +
"einschließlich der Dialoge. Keine Verstöße mehr gegen WCAG 2.1 AA.",
},
{
kind: "besser",
text:
"Der Folgetermin rechnet vom Fälligkeitsdatum, nicht vom Abhaken — eine " +
"wöchentliche Aufgabe bleibt auf ihrem Wochentag. Lange Liegengebliebenes " +
"rutscht so weit vor, bis der Termin in der Zukunft liegt.",
},
],
},
{
version: "1.2.0",
date: "2026-07-26",
headline: "Ein Jahr auf einen Blick, und ein Start ohne Leerlauf.",
changes: [
{
kind: "neu",
text:
"Commit-Heatmap auf der Projektseite: ein Feld je Tag über bis zu ein Jahr, " +
"mit Farbskala und Datum im Tooltip.",
},
{
kind: "neu",
text:
"Projektvorlagen. Ein bewährtes Projekt über „Als Vorlage sichern“ festhalten — " +
"Tags, Notizen und offene Aufgaben stehen dann beim nächsten Projekt bereit.",
},
{
kind: "besser",
text:
"Die Heatmap-Daten kommen höchstens einmal am Tag und getrennt vom übrigen " +
"Abgleich. Bei GitHub genügt ein Aufruf der Jahresstatistik, bei GitLab und " +
"Gitea werden bis zu fünf Seiten Commits geholt.",
},
{
kind: "besser",
text:
"Reicht die Historie nicht ein Jahr zurück, beginnt die Heatmap beim ältesten " +
"bekannten Commit — leere Felder für unbekannte Zeiträume wären eine Lüge.",
},
],
},
{ {
version: "1.1.0", version: "1.1.0",
date: "2026-07-26", date: "2026-07-26",
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import {
CALENDAR_MAX_PAGES,
CALENDAR_PAGE_SIZE,
calendarByPaging,
calendarSince,
calendarTotal,
dayKey,
} from "./calendar";
/**
* Der Kalender darf ueber den Zeitraum nicht luegen. Reichen die geholten
* Seiten nur ein paar Monate zurueck, faengt die Heatmap dort an – sie zeigt
* dann keine leeren Felder fuer Zeiten, ueber die schlicht nichts bekannt ist.
* Genau diesen Fehler hatte die Verlaufslinie schon einmal.
*/
const JETZT = new Date("2026-07-26T12:00:00.000Z");
const TAG = 86_400_000;
/** Erzeugt n Commit-Zeitpunkte, beginnend vor `abTagen` Tagen, einer je Tag. */
function commits(anzahl: number, abTagen: number): string[] {
return Array.from({ length: anzahl }, (_, index) =>
new Date(JETZT.getTime() - (abTagen + index) * TAG).toISOString(),
);
}
describe("dayKey", () => {
it("liefert das Datum ohne Uhrzeit", () => {
expect(dayKey(new Date("2026-07-26T23:59:00.000Z"))).toBe("2026-07-26");
});
});
describe("calendarSince", () => {
it("liegt ein Jahr zurueck", () => {
const seit = calendarSince(JETZT);
expect(Math.round((JETZT.getTime() - seit.getTime()) / TAG)).toBe(365);
});
});
describe("calendarByPaging", () => {
it("liefert null, wenn es keine Commits gibt", async () => {
expect(await calendarByPaging(async () => [], JETZT)).toBeNull();
});
it("zaehlt mehrere Commits desselben Tages zusammen", async () => {
const heute = JETZT.toISOString();
const kalender = await calendarByPaging(async () => [heute, heute, heute], JETZT);
expect(kalender?.days["2026-07-26"]).toBe(3);
expect(calendarTotal(kalender!)).toBe(3);
});
it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => {
// Nur eine halbe Seite, zehn Tage zurueck: alles davor ist unbekannt.
const kalender = await calendarByPaging(async () => commits(10, 0), JETZT);
expect(kalender?.from).toBe("2026-07-17");
expect(kalender?.to).toBe("2026-07-26");
expect(kalender?.truncated).toBe(false);
});
it("hoert auf, sobald eine Seite nicht mehr voll ist", async () => {
const ladeSeite = vi.fn(async (seite: number) =>
seite === 1 ? commits(CALENDAR_PAGE_SIZE, 0) : commits(5, CALENDAR_PAGE_SIZE),
);
await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(2);
});
it("deckelt die Zahl der Seiten und sagt es", async () => {
// Immer volle Seiten innerhalb des Zeitraums: ohne Deckel liefe das ewig.
const ladeSeite = vi.fn(async () =>
Array.from({ length: CALENDAR_PAGE_SIZE }, () =>
new Date(JETZT.getTime() - 3 * TAG).toISOString(),
),
);
const kalender = await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(CALENDAR_MAX_PAGES);
expect(kalender?.truncated).toBe(true);
});
it("bricht ab, wenn eine ganze Seite aelter ist als der Zeitraum", async () => {
const ladeSeite = vi.fn(async (seite: number) =>
seite === 1
? commits(CALENDAR_PAGE_SIZE, 0)
: Array.from({ length: CALENDAR_PAGE_SIZE }, () =>
new Date(JETZT.getTime() - 400 * TAG).toISOString(),
),
);
await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(2);
});
it("uebergeht Commits mit Datum in der Zukunft", async () => {
// Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern.
const kalender = await calendarByPaging(
async () => [
new Date(JETZT.getTime() + 5 * TAG).toISOString(),
JETZT.toISOString(),
],
JETZT,
);
expect(kalender?.to).toBe("2026-07-26");
expect(calendarTotal(kalender!)).toBe(1);
});
it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => {
const kalender = await calendarByPaging(
async () => [null, "gestern", "", JETZT.toISOString()],
JETZT,
);
expect(calendarTotal(kalender!)).toBe(1);
});
it("laesst Tage ohne Commits weg", async () => {
// Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen.
const kalender = await calendarByPaging(
async () => [JETZT.toISOString(), new Date(JETZT.getTime() - 30 * TAG).toISOString()],
JETZT,
);
expect(Object.keys(kalender!.days)).toHaveLength(2);
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Commits je Tag ueber ein Jahr – die Grundlage der Heatmap.
*
* Getrennt vom uebrigen Abgleich, weil es zusaetzliche Aufrufe kostet: der
* Kalender wird hoechstens einmal am Tag geholt. Von Stunde zu Stunde
* aendert sich an einem Jahresueberblick ohnehin nichts Sichtbares.
*
* Wichtig ist die Ehrlichkeit ueber den Zeitraum. Wo es keine
* Jahresstatistik gibt, reichen die Daten nur so weit zurueck wie die
* abgerufenen Seiten – dann faengt die Heatmap dort an und tut nicht so, als
* waere ein Jahr lang nichts passiert. Diesen Fehler hatte die Verlaufslinie
* schon einmal.
*/
/** Wie weit die Heatmap hoechstens zurueckreicht. */
export const CALENDAR_DAYS = 365;
/**
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Fuenf Seiten sind
* 500 Commits – genug fuer die allermeisten Repositories und wenig genug,
* um kein fremdes Kontingent zu sprengen.
*/
export const CALENDAR_MAX_PAGES = 5;
export const CALENDAR_PAGE_SIZE = 100;
export interface CommitCalendar {
/** Erster Tag, fuer den wirklich Daten vorliegen (yyyy-mm-dd). */
from: string;
/** Letzter beruecksichtigter Tag (yyyy-mm-dd). */
to: string;
/** Nur Tage mit Commits: { "2026-07-26": 3 }. Die Nullen ergaenzt die Anzeige. */
days: Record<string, number>;
/**
* "stats" = Jahresstatistik des Anbieters, der Zeitraum ist vollstaendig.
* "commits" = so weit, wie die Seiten zurueckreichten.
*/
source: "stats" | "commits";
/** Der Deckel hat gegriffen; aeltere Commits fehlen. */
truncated: boolean;
}
/** Tag im Format yyyy-mm-dd, in UTC – so liefern es die Anbieter. */
export function dayKey(value: Date): string {
return value.toISOString().slice(0, 10);
}
/** Der Tag, ab dem der Kalender hoechstens reicht. */
export function calendarSince(now: Date = new Date()): Date {
return new Date(now.getTime() - CALENDAR_DAYS * 86_400_000);
}
/**
* Holt seitenweise Commit-Zeitpunkte und zaehlt sie zu Tagen zusammen.
*
* Abgebrochen wird, sobald eine Seite nicht mehr voll ist (dann gibt es
* nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem
* Zeitraum stammt, oder wenn der Deckel greift.
*/
export async function calendarByPaging(
ladeSeite: (seite: number) => Promise<Array<string | null>>,
now: Date = new Date(),
): Promise<CommitCalendar | null> {
const grenze = calendarSince(now).getTime();
const days: Record<string, number> = {};
let aeltester: number | null = null;
let gedeckelt = false;
for (let seite = 1; seite <= CALENDAR_MAX_PAGES; seite += 1) {
const daten = await ladeSeite(seite);
if (daten.length === 0) break;
let innerhalb = 0;
for (const value of daten) {
if (!value) continue;
const time = Date.parse(value);
if (Number.isNaN(time)) continue;
// Ein Commit-Datum in der Zukunft kommt vor – falsch gestellte Uhren.
if (time > now.getTime()) continue;
if (time < grenze) continue;
innerhalb += 1;
const key = dayKey(new Date(time));
days[key] = (days[key] ?? 0) + 1;
if (aeltester === null || time < aeltester) aeltester = time;
}
// Nicht mehr voll: das war die letzte Seite.
if (daten.length < CALENDAR_PAGE_SIZE) break;
// Die ganze Seite lag schon vor dem Zeitraum – weiter zurueck lohnt nicht.
if (innerhalb === 0) break;
if (seite === CALENDAR_MAX_PAGES) gedeckelt = true;
}
if (aeltester === null) return null;
return {
from: dayKey(new Date(aeltester)),
to: dayKey(now),
days,
source: "commits",
truncated: gedeckelt,
};
}
/** Wie viele Commits der Kalender insgesamt kennt. */
export function calendarTotal(calendar: CommitCalendar): number {
return Object.values(calendar.days).reduce((sum, value) => sum + value, 0);
}
+65
View File
@@ -1,4 +1,11 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
calendarSince,
type CommitCalendar,
} from "./calendar";
import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme";
import type { import type {
BranchInfo, BranchInfo,
CiInfo, CiInfo,
@@ -219,6 +226,64 @@ export class GiteaClient implements GitClient {
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)), commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
}; };
} }
/**
* Gitea kennt keine Jahresstatistik ueber die API, also seitenweise.
* `stat=false` und `verification=false` sparen dabei spuerbar Rechenzeit
* auf der Gegenseite – gebraucht wird nur das Datum.
*/
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const seit = calendarSince().toISOString();
// getJson und nicht getJsonSafe: ein Rate-Limit darf nicht als "keine
// Daten" durchgehen – siehe maybeRefreshCalendar.
return calendarByPaging(async (seite) => {
const commits = await getJson<GiteaCommit[]>(
`${base}/commits?limit=${CALENDAR_PAGE_SIZE}&page=${seite}` +
`&stat=false&verification=false&files=false&since=${encodeURIComponent(seit)}`,
{ headers: this.headers() },
);
return commits.map((commit) => toIso(commit.commit?.author?.date));
});
}
/**
* Gitea kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis
* auflisten, dann die gefundene Datei holen – zwei Aufrufe.
*/
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const headers = this.headers();
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
`${base}/contents`,
[],
{ headers },
);
const name = pickReadme(
eintraege.filter((eintrag) => eintrag.type === "file").map((eintrag) => eintrag.name),
);
if (!name) return null;
const datei = await getJson<{
content?: string;
encoding?: string;
path?: string;
html_url?: string | null;
}>(`${base}/contents/${encodeURIComponent(name)}`, { headers });
if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content));
return {
content,
path: datei.path ?? name,
// html_url zeigt auf die Datei; fuer relative Links braucht es das
// Verzeichnis darum herum.
baseUrl: datei.html_url ? datei.html_url.replace(/\/[^/]*$/, "/") : null,
truncated,
};
}
} }
function mapCommitStatus(state: string): CiState { function mapCommitStatus(state: string): CiState {
+92
View File
@@ -3,6 +3,7 @@ import type {
BranchInfo, BranchInfo,
CiInfo, CiInfo,
CiState, CiState,
CommitCalendar,
CommitInfo, CommitInfo,
ConnectionCheck, ConnectionCheck,
GitClient, GitClient,
@@ -14,6 +15,8 @@ import type {
TagInfo, TagInfo,
TicketInfo, TicketInfo,
} from "./types"; } from "./types";
import { dayKey } from "./calendar";
import { capReadme, decodeBase64, type RepoReadme } from "./readme";
import { GitApiError, bucketCommitActivity } from "./types"; import { GitApiError, bucketCommitActivity } from "./types";
/** /**
@@ -206,6 +209,95 @@ export class GitHubClient implements GitClient {
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)), commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
}; };
} }
/**
* GitHub fuehrt die Statistik selbst: /stats/commit_activity liefert 52
* Wochen mit Tageswerten in einem einzigen Aufruf. Das ist deutlich
* sparsamer, als ein Jahr Commits seitenweise abzuholen.
*
* Die Statistik wird bei Bedarf erst berechnet; dann antwortet GitHub mit
* 202 und einem leeren Rumpf – daher der leere Fall unten.
*
* Bewusst getJson und nicht getJsonSafe: ein Rate-Limit oder ein Ausfall
* darf nicht als "keine Daten" durchgehen. Der Fehler geht nach oben, und
* der naechste Abgleich versucht es erneut.
*/
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const wochen = await getJson<GitHubWeek[]>(`${base}/stats/commit_activity`, {
headers: this.headers(),
});
if (!Array.isArray(wochen) || wochen.length === 0) {
// Entweder rechnet GitHub noch, oder das Repository hat keine Commits.
// Ersatzweise aus den geholten Commits zu bauen waere irrefuehrend –
// das taete so, als reichte der Zeitraum ein Jahr zurueck.
return null;
}
const days: Record<string, number> = {};
let von: string | null = null;
const heute = dayKey(new Date());
for (const woche of wochen) {
if (!woche?.week || !Array.isArray(woche.days)) continue;
woche.days.forEach((anzahl, index) => {
const tag = dayKey(new Date((woche.week + index * 86_400) * 1000));
if (tag > heute) return;
if (von === null || tag < von) von = tag;
if (anzahl > 0) days[tag] = anzahl;
});
}
if (von === null) return null;
return { from: von, to: heute, days, source: "stats", truncated: false };
}
/**
* GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet – ein
* Aufruf genuegt. Ohne README antwortet er mit 404; das ist kein Fehler,
* sondern die Auskunft "gibt es nicht".
*/
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
let datei: GitHubContent;
try {
datei = await getJson<GitHubContent>(`${base}/readme`, { headers: this.headers() });
} catch (error) {
if (error instanceof GitApiError && error.status === 404) return null;
throw error;
}
if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content));
return {
content,
path: datei.path ?? datei.name ?? "README",
// html_url zeigt auf die Datei; fuer relative Links braucht es das
// Verzeichnis darum herum.
baseUrl: datei.html_url ? datei.html_url.replace(/\/[^/]*$/, "/") : null,
truncated,
};
}
}
interface GitHubContent {
name?: string;
path?: string;
content?: string;
encoding?: string;
html_url?: string | null;
}
interface GitHubWeek {
/** Unix-Zeit des Wochenanfangs (Sonntag). */
week: number;
/** Sieben Werte, Sonntag bis Samstag. */
days: number[];
total: number;
} }
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */ /** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
+71 -3
View File
@@ -1,4 +1,11 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
calendarSince,
type CommitCalendar,
} from "./calendar";
import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme";
import type { import type {
BranchInfo, BranchInfo,
CiInfo, CiInfo,
@@ -76,10 +83,13 @@ export class GitLabClient implements GitClient {
}); });
} }
/** "gruppe/sub/projekt" URL-kodiert – so adressiert GitLab ein Projekt. */
private projectPath(ref: RepoRef): string {
return encodeURIComponent([ref.owner, ref.name].filter(Boolean).join("/"));
}
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> { async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
const path = [ref.owner, ref.name].filter(Boolean).join("/"); const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const encoded = encodeURIComponent(path);
const base = `${this.apiBase}/projects/${encoded}`;
const headers = this.headers(); const headers = this.headers();
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers }); const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
@@ -198,6 +208,64 @@ export class GitLabClient implements GitClient {
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)), commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
}; };
} }
/**
* GitLab kennt keine fertige Jahresstatistik, also seitenweise mit `since`.
* Der Deckel in calendarByPaging begrenzt das auf wenige Aufrufe.
*/
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const seit = calendarSince().toISOString();
// getJson und nicht getJsonSafe: ein Rate-Limit darf nicht als "keine
// Daten" durchgehen – siehe maybeRefreshCalendar.
return calendarByPaging(async (seite) => {
const commits = await getJson<GitLabCommit[]>(
`${base}/repository/commits?per_page=${CALENDAR_PAGE_SIZE}&page=${seite}` +
`&since=${encodeURIComponent(seit)}`,
{ headers: this.headers() },
);
return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at));
});
}
/**
* GitLab kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis
* auflisten, dann die gefundene Datei holen – zwei Aufrufe.
*/
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const headers = this.headers();
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
`${base}/repository/tree?per_page=100`,
[],
{ headers },
);
const name = pickReadme(
eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name),
);
if (!name) return null;
const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>(
`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`,
{ headers },
);
if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content));
const projekt = await getJsonSafe<GitLabProject>(base, {} as GitLabProject, { headers });
return {
content,
path: datei.file_path ?? name,
baseUrl:
projekt?.web_url && projekt.default_branch
? `${projekt.web_url}/-/blob/${encodeURIComponent(projekt.default_branch)}/`
: null,
truncated,
};
}
} }
function mapPipelineState(status: string | null): CiState { function mapPipelineState(status: string | null): CiState {
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { README_MAX_CHARS, capReadme, decodeBase64, pickReadme } from "./readme";
describe("pickReadme", () => {
it("findet die uebliche Schreibweise", () => {
expect(pickReadme(["src", "README.md", "package.json"])).toBe("README.md");
});
it("ist bei der Gross- und Kleinschreibung nachsichtig", () => {
expect(pickReadme(["readme.md"])).toBe("readme.md");
expect(pickReadme(["ReadMe.MD"])).toBe("ReadMe.MD");
});
it("zieht Markdown den uebrigen Formaten vor", () => {
// Beides kommt vor; nur das erste laesst sich wirklich darstellen.
expect(pickReadme(["README.rst", "README.md"])).toBe("README.md");
});
it("nimmt auch ein README ohne Endung", () => {
expect(pickReadme(["LICENSE", "README"])).toBe("README");
});
it("liefert null, wenn keines dabei ist", () => {
expect(pickReadme(["src", "package.json"])).toBeNull();
expect(pickReadme([])).toBeNull();
});
it("verwechselt Aehnliches nicht mit dem README", () => {
expect(pickReadme(["README-CONTRIBUTING.md", "readme.md.bak"])).toBeNull();
});
});
describe("capReadme", () => {
it("laesst Kurzes unangetastet", () => {
const { content, truncated } = capReadme("# Titel\n\nEin Satz.");
expect(content).toBe("# Titel\n\nEin Satz.");
expect(truncated).toBe(false);
});
it("kuerzt Langes an einer Zeilengrenze", () => {
// Sonst bliebe womoeglich ein halber Codeblock stehen.
const zeile = "x".repeat(80);
const lang = Array.from({ length: 2000 }, () => zeile).join("\n");
const { content, truncated } = capReadme(lang);
expect(truncated).toBe(true);
expect(content.length).toBeLessThanOrEqual(README_MAX_CHARS);
expect(content.endsWith("x")).toBe(true);
expect(content.split("\n").at(-1)).toBe(zeile);
});
it("kuerzt notfalls hart, wenn es gar keine Zeilengrenze gibt", () => {
// Eine einzige riesige Zeile: hier waere der letzte Umbruch bei -1.
const { content, truncated } = capReadme("y".repeat(README_MAX_CHARS + 500));
expect(truncated).toBe(true);
expect(content).toHaveLength(README_MAX_CHARS);
});
});
describe("decodeBase64", () => {
it("dekodiert UTF-8 richtig", () => {
// Ueber Bytes gedacht kaeme hier Kauderwelsch heraus.
const kodiert = Buffer.from("# Nebula – Übersicht", "utf8").toString("base64");
expect(decodeBase64(kodiert)).toBe("# Nebula – Übersicht");
});
it("uebergeht Zeilenumbrueche in der Kodierung", () => {
// GitHub bricht den base64-Block alle 60 Zeichen um.
const roh = Buffer.from("Hallo Welt", "utf8").toString("base64");
const umgebrochen = `${roh.slice(0, 4)}\n${roh.slice(4)}`;
expect(decodeBase64(umgebrochen)).toBe("Hallo Welt");
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Das README eines Repositories – gemeinsame Teile fuer alle drei Anbieter.
*
* Gezeigt wird es auf der Projektseite, damit man nicht ins Git wechseln
* muss, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging.
*/
export interface RepoReadme {
/** Roher Markdown-Text. */
content: string;
/** Dateiname im Repository, etwa "README.md". */
path: string;
/**
* Basis, gegen die relative Links im Text aufzuloesen sind – die
* Web-Ansicht des Repositories im Verzeichnis des READMEs.
*/
baseUrl: string | null;
/** Wurde am Deckel abgeschnitten? */
truncated: boolean;
}
/**
* Hoechstlaenge. Ein README von einem Megabyte gibt es, und es waere in
* einer Vorschau ohnehin unlesbar – abgeschnitten wird an einer
* Zeilengrenze, damit kein halber Codeblock stehen bleibt.
*/
export const README_MAX_CHARS = 60_000;
/**
* Dateinamen, die als README gelten – in dieser Reihenfolge. Markdown zuerst,
* weil nur das dargestellt werden kann; die uebrigen landen als Klartext in
* einem Codeblock, was immer noch besser ist als nichts.
*/
const KANDIDATEN = [
"readme.md",
"readme.markdown",
"readme.mdown",
"readme",
"readme.txt",
"readme.rst",
];
/**
* Sucht aus einer Verzeichnisauflistung die README-Datei heraus.
* Gross- und Kleinschreibung ist egal; die Reihenfolge oben entscheidet.
*/
export function pickReadme(namen: string[]): string | null {
const klein = new Map(namen.map((name) => [name.toLowerCase(), name]));
for (const kandidat of KANDIDATEN) {
const treffer = klein.get(kandidat);
if (treffer) return treffer;
}
return null;
}
/** Kuerzt am Deckel, aber an einer Zeilengrenze. */
export function capReadme(text: string): { content: string; truncated: boolean } {
if (text.length <= README_MAX_CHARS) return { content: text, truncated: false };
const roh = text.slice(0, README_MAX_CHARS);
const letzterUmbruch = roh.lastIndexOf("\n");
return {
// Nur wenn der Umbruch nicht ganz am Anfang liegt – sonst bliebe fast
// nichts uebrig.
content: letzterUmbruch > README_MAX_CHARS / 2 ? roh.slice(0, letzterUmbruch) : roh,
truncated: true,
};
}
/**
* Dekodiert, was die Anbieter als base64 liefern.
*
* Bewusst ueber Buffer und nicht ueber atob: der Inhalt ist UTF-8, und atob
* gaebe Bytes zurueck, aus denen Umlaute als Kauderwelsch herauskaemen.
*/
export function decodeBase64(value: string): string {
return Buffer.from(value.replace(/\s/g, ""), "base64").toString("utf8");
}
+17
View File
@@ -1,4 +1,8 @@
import type { GitProvider } from "@prisma/client"; import type { GitProvider } from "@prisma/client";
import type { CommitCalendar } from "./calendar";
import type { RepoReadme } from "./readme";
export type { CommitCalendar, RepoReadme };
export type ProviderKind = GitProvider; export type ProviderKind = GitProvider;
@@ -171,6 +175,19 @@ export interface GitClient {
listRepos(query?: string): Promise<RepoListItem[]>; listRepos(query?: string): Promise<RepoListItem[]>;
/** Vollstaendiger Stand eines Repositories. */ /** Vollstaendiger Stand eines Repositories. */
fetchRepo(ref: RepoRef): Promise<RepoSnapshotData>; fetchRepo(ref: RepoRef): Promise<RepoSnapshotData>;
/**
* Commits je Tag ueber ein Jahr – fuer die Heatmap.
*
* Getrennt von fetchRepo, weil es zusaetzliche Aufrufe kostet und nur
* hoechstens einmal am Tag noetig ist. null heisst: nichts zu holen.
*/
fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null>;
/**
* Das README des Repositories. Wie der Kalender getrennt, weil es
* zusaetzliche Aufrufe kostet und sich selten aendert. null heisst: es
* gibt keins.
*/
fetchReadme(ref: RepoRef): Promise<RepoReadme | null>;
} }
/** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */ /** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */
+97
View File
@@ -2,6 +2,7 @@ import type { Repository } from "@prisma/client";
import { recordActivity, shorten } from "@/lib/activity"; import { recordActivity, shorten } from "@/lib/activity";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git"; import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import type { GitClient } from "@/lib/git/types";
import { notifyAfterSync } from "@/lib/notify"; import { notifyAfterSync } from "@/lib/notify";
import { repoTitle } from "@/lib/repositories"; import { repoTitle } from "@/lib/repositories";
import type { RepoSnapshotData } from "@/lib/git/types"; import type { RepoSnapshotData } from "@/lib/git/types";
@@ -57,6 +58,10 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
try { try {
const data = await client.fetchRepo(ref); const data = await client.fetchRepo(ref);
await persistSnapshot(repository, data); await persistSnapshot(repository, data);
// Erst nach dem Snapshot: die Zeile muss existieren, bevor Kalender und
// README hineingeschrieben werden koennen.
await maybeRefreshCalendar(repository.id, client, ref);
await maybeRefreshReadme(repository.id, client, ref);
// Nach dem Speichern, damit die Meldung den neuen Stand sieht. // Nach dem Speichern, damit die Meldung den neuen Stand sieht.
await notifyAfterSync(repository.id); await notifyAfterSync(repository.id);
return { return {
@@ -141,6 +146,98 @@ export async function syncProject(projectId: string): Promise<ProjectSyncResult>
}; };
} }
/** Wie lange ein geholter Kalender genuegt. */
const CALENDAR_TTL_MS = 20 * 60 * 60 * 1000;
/**
* Holt die Jahresuebersicht fuer die Heatmap – hoechstens einmal am Tag.
*
* Sie kostet zusaetzliche Aufrufe, bei GitLab und Gitea sogar mehrere. An
* einem Jahresueberblick aendert sich von Stunde zu Stunde aber nichts
* Sichtbares, und der Abgleich laeuft im Zweifel stuendlich.
*
* Fehler bleiben hier: der Kalender ist Beiwerk. Wenn er nicht zu holen ist,
* soll deshalb nicht der ganze Abgleich als gescheitert gelten.
*/
async function maybeRefreshCalendar(
repositoryId: string,
client: GitClient,
ref: { owner: string; name: string },
): Promise<void> {
try {
const snapshot = await prisma.repoSnapshot.findUnique({
where: { repositoryId },
select: { commitCalendarAt: true },
});
const zuletzt = snapshot?.commitCalendarAt?.getTime() ?? 0;
if (Date.now() - zuletzt < CALENDAR_TTL_MS) return;
const calendar = await client.fetchCommitCalendar(ref);
// Nichts erhalten: entweder rechnet GitHub die Statistik gerade erst,
// oder das Repository hat keine Commits. Der Zeitstempel bleibt dann
// ungesetzt, damit es der naechste Abgleich erneut versucht – ihn hier zu
// setzen wuerde die Heatmap fuer einen Tag unterdruecken, obwohl sie
// vielleicht schon in Minuten bereitsteht.
if (!calendar) return;
await prisma.repoSnapshot.update({
where: { repositoryId },
data: {
commitCalendar: calendar as unknown as object,
commitCalendarAt: new Date(),
},
});
} catch (error) {
// Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und
// soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es
// der naechste Durchgang von selbst wieder.
console.error("[nebula] Commit-Kalender konnte nicht geholt werden:", error);
}
}
/**
* Holt das README – wie der Kalender hoechstens einmal am Tag.
*
* Ein eigener Zeitstempel und nicht der des Kalenders: schlaegt das eine
* fehl, soll das andere nicht mit blockiert werden.
*/
async function maybeRefreshReadme(
repositoryId: string,
client: GitClient,
ref: { owner: string; name: string },
): Promise<void> {
try {
const snapshot = await prisma.repoSnapshot.findUnique({
where: { repositoryId },
select: { readmeAt: true },
});
const zuletzt = snapshot?.readmeAt?.getTime() ?? 0;
if (Date.now() - zuletzt < CALENDAR_TTL_MS) return;
const readme = await client.fetchReadme(ref);
// Kein README im Repository: das ist eine Auskunft, kein Fehlschlag.
// Der Zeitstempel wird gesetzt, damit nicht jeder Abgleich erneut sucht,
// und ein etwaiger alter Stand verschwindet.
await prisma.repoSnapshot.update({
where: { repositoryId },
data: {
readme: readme?.content ?? null,
readmePath: readme?.path ?? null,
readmeBaseUrl: readme?.baseUrl ?? null,
readmeAt: new Date(),
},
});
} catch (error) {
// Wie beim Kalender: Beiwerk. Ohne Zeitstempel versucht es der naechste
// Durchgang von selbst wieder.
console.error("[nebula] README konnte nicht geholt werden:", error);
}
}
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */ /** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
export function resolveRepoRef( export function resolveRepoRef(
repository: Pick<Repository, "repoOwner" | "repoName" | "repoUrl">, repository: Pick<Repository, "repoOwner" | "repoName" | "repoUrl">,
+55 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { TASK_STATUS_META, TASK_STATUS_ORDER, progressFromTasks } from "./tasks"; import {
TASK_STATUS_META,
TASK_STATUS_ORDER,
nextDueDate,
progressFromTasks,
} from "./tasks";
describe("progressFromTasks", () => { describe("progressFromTasks", () => {
it("rechnet den Anteil erledigter Aufgaben in Prozent um", () => { it("rechnet den Anteil erledigter Aufgaben in Prozent um", () => {
@@ -34,3 +39,52 @@ describe("Spaltenreihenfolge", () => {
} }
}); });
}); });
describe("nextDueDate", () => {
const abhaken = (iso: string) => new Date(iso);
it("rechnet vom bisherigen Termin, nicht vom Abhaken", () => {
// Woechentlich, faellig Montag, erledigt erst Mittwoch: der naechste
// Termin bleibt der Montag darauf, nicht der uebernaechste Mittwoch.
const next = nextDueDate("WEEKLY", new Date("2026-07-06T12:00:00Z"), abhaken("2026-07-08T09:00:00Z"));
expect(next.toISOString().slice(0, 10)).toBe("2026-07-13");
});
it("nimmt ohne bisherigen Termin den Zeitpunkt des Abhakens", () => {
const next = nextDueDate("DAILY", null, abhaken("2026-07-26T09:00:00Z"));
expect(next.toISOString().slice(0, 10)).toBe("2026-07-27");
});
it("schiebt so lange weiter, bis der Termin in der Zukunft liegt", () => {
// Ein halbes Jahr liegengeblieben: die naechste Fassung darf nicht
// schon bei ihrer Entstehung ueberfaellig sein.
const next = nextDueDate("WEEKLY", new Date("2026-01-05T12:00:00Z"), abhaken("2026-07-26T09:00:00Z"));
expect(next.getTime()).toBeGreaterThan(abhaken("2026-07-26T09:00:00Z").getTime());
// Und es bleibt ein Montag.
expect(next.getUTCDay()).toBe(1);
});
it("haelt beim Monatsintervall den Tag im Monat", () => {
const next = nextDueDate("MONTHLY", new Date("2026-03-15T12:00:00Z"), abhaken("2026-03-16T09:00:00Z"));
expect(next.toISOString().slice(0, 10)).toBe("2026-04-15");
});
it("rollt beim Monatsintervall nicht ueber kurze Monate hinweg", () => {
// Der 31. Januar plus ein Monat ist der 28. Februar, nicht der 3. Maerz.
const next = nextDueDate("MONTHLY", new Date("2026-01-31T12:00:00Z"), abhaken("2026-02-01T09:00:00Z"));
expect(next.toISOString().slice(0, 10)).toBe("2026-02-28");
});
it("kennt zwei Wochen", () => {
const next = nextDueDate("BIWEEKLY", new Date("2026-07-01T12:00:00Z"), abhaken("2026-07-02T09:00:00Z"));
expect(next.toISOString().slice(0, 10)).toBe("2026-07-15");
});
it("endet auch bei einem absurd alten Termin", () => {
// Ohne Deckel liefe die Schleife bei einem taeglichen Intervall und
// einem Datum von 1970 sehr lange.
const next = nextDueDate("DAILY", new Date("1970-01-01T00:00:00Z"), abhaken("2026-07-26T09:00:00Z"));
expect(next instanceof Date).toBe(true);
expect(Number.isNaN(next.getTime())).toBe(false);
});
});
+98 -1
View File
@@ -1,4 +1,4 @@
import type { TaskStatus } from "@prisma/client"; import type { Recurrence, TaskStatus } from "@prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
/** Spaltenreihenfolge des Aufgabenbretts. */ /** Spaltenreihenfolge des Aufgabenbretts. */
@@ -64,6 +64,103 @@ export async function refreshDerivedProgress(projectId: string): Promise<void> {
}); });
} }
// ── Wiederkehrende Aufgaben ──────────────────────────────────
export const RECURRENCE_META: Record<Recurrence, { label: string; short: string }> = {
DAILY: { label: "Täglich", short: "1 T" },
WEEKLY: { label: "Wöchentlich", short: "1 W" },
BIWEEKLY: { label: "Alle zwei Wochen", short: "2 W" },
MONTHLY: { label: "Monatlich", short: "1 M" },
};
/** Einen Termin um ein Intervall weiterschieben. */
function addInterval(from: Date, recurrence: Recurrence): Date {
const next = new Date(from);
switch (recurrence) {
case "DAILY":
next.setDate(next.getDate() + 1);
break;
case "WEEKLY":
next.setDate(next.getDate() + 7);
break;
case "BIWEEKLY":
next.setDate(next.getDate() + 14);
break;
case "MONTHLY": {
// setMonth rollt ueber: der 31. Januar wuerde zum 3. Maerz. Bei einem
// kuerzeren Folgemonat gehoert der Termin auf dessen letzten Tag.
const tag = next.getDate();
next.setDate(1);
next.setMonth(next.getMonth() + 1);
const letzterTag = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
next.setDate(Math.min(tag, letzterTag));
break;
}
}
return next;
}
/**
* Wann die naechste Fassung faellig ist.
*
* Gerechnet wird vom bisherigen Termin, nicht vom Zeitpunkt des Abhakens:
* eine woechentliche Aufgabe soll auf ihrem Montag bleiben, auch wenn sie
* einmal erst am Mittwoch erledigt wurde.
*
* Wurde sie lange liegengelassen, laege der naechste Termin allerdings
* immer noch in der Vergangenheit – dann wird so lange weitergeschoben, bis
* er in der Zukunft liegt. Sonst entstuende eine Aufgabe, die schon bei
* ihrer Entstehung ueberfaellig ist.
*
* Ohne bisherigen Termin zaehlt der Zeitpunkt des Abhakens.
*/
export function nextDueDate(
recurrence: Recurrence,
previousDue: Date | null,
completedAt: Date = new Date(),
): Date {
if (!previousDue) return addInterval(completedAt, recurrence);
let next = addInterval(previousDue, recurrence);
// Deckel gegen eine Endlosschleife bei absurden Datumsangaben.
for (let schritt = 0; schritt < 500 && next <= completedAt; schritt += 1) {
next = addInterval(next, recurrence);
}
return next;
}
/**
* Legt die naechste Fassung einer wiederkehrenden Aufgabe an.
*
* Wird genau beim Uebergang nach ERLEDIGT aufgerufen – aus dem Dialog wie
* aus dem Ziehen im Brett. Ein erneutes Speichern einer bereits erledigten
* Aufgabe darf keine zweite Fassung erzeugen.
*
* Gibt die neue Aufgabe zurueck oder null, wenn nichts zu tun war.
*/
export async function spawnRecurrence(taskId: string) {
const erledigt = await prisma.task.findUnique({ where: { id: taskId } });
if (!erledigt?.recurrence) return null;
const faellig = nextDueDate(erledigt.recurrence, erledigt.dueDate, erledigt.doneAt ?? new Date());
return prisma.task.create({
data: {
projectId: erledigt.projectId,
title: erledigt.title,
description: erledigt.description,
status: "TODO",
position: await nextPosition(erledigt.projectId, "TODO"),
dueDate: faellig,
labels: erledigt.labels,
recurrence: erledigt.recurrence,
recurredFrom: erledigt.id,
// Die Herkunft aus einem Issue wandert nicht mit: das Issue ist
// abgearbeitet, und die Eindeutigkeit je Repository verboete es ohnehin.
},
});
}
/** Naechste freie Position am Ende einer Spalte. */ /** Naechste freie Position am Ende einer Spalte. */
export async function nextPosition( export async function nextPosition(
projectId: string, projectId: string,
+57 -3
View File
@@ -3,6 +3,8 @@ import { z } from "zod";
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const; const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const; const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const; const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const;
const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const;
const RECURRENCE = ["DAILY", "WEEKLY", "BIWEEKLY", "MONTHLY"] as const;
const emptyToNull = (value: unknown) => const emptyToNull = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? null : value; typeof value === "string" && value.trim() === "" ? null : value;
@@ -31,9 +33,17 @@ export const projectCreateSchema = z.object({
autoSync: z.boolean().default(true), autoSync: z.boolean().default(true),
/** Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen. */ /** Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen. */
progressFromTasks: z.boolean().default(false), progressFromTasks: z.boolean().default(false),
/**
* Aus welcher Vorlage. Die Stammdaten hat die Oberflaeche bereits in die
* Felder oben uebernommen – hier zaehlen nur noch Notizen und Aufgaben,
* die sich im Dialog nicht darstellen lassen.
*/
templateId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
}); });
export const projectUpdateSchema = projectCreateSchema.partial(); // templateId gilt nur beim Anlegen. Ein bestehendes Projekt nachtraeglich
// "aus einer Vorlage" zu befuellen wuerde Notizen und Aufgaben verdoppeln.
export const projectUpdateSchema = projectCreateSchema.partial().omit({ templateId: true });
/** /**
* Neue Reihenfolge im Brett. Uebergeben wird je betroffener Spalte die * Neue Reihenfolge im Brett. Uebergeben wird je betroffener Spalte die
@@ -70,6 +80,50 @@ export const projectBulkSchema = z
path: ["favorite"], path: ["favorite"],
}); });
// ── Projektvorlagen ──────────────────────────────────────────
const templateNoteSchema = z.object({
title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()),
content: z.string().trim().min(1).max(20000),
pinned: z.boolean().optional().default(false),
});
const templateTaskSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.preprocess(emptyToNull, z.string().trim().max(5000).nullable().optional()),
status: z.enum(TASK_STATUS).optional().default("TODO"),
labels: z.array(z.string().trim().min(1).max(30)).max(8).optional().default([]),
});
export const templateCreateSchema = z.object({
name: z.string().trim().min(1, "Name darf nicht leer sein").max(80),
description: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()),
summary: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()),
projectDescription: z.preprocess(
emptyToNull,
z.string().trim().max(20000).nullable().optional(),
),
status: z.enum(STATUS).default("IDEA"),
priority: z.coerce.number().int().min(1).max(4).default(2),
accent: z.enum(ACCENTS).default("violet"),
tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]),
progressFromTasks: z.boolean().default(false),
notes: z.array(templateNoteSchema).max(50).default([]),
tasks: z.array(templateTaskSchema).max(100).default([]),
});
export const templateUpdateSchema = templateCreateSchema.partial();
/** Aus einem vorhandenen Projekt eine Vorlage machen. */
export const templateFromProjectSchema = z.object({
projectId: z.string().min(1),
name: z.string().trim().min(1).max(80),
description: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()),
/** Notizen und Aufgaben mitnehmen – oder nur die Stammdaten. */
withNotes: z.boolean().default(true),
withTasks: z.boolean().default(true),
});
// ── Repositories ───────────────────────────────────────────── // ── Repositories ─────────────────────────────────────────────
export const repositoryCreateSchema = z.object({ export const repositoryCreateSchema = z.object({
@@ -88,8 +142,6 @@ export const repositoryUpdateSchema = repositoryCreateSchema.partial().extend({
// ── Aufgaben ───────────────────────────────────────────────── // ── Aufgaben ─────────────────────────────────────────────────
const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const;
const dueDateField = z.preprocess( const dueDateField = z.preprocess(
emptyToNull, emptyToNull,
z z
@@ -106,6 +158,8 @@ export const taskCreateSchema = z.object({
status: z.enum(TASK_STATUS).default("TODO"), status: z.enum(TASK_STATUS).default("TODO"),
dueDate: dueDateField, dueDate: dueDateField,
labels: z.array(z.string().trim().min(1).max(30)).max(8).default([]), labels: z.array(z.string().trim().min(1).max(30)).max(8).default([]),
/** Wiederholt sich die Aufgabe? null = einmalig. */
recurrence: z.preprocess(emptyToNull, z.enum(RECURRENCE).nullable().optional()),
}); });
export const taskUpdateSchema = taskCreateSchema.partial(); export const taskUpdateSchema = taskCreateSchema.partial();
+21
View File
@@ -9,6 +9,7 @@ import type {
Task, Task,
TaskStatus, TaskStatus,
} from "@prisma/client"; } from "@prisma/client";
import type { CommitCalendar } from "@/lib/git/calendar";
import type { import type {
BranchInfo, BranchInfo,
CiInfo, CiInfo,
@@ -83,6 +84,7 @@ export interface RepoListEntry {
export type { export type {
ActivityKind, ActivityKind,
BranchInfo, BranchInfo,
CommitCalendar,
CiInfo, CiInfo,
CommitInfo, CommitInfo,
Note, Note,
@@ -101,6 +103,25 @@ export function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : []; return Array.isArray(value) ? (value as T[]) : [];
} }
/**
* Der Commit-Kalender aus dem Snapshot. Prisma liefert Json als unknown, und
* ein Datensatz aus der Zeit vor der Heatmap hat dort ein leeres Objekt –
* beides muss hier zu null werden, statt die Anzeige zu zerlegen.
*/
export function asCalendar(value: unknown): CommitCalendar | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const kandidat = value as Partial<CommitCalendar>;
if (typeof kandidat.from !== "string" || typeof kandidat.to !== "string") return null;
if (!kandidat.days || typeof kandidat.days !== "object") return null;
return {
from: kandidat.from,
to: kandidat.to,
days: kandidat.days as Record<string, number>,
source: kandidat.source === "stats" ? "stats" : "commits",
truncated: Boolean(kandidat.truncated),
};
}
export function asRecord(value: unknown): Record<string, number> { export function asRecord(value: unknown): Record<string, number> {
if (value && typeof value === "object" && !Array.isArray(value)) { if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, number>; return value as Record<string, number>;