Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72e5eb43c4 | ||
|
|
e97bd43b23 | ||
|
|
f8e6c66876 | ||
|
|
01884f4747 | ||
|
|
04a07bd36c | ||
|
|
a3093dc68a | ||
|
|
52cfb075e3 | ||
|
|
57c50cfb7f | ||
|
|
ca6181b58f | ||
|
|
fce0a90cc3 | ||
|
|
16e8d31277 | ||
|
|
d97b6f650a | ||
|
|
49e253f70b | ||
|
|
1400bca047 |
@@ -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
|
||||
|
||||
+106
-6
@@ -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,
|
||||
@@ -248,6 +299,29 @@ App und Infrastruktur. Sie gehören trotzdem in **eine** Karte.
|
||||
Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das
|
||||
Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste.
|
||||
|
||||
### 4.0a README-Vorschau
|
||||
|
||||
Das README des führenden Repositories steht auf der Projektseite — man muss nicht ins Git
|
||||
wechseln, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging. Lange
|
||||
READMEs sind eingeklappt, ein Knopf zeigt alles.
|
||||
|
||||
- Geholt wird **höchstens einmal am Tag**, wie die Heatmap. GitHub hat einen eigenen
|
||||
Endpunkt, der jede Schreibweise findet; bei GitLab und Gitea braucht es zwei Aufrufe
|
||||
(Wurzelverzeichnis auflisten, dann die Datei holen).
|
||||
- Erkannt werden `README.md` und die üblichen Varianten, Groß- und Kleinschreibung egal.
|
||||
Markdown hat Vorrang — nur das lässt sich wirklich darstellen.
|
||||
- Bei 60 000 Zeichen wird an einer **Zeilengrenze** abgeschnitten, damit kein halber
|
||||
Codeblock stehen bleibt.
|
||||
- **Relative Links** werden gegen das Verzeichnis der Datei im Repository aufgelöst, sonst
|
||||
liefen sie ins Leere.
|
||||
- **Bilder werden nicht geladen.** Die Content-Security-Policy erlaubt `img-src` nur von
|
||||
der eigenen Instanz; sie dafür zu öffnen hieße, jedem fremden README das Nachladen
|
||||
beliebiger Bilder zu gestatten — also auch Zählpixeln. Für Abzeichen und
|
||||
Bildschirmfotos, die in einer Vorschau ohnehin Beiwerk sind, ist das kein guter Tausch.
|
||||
Stattdessen steht ein anklickbarer Verweis mit dem Alternativtext.
|
||||
- Der Inhalt stammt aus einem fremden Repository und läuft deshalb durch dieselbe
|
||||
Bereinigung wie Notizen (`rehype-sanitize`).
|
||||
|
||||
### 4.1 Unterstützte Systeme
|
||||
|
||||
| System | API | Authentifizierung | Besonderheit |
|
||||
@@ -285,6 +359,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 +386,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 +542,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 +578,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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+29
-20
@@ -20,59 +20,64 @@ erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Danach
|
||||
### 1.2 Läuft es noch? `M`
|
||||
|
||||
### 2.1 Commit-Aktivität als Heatmap `S`
|
||||
Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt.
|
||||
Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft.
|
||||
Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz
|
||||
greift dabei wie bei den Git-Aufrufen.
|
||||
|
||||
Die Sparkline gibt es auf Karte und Projektseite. Eine Heatmap über ein Jahr
|
||||
wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine
|
||||
Seite Commits, also Paginierung oder die Statistik-Endpunkte.
|
||||
### 1.3 Persönliche API-Token `M`
|
||||
|
||||
### 2.2 Kanban mit freier Reihenfolge `M`
|
||||
|
||||
Heute folgt die Reihenfolge innerhalb einer Spalte der gewählten Sortierung.
|
||||
Frei sortieren hieße, eine Position je Projekt zu speichern.
|
||||
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 +106,10 @@ 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 | ✓ |
|
||||
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
|
||||
|
||||
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
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Generated
+31
-6
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nebula",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.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",
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "RepoSnapshot" ADD COLUMN "readme" TEXT,
|
||||
ADD COLUMN "readmeAt" TIMESTAMP(3),
|
||||
ADD COLUMN "readmeBaseUrl" TEXT,
|
||||
ADD COLUMN "readmePath" TEXT;
|
||||
@@ -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,23 @@ 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?
|
||||
|
||||
/// Das README des Repositories, roh als Markdown. Wie der Kalender
|
||||
/// hoechstens einmal am Tag geholt – es aendert sich selten, und der
|
||||
/// Abgleich laeuft im Zweifel stuendlich.
|
||||
readme String?
|
||||
/// Dateiname, wie er im Repository steht ("README.md", "readme.rst" …).
|
||||
readmePath String?
|
||||
/// Wogegen relative Links im README aufzuloesen sind.
|
||||
readmeBaseUrl String?
|
||||
readmeAt DateTime?
|
||||
|
||||
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
|
||||
/// 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 |
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -124,6 +124,30 @@
|
||||
}
|
||||
.md a:hover { color: #ddd6fe; }
|
||||
|
||||
/* Bilder werden nicht geladen, sondern als Marke gezeigt – siehe die
|
||||
Begruendung in Markdown.tsx. Damit sie nicht wie Fliesstext aussieht,
|
||||
bekommt sie einen Rahmen und ein Symbol davor. */
|
||||
.md .md-image {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.16);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.05em 0.45em;
|
||||
font-size: 0.85em;
|
||||
color: #8a98b0;
|
||||
text-decoration: none;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.md .md-image::before {
|
||||
content: "▣";
|
||||
opacity: 0.6;
|
||||
}
|
||||
.md a.md-image:hover {
|
||||
color: #c4b5fd;
|
||||
border-color: rgba(196, 181, 253, 0.4);
|
||||
}
|
||||
|
||||
.md strong { color: #f1f5f9; font-weight: 600; }
|
||||
|
||||
.md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; }
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ActivityFeed } from "@/components/ActivityFeed";
|
||||
import { GitPanel } from "@/components/GitPanel";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { NotesPanel } from "@/components/NotesPanel";
|
||||
import { ReadmePanel } from "@/components/ReadmePanel";
|
||||
import { ProjectHeader } from "@/components/ProjectHeader";
|
||||
import { TaskBoard } from "@/components/tasks/TaskBoard";
|
||||
import { requireUserPage } from "@/lib/auth/guard";
|
||||
@@ -54,6 +55,20 @@ export default async function ProjectPage({ params }: PageProps) {
|
||||
|
||||
const detail = project as unknown as ProjectDetail;
|
||||
|
||||
const fuehrend = detail.repositories[0]?.snapshot;
|
||||
const readme = fuehrend?.readme
|
||||
? {
|
||||
content: fuehrend.readme,
|
||||
path: fuehrend.readmePath,
|
||||
baseUrl: fuehrend.readmeBaseUrl,
|
||||
// Die Basis endet auf dem Verzeichnis; angehaengt ergibt das die Datei.
|
||||
fileUrl:
|
||||
fuehrend.readmeBaseUrl && fuehrend.readmePath
|
||||
? `${fuehrend.readmeBaseUrl}${fuehrend.readmePath.split("/").pop()}`
|
||||
: (fuehrend.htmlUrl ?? null),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pt-4">
|
||||
<ProjectHeader
|
||||
@@ -83,6 +98,17 @@ export default async function ProjectPage({ params }: PageProps) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Das README des fuehrenden Repositories – bei mehreren waere eine
|
||||
zweite Vorschau untereinander mehr Laenge als Nutzen. */}
|
||||
{readme ? (
|
||||
<ReadmePanel
|
||||
content={readme.content}
|
||||
path={readme.path}
|
||||
baseUrl={readme.baseUrl}
|
||||
fileUrl={readme.fileUrl}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<NotesPanel projectId={detail.id} notes={detail.notes} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+177
-65
@@ -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}`}
|
||||
|
||||
@@ -24,12 +24,93 @@ const schema = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Loest relative Verweise gegen die Basis auf; laesst absolute in Ruhe. */
|
||||
function absolut(href: string | undefined, base: string | null | undefined): string | undefined {
|
||||
if (!href || !base) return href;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("#")) {
|
||||
return href;
|
||||
}
|
||||
try {
|
||||
return new URL(href, base).toString();
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
type HastNode = {
|
||||
type: string;
|
||||
tagName?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
children?: HastNode[];
|
||||
value?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ersetzt Bilder durch eine anklickbare Marke – siehe die Begruendung
|
||||
* unten am `.md-image`-Stil.
|
||||
*
|
||||
* Das passiert im Baum und nicht ueber eine Komponente, weil es auf den
|
||||
* Elternknoten ankommt: `[](ziel)` ist in READMEs die
|
||||
* Regel, und ein `<a>` in einem `<a>` ist ungueltiges HTML. Der Browser
|
||||
* zieht es beim Parsen auseinander, was zu einem Hydrationsfehler fuehrt –
|
||||
* genau daran ist es beim ersten Versuch gescheitert. Innerhalb eines Links
|
||||
* bleibt deshalb nur der Text stehen; der umgebende Link ist ohnehin das,
|
||||
* was man anklicken will.
|
||||
*/
|
||||
function bilderAlsMarke(base: string | null | undefined) {
|
||||
return () => (tree: HastNode) => {
|
||||
const gehe = (knoten: HastNode, inLink: boolean) => {
|
||||
const kinder = knoten.children;
|
||||
if (!kinder) return;
|
||||
|
||||
for (let index = 0; index < kinder.length; index += 1) {
|
||||
const kind = kinder[index];
|
||||
|
||||
if (kind.type === "element" && kind.tagName === "img") {
|
||||
const quelle = typeof kind.properties?.src === "string" ? kind.properties.src : undefined;
|
||||
const alt = typeof kind.properties?.alt === "string" ? kind.properties.alt.trim() : "";
|
||||
const text: HastNode = { type: "text", value: alt || "Bild" };
|
||||
const ziel = absolut(quelle, base);
|
||||
|
||||
kinder[index] =
|
||||
inLink || !ziel
|
||||
? { type: "element", tagName: "span", properties: { className: ["md-image"] }, children: [text] }
|
||||
: {
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: {
|
||||
className: ["md-image"],
|
||||
href: ziel,
|
||||
title: ziel,
|
||||
target: "_blank",
|
||||
rel: "noreferrer noopener nofollow",
|
||||
},
|
||||
children: [text],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
gehe(kind, inLink || (kind.type === "element" && kind.tagName === "a"));
|
||||
}
|
||||
};
|
||||
|
||||
gehe(tree, false);
|
||||
};
|
||||
}
|
||||
|
||||
export function Markdown({
|
||||
children,
|
||||
className,
|
||||
baseUrl,
|
||||
}: {
|
||||
children: string | null | undefined;
|
||||
className?: string;
|
||||
/**
|
||||
* Wogegen relative Verweise aufzuloesen sind. Gesetzt beim README aus
|
||||
* einem Repository – dort zeigen Links auf Dateien daneben, die ohne
|
||||
* Basis ins Leere liefen.
|
||||
*/
|
||||
baseUrl?: string | null;
|
||||
}) {
|
||||
if (!children?.trim()) return null;
|
||||
|
||||
@@ -37,12 +118,22 @@ export function Markdown({
|
||||
<div className={cn("md", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeSanitize, schema]]}
|
||||
// Erst bereinigen, dann umbauen: die Marke soll nicht selbst durch
|
||||
// die Bereinigung laufen und dabei ihre Klassen verlieren.
|
||||
rehypePlugins={[[rehypeSanitize, schema], bilderAlsMarke(baseUrl)]}
|
||||
components={{
|
||||
// Links aus fremdem Inhalt immer in neuem Tab und ohne
|
||||
// Zugriff auf das oeffnende Fenster.
|
||||
a: ({ href, children: content }) => (
|
||||
<a href={href} target="_blank" rel="noreferrer noopener nofollow">
|
||||
// className muss durchgereicht werden, sonst verlieren die
|
||||
// Bildmarken aus dem Umbau oben ihre Gestalt.
|
||||
a: ({ href, className: klasse, title, children: content }) => (
|
||||
<a
|
||||
href={absolut(href, baseUrl)}
|
||||
className={klasse}
|
||||
title={title}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BookOpen, ChevronDown, ExternalLink } from "lucide-react";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Das README des verknuepften Repositories.
|
||||
*
|
||||
* Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
|
||||
* Projekt eigentlich ging. Nicht zum Lesen ganzer Handbuecher gedacht:
|
||||
* eingeklappt zeigt es die ersten Zeilen, ausgeklappt alles.
|
||||
*/
|
||||
export function ReadmePanel({
|
||||
content,
|
||||
path,
|
||||
baseUrl,
|
||||
fileUrl,
|
||||
}: {
|
||||
content: string;
|
||||
path: string | null;
|
||||
baseUrl: string | null;
|
||||
/** Direktlink auf die Datei im Git-System. */
|
||||
fileUrl: string | null;
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
|
||||
// Kurze READMEs vollstaendig zu zeigen ist einfacher, als sie mit einem
|
||||
// Knopf zu versehen, der nichts freigibt.
|
||||
const kurz = content.length < 900;
|
||||
|
||||
return (
|
||||
<section className="glass p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
|
||||
<BookOpen className="h-4 w-4 text-cyan-300" />
|
||||
{path ?? "README"}
|
||||
</h2>
|
||||
{fileUrl ? (
|
||||
<a
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-violet-300"
|
||||
>
|
||||
Im Git öffnen
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-[max-height] duration-300",
|
||||
!kurz && !offen && "max-h-72",
|
||||
)}
|
||||
>
|
||||
<Markdown baseUrl={baseUrl}>{content}</Markdown>
|
||||
</div>
|
||||
|
||||
{/* Verlauf ueber den abgeschnittenen Rand, damit erkennbar ist, dass
|
||||
es weitergeht. Nur wenn wirklich etwas verdeckt wird. */}
|
||||
{!kurz && !offen ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#0b0d1c] to-transparent"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!kurz ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen((wert) => !wert)}
|
||||
className="btn btn-ghost mt-2 w-full text-xs"
|
||||
aria-expanded={offen}
|
||||
>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", offen && "rotate-180")} />
|
||||
{offen ? "Weniger" : "Ganzes README anzeigen"}
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* 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.4.0",
|
||||
date: "2026-07-26",
|
||||
headline: "Worum ging es hier nochmal?",
|
||||
changes: [
|
||||
{
|
||||
kind: "neu",
|
||||
text:
|
||||
"README-Vorschau auf der Projektseite. Erspart den Wechsel ins Git, nur um zu " +
|
||||
"sehen, worum ein halbvergessenes Projekt eigentlich ging.",
|
||||
},
|
||||
{
|
||||
kind: "besser",
|
||||
text:
|
||||
"Relative Links im README zeigen dorthin, wo sie hingehören — aufgelöst gegen " +
|
||||
"das Verzeichnis der Datei im Repository.",
|
||||
},
|
||||
{
|
||||
kind: "besser",
|
||||
text:
|
||||
"Bilder im README werden bewusst nicht geladen, sondern als anklickbarer " +
|
||||
"Verweis gezeigt. Die Content-Security-Policy dafür zu öffnen hieße, jedem " +
|
||||
"fremden README das Nachladen beliebiger Bilder zu erlauben — auch Zählpixeln.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "1.3.0",
|
||||
date: "2026-07-26",
|
||||
headline: "Was sich wiederholt, und was besser lesbar ist.",
|
||||
changes: [
|
||||
{
|
||||
kind: "neu",
|
||||
text:
|
||||
"Wiederkehrende Aufgaben: täglich, wöchentlich, alle zwei Wochen oder monatlich. " +
|
||||
"Wandert die Aufgabe nach „Erledigt“, entsteht die nächste von selbst.",
|
||||
},
|
||||
{
|
||||
kind: "behoben",
|
||||
text:
|
||||
"Gedämpfter Text erreichte auf dem dunklen Untergrund nur 3,8 statt der " +
|
||||
"geforderten 4,5 zu 1 — an 26 Stellen. Beide Stufen sind jetzt aufgehellt, " +
|
||||
"der Abstand zwischen ihnen bleibt sichtbar.",
|
||||
},
|
||||
{
|
||||
kind: "besser",
|
||||
text:
|
||||
"Die Barrierefreiheit ist mit axe-core über zehn Seitenzustände geprüft, " +
|
||||
"einschließlich der Dialoge. Keine Verstöße mehr gegen WCAG 2.1 AA.",
|
||||
},
|
||||
{
|
||||
kind: "besser",
|
||||
text:
|
||||
"Der Folgetermin rechnet vom Fälligkeitsdatum, nicht vom Abhaken — eine " +
|
||||
"wöchentliche Aufgabe bleibt auf ihrem Wochentag. Lange Liegengebliebenes " +
|
||||
"rutscht so weit vor, bis der Termin in der Zukunft liegt.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "1.2.0",
|
||||
date: "2026-07-26",
|
||||
headline: "Ein Jahr auf einen Blick, und ein Start ohne Leerlauf.",
|
||||
changes: [
|
||||
{
|
||||
kind: "neu",
|
||||
text:
|
||||
"Commit-Heatmap auf der Projektseite: ein Feld je Tag über bis zu ein Jahr, " +
|
||||
"mit Farbskala und Datum im Tooltip.",
|
||||
},
|
||||
{
|
||||
kind: "neu",
|
||||
text:
|
||||
"Projektvorlagen. Ein bewährtes Projekt über „Als Vorlage sichern“ festhalten — " +
|
||||
"Tags, Notizen und offene Aufgaben stehen dann beim nächsten Projekt bereit.",
|
||||
},
|
||||
{
|
||||
kind: "besser",
|
||||
text:
|
||||
"Die Heatmap-Daten kommen höchstens einmal am Tag und getrennt vom übrigen " +
|
||||
"Abgleich. Bei GitHub genügt ein Aufruf der Jahresstatistik, bei GitLab und " +
|
||||
"Gitea werden bis zu fünf Seiten Commits geholt.",
|
||||
},
|
||||
{
|
||||
kind: "besser",
|
||||
text:
|
||||
"Reicht die Historie nicht ein Jahr zurück, beginnt die Heatmap beim ältesten " +
|
||||
"bekannten Commit — leere Felder für unbekannte Zeiträume wären eine Lüge.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "1.1.0",
|
||||
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." },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||
import {
|
||||
CALENDAR_PAGE_SIZE,
|
||||
calendarByPaging,
|
||||
calendarSince,
|
||||
type CommitCalendar,
|
||||
} from "./calendar";
|
||||
import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
@@ -219,6 +226,64 @@ 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));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis
|
||||
* auflisten, dann die gefundene Datei holen – zwei Aufrufe.
|
||||
*/
|
||||
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
|
||||
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||
const headers = this.headers();
|
||||
|
||||
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
|
||||
`${base}/contents`,
|
||||
[],
|
||||
{ headers },
|
||||
);
|
||||
const name = pickReadme(
|
||||
eintraege.filter((eintrag) => eintrag.type === "file").map((eintrag) => eintrag.name),
|
||||
);
|
||||
if (!name) return null;
|
||||
|
||||
const datei = await getJson<{
|
||||
content?: string;
|
||||
encoding?: string;
|
||||
path?: string;
|
||||
html_url?: string | null;
|
||||
}>(`${base}/contents/${encodeURIComponent(name)}`, { headers });
|
||||
if (!datei?.content || datei.encoding !== "base64") return null;
|
||||
|
||||
const { content, truncated } = capReadme(decodeBase64(datei.content));
|
||||
return {
|
||||
content,
|
||||
path: datei.path ?? name,
|
||||
// html_url zeigt auf die Datei; fuer relative Links braucht es das
|
||||
// Verzeichnis darum herum.
|
||||
baseUrl: datei.html_url ? datei.html_url.replace(/\/[^/]*$/, "/") : null,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mapCommitStatus(state: string): CiState {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
CiState,
|
||||
CommitCalendar,
|
||||
CommitInfo,
|
||||
ConnectionCheck,
|
||||
GitClient,
|
||||
@@ -14,6 +15,8 @@ import type {
|
||||
TagInfo,
|
||||
TicketInfo,
|
||||
} from "./types";
|
||||
import { dayKey } from "./calendar";
|
||||
import { capReadme, decodeBase64, type RepoReadme } from "./readme";
|
||||
import { GitApiError, bucketCommitActivity } from "./types";
|
||||
|
||||
/**
|
||||
@@ -206,6 +209,95 @@ 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet – ein
|
||||
* Aufruf genuegt. Ohne README antwortet er mit 404; das ist kein Fehler,
|
||||
* sondern die Auskunft "gibt es nicht".
|
||||
*/
|
||||
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
|
||||
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||
|
||||
let datei: GitHubContent;
|
||||
try {
|
||||
datei = await getJson<GitHubContent>(`${base}/readme`, { headers: this.headers() });
|
||||
} catch (error) {
|
||||
if (error instanceof GitApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!datei?.content || datei.encoding !== "base64") return null;
|
||||
|
||||
const { content, truncated } = capReadme(decodeBase64(datei.content));
|
||||
return {
|
||||
content,
|
||||
path: datei.path ?? datei.name ?? "README",
|
||||
// html_url zeigt auf die Datei; fuer relative Links braucht es das
|
||||
// Verzeichnis darum herum.
|
||||
baseUrl: datei.html_url ? datei.html_url.replace(/\/[^/]*$/, "/") : null,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface GitHubContent {
|
||||
name?: string;
|
||||
path?: string;
|
||||
content?: string;
|
||||
encoding?: string;
|
||||
html_url?: string | null;
|
||||
}
|
||||
|
||||
interface GitHubWeek {
|
||||
/** Unix-Zeit des Wochenanfangs (Sonntag). */
|
||||
week: number;
|
||||
/** Sieben Werte, Sonntag bis Samstag. */
|
||||
days: number[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
|
||||
|
||||
+71
-3
@@ -1,4 +1,11 @@
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||
import {
|
||||
CALENDAR_PAGE_SIZE,
|
||||
calendarByPaging,
|
||||
calendarSince,
|
||||
type CommitCalendar,
|
||||
} from "./calendar";
|
||||
import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
@@ -76,10 +83,13 @@ export class GitLabClient implements GitClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** "gruppe/sub/projekt" URL-kodiert – so adressiert GitLab ein Projekt. */
|
||||
private projectPath(ref: RepoRef): string {
|
||||
return encodeURIComponent([ref.owner, ref.name].filter(Boolean).join("/"));
|
||||
}
|
||||
|
||||
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
|
||||
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 +208,64 @@ 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));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GitLab kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis
|
||||
* auflisten, dann die gefundene Datei holen – zwei Aufrufe.
|
||||
*/
|
||||
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
|
||||
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
|
||||
const headers = this.headers();
|
||||
|
||||
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
|
||||
`${base}/repository/tree?per_page=100`,
|
||||
[],
|
||||
{ headers },
|
||||
);
|
||||
const name = pickReadme(
|
||||
eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name),
|
||||
);
|
||||
if (!name) return null;
|
||||
|
||||
const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>(
|
||||
`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`,
|
||||
{ headers },
|
||||
);
|
||||
if (!datei?.content || datei.encoding !== "base64") return null;
|
||||
|
||||
const { content, truncated } = capReadme(decodeBase64(datei.content));
|
||||
const projekt = await getJsonSafe<GitLabProject>(base, {} as GitLabProject, { headers });
|
||||
|
||||
return {
|
||||
content,
|
||||
path: datei.file_path ?? name,
|
||||
baseUrl:
|
||||
projekt?.web_url && projekt.default_branch
|
||||
? `${projekt.web_url}/-/blob/${encodeURIComponent(projekt.default_branch)}/`
|
||||
: null,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mapPipelineState(status: string | null): CiState {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { README_MAX_CHARS, capReadme, decodeBase64, pickReadme } from "./readme";
|
||||
|
||||
describe("pickReadme", () => {
|
||||
it("findet die uebliche Schreibweise", () => {
|
||||
expect(pickReadme(["src", "README.md", "package.json"])).toBe("README.md");
|
||||
});
|
||||
|
||||
it("ist bei der Gross- und Kleinschreibung nachsichtig", () => {
|
||||
expect(pickReadme(["readme.md"])).toBe("readme.md");
|
||||
expect(pickReadme(["ReadMe.MD"])).toBe("ReadMe.MD");
|
||||
});
|
||||
|
||||
it("zieht Markdown den uebrigen Formaten vor", () => {
|
||||
// Beides kommt vor; nur das erste laesst sich wirklich darstellen.
|
||||
expect(pickReadme(["README.rst", "README.md"])).toBe("README.md");
|
||||
});
|
||||
|
||||
it("nimmt auch ein README ohne Endung", () => {
|
||||
expect(pickReadme(["LICENSE", "README"])).toBe("README");
|
||||
});
|
||||
|
||||
it("liefert null, wenn keines dabei ist", () => {
|
||||
expect(pickReadme(["src", "package.json"])).toBeNull();
|
||||
expect(pickReadme([])).toBeNull();
|
||||
});
|
||||
|
||||
it("verwechselt Aehnliches nicht mit dem README", () => {
|
||||
expect(pickReadme(["README-CONTRIBUTING.md", "readme.md.bak"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("capReadme", () => {
|
||||
it("laesst Kurzes unangetastet", () => {
|
||||
const { content, truncated } = capReadme("# Titel\n\nEin Satz.");
|
||||
expect(content).toBe("# Titel\n\nEin Satz.");
|
||||
expect(truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("kuerzt Langes an einer Zeilengrenze", () => {
|
||||
// Sonst bliebe womoeglich ein halber Codeblock stehen.
|
||||
const zeile = "x".repeat(80);
|
||||
const lang = Array.from({ length: 2000 }, () => zeile).join("\n");
|
||||
const { content, truncated } = capReadme(lang);
|
||||
expect(truncated).toBe(true);
|
||||
expect(content.length).toBeLessThanOrEqual(README_MAX_CHARS);
|
||||
expect(content.endsWith("x")).toBe(true);
|
||||
expect(content.split("\n").at(-1)).toBe(zeile);
|
||||
});
|
||||
|
||||
it("kuerzt notfalls hart, wenn es gar keine Zeilengrenze gibt", () => {
|
||||
// Eine einzige riesige Zeile: hier waere der letzte Umbruch bei -1.
|
||||
const { content, truncated } = capReadme("y".repeat(README_MAX_CHARS + 500));
|
||||
expect(truncated).toBe(true);
|
||||
expect(content).toHaveLength(README_MAX_CHARS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeBase64", () => {
|
||||
it("dekodiert UTF-8 richtig", () => {
|
||||
// Ueber Bytes gedacht kaeme hier Kauderwelsch heraus.
|
||||
const kodiert = Buffer.from("# Nebula – Übersicht", "utf8").toString("base64");
|
||||
expect(decodeBase64(kodiert)).toBe("# Nebula – Übersicht");
|
||||
});
|
||||
|
||||
it("uebergeht Zeilenumbrueche in der Kodierung", () => {
|
||||
// GitHub bricht den base64-Block alle 60 Zeichen um.
|
||||
const roh = Buffer.from("Hallo Welt", "utf8").toString("base64");
|
||||
const umgebrochen = `${roh.slice(0, 4)}\n${roh.slice(4)}`;
|
||||
expect(decodeBase64(umgebrochen)).toBe("Hallo Welt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Das README eines Repositories – gemeinsame Teile fuer alle drei Anbieter.
|
||||
*
|
||||
* Gezeigt wird es auf der Projektseite, damit man nicht ins Git wechseln
|
||||
* muss, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging.
|
||||
*/
|
||||
|
||||
export interface RepoReadme {
|
||||
/** Roher Markdown-Text. */
|
||||
content: string;
|
||||
/** Dateiname im Repository, etwa "README.md". */
|
||||
path: string;
|
||||
/**
|
||||
* Basis, gegen die relative Links im Text aufzuloesen sind – die
|
||||
* Web-Ansicht des Repositories im Verzeichnis des READMEs.
|
||||
*/
|
||||
baseUrl: string | null;
|
||||
/** Wurde am Deckel abgeschnitten? */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoechstlaenge. Ein README von einem Megabyte gibt es, und es waere in
|
||||
* einer Vorschau ohnehin unlesbar – abgeschnitten wird an einer
|
||||
* Zeilengrenze, damit kein halber Codeblock stehen bleibt.
|
||||
*/
|
||||
export const README_MAX_CHARS = 60_000;
|
||||
|
||||
/**
|
||||
* Dateinamen, die als README gelten – in dieser Reihenfolge. Markdown zuerst,
|
||||
* weil nur das dargestellt werden kann; die uebrigen landen als Klartext in
|
||||
* einem Codeblock, was immer noch besser ist als nichts.
|
||||
*/
|
||||
const KANDIDATEN = [
|
||||
"readme.md",
|
||||
"readme.markdown",
|
||||
"readme.mdown",
|
||||
"readme",
|
||||
"readme.txt",
|
||||
"readme.rst",
|
||||
];
|
||||
|
||||
/**
|
||||
* Sucht aus einer Verzeichnisauflistung die README-Datei heraus.
|
||||
* Gross- und Kleinschreibung ist egal; die Reihenfolge oben entscheidet.
|
||||
*/
|
||||
export function pickReadme(namen: string[]): string | null {
|
||||
const klein = new Map(namen.map((name) => [name.toLowerCase(), name]));
|
||||
for (const kandidat of KANDIDATEN) {
|
||||
const treffer = klein.get(kandidat);
|
||||
if (treffer) return treffer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Kuerzt am Deckel, aber an einer Zeilengrenze. */
|
||||
export function capReadme(text: string): { content: string; truncated: boolean } {
|
||||
if (text.length <= README_MAX_CHARS) return { content: text, truncated: false };
|
||||
|
||||
const roh = text.slice(0, README_MAX_CHARS);
|
||||
const letzterUmbruch = roh.lastIndexOf("\n");
|
||||
return {
|
||||
// Nur wenn der Umbruch nicht ganz am Anfang liegt – sonst bliebe fast
|
||||
// nichts uebrig.
|
||||
content: letzterUmbruch > README_MAX_CHARS / 2 ? roh.slice(0, letzterUmbruch) : roh,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dekodiert, was die Anbieter als base64 liefern.
|
||||
*
|
||||
* Bewusst ueber Buffer und nicht ueber atob: der Inhalt ist UTF-8, und atob
|
||||
* gaebe Bytes zurueck, aus denen Umlaute als Kauderwelsch herauskaemen.
|
||||
*/
|
||||
export function decodeBase64(value: string): string {
|
||||
return Buffer.from(value.replace(/\s/g, ""), "base64").toString("utf8");
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { GitProvider } from "@prisma/client";
|
||||
import type { CommitCalendar } from "./calendar";
|
||||
import type { RepoReadme } from "./readme";
|
||||
|
||||
export type { CommitCalendar, RepoReadme };
|
||||
|
||||
export type ProviderKind = GitProvider;
|
||||
|
||||
@@ -171,6 +175,19 @@ 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>;
|
||||
/**
|
||||
* Das README des Repositories. Wie der Kalender getrennt, weil es
|
||||
* zusaetzliche Aufrufe kostet und sich selten aendert. null heisst: es
|
||||
* gibt keins.
|
||||
*/
|
||||
fetchReadme(ref: RepoRef): Promise<RepoReadme | null>;
|
||||
}
|
||||
|
||||
/** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */
|
||||
|
||||
+20
-1
@@ -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.
|
||||
|
||||
@@ -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,10 @@ 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 Kalender und
|
||||
// README hineingeschrieben werden koennen.
|
||||
await maybeRefreshCalendar(repository.id, client, ref);
|
||||
await maybeRefreshReadme(repository.id, client, ref);
|
||||
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
||||
await notifyAfterSync(repository.id);
|
||||
return {
|
||||
@@ -141,6 +146,98 @@ export async function syncProject(projectId: string): Promise<ProjectSyncResult>
|
||||
};
|
||||
}
|
||||
|
||||
/** Wie lange ein geholter Kalender genuegt. */
|
||||
const CALENDAR_TTL_MS = 20 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Holt die Jahresuebersicht fuer die Heatmap – hoechstens einmal am Tag.
|
||||
*
|
||||
* Sie kostet zusaetzliche Aufrufe, bei GitLab und Gitea sogar mehrere. An
|
||||
* einem Jahresueberblick aendert sich von Stunde zu Stunde aber nichts
|
||||
* Sichtbares, und der Abgleich laeuft im Zweifel stuendlich.
|
||||
*
|
||||
* Fehler bleiben hier: der Kalender ist Beiwerk. Wenn er nicht zu holen ist,
|
||||
* soll deshalb nicht der ganze Abgleich als gescheitert gelten.
|
||||
*/
|
||||
async function maybeRefreshCalendar(
|
||||
repositoryId: string,
|
||||
client: GitClient,
|
||||
ref: { owner: string; name: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const snapshot = await prisma.repoSnapshot.findUnique({
|
||||
where: { repositoryId },
|
||||
select: { commitCalendarAt: true },
|
||||
});
|
||||
|
||||
const zuletzt = snapshot?.commitCalendarAt?.getTime() ?? 0;
|
||||
if (Date.now() - zuletzt < CALENDAR_TTL_MS) return;
|
||||
|
||||
const calendar = await client.fetchCommitCalendar(ref);
|
||||
|
||||
// Nichts erhalten: entweder rechnet GitHub die Statistik gerade erst,
|
||||
// oder das Repository hat keine Commits. Der Zeitstempel bleibt dann
|
||||
// ungesetzt, damit es der naechste Abgleich erneut versucht – ihn hier zu
|
||||
// setzen wuerde die Heatmap fuer einen Tag unterdruecken, obwohl sie
|
||||
// vielleicht schon in Minuten bereitsteht.
|
||||
if (!calendar) return;
|
||||
|
||||
await prisma.repoSnapshot.update({
|
||||
where: { repositoryId },
|
||||
data: {
|
||||
commitCalendar: calendar as unknown as object,
|
||||
commitCalendarAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und
|
||||
// soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es
|
||||
// der naechste Durchgang von selbst wieder.
|
||||
console.error("[nebula] Commit-Kalender konnte nicht geholt werden:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt das README – wie der Kalender hoechstens einmal am Tag.
|
||||
*
|
||||
* Ein eigener Zeitstempel und nicht der des Kalenders: schlaegt das eine
|
||||
* fehl, soll das andere nicht mit blockiert werden.
|
||||
*/
|
||||
async function maybeRefreshReadme(
|
||||
repositoryId: string,
|
||||
client: GitClient,
|
||||
ref: { owner: string; name: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const snapshot = await prisma.repoSnapshot.findUnique({
|
||||
where: { repositoryId },
|
||||
select: { readmeAt: true },
|
||||
});
|
||||
|
||||
const zuletzt = snapshot?.readmeAt?.getTime() ?? 0;
|
||||
if (Date.now() - zuletzt < CALENDAR_TTL_MS) return;
|
||||
|
||||
const readme = await client.fetchReadme(ref);
|
||||
|
||||
// Kein README im Repository: das ist eine Auskunft, kein Fehlschlag.
|
||||
// Der Zeitstempel wird gesetzt, damit nicht jeder Abgleich erneut sucht,
|
||||
// und ein etwaiger alter Stand verschwindet.
|
||||
await prisma.repoSnapshot.update({
|
||||
where: { repositoryId },
|
||||
data: {
|
||||
readme: readme?.content ?? null,
|
||||
readmePath: readme?.path ?? null,
|
||||
readmeBaseUrl: readme?.baseUrl ?? null,
|
||||
readmeAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Wie beim Kalender: Beiwerk. Ohne Zeitstempel versucht es der naechste
|
||||
// Durchgang von selbst wieder.
|
||||
console.error("[nebula] README konnte nicht geholt werden:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
|
||||
export function resolveRepoRef(
|
||||
repository: Pick<Repository, "repoOwner" | "repoName" | "repoUrl">,
|
||||
|
||||
+55
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user