11 Commits
Author SHA1 Message Date
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
florian.wildeandClaude Opus 5 fce0a90cc3 chore: prisma.config.ts, Tests und Doku, Version 1.1.0
CI / pruefen (push) Has been cancelled
Prisma liest seine Einstellungen aus prisma.config.ts statt aus der
package.json; die Warnung vor Prisma 7 ist damit weg. Der dotenv-Aufruf
darin ist nicht optional: sobald die Datei existiert, laedt Prisma die .env
nicht mehr von selbst, und migrate deploy staende ohne DATABASE_URL da.

Vier neue Ende-zu-Ende-Tests: Reihenfolge und Mehrfachauswahl ueber die API,
Mehrfachauswahl in der Oberflaeche, der Aenderungsverlauf und das Manifest
samt Symbolen ohne Anmeldung. Damit sind es 21.

Der Test zum Ziehen von Aufgaben war gelegentlich rot, ohne dass etwas kaputt
war: das Neuladen brach den noch laufenden Speicheraufruf ab. Er wartet jetzt
auf die Antwort, statt auf eine feste Pause zu hoffen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:44 +02:00
florian.wildeandClaude Opus 5 16e8d31277 feat(ui): Aenderungsverlauf hinter der Versionsnummer
Ein Klick auf die Version in der Fusszeile zeigt, was in den bisherigen
Fassungen dazugekommen ist, was besser wurde und welche Fehler behoben sind.
Kein eigener Menuepunkt: gesucht wird so etwas dort, wo die Version steht.

Die Liste steht in lib/changelog.ts und nicht in einer CHANGELOG.md. Sie
wird in der Anwendung angezeigt, und zwei Quellen liefen prompt auseinander
- wie es der Versionsnummer schon einmal passiert ist, die eine Zeit lang in
der Fusszeile und in der package.json verschiedene Werte zeigte. Ein Test
haelt den obersten Eintrag und die package.json seitdem zusammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:43 +02:00
florian.wildeandClaude Opus 5 d97b6f650a feat(pwa): als App installierbar
Web-Manifest mit eigenen Symbolen; auf dem Handy laesst sich Nebula zum
Startbildschirm hinzufuegen und laeuft dann ohne Adressleiste.

Das Manifest entsteht wie die uebrigen Metadaten aus einer Funktion, nicht
aus einer Konstanten: nur so wird APP_NAME zur Laufzeit gelesen und das
fertige Docker-Image bleibt umbenennbar.

Manifest und Symbole muessen an der Zugangsschranke vorbei - der Browser
holt sie, bevor sich jemand angemeldet hat. Sonst landeten sie auf der
Anmeldeseite und die Anwendung waere nicht installierbar.

Die maskierbare Fassung hat mehr Rand: Android schneidet Symbole je nach
Geraet rund, eckig oder als Tropfen zu. Die PNGs sind aus dem vorhandenen
SVG mit Chrome erzeugt - das spart eine Bildbibliothek als Abhaengigkeit.

Beim Ergaenzen des Apple-Symbols ist mir aufgefallen, dass ein eigener
icons-Eintrag den Verweis ersetzt, den Next aus app/icon.svg von selbst
erzeugt: die Seite hatte danach gar kein Symbol mehr und der Browser fiel
auf /favicon.ico zurueck, das es hier nicht gibt. Beide Eintraege stehen
jetzt ausdruecklich da, ein Test prueft den Verweis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:43 +02:00
florian.wildeandClaude Opus 5 49e253f70b feat(board): Mehrfachauswahl fuer Status, Tags und Loeschen
Kaestchen auf jeder Karte, darunter eine Leiste am unteren Rand. Fest
verankert, weil sie bei einer langen Liste oben laengst aus dem Blick
gescrollt waere, waehrend man noch auswaehlt.

Ein eigener Endpunkt statt vieler einzelner PATCHes: eine Transaktion statt
zwanzig Rundgaengen, und der Verlauf bekommt je Projekt genau einen Eintrag
statt eines Schwalls.

Was der Filter ausblendet, faellt aus der Auswahl - sonst traefe eine
Massenaenderung Projekte, die man gar nicht mehr vor Augen hat. Fremde IDs
sammelt der Server wegen der Besitzerbedingung erst gar nicht ein und meldet
sie als uebergangen.

Das Kaestchen ist gedaempft, aber immer sichtbar. Erst beim Ueberfahren
einzublenden waere auf einem Touchgeraet nicht erreichbar - und wer es nie
sieht, weiss nicht, dass es die Auswahl gibt.

Ansicht, Sortierung und Archivschalter ueberdauern nun das Neuladen. Wer sein
Brett von Hand anordnet, will es beim naechsten Aufruf auch sehen. Der
Suchbegriff bleibt aussen vor: eine Suche von gestern wiederherzustellen
waere eher verwirrend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
florian.wildeandClaude Opus 5 1400bca047 feat(board): freie Reihenfolge im Projektbrett
Karten lassen sich innerhalb einer Spalte anordnen, die Position bleibt am
Projekt. Das Aufgabenbrett kann das laengst - dass ausgerechnet das
Projektbrett der gewaehlten Sortierung folgte, war ein Bruch.

Die Sortierung im Kopf ist in dieser Ansicht deshalb abgeschaltet. Sie
stehen zu lassen, ohne dass sie wirkt, waere irrefuehrend.

Umsortieren fasst das Aenderungsdatum nicht an. Sonst schoebe ein Aufraeumen
im Brett saemtliche Projekte in "Zuletzt geaendert" nach oben - das Brett
wuerde die Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
Ein Statuswechsel dagegen ist eine echte Aenderung und darf das Datum
setzen. Nur er kommt auch in den Verlauf; ein blosser Rangwechsel nicht,
sonst bestuende der Verlauf bald aus nichts anderem.

Dabei fiel auf, dass neu angelegte, importierte und per Status verschobene
Projekte gar keine Position bekamen und sich alle auf 0 stapelten. Der Rang
wird jetzt an allen vier Stellen gesetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
60 changed files with 3649 additions and 139 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.
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`),
muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein
JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden
+83 -6
View File
@@ -138,6 +138,33 @@ 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
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
Ein Kästchen auf jeder Karte — es erscheint erst beim Überfahren, damit nicht auf jeder
Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Leiste ein:
**Status setzen**, **Tags ergänzen oder entfernen**, **als Favorit markieren**, **löschen**.
Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte,
die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch.
### Kanban
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
@@ -147,8 +174,15 @@ erst danach gespeichert; scheitert das Speichern, springt die Karte zurück.
normaler Link.
- Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren.
Auf Touchgeräten nach kurzem Halten.
- Die Reihenfolge innerhalb einer Spalte folgt der gewählten Sortierung. Frei sortieren
ginge nur mit einer gespeicherten Position — bewusst nicht gebaut.
- **Freie Reihenfolge innerhalb einer Spalte.** Die Position wird am Projekt gespeichert,
wie beim Aufgabenbrett. Deshalb ist die Sortierung im Kopf in dieser Ansicht abgeschaltet
— sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.
- Reines Umsortieren fasst das Änderungsdatum **nicht** an. Sonst schöbe ein Aufräumen im
Brett sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.
- Neue, importierte und per Status verschobene Projekte landen am Ende ihrer Spalte.
- **Ansicht, Sortierung und Archivschalter überdauern das Neuladen** (im Browser
gespeichert, nicht am Konto). Der Suchbegriff bleibt bewusst außen vor — eine Suche von
gestern beim Öffnen wiederherzustellen wäre eher verwirrend als hilfreich.
### Projektkarte auf einen Blick
@@ -173,6 +207,23 @@ Blockiert · Erledigt**.
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
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
Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel,
@@ -285,6 +336,7 @@ Firmen-Gitea nebeneinander.
| Tags | bis zu 25, mit SHA, Nachricht und Datum |
| 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-Heatmap** | Ein Feld je Tag über bis zu ein Jahr, auf der Projektseite |
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| Sprachen | Verteilung als Balken mit Prozentwerten |
@@ -311,9 +363,19 @@ bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
| Einstellung | Bedeutung |
| --- | --- |
| 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 |
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:
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
@@ -457,10 +519,24 @@ letzten Zustellung stehen an der Karte.
- **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand,
animierte Fortschrittsbalken.
- **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten).
- **Barrierearm**: durchgehende ARIA-Labels, Escape schließt Dialoge, sichtbarer
Fokusring, `prefers-reduced-motion` schaltet Animationen ab.
- **Barrierefrei nach WCAG 2.1 AA**, und zwar geprüft statt behauptet: axe-core läuft über
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
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
- **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy
„Zum Startbildschirm hinzufügen“, danach läuft Nebula ohne Adressleiste. Die
maskierbare Fassung hat mehr Rand, weil Android Symbole je nach Gerät rund, eckig
oder als Tropfen zuschneidet.
- **Änderungsverlauf** hinter der Versionsnummer in der Fußzeile: was neu ist, was besser
wurde und welche Fehler behoben sind. Die Liste steht in `src/lib/changelog.ts` und ist
die einzige Quelle — ein Test hält sie und `package.json` zusammen, weil genau diese
beiden Angaben hier schon einmal auseinandergelaufen sind.
---
@@ -479,7 +555,8 @@ letzten Zustellung stehen an der Karte.
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Besitzer |
| `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 |
| `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis |
| `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/:id/export` | Ein Projekt (`?format=md` für Markdown) |
| `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/search?q=` | Volltextsuche über die eigenen Notizen |
| `GET` `POST` | `/api/notifications` | Benachrichtigungsziele |
@@ -348,6 +351,8 @@ ausschließlich Daten des angemeldeten Kontos.
| `POST` | `/api/notifications/:id/test` | Testnachricht senden |
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `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` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts |
| `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen |
+33 -19
View File
@@ -20,59 +20,70 @@ erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
---
## 2 · Danach
### 1.2 README-Vorschau `S`
### 2.1 Commit-Aktivität als Heatmap `S`
Das README aus dem Repo holen und auf der Projektseite anzeigen. Ein
zusätzlicher API-Aufruf, Markdown kann Nebula schon. Erspart den Wechsel ins
Git, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging.
Die Sparkline gibt es auf Karte und Projektseite. Eine Heatmap über ein Jahr
wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine
Seite Commits, also Paginierung oder die Statistik-Endpunkte.
### 1.3 Läuft es noch? `M`
### 2.2 Kanban mit freier Reihenfolge `M`
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.
Heute folgt die Reihenfolge innerhalb einer Spalte der gewählten Sortierung.
Frei sortieren hieße, eine Position je Projekt zu speichern.
### 1.4 Persönliche API-Token `M`
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
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
### 3.4 Mehrsprachigkeit `L`
### 2.2 Mehrsprachigkeit `L`
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
Voraussetzung, falls du das Projekt veröffentlichen willst.
---
## 4 · Kleinigkeiten
## 3 · Kleinigkeiten
| 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 |
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal |
| Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" |
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
| Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt |
| Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
| `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7. Achtung: danach lädt Prisma die `.env` nicht mehr von selbst |
| 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:
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
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
Übersicht, keine Entwicklungsumgebung.
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
@@ -101,6 +112,9 @@ Damit klar ist, was **nicht** kommt und warum:
| **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ |
| **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.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 | ✓ |
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
+253
View File
@@ -279,6 +279,259 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
expect(zumSchluss.repositories[0].repoName).toBe("swr");
});
test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => {
await login(request, ADMIN);
const angelegt = [];
for (const name of ["Brett A", "Brett B", "Brett C"]) {
const response = await request.post("/api/projects", { data: { name, status: "IDEA" } });
angelegt.push((await response.json()).project);
}
// Neue Projekte haengen sich hinten an ihre Spalte an – fortlaufend, nicht
// alle auf denselben Platz. Absolut zaehlen laesst sich nicht: in der
// Spalte stehen schon Projekte aus frueheren Tests.
const [a, b, c] = angelegt.map((p) => p.position);
expect([b, c]).toEqual([a + 1, a + 2]);
const ids = angelegt.map((p) => p.id);
const vorher = angelegt[0].updatedAt;
// Umdrehen und dabei eines in eine andere Spalte schieben.
const gedreht = [ids[2], ids[1]];
const reorder = await request.patch("/api/projects/reorder", {
data: { columns: { IDEA: gedreht, IN_DEVELOPMENT: [ids[0]] } },
});
expect(reorder.ok()).toBeTruthy();
const alle = (await (await request.get("/api/projects")).json()).projects;
const finde = (id: string) => alle.find((p: { id: string }) => p.id === id);
expect(finde(ids[2]).position).toBe(0);
expect(finde(ids[1]).position).toBe(1);
expect(finde(ids[0]).status).toBe("IN_DEVELOPMENT");
// Reines Umsortieren darf das Aenderungsdatum nicht anfassen – sonst
// schoebe ein Aufraeumen im Brett alles in "Zuletzt geaendert" nach oben.
expect(new Date(finde(ids[1]).updatedAt).getTime()).toBe(
new Date(angelegt[1].updatedAt).getTime(),
);
// Das verschobene dagegen hat sich wirklich geaendert.
expect(new Date(finde(ids[0]).updatedAt).getTime()).toBeGreaterThan(
new Date(vorher).getTime(),
);
// Mehrfachauswahl: Tags ergaenzen und wieder entfernen.
const ergaenzt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["Sammel", "test"], tagMode: "add" },
});
expect((await ergaenzt.json()).changed).toBe(3);
const mitTags = (await (await request.get("/api/projects")).json()).projects;
// Kleingeschrieben abgelegt, wie ueberall.
expect(mitTags.find((p: { id: string }) => p.id === ids[0]).tags).toContain("sammel");
const entfernt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["sammel"], tagMode: "remove" },
});
expect((await entfernt.json()).changed).toBe(3);
// Status fuer mehrere: die Verschobenen landen hintereinander, nicht
// uebereinander auf derselben Position.
const verschoben = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await verschoben.json()).changed).toBe(3);
const geplant = (await (await request.get("/api/projects?status=PLANNING")).json()).projects;
const positionen = geplant.map((p: { position: number }) => p.position).sort();
expect(new Set(positionen).size).toBe(positionen.length);
// Wer schon so steht, wird nicht noch einmal angefasst.
const nochmal = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await nochmal.json()).changed).toBe(0);
// Fremde IDs fallen still unter den Tisch, statt etwas anzurichten.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const untergeschoben = await fremd.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await untergeschoben.json()).changed).toBe(0);
const uebrig = (await (await request.get("/api/projects")).json()).projects;
expect(uebrig.filter((p: { id: string }) => ids.includes(p.id))).toHaveLength(3);
const fremdSortieren = await fremd.patch("/api/projects/reorder", {
data: { columns: { DONE: ids } },
});
expect((await fremdSortieren.json()).updated).toBe(0);
await fremd.dispose();
// Und zum Schluss die eigenen wirklich loeschen.
const geloescht = await request.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
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 }) => {
await login(request, ADMIN);
+75
View File
@@ -72,9 +72,17 @@ test("Aufgaben anlegen und per Ziehen verschieben", async ({ page }) => {
// dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese
// Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck.
await page.waitForTimeout(250);
// Auf die Antwort warten, bevor neu geladen wird. Sonst bricht das Neuladen
// den noch laufenden Aufruf ab, und der Test schlaegt gelegentlich fehl,
// obwohl die Anwendung in Ordnung ist – genau so ist es passiert.
const gespeichert = page.waitForResponse(
(response) => response.url().includes("/tasks/reorder") && response.request().method() === "PATCH",
);
await page.mouse.up();
await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible();
expect((await gespeichert).ok()).toBeTruthy();
// Und der Server hat es auch so gespeichert.
await page.reload();
@@ -119,6 +127,73 @@ test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden();
});
test("Mehrfachauswahl aendert mehrere Projekte auf einmal", async ({ page }) => {
const kaestchen = page.locator('input[aria-label$="auswaehlen"]');
const anzahl = await kaestchen.count();
expect(anzahl).toBeGreaterThan(0);
// Ohne Auswahl gibt es keine Leiste – sie soll nicht dauerhaft im Weg sein.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
await kaestchen.first().check();
await expect(page.getByText("1 ausgewählt")).toBeVisible();
await page.getByRole("button", { name: "Tags", exact: true }).click();
await page.fill("#bulk-tags", "sammeltest");
await page.getByRole("button", { name: "Hinzufügen" }).click();
await expect(page.getByText("#sammeltest").first()).toBeVisible();
// Nach getaner Arbeit ist die Auswahl aufgehoben.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
});
test("Die Versionsnummer oeffnet den Aenderungsverlauf", async ({ page }) => {
await page.getByRole("button", { name: /^v\d+\.\d+\.\d+$/ }).click();
// Ueber die Rolle, nicht ueber den Text: das Wort steht auch in einem der
// Eintraege, und ein blosser Textvergleich waere mehrdeutig.
const dialog = page.getByRole("dialog", { name: "Änderungsverlauf" });
await expect(dialog).toBeVisible();
// Die laufende Fassung ist als solche gekennzeichnet.
await expect(dialog.getByText("installiert")).toBeVisible();
// Und es steht wirklich etwas drin.
await expect(dialog.getByRole("heading", { level: 3 }).first()).toBeVisible();
await expect(dialog.getByText("Neu").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
});
test("Das Manifest liegt bereit, auch ohne Anmeldung", async ({ page, playwright }) => {
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Ohne diesen Weg an der Zugangsschranke vorbei liesse sich die Anwendung
// nicht installieren: der Browser holt das Manifest, bevor jemand angemeldet ist.
const antwort = await anonym.get("/manifest.webmanifest");
expect(antwort.ok()).toBeTruthy();
const manifest = await antwort.json();
expect(manifest.name).toContain("Nebula");
expect(manifest.display).toBe("standalone");
expect(manifest.icons.some((icon: { purpose: string }) => icon.purpose === "maskable")).toBe(true);
for (const icon of manifest.icons) {
const bild = await anonym.get(icon.src);
expect(bild.ok(), icon.src).toBeTruthy();
expect(bild.headers()["content-type"]).toContain("image/png");
}
await anonym.dispose();
// Die Seite muss ein Symbol verweisen. Fehlt es, sucht der Browser
// /favicon.ico – das es hier nicht gibt, und die Konsole fuellt sich mit
// 404ern. Genau das ist beim Ergaenzen des Apple-Symbols passiert.
await page.goto("/");
await expect(page.locator('link[rel="icon"]').first()).toHaveAttribute("href", /icon/);
});
test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => {
await page.getByRole("button", { name: /Benutzermenue/ }).click();
await page.getByRole("menuitem", { name: "Abmelden" }).click();
+31 -6
View File
@@ -1,12 +1,12 @@
{
"name": "nebula",
"version": "0.9.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nebula",
"version": "0.9.0",
"version": "1.2.0",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -34,6 +34,8 @@
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2",
"prisma": "^6.8.2",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
@@ -2535,6 +2537,16 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -2574,6 +2586,19 @@
}
}
},
"node_modules/c12/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@@ -2866,10 +2891,10 @@
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "nebula",
"version": "1.0.0",
"version": "1.3.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": {
@@ -30,9 +30,6 @@
"test:e2e:ui": "playwright test --ui",
"test:all": "npm test && npm run test:e2e"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -59,6 +56,8 @@
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2",
"prisma": "^6.8.2",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
+24
View File
@@ -0,0 +1,24 @@
import { config as loadEnv } from "dotenv";
import { defineConfig } from "prisma/config";
/**
* Konfiguration der Prisma-CLI.
*
* Loest den Abschnitt `prisma` in der package.json ab, der mit Prisma 7
* wegfaellt.
*
* **Der Aufruf von dotenv ist nicht optional.** Sobald diese Datei
* existiert, liest Prisma die .env nicht mehr von selbst ein – ohne die
* folgenden Zeilen stuende `migrate deploy` plotzlich ohne DATABASE_URL da,
* und zwar mit einer Fehlermeldung, die nicht darauf hindeutet. Erst .env,
* dann .env.local: eine oertliche Abweichung soll gewinnen.
*/
loadEnv({ path: ".env", quiet: true });
loadEnv({ path: ".env.local", override: true, quiet: true });
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
seed: "tsx prisma/seed.ts",
},
});
@@ -0,0 +1,20 @@
-- Freie Reihenfolge im Projektbrett.
--
-- Der Bestand bekommt seine Position aus der bisherigen Anzeige: je Konto und
-- Spalte nach Aenderungsdatum absteigend. Ohne diesen Schritt staenden alle
-- auf 0 und das Brett zeigte eine willkuerliche Reihenfolge, die sich bei
-- jedem Laden anders ergeben koennte.
ALTER TABLE "Project" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
UPDATE "Project" AS p
SET "position" = nummeriert.rang - 1
FROM (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "ownerId", "status" ORDER BY "updatedAt" DESC, "id"
) AS rang
FROM "Project"
) AS nummeriert
WHERE nummeriert."id" = p."id";
CREATE INDEX "Project_ownerId_status_position_idx" ON "Project"("ownerId", "status", "position");
@@ -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";
+66
View File
@@ -71,6 +71,16 @@ enum ActivityKind {
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.
enum TaskStatus {
TODO
@@ -127,6 +137,7 @@ model User {
activities Activity[]
recoveryCodes RecoveryCode[]
pendingLogins PendingLogin[]
templates ProjectTemplate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -315,6 +326,9 @@ model Project {
accent String @default("violet")
tags String[] @default([])
favorite Boolean @default(false)
/// Rang innerhalb der Statusspalte im Brett. Nur dort wirksam – die
/// uebrigen Ansichten folgen der gewaehlten Sortierung.
position Int @default(0)
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen.
@@ -343,6 +357,7 @@ model Project {
@@index([status])
@@index([updatedAt])
@@index([ownerId])
@@index([ownerId, status, position])
}
/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren –
@@ -445,6 +460,15 @@ model Task {
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
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
/// merken, damit dasselbe Issue nicht zweimal uebernommen wird.
sourceIssue Int?
@@ -462,6 +486,41 @@ model Task {
@@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.
model Activity {
id String @id @default(cuid())
@@ -546,6 +605,13 @@ model RepoSnapshot {
/// Commits je Tag der letzten 30 Tage, aeltester zuerst.
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?
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled
ciState String?
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+13 -1
View File
@@ -3,7 +3,12 @@ import { NextResponse } from "next/server";
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects";
import {
nextProjectPosition,
projectDetailInclude,
projectListInclude,
uniqueSlug,
} from "@/lib/projects";
import { applyLeadingRepo, type RepoInput } from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { refreshDerivedProgress } from "@/lib/tasks";
@@ -82,6 +87,13 @@ export async function PATCH(request: Request, { params }: Params) {
if (data[key] !== undefined) update[key] = data[key];
}
// Beim Statuswechsel ans Ende der neuen Spalte – sonst behielte das Projekt
// seinen alten Rang und saesse mitten in einer Spalte, in der es eben erst
// angekommen ist.
if (data.status !== undefined && data.status !== existing.status) {
update.position = await nextProjectPosition(auth.user.id, data.status);
}
await prisma.project.update({ where: { id }, data: update });
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
@@ -3,7 +3,7 @@ import type { Prisma, TaskStatus } from "@prisma/client";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
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";
export const dynamic = "force-dynamic";
@@ -52,11 +52,14 @@ export async function PATCH(request: Request, { params }: Params) {
const now = new Date();
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[]]>) {
ids.forEach((taskId, index) => {
if (!ownedIds.has(taskId)) return;
const before = previousStatus.get(taskId);
if (status === "DONE" && before !== "DONE") frischErledigt.push(taskId);
updates.push(
prisma.task.update({
where: { id: taskId },
@@ -77,6 +80,14 @@ export async function PATCH(request: Request, { params }: Params) {
if (updates.length > 0) {
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);
}
+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({
data: {
@@ -58,6 +58,7 @@ export async function POST(request: Request, { params }: Params) {
position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null,
labels,
recurrence: recurrence ?? null,
doneAt: status === "DONE" ? new Date() : null,
},
});
+150
View File
@@ -0,0 +1,150 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, projectBulkSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Dieselbe Aenderung fuer mehrere Projekte auf einmal.
*
* Der Weg ueber einen eigenen Endpunkt statt vieler einzelner PATCHes hat
* zwei Gruende: es ist eine Transaktion statt zwanzig Rundgaengen, und der
* Verlauf bekommt je Projekt genau einen Eintrag statt eines Schwalls.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = projectBulkSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { ids, action } = parsed.data;
// Die Besitzerbedingung steht zuerst: fremde IDs werden hier gar nicht
// erst eingesammelt und fallen damit still unter den Tisch.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: ids } },
select: { id: true, status: true, tags: true, favorite: true, updatedAt: true },
});
if (owned.length === 0) {
return NextResponse.json({ changed: 0, ignored: ids.length });
}
const eigene = owned.map((project) => project.id);
const entries: Parameters<typeof recordActivities>[0] = [];
if (action === "delete") {
const result = await prisma.project.deleteMany({
where: { ownerId: auth.user.id, id: { in: eigene } },
});
return NextResponse.json({ changed: result.count, ignored: ids.length - result.count });
}
const updates: Prisma.PrismaPromise<unknown>[] = [];
// Einmal vorab bestimmen und hochzaehlen. Je Projekt neu abzufragen ergaebe
// immer dieselbe Zahl – die Transaktion ist ja noch nicht geschrieben, und
// alle Verschobenen landeten uebereinander auf derselben Position.
let position =
action === "status" ? await nextPosition(auth.user.id, parsed.data.status!) : 0;
for (const project of owned) {
if (action === "status") {
const ziel = parsed.data.status!;
if (project.status === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ans Ende der Zielspalte, wie beim einzelnen Verschieben auch.
data: { status: ziel, position: position++ },
}),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(project.status, ziel),
meta: { from: project.status, to: ziel, bulk: true },
});
continue;
}
if (action === "favorite") {
const ziel = parsed.data.favorite!;
if (project.favorite === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ein Stern ist keine inhaltliche Aenderung.
data: { favorite: ziel, updatedAt: project.updatedAt },
}),
);
continue;
}
// action === "tags"
const gewuenscht = parsed.data.tags!.map((tag) => tag.toLowerCase());
const naechste =
parsed.data.tagMode === "add"
? Array.from(new Set([...project.tags, ...gewuenscht])).slice(0, 12)
: project.tags.filter((tag) => !gewuenscht.includes(tag));
if (naechste.length === project.tags.length &&
naechste.every((tag, index) => tag === project.tags[index])) {
continue;
}
updates.push(
prisma.project.update({ where: { id: project.id }, data: { tags: naechste } }),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_UPDATED",
summary: "Tags geändert",
meta: { tags: naechste, bulk: true },
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
changed: updates.length,
// Nicht geaendert, weil schon so, oder weil die ID nicht dem Konto gehoert.
ignored: ids.length - updates.length,
});
}
/** Naechste freie Position am Ende einer Statusspalte. */
async function nextPosition(ownerId: string, status: ProjectStatus) {
const last = await prisma.project.findFirst({
where: { ownerId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
+3 -1
View File
@@ -8,7 +8,7 @@ import {
repositoriesOf,
type ExportedRepository,
} from "@/lib/export/format";
import { uniqueSlug } from "@/lib/projects";
import { nextProjectPosition, uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import {
IMPORT_MAX_BODY_BYTES,
@@ -164,6 +164,8 @@ export async function POST(request: Request) {
}
const data = {
// Hinten an die Statusspalte des Bretts, in der Reihenfolge der Datei.
position: await nextProjectPosition(auth.user.id, incoming.status),
name: incoming.name,
summary: incoming.summary ?? null,
description: incoming.description ?? null,
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { formatZodError, projectReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Neue Reihenfolge im Brett nach dem Ziehen.
*
* Wie beim Aufgabenbrett wird je betroffener Spalte die vollstaendige Liste
* uebergeben. Status und Position ergeben sich daraus eindeutig; ein
* verlorener Aufruf laesst sich einfach erneut senden, ohne Schaden
* anzurichten.
*/
export async function PATCH(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 = projectReorderSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const columns = parsed.data.columns as Partial<Record<ProjectStatus, string[]>>;
const requestedIds = Object.values(columns).flat();
// Nur eigene Projekte anfassen – sonst liessen sich fremde ueber
// untergeschobene IDs verschieben.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: requestedIds } },
select: { id: true, status: true, updatedAt: true },
});
const ownedIds = new Set(owned.map((project) => project.id));
const vorher = new Map(owned.map((project) => [project.id, project]));
const updates: Prisma.PrismaPromise<unknown>[] = [];
const entries: Parameters<typeof recordActivities>[0] = [];
for (const [status, ids] of Object.entries(columns) as Array<[ProjectStatus, string[]]>) {
ids.forEach((projectId, index) => {
const before = vorher.get(projectId);
if (!ownedIds.has(projectId) || !before) return;
const gewandert = before.status !== status;
updates.push(
prisma.project.update({
where: { id: projectId },
data: {
status,
position: index,
// Beim reinen Umsortieren das Aenderungsdatum festhalten. Sonst
// schoebe ein Aufraeumen im Brett saemtliche Projekte in der
// Sortierung "Zuletzt geaendert" nach oben – das Brett wuerde die
// Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
...(gewandert ? {} : { updatedAt: before.updatedAt }),
},
}),
);
// Ein Statuswechsel gehoert in den Verlauf, ein blosses Umsortieren
// nicht – sonst besteht er bald nur noch aus Rangaenderungen.
if (gewandert) {
entries.push({
projectId,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(before.status, status),
meta: { from: before.status, to: status },
});
}
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
updated: updates.length,
ignored: requestedIds.length - updates.length,
});
}
+67 -4
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { projectListInclude, uniqueSlug } from "@/lib/projects";
import { nextProjectPosition, projectListInclude, uniqueSlug } from "@/lib/projects";
import { applyLeadingRepo } from "@/lib/repositories";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
@@ -74,6 +74,20 @@ export async function POST(request: Request) {
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({
data: {
ownerId: auth.user.id,
@@ -87,6 +101,11 @@ export async function POST(request: Request) {
accent: data.accent,
tags: data.tags,
favorite: data.favorite,
progressFromTasks: data.progressFromTasks,
// Hinten an die Statusspalte des Bretts.
position: await nextProjectPosition(auth.user.id, data.status),
notes: { create: notes },
tasks: { create: tasks },
},
select: { id: true },
});
@@ -109,12 +128,56 @@ export async function POST(request: Request) {
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_CREATED",
summary:
data.status === "IDEA"
summary: vorlage
? `Aus Vorlage „${vorlage.name}“ angelegt`
: data.status === "IDEA"
? "Als Idee angelegt"
: `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 });
}
/**
* 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 { prisma } from "@/lib/db";
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";
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.description !== undefined) update.description = data.description;
if (data.labels !== undefined) update.labels = data.labels;
if (data.recurrence !== undefined) update.recurrence = data.recurrence;
if (data.dueDate !== undefined) {
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 });
// 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);
// 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,
userId: auth.user.id,
kind: "TASK_MOVED",
summary: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
meta: { taskId: id, from: existing.status, to: nach },
summary: folge
? `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) {
+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 });
}
+16
View File
@@ -16,6 +16,22 @@
--color-void-soft: #0a0c1a;
--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,
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
+16 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import { ChangelogButton } from "@/components/ChangelogButton";
import { CommandPalette } from "@/components/CommandPalette";
import { TopNav } from "@/components/TopNav";
import { getSessionUser } from "@/lib/auth/session";
@@ -20,6 +21,20 @@ export async function generateMetadata(): Promise<Metadata> {
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
applicationName: APP_NAME,
robots: { index: false, follow: false },
manifest: "/manifest.webmanifest",
// iOS liest das Manifest nicht; dort braucht es diese Angaben einzeln.
appleWebApp: { capable: true, title: APP_NAME, statusBarStyle: "black-translucent" },
// Beide Eintraege ausdruecklich: sobald hier etwas steht, ersetzt es den
// Verweis, den Next aus app/icon.svg von selbst erzeugt. Ohne die erste
// Zeile haette die Seite gar kein Symbol mehr und der Browser fiele auf
// /favicon.ico zurueck – das es hier nicht gibt.
icons: {
icon: [
{ url: "/icon.svg", type: "image/svg+xml" },
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
],
apple: "/apple-touch-icon.png",
},
};
}
@@ -56,7 +71,7 @@ export default async function RootLayout({
<footer className="mx-auto w-full max-w-[1400px] px-4 pb-10 text-xs text-slate-500 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/5 pt-6">
<span>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span>
<span className="font-mono text-slate-600">v{APP_VERSION}</span>
<ChangelogButton version={APP_VERSION} />
</div>
</footer>
</body>
+41
View File
@@ -0,0 +1,41 @@
import type { MetadataRoute } from "next";
import { APP_NAME, APP_TAGLINE } from "@/lib/config";
/**
* Web-App-Manifest: macht Nebula auf dem Handy installierbar.
*
* Als Funktion und nicht als Konstante, aus demselben Grund wie bei
* generateMetadata: nur so wird APP_NAME zur Laufzeit gelesen. Ein
* konstanter Export waere beim Build eingefroren und liesse sich im
* fertigen Docker-Image nicht mehr aendern.
*/
export const dynamic = "force-dynamic";
export default function manifest(): MetadataRoute.Manifest {
return {
name: `${APP_NAME} – ${APP_TAGLINE} Dashboard`,
short_name: APP_NAME,
description:
"Uebersicht ueber alle Vibe-Coding-Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
// Ohne Adressleiste, aber mit den Systemleisten – die Anwendung braucht
// keinen Vollbildmodus, wohl aber die Uhrzeit und den Akkustand.
display: "standalone",
start_url: "/",
scope: "/",
background_color: "#05060f",
theme_color: "#05060f",
lang: "de",
orientation: "any",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
// Getrennte Fassung mit mehr Rand: Android schneidet maskierbare
// Symbole je nach Geraet rund, eckig oder als Tropfen zu.
{ src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
shortcuts: [
{ name: "Dashboard", url: "/" },
{ name: "Mein Konto", url: "/account" },
],
};
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Star, Tag, Trash2, X } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { Popover } from "@/components/ui/Popover";
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn, parseTags } from "@/lib/utils";
/**
* Leiste fuer die Mehrfachauswahl. Erscheint erst, wenn etwas ausgewaehlt ist,
* und liegt fest am unteren Rand – bei einer langen Liste waere sie oben
* sonst laengst aus dem Blick gescrollt, waehrend man noch auswaehlt.
*/
export function BulkBar({
ids,
onDone,
onClear,
}: {
ids: string[];
onDone: () => void;
onClear: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [tagInput, setTagInput] = useState("");
const statusRef = useRef<HTMLButtonElement>(null);
const tagsRef = useRef<HTMLButtonElement>(null);
async function apply(body: Record<string, unknown>) {
setBusy(true);
setError(null);
try {
const response = await fetch("/api/projects/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, ...body }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Das hat nicht geklappt.");
onDone();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
setStatusOpen(false);
setTagsOpen(false);
}
}
async function remove() {
if (
!window.confirm(
`${ids.length} Projekt${ids.length === 1 ? "" : "e"} mit allen Notizen und Aufgaben ` +
"unwiderruflich loeschen?",
)
) {
return;
}
await apply({ action: "delete" });
}
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[90] flex justify-center px-4">
<div className="popover pointer-events-auto flex flex-wrap items-center gap-2 px-3 py-2 shadow-2xl">
<span className="px-1 text-sm tabular-nums text-slate-200">
{ids.length} ausgewählt
</span>
<span className="h-5 w-px bg-white/10" aria-hidden />
<button
ref={statusRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setStatusOpen((value) => !value)}
disabled={busy}
aria-haspopup="menu"
aria-expanded={statusOpen}
>
Status
</button>
<Popover
open={statusOpen}
anchorRef={statusRef}
onClose={() => setStatusOpen(false)}
align="start"
>
<div role="menu" className="min-w-[12rem] p-1">
{STATUS_ORDER.map((status) => (
<button
key={status}
type="button"
role="menuitem"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-slate-300 hover:bg-white/6"
onClick={() => apply({ action: "status", status })}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: STATUS_META[status].color }}
/>
{STATUS_META[status].label}
</button>
))}
</div>
</Popover>
<button
ref={tagsRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setTagsOpen((value) => !value)}
disabled={busy}
aria-haspopup="menu"
aria-expanded={tagsOpen}
>
<Tag className="h-3.5 w-3.5" />
Tags
</button>
<Popover open={tagsOpen} anchorRef={tagsRef} onClose={() => setTagsOpen(false)} align="start">
<div className="w-64 space-y-2 p-3">
<label className="field-label" htmlFor="bulk-tags">
Tags (kommagetrennt)
</label>
<input
id="bulk-tags"
className="field"
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
placeholder="experiment, archiv"
autoFocus
/>
<div className="flex gap-2">
<button
type="button"
className="btn btn-primary flex-1 px-2 py-1.5 text-xs"
disabled={busy || parseTags(tagInput).length === 0}
onClick={() =>
apply({ action: "tags", tags: parseTags(tagInput), tagMode: "add" })
}
>
Hinzufügen
</button>
<button
type="button"
className="btn btn-ghost flex-1 px-2 py-1.5 text-xs"
disabled={busy || parseTags(tagInput).length === 0}
onClick={() =>
apply({ action: "tags", tags: parseTags(tagInput), tagMode: "remove" })
}
>
Entfernen
</button>
</div>
</div>
</Popover>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => apply({ action: "favorite", favorite: true })}
disabled={busy}
title="Als Favoriten markieren"
>
<Star className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs text-rose-200"
onClick={remove}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Löschen
</button>
{busy ? <Loader2 className="h-4 w-4 animate-spin text-slate-400" /> : null}
<button
type="button"
onClick={onClear}
className="rounded-lg p-1.5 text-slate-500 hover:text-white"
aria-label="Auswahl aufheben"
>
<X className="h-4 w-4" />
</button>
{error ? (
<p className={cn("w-full px-1 text-xs text-rose-300")}>{error}</p>
) : null}
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Modal } from "@/components/ui/Modal";
import { CHANGELOG, KIND_META } from "@/lib/changelog";
import { cn, formatDate } from "@/lib/utils";
/**
* Die Versionsnummer in der Fusszeile oeffnet den Aenderungsverlauf.
*
* Kein eigener Menuepunkt: gesucht wird so etwas erfahrungsgemaess dort, wo
* die Version steht – und einen Platz in der Navigation ist es nicht wert.
*/
export function ChangelogButton({ version }: { version: string }) {
const [open, setOpen] = useState(false);
const aktuell = CHANGELOG[0]?.version;
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="rounded font-mono text-slate-600 underline decoration-dotted underline-offset-4 transition-colors hover:text-violet-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400/60"
title="Was ist neu?"
>
v{version}
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
width="lg"
title="Änderungsverlauf"
description={
// Ein Hinweis, falls die laufende Fassung neuer ist als der Eintrag.
// Das passiert, wenn jemand die Version hochzaehlt und den Eintrag
// vergisst – dann soll es auffallen statt still danebenzustehen.
aktuell === version
? "Was in den letzten Fassungen dazugekommen ist."
: `Diese Instanz läuft auf v${version}; der Verlauf endet bei v${aktuell}.`
}
>
<ol className="space-y-7">
{CHANGELOG.map((release) => (
<li key={release.version}>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-mono text-base font-semibold text-white">
v{release.version}
</h3>
{release.version === version ? (
<span className="rounded-full bg-violet-500/15 px-2 py-0.5 text-[10px] uppercase tracking-wider text-violet-200 ring-1 ring-violet-400/30">
installiert
</span>
) : null}
<span className="text-xs text-slate-500">{formatDate(release.date)}</span>
</div>
<p className="mt-1 text-sm text-slate-400">{release.headline}</p>
<ul className="mt-3 space-y-2">
{release.changes.map((change, index) => {
const meta = KIND_META[change.kind];
return (
<li key={index} className="flex gap-2.5 text-sm leading-snug text-slate-300">
<span
className={cn(
"mt-0.5 h-fit shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1",
meta.className,
)}
>
{meta.label}
</span>
<span className="min-w-0">{change.text}</span>
</li>
);
})}
</ul>
</li>
))}
</ol>
</Modal>
</>
);
}
+6 -1
View File
@@ -24,10 +24,11 @@ import {
Unlink,
} from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { Heatmap } from "@/components/Heatmap";
import { RepositoryDialog } from "@/components/RepositoryDialog";
import { Sparkline } from "@/components/Sparkline";
import { repoTitle } from "@/lib/git/meta";
import { asArray, asRecord } from "@/types";
import { asArray, asCalendar, asRecord } from "@/types";
import type {
BranchInfo,
CommitInfo,
@@ -109,6 +110,7 @@ export function GitPanel({
const pulls = asArray<TicketInfo>(snapshot?.pullRequests);
const issues = asArray<TicketInfo>(snapshot?.issues);
const activity = asArray<number>(snapshot?.commitActivity);
const calendar = asCalendar(snapshot?.commitCalendar);
/** Ein einzelnes Repository oder – ohne Argument – alle. */
async function sync(nurDieses?: string) {
@@ -416,6 +418,9 @@ export function GitPanel({
</div>
) : null}
{/* Der Jahresueberblick, sobald er einmal geholt wurde. */}
{calendar ? <Heatmap calendar={calendar} /> : null}
{/* Sprachen */}
{languageTotal > 0 ? (
<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>
);
}
+178 -66
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@@ -8,53 +8,81 @@ import {
DragOverlay,
PointerSensor,
TouchSensor,
closestCorners,
useDraggable,
pointerWithin,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { GitBranch, GripVertical, NotebookPen } from "lucide-react";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GitBranch, GripVertical, Loader2, NotebookPen } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { CiBadge } from "@/components/CiBadge";
import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status";
import { cn, truncate } from "@/lib/utils";
import type { ProjectListItem } from "@/types";
type Columns = Record<ProjectStatus, ProjectListItem[]>;
/**
* Statusspalten mit Ziehen und Ablegen.
* Statusspalten mit Ziehen, Ablegen und freier Reihenfolge.
*
* Der Statuswechsel wird sofort angezeigt und erst danach gespeichert;
* scheitert das Speichern, springt die Karte zurueck. Die Reihenfolge
* innerhalb einer Spalte ist nicht frei sortierbar – sie folgt der im Board
* gewaehlten Sortierung, sonst muesste eine Position mitgespeichert werden.
* Die Reihenfolge innerhalb einer Spalte wird als Position am Projekt
* gespeichert – wie beim Aufgabenbrett. Deshalb richtet sich das Brett auch
* nicht nach der im Kopf gewaehlten Sortierung: die gilt fuer Raster und
* Gruppen, hier zaehlt die Anordnung von Hand.
*
* Verschieben wird sofort angezeigt und erst danach gespeichert; scheitert
* das Speichern, springt alles auf den Serverstand zurueck.
*/
export function KanbanBoard({
projects,
showArchived,
selection,
onToggleSelect,
}: {
projects: ProjectListItem[];
showArchived: boolean;
selection: Set<string>;
onToggleSelect: (id: string, selected: boolean) => void;
}) {
const router = useRouter();
const [overrides, setOverrides] = useState<Record<string, ProjectStatus>>({});
const [activeId, setActiveId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Eine Verschiebung, die der Server bestaetigt hat, braucht keine
// oertliche Ausnahme mehr.
useEffect(() => {
setOverrides((current) => {
const next: Record<string, ProjectStatus> = {};
for (const [id, status] of Object.entries(current)) {
const project = projects.find((entry) => entry.id === id);
if (project && project.status !== status) next[id] = status;
const sichtbar = useMemo(
() => STATUS_ORDER.filter((status) => showArchived || status !== "ARCHIVED"),
[showArchived],
);
const group = useMemo(
() => (list: ProjectListItem[]): Columns => {
const leer = Object.fromEntries(
STATUS_ORDER.map((status) => [status, [] as ProjectListItem[]]),
) as Columns;
for (const project of list) leer[project.status].push(project);
for (const status of STATUS_ORDER) {
// Bei gleicher Position bleibt es bei der Reihenfolge, in der die
// Liste kam – das ist die im Kopf gewaehlte Sortierung.
leer[status].sort((a, b) => a.position - b.position);
}
return Object.keys(next).length === Object.keys(current).length ? current : next;
});
}, [projects]);
return leer;
},
[],
);
const [columns, setColumns] = useState<Columns>(() => group(projects));
// Waehrend eines Zuges nicht dazwischenfunken: der Serverstand ist dann
// noch der alte und wuerde die Karte zurueckspringen lassen.
const dragging = useRef(false);
useEffect(() => {
if (!dragging.current) setColumns(group(projects));
}, [projects, group]);
const sensors = useSensors(
// Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte
@@ -63,72 +91,138 @@ export function KanbanBoard({
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
);
const statusOf = (project: ProjectListItem) => overrides[project.id] ?? project.status;
const active = activeId
? Object.values(columns).flat().find((project) => project.id === activeId) ?? null
: null;
const columns = useMemo(() => {
const visible = STATUS_ORDER.filter(
(status) => showArchived || status !== "ARCHIVED",
/** In welcher Spalte steckt diese Kennung – oder ist sie selbst eine? */
function containerOf(key: string): ProjectStatus | null {
if ((STATUS_ORDER as string[]).includes(key)) return key as ProjectStatus;
return (
STATUS_ORDER.find((status) => columns[status].some((project) => project.id === key)) ?? null
);
return visible.map((status) => ({
status,
items: projects.filter((project) => statusOf(project) === status),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, overrides, showArchived]);
const active = activeId ? projects.find((project) => project.id === activeId) : null;
}
function onDragStart(event: DragStartEvent) {
dragging.current = true;
setActiveId(String(event.active.id));
}
function onDragOver(event: DragOverEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
if (!overKey) return;
const from = containerOf(activeKey);
const to = containerOf(overKey);
if (!from || !to || from === to) return;
setColumns((current) => {
const moved = current[from].find((project) => project.id === activeKey);
if (!moved) return current;
const zielIndex = current[to].findIndex((project) => project.id === overKey);
const einfuegenBei = zielIndex >= 0 ? zielIndex : current[to].length;
return {
...current,
[from]: current[from].filter((project) => project.id !== activeKey),
[to]: [
...current[to].slice(0, einfuegenBei),
{ ...moved, status: to },
...current[to].slice(einfuegenBei),
],
};
});
}
async function onDragEnd(event: DragEndEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
let next = columns;
if (overKey) {
const container = containerOf(activeKey);
const ueber = containerOf(overKey);
if (container && container === ueber) {
const list = columns[container];
const alt = list.findIndex((project) => project.id === activeKey);
const neu = list.findIndex((project) => project.id === overKey);
if (alt >= 0 && neu >= 0 && alt !== neu) {
const sortiert = [...list];
const [moved] = sortiert.splice(alt, 1);
sortiert.splice(neu, 0, moved);
next = { ...columns, [container]: sortiert };
setColumns(next);
}
}
}
setActiveId(null);
dragging.current = false;
await persist(next);
}
const id = String(event.active.id);
const target = event.over?.id ? (String(event.over.id) as ProjectStatus) : null;
if (!target) return;
const project = projects.find((entry) => entry.id === id);
if (!project) return;
const previous = statusOf(project);
if (previous === target) return;
setOverrides((current) => ({ ...current, [id]: target }));
async function persist(next: Columns) {
setSaving(true);
try {
const response = await fetch(`/api/projects/${id}`, {
const response = await fetch("/api/projects/reorder", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: target }),
body: JSON.stringify({
columns: Object.fromEntries(
sichtbar.map((status) => [status, next[status].map((project) => project.id)]),
),
}),
});
if (!response.ok) throw new Error("failed");
router.refresh();
} catch {
setOverrides((current) => {
const next = { ...current };
delete next[id];
return next;
});
// Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
setColumns(group(projects));
} finally {
setSaving(false);
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
collisionDetection={pointerWithin}
modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDragCancel={() => setActiveId(null)}
onDragCancel={() => {
setActiveId(null);
dragging.current = false;
setColumns(group(projects));
}}
>
{saving ? (
<p className="mb-2 flex items-center gap-1.5 text-[11px] text-slate-500">
<Loader2 className="h-3 w-3 animate-spin" />
Reihenfolge wird gespeichert…
</p>
) : null}
<div className="flex gap-3 overflow-x-auto pb-3">
{columns.map((column) => (
<Column key={column.status} status={column.status} count={column.items.length}>
{column.items.map((project) => (
<KanbanCard key={project.id} project={project} />
))}
{sichtbar.map((status) => (
<Column key={status} status={status} count={columns[status].length}>
<SortableContext
items={columns[status].map((project) => project.id)}
strategy={verticalListSortingStrategy}
>
{columns[status].map((project) => (
<KanbanCard
key={project.id}
project={project}
selected={selection.has(project.id)}
onToggleSelect={onToggleSelect}
/>
))}
</SortableContext>
</Column>
))}
</div>
@@ -196,28 +290,36 @@ function Column({
function KanbanCard({
project,
overlay = false,
selected = false,
onToggleSelect,
}: {
project: ProjectListItem;
overlay?: boolean;
selected?: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: project.id });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: project.id,
});
return (
<article
ref={overlay ? undefined : setNodeRef}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
...(overlay ? {} : { transform: CSS.Transform.toString(transform), transition }),
} as React.CSSProperties
}
className={cn(
"popover group relative overflow-hidden p-3",
// Das Original bleibt als blasser Platzhalter stehen.
isDragging && !overlay && "opacity-30",
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
selected && "ring-1 ring-violet-400/60",
)}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
} as React.CSSProperties
}
>
<span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" />
@@ -233,6 +335,16 @@ function KanbanCard({
<GripVertical className="h-3.5 w-3.5" />
</button>
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
className="mt-1 h-3.5 w-3.5 shrink-0 accent-violet-500"
aria-label={`${project.name} auswaehlen`}
/>
) : null}
<div className="min-w-0 flex-1">
<Link
href={`/projects/${project.id}`}
+105 -3
View File
@@ -16,6 +16,7 @@ import {
X,
} from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { BulkBar } from "@/components/BulkBar";
import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard";
import { ProjectCard } from "@/components/ProjectCard";
@@ -54,6 +55,11 @@ function highlight(snippet: string): React.ReactNode[] {
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped" | "kanban";
const VIEWS: ViewMode[] = ["grid", "grouped", "kanban"];
/** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */
const ANSICHT_SCHLUESSEL = "nebula.board";
const SORT_LABEL: Record<SortKey, string> = {
updated: "Zuletzt geaendert",
created: "Neueste zuerst",
@@ -74,11 +80,52 @@ export function ProjectBoard({
const [sort, setSort] = useState<SortKey>("updated");
const [view, setView] = useState<ViewMode>("grid");
const [showArchived, setShowArchived] = useState(false);
/**
* Ansicht und Sortierung ueberdauern das Neuladen.
*
* Wer sein Brett von Hand anordnet, will es beim naechsten Aufruf auch
* sehen und nicht wieder das Raster. Bewusst erst nach dem ersten Rendern
* gelesen: der Server kennt den gespeicherten Wert nicht, und ein
* Unterschied zwischen beiden waere ein Hydrationsfehler.
*
* Der Suchbegriff bleibt aussen vor – eine Suche von gestern beim Oeffnen
* wiederherzustellen waere eher verwirrend als hilfreich.
*/
useEffect(() => {
try {
const gespeichert = window.localStorage.getItem(ANSICHT_SCHLUESSEL);
if (!gespeichert) return;
const wert = JSON.parse(gespeichert) as Partial<{
view: ViewMode;
sort: SortKey;
showArchived: boolean;
}>;
if (wert.view && VIEWS.includes(wert.view)) setView(wert.view);
if (wert.sort && wert.sort in SORT_LABEL) setSort(wert.sort);
if (typeof wert.showArchived === "boolean") setShowArchived(wert.showArchived);
} catch {
// Kaputter oder gesperrter Speicher: dann eben die Voreinstellung.
}
}, []);
useEffect(() => {
try {
window.localStorage.setItem(
ANSICHT_SCHLUESSEL,
JSON.stringify({ view, sort, showArchived }),
);
} catch {
// Im privaten Modus mancher Browser nicht beschreibbar – kein Grund,
// die Seite scheitern zu lassen.
}
}, [view, sort, showArchived]);
const [dialogOpen, setDialogOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dataMenuOpen, setDataMenuOpen] = useState(false);
const dataButtonRef = useRef<HTMLButtonElement>(null);
const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
const [selection, setSelection] = useState<Set<string>>(new Set());
/**
* Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite
@@ -174,6 +221,28 @@ export function ProjectBoard({
})).filter((group) => group.items.length > 0);
}, [visible]);
/**
* Was der Filter ausblendet, bleibt nicht ausgewaehlt. Sonst traefe eine
* Massenaenderung Projekte, die man gar nicht mehr vor Augen hat.
*/
useEffect(() => {
setSelection((current) => {
if (current.size === 0) return current;
const sichtbar = new Set(visible.map((project) => project.id));
const next = new Set([...current].filter((id) => sichtbar.has(id)));
return next.size === current.size ? current : next;
});
}, [visible]);
function toggleSelect(id: string, selected: boolean) {
setSelection((current) => {
const next = new Set(current);
if (selected) next.add(id);
else next.delete(id);
return next;
});
}
function toggleStatus(status: ProjectStatus) {
setStatusFilter((current) =>
current.includes(status)
@@ -218,6 +287,14 @@ export function ProjectBoard({
className="field w-auto min-w-[11rem]"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
// Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier
// stehen zu lassen, ohne dass sie etwas bewirkt, waere irrefuehrend.
disabled={view === "kanban"}
title={
view === "kanban"
? "Im Brett bestimmt die Reihenfolge, in der du die Karten ablegst."
: undefined
}
>
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
<option key={key} value={key}>
@@ -388,11 +465,22 @@ export function ProjectBoard({
{visible.length === 0 ? (
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
) : view === "kanban" ? (
<KanbanBoard projects={visible} showArchived={showArchived} />
<KanbanBoard
projects={visible}
showArchived={showArchived}
selection={selection}
onToggleSelect={toggleSelect}
/>
) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} />
<ProjectCard
key={project.id}
project={project}
index={index}
selected={selection.has(project.id)}
onToggleSelect={toggleSelect}
/>
))}
</div>
) : (
@@ -416,7 +504,13 @@ export function ProjectBoard({
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{group.items.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} />
<ProjectCard
key={project.id}
project={project}
index={index}
selected={selection.has(project.id)}
onToggleSelect={toggleSelect}
/>
))}
</div>
</div>
@@ -425,6 +519,14 @@ export function ProjectBoard({
</div>
)}
{selection.size > 0 ? (
<BulkBar
ids={[...selection]}
onDone={() => setSelection(new Set())}
onClear={() => setSelection(new Set())}
/>
) : null}
<Popover
anchorRef={dataButtonRef}
open={dataMenuOpen}
+30 -2
View File
@@ -29,7 +29,17 @@ const PROVIDER_LABEL: Record<string, string> = {
* Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"),
* interaktive Elemente liegen darueber und bekommen pointer-events zurueck.
*/
export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) {
export function ProjectCard({
project,
index = 0,
selected = false,
onToggleSelect,
}: {
project: ProjectListItem;
index?: number;
selected?: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
// Auf der Karte ist nur fuer eines Platz: das fuehrende. Wie viele es
@@ -60,7 +70,10 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
return (
<article
className="glass card-glow fade-up group relative overflow-hidden p-5"
className={cn(
"glass card-glow fade-up group relative overflow-hidden p-5",
selected && "ring-1 ring-violet-400/60",
)}
style={
{
"--accent-from": accent.from,
@@ -81,6 +94,21 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{/* Gedaempft, aber immer da. Nur beim Ueberfahren einzublenden
waere auf einem Touchgeraet gar nicht erreichbar – und wer
es nie sieht, weiss nicht, dass es die Auswahl gibt. */}
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
aria-label={`${project.name} auswaehlen`}
className={cn(
"pointer-events-auto h-3.5 w-3.5 shrink-0 accent-violet-500 transition-opacity",
!selected && "opacity-35 group-hover:opacity-100 focus-visible:opacity-100",
)}
/>
) : null}
<h3 className="truncate text-base font-semibold text-white transition-colors group-hover:text-violet-200">
{project.name}
</h3>
+140 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
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 { Modal } from "@/components/ui/Modal";
import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status";
@@ -17,6 +17,33 @@ interface ProjectDialogProps {
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 {
name: string;
summary: string;
@@ -33,6 +60,8 @@ interface FormState {
repoName: string;
autoSync: boolean;
progressFromTasks: boolean;
/** Nur beim Anlegen: aus welcher Vorlage Notizen und Aufgaben kommen. */
templateId: string;
}
const EMPTY: FormState = {
@@ -51,6 +80,7 @@ const EMPTY: FormState = {
repoName: "",
autoSync: true,
progressFromTasks: false,
templateId: "",
};
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 [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 [repos, setRepos] = useState<RepoListEntry[]>([]);
const [reposLoading, setReposLoading] = useState(false);
const [repoFilter, setRepoFilter] = useState("");
const [repoError, setRepoError] = useState<string | null>(null);
const isEdit = Boolean(project);
useEffect(() => {
if (!open) return;
setError(null);
@@ -96,6 +184,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoName: project.repositories[0]?.repoName ?? "",
autoSync: project.repositories[0]?.autoSync ?? true,
progressFromTasks: project.progressFromTasks,
templateId: "",
}
: EMPTY,
);
@@ -165,6 +254,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoName: form.repoName,
autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks,
// Nur beim Anlegen; beim Bearbeiten kennt das Schema das Feld nicht.
...(isEdit ? {} : { templateId: form.templateId || null }),
};
try {
@@ -203,6 +294,52 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
}
>
<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="sm:col-span-2">
<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 {
ArrowLeft,
BookmarkPlus,
CalendarDays,
CheckSquare,
Download,
@@ -17,6 +18,7 @@ import {
Trash2,
} from "lucide-react";
import { ProjectDialog } from "@/components/ProjectDialog";
import { SaveTemplateDialog } from "@/components/SaveTemplateDialog";
import { StatusSelect } from "@/components/StatusSelect";
import { Popover } from "@/components/ui/Popover";
import { repoTitle } from "@/lib/git/meta";
@@ -36,6 +38,7 @@ export function ProjectHeader({
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const [editOpen, setEditOpen] = useState(false);
const [templateOpen, setTemplateOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const [favorite, setFavorite] = useState(project.favorite);
@@ -179,6 +182,14 @@ export function ProjectHeader({
>
<Download className="h-3.5 w-3.5" />
</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
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
@@ -284,6 +295,16 @@ export function ProjectHeader({
connections={connections}
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>
);
}
+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";
import { AlignLeft, CalendarClock, GripVertical } from "lucide-react";
import { AlignLeft, CalendarClock, GripVertical, Repeat } from "lucide-react";
import type { Task } from "@prisma/client";
import { RECURRENCE_META } from "@/lib/tasks";
import { cn, formatDate } from "@/lib/utils";
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
@@ -69,12 +70,22 @@ export function TaskCard({
{task.title}
</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]">
{task.description ? (
<AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" />
) : 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 ? (
<span className={cn("inline-flex items-center gap-1", due?.className)}>
<CalendarClock className="h-2.5 w-2.5" />
+31 -2
View File
@@ -3,9 +3,9 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
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 { 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";
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
@@ -36,6 +36,7 @@ export function TaskDialog({
const [status, setStatus] = useState<TaskStatus>(initialStatus);
const [dueDate, setDueDate] = useState("");
const [labels, setLabels] = useState("");
const [recurrence, setRecurrence] = useState<string>("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -48,6 +49,7 @@ export function TaskDialog({
setStatus(task?.status ?? initialStatus);
setDueDate(toDateInput(task?.dueDate ?? null));
setLabels(task?.labels.join(", ") ?? "");
setRecurrence(task?.recurrence ?? "");
setError(null);
}, [open, task, initialStatus]);
@@ -64,6 +66,7 @@ export function TaskDialog({
// Datum nicht auf den Vortag ziehen.
dueDate: dueDate ? `${dueDate}T12:00:00` : null,
labels: parseTags(labels),
recurrence: recurrence || null,
};
try {
@@ -194,6 +197,32 @@ export function TaskDialog({
placeholder="bug, ui"
/>
</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>
{error ? (
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { CHANGELOG, KIND_META } from "./changelog";
import { APP_VERSION } from "./config";
/**
* Der Verlauf und die Versionsnummer sind zwei Angaben, die zusammenpassen
* muessen. Genau daran ist dieses Projekt schon einmal gescheitert: die
* Version stand in der Fusszeile und in der package.json und zeigte
* verschiedene Werte.
*/
describe("Aenderungsverlauf", () => {
it("beginnt mit der Fassung, die auch installiert ist", () => {
expect(CHANGELOG[0].version).toBe(APP_VERSION);
});
it("nennt jede Fassung nur einmal", () => {
const versionen = CHANGELOG.map((release) => release.version);
expect(new Set(versionen).size).toBe(versionen.length);
});
it("zaehlt absteigend", () => {
const zahl = (version: string) =>
version.split(".").reduce((sum, teil) => sum * 1000 + Number(teil), 0);
for (let index = 1; index < CHANGELOG.length; index += 1) {
expect(
zahl(CHANGELOG[index - 1].version),
`${CHANGELOG[index - 1].version} muss ueber ${CHANGELOG[index].version} stehen`,
).toBeGreaterThan(zahl(CHANGELOG[index].version));
}
});
it("hat zu jeder Fassung ein Datum, eine Zeile und mindestens eine Aenderung", () => {
for (const release of CHANGELOG) {
expect(release.version, "Version im Format x.y.z").toMatch(/^\d+\.\d+\.\d+$/);
expect(Number.isNaN(Date.parse(release.date)), `Datum von ${release.version}`).toBe(false);
expect(release.headline.length, `Zeile zu ${release.version}`).toBeGreaterThan(0);
expect(release.changes.length, `Eintraege zu ${release.version}`).toBeGreaterThan(0);
}
});
it("kennt zu jeder Art eine Beschriftung", () => {
for (const release of CHANGELOG) {
for (const change of release.changes) {
expect(KIND_META[change.kind], `${change.kind} in v${release.version}`).toBeDefined();
expect(change.text.trim().length).toBeGreaterThan(0);
}
}
});
});
+372
View File
@@ -0,0 +1,372 @@
/**
* Was sich von Fassung zu Fassung geaendert hat.
*
* Bewusst hier und nicht als CHANGELOG.md: die Liste wird in der Anwendung
* angezeigt, und zwei Quellen liefen prompt auseinander – wie es der
* Versionsnummer schon einmal passiert ist, die eine Zeit lang in der
* Fusszeile und in der package.json stand und verschiedene Werte zeigte.
*
* Die oberste Fassung ist die aktuelle. Neue Eintraege kommen nach vorne.
*/
export type ChangeKind = "neu" | "besser" | "behoben";
export interface ChangeEntry {
kind: ChangeKind;
text: string;
}
export interface Release {
version: string;
/** ISO-Datum, ohne Uhrzeit. */
date: string;
/** Ein Satz darueber, worum es in dieser Fassung ging. */
headline: string;
changes: ChangeEntry[];
}
export const KIND_META: Record<ChangeKind, { label: string; className: string }> = {
neu: {
label: "Neu",
className: "bg-violet-500/12 text-violet-200 ring-violet-400/30",
},
besser: {
label: "Besser",
className: "bg-cyan-500/12 text-cyan-200 ring-cyan-400/30",
},
behoben: {
label: "Behoben",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
},
};
export const CHANGELOG: Release[] = [
{
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",
date: "2026-07-26",
headline: "Das Brett wird zur Arbeitsfläche.",
changes: [
{
kind: "neu",
text:
"Freie Reihenfolge im Brett: Karten lassen sich innerhalb einer Spalte anordnen, " +
"die Position bleibt erhalten.",
},
{
kind: "neu",
text:
"Mehrfachauswahl. Über die Kästchen mehrere Projekte wählen und Status, Tags oder " +
"Favoriten in einem Zug ändern — oder alle auf einmal löschen.",
},
{
kind: "neu",
text:
"Als App installierbar: Nebula bringt ein Web-Manifest und eigene Symbole mit und " +
"lässt sich auf dem Handy zum Startbildschirm hinzufügen.",
},
{
kind: "neu",
text: "Dieser Änderungsverlauf, erreichbar über die Versionsnummer in der Fußzeile.",
},
{
kind: "besser",
text:
"Ansicht, Sortierung und der Archivschalter überdauern das Neuladen. Wer sein " +
"Brett von Hand anordnet, will es beim nächsten Aufruf auch sehen.",
},
{
kind: "behoben",
text:
"Neu angelegte, importierte und verschobene Projekte bekamen keine Position und " +
"stapelten sich im Brett alle auf demselben Platz.",
},
{
kind: "besser",
text:
"Umsortieren im Brett fasst das Änderungsdatum nicht mehr an. Vorher schob ein " +
"Aufräumen sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.",
},
{
kind: "besser",
text:
"Die Sortierung im Kopf ist im Brett abgeschaltet — dort zählt die Anordnung von Hand. " +
"Sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.",
},
{
kind: "besser",
text:
"Prisma liest seine Einstellungen aus prisma.config.ts statt aus der package.json. " +
"Damit ist die Warnung vor Prisma 7 weg.",
},
],
},
{
version: "1.0.0",
date: "2026-07-26",
headline: "Der Funktionsumfang ist beisammen.",
changes: [
{
kind: "neu",
text:
"Aktivitätsverlauf je Projekt: Statuswechsel, Notizen, Aufgaben und neue Commits " +
"in einer Zeitleiste.",
},
{
kind: "neu",
text:
"Zwei-Faktor-Anmeldung per TOTP, mit QR-Code zum Einrichten und zehn " +
"Wiederherstellungscodes für den Fall, dass das Gerät verloren geht.",
},
{
kind: "neu",
text:
"Mehrere Repositories je Projekt — Frontend und Backend gehören in eine Karte. " +
"Jedes mit eigenen Kennzahlen, eigener CI und eigenem Abgleich.",
},
{
kind: "besser",
text:
"Benachrichtigungen merken sich den gemeldeten Zustand je Repository. Vorher " +
"verdeckte ein rotes Frontend die rote Pipeline des Backends.",
},
{
kind: "besser",
text:
"Stillstand bemisst sich am jüngsten Commit über alle Repositories: an einem " +
"Vorhaben wird gearbeitet, sobald an einem seiner Teile gearbeitet wird.",
},
{
kind: "behoben",
text:
"Der Verlauf lieferte ohne ausdrückliche Angabe genau einen Eintrag statt dreißig " +
"— Number(null) ist 0, nicht „nicht angegeben“.",
},
{
kind: "behoben",
text:
"Die Issue-Übernahme antwortete mit „nicht gefunden“, obwohl gar kein bestimmtes " +
"Repository angefragt war.",
},
],
},
{
version: "0.9.0",
date: "2026-07-26",
headline: "Schneller ans Ziel.",
changes: [
{
kind: "neu",
text:
"Schnellsuche mit Strg+K: Projekte, Notizen und Bereiche, bedienbar allein mit " +
"der Tastatur.",
},
{
kind: "neu",
text:
"Offene Git-Issues als Aufgaben übernehmen, mit Titel, Labels und Link zurück. " +
"Bereits übernommene werden übersprungen.",
},
],
},
{
version: "0.8.0",
date: "2026-07-26",
headline: "Der sanfte Stups.",
changes: [
{
kind: "neu",
text:
"Stillstehende Projekte melden sich, wenn länger kein Commit kam. Die Schwelle " +
"steht im Adminbereich.",
},
{
kind: "behoben",
text:
"Der neue Anlass fehlte im Prüfschema — die Oberfläche bot ihn an, der Server " +
"lehnte ihn ab. Ein Test hält beide Listen seitdem zusammen.",
},
],
},
{
version: "0.7.0",
date: "2026-07-26",
headline: "Bescheid bekommen, ohne nachzusehen.",
changes: [
{
kind: "neu",
text:
"Benachrichtigungen über ntfy, Webhook (HMAC-signiert) und E-Mail — bei " +
"fehlgeschlagenem Abgleich, roter CI oder neuer Version.",
},
{
kind: "besser",
text:
"Gemeldet wird nur bei einer Änderung. Sonst käme bei einem Repo mit abgelaufenem " +
"Token mit jedem Durchlauf dieselbe Nachricht.",
},
{
kind: "behoben",
text: "Im ntfy-Betreff wurden Emojis zu Fragezeichen, weil die Kopfzeile nur Latin-1 kann.",
},
],
},
{
version: "0.6.0",
date: "2026-07-26",
headline: "Prüfen statt behaupten.",
changes: [
{
kind: "neu",
text: "Ende-zu-Ende-Tests mit Playwright gegen die gebaute Anwendung und eine eigene Datenbank.",
},
{ kind: "neu", text: "CI-Pipeline für Gitea Actions." },
],
},
{
version: "0.5.0",
date: "2026-07-26",
headline: "Aufgaben je Projekt.",
changes: [
{
kind: "neu",
text: "Aufgabenbrett mit sortierbaren Spalten, Fälligkeitsdatum und Labels.",
},
{ kind: "neu", text: "Fortschritt wahlweise aus dem Verhältnis erledigter Aufgaben." },
],
},
{
version: "0.4.0",
date: "2026-07-26",
headline: "Ziehen und ablegen.",
changes: [
{ kind: "neu", text: "Kanban-Ansicht: Status per Ziehen ändern." },
{ kind: "neu", text: "CI-Status je Branch und Commit-Aktivität als Verlaufslinie." },
{
kind: "behoben",
text:
"Die Verlaufslinie log: bei 100 Commits innerhalb weniger Tage zeigte sie für die " +
"übrigen Tage Nullen, obwohl schlicht keine Daten vorlagen.",
},
],
},
{
version: "0.3.0",
date: "2026-07-26",
headline: "Text und Suche.",
changes: [
{ kind: "neu", text: "Markdown in Beschreibung und Notizen." },
{ kind: "neu", text: "Volltextsuche über Notizen, mit hervorgehobenen Fundstellen." },
{ kind: "neu", text: "Listen offener Pull Requests und Issues." },
{ kind: "behoben", text: "Die Versionsnummer stand an zwei Stellen und lief auseinander." },
],
},
{
version: "0.2.0",
date: "2026-07-25",
headline: "Konten und Härtung.",
changes: [
{ kind: "neu", text: "Benutzerverwaltung mit Passwort und Passkeys, Einzel- und Mehrbenutzerbetrieb." },
{ kind: "neu", text: "Export, Import und vollständige Sicherung." },
{ kind: "neu", text: "Automatischer Abgleich nach Zeitplan, einstellbar im Adminbereich." },
{
kind: "besser",
text:
"Härtung: CSRF-Prüfung, Content-Security-Policy mit Nonce, SSRF-Schutz mit " +
"Namensauflösung, Ratenbegrenzung und harte Datentrennung je Konto.",
},
{
kind: "behoben",
text:
"Menüs verschwanden hinter den Karten. Ursache war der Weichzeichner der " +
"Glasoptik, der einen eigenen Bezugsrahmen aufspannt; Menüs hängen seitdem am " +
"Seitenende statt in der Karte.",
},
{
kind: "behoben",
text:
"Eingabefelder überlappten ihre Symbole, weil eigene CSS-Klassen außerhalb der " +
"Kaskadenschichten gegen die Hilfsklassen gewannen.",
},
],
},
{
version: "0.1.0",
date: "2026-07-25",
headline: "Der Anfang.",
changes: [
{
kind: "neu",
text:
"Projekte mit Status, Priorität, Fortschritt, Tags und Notizen — von der reinen " +
"Idee bis zum fertigen Repo.",
},
{ kind: "neu", text: "Git-Anbindung an GitHub, GitLab und Gitea über Access Token." },
{ kind: "neu", text: "Betrieb per Docker oder eigenständig, Daten in einer lokalen PostgreSQL." },
],
},
];
+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);
}
+27
View File
@@ -1,4 +1,10 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
calendarSince,
type CommitCalendar,
} from "./calendar";
import type {
BranchInfo,
CiInfo,
@@ -219,6 +225,27 @@ export class GiteaClient implements GitClient {
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));
});
}
}
function mapCommitStatus(state: string): CiState {
+54
View File
@@ -3,6 +3,7 @@ import type {
BranchInfo,
CiInfo,
CiState,
CommitCalendar,
CommitInfo,
ConnectionCheck,
GitClient,
@@ -14,6 +15,7 @@ import type {
TagInfo,
TicketInfo,
} from "./types";
import { dayKey } from "./calendar";
import { GitApiError, bucketCommitActivity } from "./types";
/**
@@ -206,6 +208,58 @@ export class GitHubClient implements GitClient {
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 };
}
}
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. */
+32 -3
View File
@@ -1,4 +1,10 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
calendarSince,
type CommitCalendar,
} from "./calendar";
import type {
BranchInfo,
CiInfo,
@@ -76,10 +82,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> {
const path = [ref.owner, ref.name].filter(Boolean).join("/");
const encoded = encodeURIComponent(path);
const base = `${this.apiBase}/projects/${encoded}`;
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const headers = this.headers();
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
@@ -198,6 +207,26 @@ export class GitLabClient implements GitClient {
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));
});
}
}
function mapPipelineState(status: string | null): CiState {
+10
View File
@@ -1,4 +1,7 @@
import type { GitProvider } from "@prisma/client";
import type { CommitCalendar } from "./calendar";
export type { CommitCalendar };
export type ProviderKind = GitProvider;
@@ -171,6 +174,13 @@ export interface GitClient {
listRepos(query?: string): Promise<RepoListItem[]>;
/** Vollstaendiger Stand eines Repositories. */
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>;
}
/** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */
+20 -1
View File
@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { Prisma, type ProjectStatus } from "@prisma/client";
import { prisma } from "@/lib/db";
import { slugify } from "@/lib/utils";
@@ -28,6 +28,25 @@ export async function uniqueSlug(
return `${base}-${Date.now()}`;
}
/**
* Naechste freie Position am Ende einer Statusspalte im Brett.
*
* Muss bei jedem Anlegen und bei jedem Statuswechsel gesetzt werden – sonst
* stehen alle auf 0 und das Brett zeigt eine Reihenfolge, die sich bei jedem
* Laden anders ergeben kann.
*/
export async function nextProjectPosition(
ownerId: string,
status: ProjectStatus,
): Promise<number> {
const last = await prisma.project.findFirst({
where: { ownerId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
/**
* Verbindungsfelder, die an den Browser gehen duerfen.
* tokenCipher ist bewusst nicht dabei.
+55
View File
@@ -2,6 +2,7 @@ import type { Repository } from "@prisma/client";
import { recordActivity, shorten } from "@/lib/activity";
import { prisma } from "@/lib/db";
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import type { GitClient } from "@/lib/git/types";
import { notifyAfterSync } from "@/lib/notify";
import { repoTitle } from "@/lib/repositories";
import type { RepoSnapshotData } from "@/lib/git/types";
@@ -57,6 +58,9 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
try {
const data = await client.fetchRepo(ref);
await persistSnapshot(repository, data);
// Erst nach dem Snapshot: die Zeile muss existieren, bevor der Kalender
// hineingeschrieben werden kann.
await maybeRefreshCalendar(repository.id, client, ref);
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
await notifyAfterSync(repository.id);
return {
@@ -141,6 +145,57 @@ 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);
}
}
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
export function resolveRepoRef(
repository: Pick<Repository, "repoOwner" | "repoName" | "repoUrl">,
+55 -1
View File
@@ -1,5 +1,10 @@
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", () => {
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";
/** 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. */
export async function nextPosition(
projectId: string,
+62 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { NOTIFICATION_EVENTS } from "@/lib/notify/types";
import { notificationTargetCreateSchema } from "./validation";
import {
notificationTargetCreateSchema,
projectBulkSchema,
projectReorderSchema,
} from "./validation";
/**
* Dieser Test entstand aus einem Fehler: beim Ergaenzen eines neuen
@@ -70,3 +74,60 @@ describe("notificationTargetCreateSchema", () => {
}
});
});
describe("projectReorderSchema", () => {
it("nimmt Spalten mit Listen an", () => {
const parsed = projectReorderSchema.safeParse({
columns: { IDEA: ["a", "b"], DONE: [] },
});
expect(parsed.success).toBe(true);
});
it("weist unbekannte Spalten ab", () => {
expect(
projectReorderSchema.safeParse({ columns: { PHANTASIE: ["a"] } }).success,
).toBe(false);
});
it("weist einen leeren Aufruf ab, statt ihn wirkungslos durchzuwinken", () => {
expect(projectReorderSchema.safeParse({ columns: {} }).success).toBe(false);
});
});
describe("projectBulkSchema", () => {
const ids = ["a", "b"];
it("verlangt zu jeder Aktion die noetige Angabe", () => {
// Ohne diese Pruefungen kaeme die Aktion durch und taete nichts – der
// schlimmste Fall, weil niemand merkt, dass nichts passiert ist.
expect(projectBulkSchema.safeParse({ ids, action: "status" }).success).toBe(false);
expect(projectBulkSchema.safeParse({ ids, action: "tags" }).success).toBe(false);
expect(projectBulkSchema.safeParse({ ids, action: "tags", tags: [] }).success).toBe(false);
expect(projectBulkSchema.safeParse({ ids, action: "favorite" }).success).toBe(false);
});
it("laesst vollstaendige Aktionen durch", () => {
expect(
projectBulkSchema.safeParse({ ids, action: "status", status: "DONE" }).success,
).toBe(true);
expect(
projectBulkSchema.safeParse({ ids, action: "tags", tags: ["archiv"] }).success,
).toBe(true);
expect(
projectBulkSchema.safeParse({ ids, action: "favorite", favorite: false }).success,
).toBe(true);
// Loeschen braucht nichts ausser den IDs.
expect(projectBulkSchema.safeParse({ ids, action: "delete" }).success).toBe(true);
});
it("ergaenzt standardmaessig, statt zu entfernen", () => {
const parsed = projectBulkSchema.parse({ ids, action: "tags", tags: ["x"] });
expect(parsed.tagMode).toBe("add");
});
it("braucht mindestens eine ID und deckelt die Menge", () => {
expect(projectBulkSchema.safeParse({ ids: [], action: "delete" }).success).toBe(false);
const zuViele = Array.from({ length: 201 }, (_, index) => `id-${index}`);
expect(projectBulkSchema.safeParse({ ids: zuViele, action: "delete" }).success).toBe(false);
});
});
+92 -3
View File
@@ -3,6 +3,8 @@ import { z } from "zod";
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] 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) =>
typeof value === "string" && value.trim() === "" ? null : value;
@@ -31,9 +33,96 @@ export const projectCreateSchema = z.object({
autoSync: z.boolean().default(true),
/** Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen. */
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
* vollstaendige Liste in ihrer neuen Reihenfolge – daraus ergeben sich Status
* und Position eindeutig, und der Aufruf laesst sich gefahrlos wiederholen.
*/
export const projectReorderSchema = z.object({
columns: z
.record(z.enum(STATUS), z.array(z.string().min(1)).max(500))
.refine((value) => Object.keys(value).length > 0, "Keine Spalte angegeben"),
});
/** Dieselbe Aenderung fuer mehrere Projekte auf einmal. */
export const projectBulkSchema = z
.object({
ids: z.array(z.string().min(1)).min(1).max(200),
action: z.enum(["status", "tags", "favorite", "delete"]),
status: z.enum(STATUS).optional(),
/** Bei action "tags": ergaenzen oder entfernen. */
tags: z.array(z.string().trim().min(1).max(30)).max(12).optional(),
tagMode: z.enum(["add", "remove"]).default("add"),
favorite: z.boolean().optional(),
})
.refine((value) => value.action !== "status" || value.status !== undefined, {
message: "Ohne Zielstatus laesst sich nichts verschieben",
path: ["status"],
})
.refine((value) => value.action !== "tags" || (value.tags?.length ?? 0) > 0, {
message: "Ohne Tags gibt es nichts zu aendern",
path: ["tags"],
})
.refine((value) => value.action !== "favorite" || value.favorite !== undefined, {
message: "Kein Zielzustand angegeben",
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 ─────────────────────────────────────────────
@@ -53,8 +142,6 @@ export const repositoryUpdateSchema = repositoryCreateSchema.partial().extend({
// ── Aufgaben ─────────────────────────────────────────────────
const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const;
const dueDateField = z.preprocess(
emptyToNull,
z
@@ -71,6 +158,8 @@ export const taskCreateSchema = z.object({
status: z.enum(TASK_STATUS).default("TODO"),
dueDate: dueDateField,
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();
+11 -2
View File
@@ -20,12 +20,21 @@ const SESSION_COOKIE = "nebula_session";
/** Ohne Anmeldung erreichbar. */
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
const PUBLIC_API_PREFIXES = ["/api/auth/"];
/** Der Docker-Healthcheck hat kein Cookie. */
const PUBLIC_API_EXACT = ["/api/health"];
/**
* Der Docker-Healthcheck hat kein Cookie. Manifest und Symbole braucht der
* Browser, bevor sich jemand angemeldet hat – wuerden sie zur Anmeldeseite
* umgeleitet, liesse sich die Anwendung nicht installieren und die
* Anmeldeseite haette kein Symbol.
*/
const PUBLIC_API_EXACT = ["/api/health", "/manifest.webmanifest"];
/** Bilder und aehnliches im Wurzelverzeichnis verraten nichts. */
const PUBLIC_FILE = /\.(png|svg|ico|webmanifest|txt|webp)$/;
function isPublicPath(pathname: string): boolean {
if (PUBLIC_PATHS.includes(pathname)) return true;
if (PUBLIC_API_EXACT.includes(pathname)) return true;
if (PUBLIC_FILE.test(pathname)) return true;
return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
+21
View File
@@ -9,6 +9,7 @@ import type {
Task,
TaskStatus,
} from "@prisma/client";
import type { CommitCalendar } from "@/lib/git/calendar";
import type {
BranchInfo,
CiInfo,
@@ -83,6 +84,7 @@ export interface RepoListEntry {
export type {
ActivityKind,
BranchInfo,
CommitCalendar,
CiInfo,
CommitInfo,
Note,
@@ -101,6 +103,25 @@ export function asArray<T>(value: unknown): 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> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, number>;