21 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 c9202fb65e chore(release): v1.6.0
CI / pruefen (push) Canceled after 0s
Der Server hat den neuen Stand ausgeliefert und in der Fusszeile weiter
"v1.5.0" angezeigt: Listenansicht, Schnellerfassung und die Aufgabenseite
waren drin, die Versionsnummer nicht.

Der Test in changelog.test.ts prueft, dass die oberste Fassung im Verlauf
zur package.json passt - er greift aber nicht, wenn beide gemeinsam
zurueckhaengen. Genau das war hier der Fall.

Der Verlauf wird in der Anwendung angezeigt (Knopf an der Versionsnummer in
der Fusszeile), deshalb steht dort jetzt, was sich geaendert hat: die
mobile Ansicht, die vierte Ansicht im Board, die Schnellerfassung, die
Aufgaben ueber alle Projekte, 16px in Eingabefeldern gegen den iOS-Zoom,
die fehlenden Beschriftungen der Symbolknoepfe und das beschnittene
Tag-Formular.

In der Roadmap ist v1.6.0 als erledigt eingetragen. Offen bleibt daraus
nur der dritte Teil, die persoenlichen API-Token - die sind
sicherheitsrelevant und bekommen einen eigenen Durchgang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:56:03 +02:00
florian.wildeandClaude Opus 5 5ae1b6ed06 feat(tasks): Schnellerfassung und Aufgaben ueber alle Projekte
CI / pruefen (push) Canceled after 0s
Zwei Wege, die zusammengehoeren: etwas festhalten, ohne den Rechner
aufzuklappen, und danach einen Ort haben, an dem alles zusammenlaeuft.

**Schnellerfassung.** Ein Blitzsymbol in der Kopfleiste, auf jeder Seite
erreichbar. Ein Feld, Enter, fertig - als Idee entsteht ein Projekt im
Status IDEA, als Aufgabe eine Aufgabe im gewaehlten Projekt. Beide Schemas
kommen mit einem einzigen Feld aus, an der API war deshalb nichts zu
aendern.

Der Dialog bleibt nach dem Anlegen offen und leert nur das Feld. Wer eine
Idee notiert, hat meistens gleich die naechste; ihn jedes Mal neu zu oeffnen
waere genau die Huerde, die hier wegsoll. Das zuletzt gewaehlte Projekt
wird gemerkt.

Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten
rechts - dort waere er auf schmalen Anzeigen mit der Auswahlleiste
kollidiert, die beim Markieren von Projekten einfaehrt.

**Aufgabenuebersicht.** /tasks sammelt jede Aufgabe aus jedem Projekt.
Geordnet wird nach Faelligkeit, nicht nach Spalte: projektuebergreifend
lautet die Frage "was ist ueberfaellig", nicht "was steht wo im Brett".
Abhaken geht direkt aus der Liste; den Rest - Zeitpunkt, abgeleiteter
Fortschritt, naechste Fassung wiederkehrender Aufgaben - erledigt der
bestehende Endpunkt.

Zwei Entscheidungen, die im Code begruendet sind:

- Gerechnet wird auf Kalendertage in fester Zeitzone, nicht auf Stunden und
  nicht nach Serverzeit. Eine um 9 Uhr faellige Aufgabe ist um 17 Uhr nicht
  ueberfaellig, sondern immer noch "heute". Und im Container laeuft die
  Anwendung nach UTC - ohne feste Zeitzone kippte die Einteilung abends um
  einen Tag. Das Fach bestimmt der Server, sonst liefen Server-Render und
  Hydration auseinander.
- Bewusst nicht per Ziehen sortierbar. Die Position gilt innerhalb ihrer
  Spalte in ihrem Projekt und waere hier ohne Bedeutung; eine Reihenfolge
  anzubieten, die nirgends ankommt, waere ein leeres Versprechen.

Die Aufgabe kennt keinen Besitzer - die Trennung laeuft ueber das Projekt,
"project: { ownerId }" steht als einzige und erste Bedingung.

Nebenbei: der Untertitel in der Kopfleiste brach mit dem dritten
Navigationspunkt auf 375px mitten im Wort ab und bleibt unterhalb von sm
jetzt weg.

Geprueft: 200 Unit-Tests (davon 7 neue zu dueBucket, inklusive des
UTC-Falls), 27 E2E-Tests mit zwei neuen Wegen, axe-core auf 375px ueber
Uebersicht und beide Zustaende der Schnellerfassung ohne Beanstandung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:33:16 +02:00
florian.wildeandClaude Opus 5 fcc903601a docs(roadmap): v1.6.0 und v1.7.0 aufgenommen
Zwei Vorhaben, die sich aus der Durchsicht der mobilen Ansicht ergeben haben.

v1.6.0 buendelt Schnellerfassung, eine projektuebergreifende Aufgabenseite
und die persoenlichen API-Token: erfassen am Telefon, ueberblicken an einer
Stelle, ansprechen per Shortcut. Die beiden Fallstricke der Aufgabenseite
stehen dabei: Task hat kein ownerId, und position gilt nur je Projekt.

v1.7.0 verknuepft Aufgaben mit Git-Issues. Die Zuordnung steht im Schema
laengst (sourceIssue, sourceUrl, sourceRepositoryId); es fehlt nur die
Gegenrichtung zur bestehenden Uebernahme. Bewusst ueber die HTTP-API der
Provider, nicht ueber eigene Git-Operationen - der Ausschluss unter
"Bewusst nicht" bleibt also unangetastet.

Ausdruecklich festgehalten, was NICHT kommt: eine echte beidseitige
Synchronisation. Die Zustaende passen ohnehin nicht aufeinander - ein Issue
kennt nur offen und geschlossen, DOING und BLOCKED gehen im Rundlauf
verloren. Das wird hingenommen, statt den Issue-Tracker mit Labels zu
fuellen, die dort niemand pflegt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:32:53 +02:00
florian.wildeandClaude Opus 5 f3c3383231 feat(board): Listenansicht, und die mobile Ansicht laeuft nicht mehr aus dem Bild
CI / pruefen (push) Canceled after 0s
Auf 375px war das Dashboard 40px breiter als der Bildschirm. Ursache war die
Steuerleiste: Suchfeld, Sortierung, Ansichtsumschalter und "Neues Projekt"
lagen in einer Flex-Zeile ohne Umbruch, und weil das Sortierfeld ein
min-width mitbringt, wurde der Knopf am Ende schlicht hinausgeschoben. Das
overflow-x: hidden am body hat ihn dann abgeschnitten statt erreichbar zu
lassen. Dasselbe Muster im Projektkopf, wo "Loeschen" ausserhalb lag.

Die Zeilen brechen jetzt um, der Ansichtsumschalter steht auf schmalen
Anzeigen ueber die volle Breite. Gemessen ueber alle Seiten und beide
Testbreiten: scrollWidth == clientWidth.

Weil der Umschalter ohnehin umgebaut wurde, kommt die vierte Ansicht gleich
mit: eine Zeile je Projekt statt einer Karte. Auf dem Telefon ist das der
eigentliche Gewinn — eine Karte braucht rund 260px Hoehe und steht dort
zwangslaeufig allein in der Zeile, fuenf Projekte fuellen also zwei
Bildschirme. Als Liste passen sie auf einen, und weil alle Zeilen gleich
gebaut sind, liegen Name, Fortschritt und Status untereinander. Was nicht
mehr in die Breite passt, faellt der Reihe nach weg, statt sie zu sprengen.

Dazu die Kleinigkeiten, die auf dem Telefon dranhaengen:

- .field bekommt auf Beruehrungsgeraeten 16px. Darunter zoomt iOS Safari
  beim Hineintippen die Seite heran und kehrt nicht von selbst zurueck.
- .btn mindestens 44px hoch, der Schieberegler einen 24px-Griff.
- Der Popover klemmt seine Breite ans Fenster. Unter 280px lieferte die
  Rechnung eine negative linke Kante, das Menue stand halb ausserhalb.
- Das Tag-Formular der Auswahlleiste war 16px breiter als sein Popover und
  wurde von dessen overflow-hidden beschnitten – auf jeder Breite.
- Die Heatmap scrollt wieder seitlich, statt sich zu stauchen.
- Die Kennzahlen stehen zweispaltig; einspaltig fuellten die fuenf Kacheln
  den ganzen ersten Bildschirm, bevor das erste Projekt kam.
- viewportFit und die safe-area-Abstaende, damit im installierten Betrieb
  nichts unter Statusleiste und Home-Anzeige verschwindet.
- Die beiden Verweise in der Kopfleiste und "Neues Projekt" tragen ein
  aria-label. Unterhalb von sm sind sie reine Symbole und hiessen fuer einen
  Screenreader gar nichts; axe-core meldete das als link-name.

axe-core laeuft auf 375px ueber alle vier Ansichten, den Dialog und die
Projektseite jetzt ohne Beanstandung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:58:27 +02:00
florian.wildeandClaude Opus 5 86f1fbeb50 fix(scripts): Ausfuehrbarkeitsbit im Git setzen
CI / pruefen (push) Has been cancelled
Die Shell-Skripte lagen als 100644 im Index. Nach einem frischen Clone auf
dem Server scheiterte damit jedes "./scripts/setup.sh" und "./scripts/update.sh"
an fehlender Berechtigung; wer sich mit chmod +x behalf, hatte danach ein
schmutziges Arbeitsverzeichnis, und genau daran brach update.sh ab.

Unter Windows faellt das nicht auf, weil core.filemode dort auf false steht --
das Bit muss deshalb mit "git update-index --chmod=+x" gesetzt werden. Auch
docker/entrypoint.sh bekommt es; der Dockerfile-Aufruf chmod +x bleibt als
zweiter Riegel bestehen.

Ausserdem sagt update.sh jetzt, welche Dateien geaendert sind, statt nur zu
melden, dass etwas nicht sauber ist. Wer die Meldung liest, soll nicht erst
"git status" nachschlagen muessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:13:56 +02:00
florian.wildeandClaude Opus 5 fc54c73791 fix(docker): die Datenbank veroeffentlicht ueberhaupt keinen Host-Port mehr
CI / pruefen (push) Has been cancelled
Der letzte Anlauf hat das Problem nicht geloest. Die Freigabe stand weiter in
der Basisdatei, nur mit anderem Vorgabewert -- und Vorgabewerte greifen nicht,
wo die Variable schon gesetzt ist: in jeder .env, die aus der bisherigen
.env.example entstanden ist, steht POSTGRES_PORT="5432". Postgres band also
unveraendert auf 0.0.0.0 und kollidierte mit anderen Stacks auf demselben
Host; der Container kam gar nicht erst hoch. Das Overlay half nicht, weil es
erst greift, wenn man es dazunimmt.

Jetzt hat der db-Dienst keinen ports-Block mehr. Gebraucht wird er nur, wenn
die Anwendung ausserhalb des Containers laeuft, und dafuer gibt es
docker-compose.dev.yml (npm run docker:db). Damit ist der sichere Fall die
Vorgabe und der Sonderfall die Ausnahme, nicht umgekehrt.

Der App-Port bindet aus demselben Grund auf 127.0.0.1: Docker traegt seine
Freigaben direkt in iptables ein und umgeht dabei ufw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:46:24 +02:00
florian.wildeandClaude Opus 5 d311fe48c0 fix(docker): keine Ports auf 0.0.0.0 und ein Overlay ohne Freigaben
CI / pruefen (push) Has been cancelled
Der Datenbankport wurde in beiden Betriebsarten auf 0.0.0.0 gelegt, obwohl
ihn nur die Entwicklung braucht: im Container spricht die Anwendung "db:5432"
ueber das Compose-Netz an, und backup.sh geht ueber docker exec. Auf einem
Server lauschte Postgres damit im Netz, geschuetzt allein durch das Passwort
aus der .env. Die Anleitung riet zwar zu 127.0.0.1, aber als Handgriff statt
als Voreinstellung -- die falsche Richtung. Datenbank und Adminer binden
jetzt von Haus aus auf 127.0.0.1.

Liegen mehrere Stacks auf einem Host und regelt ein gemeinsamer Reverse Proxy
den Zugang, braucht Nebula gar keinen Host-Port. docker-compose.server.yml
nimmt beide Freigaben zurueck und haengt die Anwendung in ein geteiltes Netz.
Das braucht !reset: Compose fuehrt "ports" beim Zusammenfuehren anhaengend
statt ersetzend zusammen, eine leere Liste im Override wuerde also nichts
loeschen.

update.sh las die Adresse aus APP_PORT und haette in diesem Modus einen Port
gemeldet, den es nicht gibt. Es fragt jetzt Docker und sagt sonst, dass nur
das interne Netz erreichbar ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:19:51 +02:00
florian.wildeandClaude Opus 5 6808d4d34a fix(scripts): Einrichtung und Update ohne Node
CI / pruefen (push) Has been cancelled
Ein reiner Docker-Host hat kein Node installiert. Die Anleitung verlangte
trotzdem "node scripts/setup.mjs", und update.sh las die Versionsnummer mit
"node -p" - beides waere dort gescheitert.

scripts/setup.sh macht dasselbe wie die .mjs-Fassung in reinem POSIX-Shell:
.env aus der Vorlage anlegen, ein APP_SECRET wuerfeln (openssl, sonst
/dev/urandom) und die Rechte auf 600 setzen. Ersetzt wird zeilenweise statt
mit einem sed-Ausdruck, weil base64 Schraegstriche enthaelt. update.sh liest
die Version jetzt selbst aus der package.json.

Dabei fielen zwei Fehler auf, die mein eigener Rat verursacht hat. Die
Anleitung empfiehlt APP_PORT="127.0.0.1:3000", damit die Anwendung nur hinter
dem Reverse Proxy erreichbar ist - die Gesundheitspruefung baute daraus aber
"http://127.0.0.1:127.0.0.1:3000". Und curl ist auf einem Docker-Host nicht
garantiert. Gefragt wird deshalb jetzt der Container selbst ueber seinen
HEALTHCHECK; curl liefert nur noch die Antwort zum Anzeigen, wenn es da ist.

Am Ende nannte das Skript den Sicherungspfad mit Anfuehrungszeichen - so
liess er sich nicht kopieren.

Nachgewiesen: dreimal gegen die laufende Instanz durchgelaufen, jedes Mal
mit Sicherung, Bau, Neustart und gesunder Antwort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:26:46 +02:00
florian.wildeandClaude Opus 5 47245152a2 docs: Anleitung fuer den Serverbetrieb und Update-Skript
CI / pruefen (push) Has been cancelled
DEPLOYMENT.md fuehrt Schritt fuer Schritt vom leeren Server zur Instanz
hinter TLS: Docker, Konfiguration, Reverse Proxy mit Caddy oder nginx,
Firewall, Sicherungen per Cron. Dazu, was an Haertung schon eingebaut ist -
und was der Betreiber selbst beisteuern muss.

scripts/update.sh macht die Schritte in der richtigen Reihenfolge: pruefen,
holen, sichern, bauen, starten, Gesundheit pruefen. Erst bauen und dann
umschalten, damit ein gescheiterter Bau die laufende Fassung nicht
mitnimmt; die Sicherung liegt vor jeder Schemaaenderung. Antwortet
/api/health nach einer Minute nicht, zeigt es die letzten Logzeilen und
nennt den Weg zurueck.

Es weigert sich zu laufen, solange APP_SECRET leer oder noch der
Entwicklungswert ist - mit diesem Wert liegen die Git-Tokens faktisch offen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:31 +02:00
florian.wildeandClaude Opus 5 d7176339b8 test: Bereinigung des Markdowns festhalten, Ratenbegrenzung nachziehen
Elf Tests halten fest, was nicht durchkommen darf: script-Elemente,
Ereignis-Attribute aus rohem HTML, javascript: als Link und als Bildquelle,
data:text/html. Dazu die Aufloesung relativer Verweise und der Fall
"Bild im Link", der kein a im a erzeugen darf.

Gerendert wird serverseitig - fuer die Frage nach dem erzeugten HTML
braucht es kein DOM. Damit .tsx ueberhaupt laeuft, muss Vitest das JSX
selbst uebersetzen; die tsconfig setzt jsx auf "preserve", weil sonst Next
zustaendig ist. Vite nimmt inzwischen oxc statt esbuild, die esbuild-Option
wuerde stillschweigend ignoriert.

Umsortieren im Brett und das Anlegen von Vorlagen hatten als einzige
schreibende Endpunkte keine Ratenbegrenzung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:31 +02:00
florian.wildeandClaude Opus 5 df94646371 fix(security): Bildoptimierer abschalten
/_next/image war ohne Anmeldung erreichbar und antwortete mit 200. Die
Middleware nimmt den Pfad aus, weil dort sonst nur Rechenzeit verbraucht
wuerde - gedacht war das fuer ausgelieferte Dateien, nicht fuer einen
Endpunkt, der Bilder verarbeitet.

Er reicht jedes Bild durch sharp und damit durch libvips, fuer das es
regelmaessig Schwachstellen gibt; derzeit sind vier offen (CVE-2026-33327,
-33328, -35590, -35591). Ausnutzbar waere das hier nur mit einer Datei, die
schon auf dem Server liegt - hochladen kann man nichts. Aber Nebula benutzt
next/image ohnehin nirgends: eine Angriffsflaeche ohne Gegenwert.

Der Optimierer ist deshalb abgeschaltet, und der Pfad ist nicht mehr von der
Zugangsschranke ausgenommen. Ein Test haelt fest, dass er ohne Anmeldung
nicht mehr mit 200 antwortet.

Die Advisories zu postcss und sharp bleiben in npm audit stehen: Next pinnt
beide fest, auch in 16.2.12 - ein Versionssprung wuerde daran nichts
aendern. postcss laeuft nur zur Bauzeit ueber eigenes CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:10 +02:00
florian.wildeandClaude Opus 5 27f51b6be8 fix(auth): Einmalkennwoerter nicht zweimal annehmen
Ein TOTP-Code blieb rund 90 Sekunden gueltig und liess sich darin beliebig
oft verwenden. RFC 6238 verlangt in Abschnitt 5.2 ausdruecklich das
Gegenteil: derselbe Code darf kein zweites Mal angenommen werden.

Ohne diese Sperre kann ein abgefangener Code - ueber die Schulter gesehen,
von einer weiterleitenden Phishing-Seite, von Schadsoftware vom Bildschirm
gelesen - die restliche Gueltigkeitsdauer ueber wiederverwendet werden.

verifyTotp gibt dafuer jetzt ueber matchTotp das Zeitfenster zurueck, das
gepasst hat; angenommen wird nur, was neuer ist als das zuletzt eingeloeste.
Der Vergleich bleibt dabei in konstanter Zeit - es wird weiterhin nicht
fruehzeitig abgebrochen.

Auch die Aktivierung verbrennt ihr Fenster: der Code, der eben
scharfgeschaltet hat, soll nicht Sekunden spaeter noch zum Anmelden taugen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:10 +02:00
florian.wildeandClaude Opus 5 72e5eb43c4 test: README-Hilfen abdecken, Doku, Version 1.4.0
CI / pruefen (push) Has been cancelled
Elf Tests: welche Dateinamen als README gelten, dass Markdown Vorrang hat,
dass an einer Zeilengrenze gekuerzt wird und dass base64 mit Umlauten und
Zeilenumbruechen richtig ankommt.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:19 +02:00
81 changed files with 5276 additions and 178 deletions
+22 -6
View File
@@ -23,11 +23,13 @@ APP_URL="http://localhost:3000"
# Port innerhalb des Containers bzw. im Standalone-Betrieb
PORT="3000"
# Host-Port-Mapping fuer den App-Container (--profile full)
APP_PORT="3000"
# Host-Port-Mapping fuer den App-Container (--profile full). Nur auf dem
# Loopback – nach draussen geht es ueber einen Reverse Proxy. Soll die
# Instanz im LAN erreichbar sein, hier "3000" eintragen.
APP_PORT="127.0.0.1:3000"
# ── Datenbank ────────────────────────────────────────────────
# Standalone-Betrieb: App laeuft lokal, Postgres via "docker compose up -d db"
# Standalone-Betrieb: App laeuft lokal, Postgres via "npm run docker:db"
# oder als nativ installierter Dienst.
DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public"
@@ -38,10 +40,24 @@ DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public"
POSTGRES_USER="nebula"
POSTGRES_PASSWORD="nebula"
POSTGRES_DB="nebula"
# Host-Port, unter dem die Datenbank erreichbar ist
POSTGRES_PORT="5432"
# Host-Port der Datenbank. Wirkt NUR zusammen mit docker-compose.dev.yml
# (npm run docker:db) – die Basisdatei veroeffentlicht keinen DB-Port, weil
# die Anwendung im Container "db:5432" anspricht und nichts davon nach
# aussen muss. Auf einem Server bleibt dieser Wert also folgenlos.
POSTGRES_PORT="127.0.0.1:5432"
# Host-Port fuer Adminer (--profile tools)
ADMINER_PORT="8080"
ADMINER_PORT="127.0.0.1:8080"
# ── Betrieb ohne veroeffentlichte Ports ──────────────────────
# Liegen mehrere Stacks auf demselben Docker-Host und regelt ein gemeinsamer
# Reverse Proxy den Zugang, veroeffentlicht Nebula gar keinen Port. Dann diese
# beiden Zeilen aktivieren – danach genuegt ueberall "docker compose up -d":
# COMPOSE_FILE="docker-compose.yml:docker-compose.server.yml"
# COMPOSE_PROFILES="full"
#
# Name des geteilten Netzes und der Alias, unter dem der Proxy Nebula findet.
# PROXY_NETWORK="proxy"
# PROXY_ALIAS="nebula"
# ── Sicherheit ───────────────────────────────────────────────
# Schluessel zur Verschluesselung der Git-Access-Tokens (AES-256-GCM).
+10 -2
View File
@@ -4,12 +4,13 @@ Dashboard für Vibe-Coding-Projekte. Next.js 15 (App Router), TypeScript,
Tailwind 4, Prisma, PostgreSQL. Oberfläche und Texte sind **deutsch**.
Funktionsumfang: [FEATURES.md](FEATURES.md) · Geplantes: [ROADMAP.md](ROADMAP.md) ·
Betrieb: [README.md](README.md)
Betrieb: [README.md](README.md) · Server: [DEPLOYMENT.md](DEPLOYMENT.md)
## Womit man arbeitet
```bash
docker compose up -d db # Postgres, muss laufen
npm run docker:db # Postgres, muss laufen (mit dem dev-Overlay,
# sonst hat die DB keinen Host-Port)
npm run dev # Entwicklung
npm test # Unit-Tests (Vitest, ohne DB)
npx tsc --noEmit # Typprüfung
@@ -102,6 +103,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
+412
View File
@@ -0,0 +1,412 @@
# Nebula auf einem Server betreiben
Schritt für Schritt: von der leeren Maschine bis zur laufenden Instanz hinter
TLS — und wie Updates danach ablaufen.
Für Installation zum Ausprobieren und die Entwicklungsvarianten siehe
[README.md](README.md). Hier geht es um den Dauerbetrieb.
---
## Was du brauchst
| | |
| --- | --- |
| Server | Linux mit Docker Engine ≥ 24 und Compose v2 |
| Sonst | `git`, `curl`, eine POSIX-Shell — **kein Node, kein npm** |
| Arbeitsspeicher | 1 GB reichen; beim Bauen kurzzeitig ~2 GB |
| Plattenplatz | ~2 GB für Abbilder, dazu die Datenbank |
| Domain | Eine (Unter-)Domain, die auf den Server zeigt |
| TLS | Pflicht, wenn du Passkeys willst — siehe Schritt 6 |
> **Warum TLS Pflicht ist:** Passkeys verlangen einen sicheren Kontext.
> Über `http://` an einer LAN-IP funktionieren sie nicht. Passwort und
> Zwei-Faktor gehen auch ohne, aber dann läuft dein Passwort im Klartext
> übers Netz — betreibe Nebula nicht ohne TLS.
---
## 1 · Docker installieren
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
newgrp docker # oder ab- und wieder anmelden
docker --version && docker compose version
```
## 2 · Nebula holen
```bash
sudo mkdir -p /opt/nebula && sudo chown "$USER" /opt/nebula
git clone https://git.example.com/dein-name/nebula.git /opt/nebula
cd /opt/nebula
```
## 3 · Konfigurieren
```bash
./scripts/setup.sh # legt .env an, würfelt ein APP_SECRET, setzt Rechte 600
```
Das Skript ist reines POSIX-Shell und braucht **kein Node** — auf einem
reinen Docker-Host ist keins installiert. Es nimmt `openssl`, falls
vorhanden, sonst `/dev/urandom`.
Ganz von Hand geht auch:
```bash
cp .env.example .env
chmod 600 .env
openssl rand -base64 32 # den Wert als APP_SECRET eintragen
```
Dann die `.env` öffnen und mindestens diese drei Werte anpassen:
```env
# Die Adresse, unter der du Nebula im Browser aufrufst.
# Bestimmt die WebAuthn-rpID — passt sie nicht, scheitern Passkeys.
APP_URL="https://nebula.deine-domain.de"
# Verschlüsselt die Git-Tokens. Lang, zufällig, niemals teilen.
APP_SECRET="…dein-langer-zufallswert…"
# Das Datenbankpasswort. Die Voreinstellung „nebula" ist für Entwicklung.
POSTGRES_PASSWORD="…noch-ein-langer-zufallswert…"
```
Die Ports müssen nicht angefasst werden: `APP_PORT` steht schon auf
`127.0.0.1:3000`, und die Datenbank veröffentlicht gar nichts.
> **`APP_SECRET` später zu ändern** macht gespeicherte Git-Tokens unlesbar.
> Sie müssen dann neu hinterlegt werden. Konten, Passwörter und Passkeys
> bleiben unberührt.
>
> **`APP_URL` später zu ändern** macht bestehende **Passkeys ungültig**, weil
> sie an die rpID gebunden sind. Passwörter funktionieren weiter.
### Welche Ports überhaupt hinausgehen
Nach einer regulären Installation ist es **genau einer**:
| Container | Host-Port | Wann |
| --- | --- | --- |
| `nebula-app` | `APP_PORT`, Vorgabe `127.0.0.1:3000` | `--profile full` |
| `nebula-db` | — | **nie** |
| `nebula-adminer` | `ADMINER_PORT`, Vorgabe `127.0.0.1:8080` | nur `--profile tools` |
**Die Datenbank veröffentlicht nichts.** Im Vollbetrieb spricht die Anwendung
Postgres über das interne Docker-Netz als `db:5432` an — die `DATABASE_URL`
setzt Compose selbst. Auch `scripts/backup.sh` und `restore.sh` gehen über
`docker exec`, nicht über den Host. Ein Host-Port wäre nur eine zusätzliche
Angriffsfläche und würde mit anderen Postgres-Containern auf demselben Host
um den Port streiten.
Wer die Datenbank beim Entwickeln doch vom Host aus braucht — `npm run dev`
läuft ja außerhalb des Containers — nimmt `docker-compose.dev.yml` dazu
(`npm run docker:db`). Auf einem Server hat diese Datei nichts zu suchen.
Auch der Port der Anwendung liegt auf `127.0.0.1`: nach draußen geht es über
den Reverse Proxy aus Schritt 6.
### Variante: gar keinen Port veröffentlichen
Liegen mehrere Stacks auf demselben Docker-Host und regelt ein gemeinsamer
Reverse Proxy den Zugang, braucht Nebula überhaupt keinen Host-Port. Dafür
gibt es **`docker-compose.server.yml`**: es nimmt die letzte verbliebene
Freigabe zurück und hängt die Anwendung in ein geteiltes Netz.
```bash
docker network create proxy # falls der Proxy-Stack es nicht schon anlegt
```
```env
# .env
COMPOSE_FILE="docker-compose.yml:docker-compose.server.yml"
COMPOSE_PROFILES="full"
PROXY_NETWORK="proxy" # so heißt das Netz
PROXY_ALIAS="nebula" # so findet der Proxy die Anwendung
```
Compose liest beides aus der `.env`. Danach genügt überall — auch in
`scripts/update.sh` — ein schlichtes `docker compose up -d`. Im Proxy zeigt
das Ziel dann nicht mehr auf `127.0.0.1:3000`, sondern auf **`nebula:3000`**;
Schritt 6 zeigt beide Schreibweisen.
> Ohne die Datei würde ein Override die Portlisten **zusammenhängen** statt
> sie zu ersetzen — Compose führt `ports` anfügend zusammen. Das `!reset`
> darin ist genau dafür da und braucht Compose 2.24 oder neuer.
>
> Das Trennzeichen in `COMPOSE_FILE` ist unter Linux der Doppelpunkt, unter
> Windows das Semikolon.
## 4 · Starten
```bash
docker compose --profile full up -d --build
```
Der erste Bau dauert ein paar Minuten. Der Container spielt beim Start
selbst die Migrationen ein und wartet dabei auf die Datenbank.
```bash
docker compose --profile full logs -f app # mitlesen, Strg+C beendet nur das Lesen
curl -s localhost:3000/api/health # {"status":"ok","database":"up",…}
```
Ohne veröffentlichten Port geht die Frage an den Container statt an den Host
— das Abbild bringt einen `HEALTHCHECK` mit:
```bash
docker inspect --format '{{.State.Health.Status}}' nebula-app # healthy
```
## 5 · Ersten Zugang anlegen
Ruf `https://nebula.deine-domain.de/setup` auf (nach Schritt 6) oder
vorübergehend über einen SSH-Tunnel:
```bash
ssh -L 3000:127.0.0.1:3000 benutzer@server
# dann lokal http://localhost:3000/setup öffnen
```
Dort legst du das Administratorkonto an und wählst die Betriebsart.
**Danach ist `/setup` dauerhaft gesperrt.**
Anschließend gleich unter *Mein Konto*:
1. **Passkey hinzufügen** — geht erst, wenn TLS steht
2. **Zwei-Faktor einrichten** und die Wiederherstellungscodes wegschreiben
## 6 · Reverse Proxy mit TLS
Nebula bringt keinen eigenen TLS-Abschluss mit. Beispiel mit **Caddy**, das
Zertifikate von selbst besorgt und erneuert:
```caddyfile
# /etc/caddy/Caddyfile
nebula.deine-domain.de {
reverse_proxy 127.0.0.1:3000
}
```
```bash
sudo systemctl reload caddy
```
Das genügt. Caddy setzt `X-Forwarded-*` von selbst, holt das Zertifikat bei
Let's Encrypt und leitet `http` auf `https` um.
Läuft der Proxy selbst als Container im geteilten Netz (die Variante ohne
veröffentlichte Ports), lautet das Ziel stattdessen `nebula:3000` —
Docker löst den Alias auf:
```caddyfile
nebula.deine-domain.de {
reverse_proxy nebula:3000
}
```
<details>
<summary>Dasselbe mit nginx</summary>
```nginx
server {
listen 443 ssl http2;
server_name nebula.deine-domain.de;
ssl_certificate /etc/letsencrypt/live/nebula.deine-domain.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nebula.deine-domain.de/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Zertifikat mit `certbot --nginx -d nebula.deine-domain.de`.
</details>
> Steht `APP_URL` auf `https://…`, setzt Nebula das `Secure`-Flag auf die
> Sitzungscookies. Läuft der Proxy auf `https`, die `APP_URL` aber noch auf
> `http`, funktioniert die Anmeldung — nur ohne dieses Flag. Also beides
> gleichziehen.
## 7 · Firewall
```bash
sudo ufw allow 22/tcp
sudo ufw allow 80,443/tcp
sudo ufw enable
```
Port 3000 bleibt **zu**, 5432 wird ohnehin nie veröffentlicht. Die Firewall
ist hier der zweite Riegel, nicht der erste.
> Docker schreibt seine Portfreigaben direkt in `iptables` und geht dabei an
> `ufw` **vorbei**. Eine Freigabe auf `0.0.0.0` steht also im Netz, auch wenn
> `ufw` sie zu melden scheint. Genau deshalb wird hier an `127.0.0.1`
> gebunden, statt sich auf die Firewall zu verlassen.
## 8 · Sicherungen automatisieren
`scripts/backup.sh` legt einen `pg_dump` unter `backups/` ab. Täglich per
Cron:
```bash
crontab -e
```
```cron
# Jede Nacht um 3:15 sichern, 30 Tage aufheben
15 3 * * * cd /opt/nebula && ./scripts/backup.sh >> /var/log/nebula-backup.log 2>&1
30 3 * * * find /opt/nebula/backups -name '*.sql.gz' -mtime +30 -delete
```
Zurückspielen:
```bash
./scripts/restore.sh backups/nebula-2026-07-27-03-15-00.sql.gz
```
> Die Sicherungen liegen auf **demselben Server**. Das hilft gegen einen
> verpatzten Import, nicht gegen einen Plattenausfall. Zieh sie regelmäßig
> woandershin — `rsync`, `restic`, was du ohnehin nutzt.
---
## Updates
Dafür gibt es `scripts/update.sh`. Es macht alles in der richtigen
Reihenfolge und bricht ab, sobald ein Schritt scheitert:
```bash
cd /opt/nebula
./scripts/update.sh
```
Was es tut:
| Schritt | |
| --- | --- |
| **1 Prüfen** | docker, compose, `.env` da? Ist `APP_SECRET` gesetzt und nicht mehr der Entwicklungswert? |
| | *(alles ohne Node — das Skript liest die Version selbst aus der `package.json`)* |
| **2 Holen** | `git pull --ff-only`; zeigt die neuen Commits und fragt nach |
| **3 Sichern** | `pg_dump` **vor** jeder Schemaänderung |
| **4 Bauen** | Erst bauen, dann umschalten — ein gescheiterter Bau lässt die alte Fassung laufen |
| **5 Starten** | Container neu; die Migrationen laufen im Entrypoint |
| **6 Prüfen** | Bis zu zwei Minuten warten, bis der Container sich gesund meldet |
Scheitert die Gesundheitsprüfung, zeigt das Skript die letzten Logzeilen und
nennt den Weg zurück.
```bash
./scripts/update.sh --yes # ohne Rückfrage, für Cron
./scripts/update.sh --ref v1.4.0 # auf eine bestimmte Fassung
./scripts/update.sh --no-pull # nur neu bauen, ohne git pull
```
### Wenn doch etwas schiefgeht
```bash
# Zurück auf den vorigen Stand (die Kennung nennt das Skript beim Abbruch)
git checkout <kennung>
./scripts/update.sh --no-pull --yes
# Nur wenn eine Migration die Daten beschädigt hat:
./scripts/restore.sh backups/<die-sicherung-von-vorhin>.sql.gz
```
> **Migrationen werden nicht zurückgerollt.** Eine Migration, die eine Spalte
> entfernt, ist nicht umkehrbar — deshalb sichert das Skript vorher. Steht
> ein Update mit Datenmigration an, lies vorher den Änderungsverlauf
> (Versionsnummer in der Fußzeile).
---
## Betrieb
### Zustand ansehen
```bash
docker compose --profile full ps
docker compose --profile full logs -f app
curl -s localhost:3000/api/health
```
### Neu starten
```bash
docker compose --profile full restart app
```
### Speicher- und Plattenverbrauch
```bash
docker stats --no-stream nebula-app nebula-db
docker system df
docker image prune -f # alte Abbilder nach mehreren Updates
```
### Automatischer Abgleich
Standardmäßig **aus**. Einzuschalten unter *Admin-Einstellungen →
Automatischer Abgleich*: Abstand, Repositories je Durchlauf, ob fertige
Projekte ausgelassen werden. Der Zeitgeber läuft im Serverprozess selbst —
kein zusätzlicher Container, kein Cron.
Wer lieber von außen taktet, lässt ihn aus und ruft
`POST /api/admin/sync/run` auf.
---
## Härtung, die schon eingebaut ist
Damit du weißt, worum du dich **nicht** kümmern musst:
- Der Container läuft als **unprivilegierter Benutzer** (`nextjs`, UID 1001),
nicht als root.
- **Content-Security-Policy** mit Nonce, `strict-dynamic` und ohne
`unsafe-inline` für Skripte.
- **CSRF-Prüfung** über `Sec-Fetch-Site`/`Origin` bei jedem schreibenden
Zugriff.
- **SSRF-Schutz** bei allen ausgehenden Aufrufen: jede Adresse wird
aufgelöst und gegen Loopback, Link-Local und Metadaten-Endpunkte geprüft,
Weiterleitungen eingeschlossen.
- **Ratenbegrenzung** auf Anmeldung, ausgehende Abrufe und schreibende
Zugriffe; Kontosperre nach acht Fehlversuchen.
- **Git-Tokens** liegen AES-256-GCM-verschlüsselt in der Datenbank und
verlassen den Server nie — auch nicht im Export.
- Der **Bildoptimierer** von Next ist abgeschaltet: Nebula benutzt ihn nicht,
und `/_next/image` wäre sonst eine Angriffsfläche ohne Gegenwert.
### Was du beisteuern musst
- **TLS** über den Reverse Proxy
- **`APP_SECRET`** lang und zufällig
- **Datenbankport** nicht nach außen öffnen
- **Sicherungen** wegschaffen
- **Systemupdates**: `unattended-upgrades` für das Betriebssystem,
`./scripts/update.sh` für Nebula
---
## Mehrbenutzerbetrieb
Nebula kann beides. Im Einzelbetrieb gibt es genau ein Konto und keine
Registrierung. Umschalten unter *Admin-Einstellungen*.
Wichtig zu wissen: **Projekte sind zwischen Konten hart getrennt.** Auch ein
Administrator sieht fremde Projekte nicht — er verwaltet Konten, keine
Inhalte. Wer gemeinsam an einem Projekt arbeiten will, ist mit Nebula falsch
beraten; das ist [bewusst nicht](ROADMAP.md) vorgesehen.
Die Selbstregistrierung ist standardmäßig aus. Lass sie aus, wenn die
Instanz aus dem Internet erreichbar ist und du nicht willst, dass sich
Fremde Konten anlegen.
+157 -6
View File
@@ -109,13 +109,31 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei
## 2. Dashboard
### Schnellerfassung
Ein Blitzsymbol in der Kopfleiste, auf jeder Seite erreichbar. Ein Feld, Enter, fertig —
als **Idee** entsteht ein Projekt im Status `IDEA`, als **Aufgabe** eine Aufgabe im
gewählten Projekt (`TODO`). Alles Weitere setzen die Vorgaben des Schemas.
Der Dialog **bleibt nach dem Anlegen offen** und leert nur das Feld. Wer eine Idee
notiert, hat meistens gleich die nächste; ihn jedes Mal neu zu öffnen wäre genau die
Hürde, die hier wegsoll. Angelegtes wird darunter mit Link bestätigt. Das zuletzt
gewählte Projekt wird gemerkt.
Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten rechts —
dort wäre er auf schmalen Anzeigen mit der Auswahlleiste kollidiert.
### Überblick
- **Kennzahlen** oben: Anzahl Projekte mit Ø-Fortschritt, offene Ideen, aktive
Entwicklungen, fertige Projekte, verknüpfte Repositories.
Entwicklungen, fertige Projekte, verknüpfte Repositories. Auf dem Telefon zweispaltig,
damit die Projekte nicht erst nach dem zweiten Wischen beginnen.
- **Volltextsuche** über Name, Kurzbeschreibung, Beschreibung, Tags und Repo-Pfad.
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
Favoriten bleiben stets oben.
- **Drei Ansichten**: freies Raster, nach Status gruppierte Abschnitte oder **Kanban**.
- **Vier Ansichten**: freies Raster, **kompakte Liste**, nach Status gruppierte Abschnitte
oder **Kanban**.
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit
@@ -138,6 +156,24 @@ Bedienung über Tastatur: Pfeile wählen, `Enter` öffnet, `Esc` schließt. Die
wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden
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
@@ -147,6 +183,23 @@ Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Lei
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.
### Liste
Eine Zeile je Projekt, alle gleich aufgebaut: Statusfarbe als Streifen, Name,
Fortschrittsbalken mit Prozentwert, danach Aufgaben, Notizen, Branch und Alter des
letzten Commits. Der Statuswechsler sitzt rechts, die ganze Zeile ist ein Link.
Gedacht ist die Ansicht für zwei Fälle:
- **Auf dem Telefon.** Eine Karte braucht rund 260 px Höhe und steht dort zwangsläufig
allein in der Zeile — fünf Projekte füllen zwei Bildschirme, bevor man überhaupt
etwas vergleichen kann. Eine Zeile kommt mit einem Viertel davon aus.
- **Beim Vergleichen.** Weil alle Zeilen gleich gebaut sind, liegen Name, Fortschritt
und Status untereinander. Genau das verliert das Raster, sobald es einspaltig wird.
Was nicht mehr in die Zeile passt, fällt der Reihe nach weg, statt sie zu sprengen:
Notizen ab `sm`, Branch ab `md`, der letzte Commit und die Priorität ab `lg`.
### Kanban
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
@@ -189,6 +242,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,
@@ -220,6 +290,46 @@ statt verschoben.
---
## 2b. Aufgaben über alle Projekte
Die Seite `/tasks` sammelt jede Aufgabe aus jedem Projekt an einer Stelle. Erreichbar
über die Kopfleiste und die Schnellsuche.
**Geordnet wird nach Fälligkeit, nicht nach Spalte.** Projektübergreifend lautet die
Frage „was ist überfällig", nicht „was steht wo im Brett". Die Fächer:
| Fach | Inhalt |
| --- | --- |
| **Überfällig** | Termin liegt vor heute |
| **Heute** | Termin ist der heutige Kalendertag |
| **Diese Woche** | in den nächsten sieben Tagen |
| **Später** | alles danach |
| **Ohne Termin** | kein Fälligkeitsdatum gesetzt |
Gerechnet wird auf **Kalendertage in fester Zeitzone** (Europe/Berlin), nicht auf
Stunden und nicht nach Serverzeit. Eine heute um 9 Uhr fällige Aufgabe ist um 17 Uhr
nicht überfällig, sondern immer noch „heute" — sonst wanderte die halbe Liste im Lauf
des Tages nach oben. Und im Container läuft die Anwendung nach UTC; ohne feste Zeitzone
kippte die Einteilung abends um einen Tag. Das Fach bestimmt der **Server**, damit
Server-Render und Hydration nicht auseinanderlaufen.
- **Filter** nach Status (mit Zähler) und Projekt; Erledigtes ist ausgeblendet und
einzeln zuschaltbar.
- **Abhaken direkt aus der Liste.** Der Endpunkt erledigt dabei den Rest: Zeitpunkt,
abgeleiteter Projektfortschritt und — bei wiederkehrenden Aufgaben — die nächste
Fassung.
- Jede Zeile nennt ihr **Projekt** und verlinkt es; ohne das ist eine übergreifende
Liste nicht zu lesen. Stammt die Aufgabe aus einem Git-Issue, führt ein Symbol dorthin.
**Bewusst nicht sortierbar per Ziehen.** Die Position einer Aufgabe gilt innerhalb ihrer
Spalte in ihrem Projekt; hier wäre sie ohne Bedeutung. Eine Reihenfolge anzubieten, die
nirgends ankommt, wäre ein Versprechen, das die Seite nicht halten kann.
Die Aufgabe kennt keinen Besitzer — die Trennung läuft über das Projekt
(`project: { ownerId }` als einzige und erste Bedingung).
---
## 3. Notizen
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
@@ -264,6 +374,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 |
@@ -301,6 +434,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 |
@@ -327,9 +461,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
@@ -473,8 +617,14 @@ 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
@@ -503,7 +653,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) |
+13 -7
View File
@@ -31,7 +31,8 @@ Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst
Access Tokens werden verschlüsselt abgelegt.
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**,
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**.
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**. Für den Dauerbetrieb auf einem
Server — TLS, Firewall, Sicherungen und Updates — gibt es **[DEPLOYMENT.md](DEPLOYMENT.md)**.
---
@@ -51,7 +52,7 @@ node scripts/setup.mjs # legt .env an und erzeugt ein zufälliges APP_SECRE
Die App läuft nativ auf dem Rechner, nur PostgreSQL kommt aus dem Container:
```bash
docker compose up -d db # Postgres 17 auf localhost:5432
npm run docker:db # Postgres 17 auf 127.0.0.1:5432
npm install
npm run db:migrate # Schema anlegen
npm run db:seed # optional: drei Beispielprojekte
@@ -119,10 +120,10 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
| `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** |
| `SESSION_TTL_DAYS` | Lebensdauer einer Anmeldung in Tagen | `30` |
| `PORT` | Port der Anwendung | `3000` |
| `APP_PORT` | Host-Port-Mapping des App-Containers | `3000` |
| `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
| `POSTGRES_PORT` | Host-Port der Datenbank | `5432` |
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `8080` |
| `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` |
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` |
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` |
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
@@ -306,7 +307,7 @@ nebula/
| `npm test` | Unit-Tests (Vitest, ohne Datenbank) |
| `npm run test:e2e` | Ende-zu-Ende-Tests (Playwright, baut vorher) |
| `npm run test:all` | Beides nacheinander |
| `npm run docker:db` | Nur die Datenbank starten |
| `npm run docker:db` | Nur die Datenbank starten, mit Host-Port für `npm run dev` |
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
| `npm run docker:tools` | Adminer auf `http://localhost:8080` |
@@ -341,6 +342,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 +352,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 |
@@ -410,7 +416,7 @@ Grenzen, die du kennen solltest:
| Symptom | Ursache / Lösung |
| --- | --- |
| `Can't reach database server` | Läuft `docker compose up -d db`? Stimmt `DATABASE_URL`? |
| `Can't reach database server` | Läuft `npm run docker:db`? Ein blosses `docker compose up -d db` startet die DB **ohne Host-Port** — dann ist sie von `npm run dev` aus nicht erreichbar. Stimmt `DATABASE_URL`? |
| `The table ... does not exist` | Migrationen fehlen: `npm run db:migrate` |
| Sync meldet `401` | Token abgelaufen oder falsche Scopes — unter Git-Verbindungen neu setzen |
| Sync meldet `404` | Owner/Repo prüfen; bei GitLab gehört die volle Gruppenkette in „Owner" |
+91 -13
View File
@@ -11,6 +11,22 @@ Was bereits steht, listet [FEATURES.md](FEATURES.md).
## 1 · Als Nächstes empfohlen
### 1.0 Rest aus v1.6.0: persönliche API-Token `M`
Schnellerfassung und die projektübergreifende Aufgabenseite sind mit v1.6.0
ausgeliefert. Offen bleibt der dritte Teil — siehe 1.3. Er gehört sachlich
dazu: erfassen am Telefon, überblicken an einer Stelle, ansprechen per
Shortcut.
Bewusst nicht mitgeliefert wurde ein **Index für die übergreifende
Aufgabenabfrage**. `Task` hat kein `ownerId`, die Trennung läuft über
`project: { ownerId }`, und `[projectId, status, position]` greift dafür
nicht. Bei einigen hundert Aufgaben fällt das nicht auf; wird die Seite
träge, ist ein Index auf `dueDate` der erste Griff — und dann eine
Migration wert.
---
### 1.1 Webhooks statt Polling `M`
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
@@ -20,51 +36,108 @@ 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`
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
### 1.4 v1.7.0 „Aufgaben und Issues" `M` + `M`
### 3.1 Passwort-Zurücksetzen per E-Mail `M`
Nachverfolgbarkeit zwischen Nebula-Aufgabe und Git. Die Zuordnung steht im
Schema bereits: `sourceIssue`, `sourceUrl`, `sourceRepositoryId` samt
`@@unique`. Die Richtung **Git → Nebula** gibt es seit v0.9.0
(`tasks/from-issues`); es fehlt die Gegenrichtung.
**Kein Widerspruch zu „Bewusst nicht".** Ausgeschlossen sind dort *eigene
Git-Operationen* — klonen, committen, pushen. Issues laufen über die
HTTP-API der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`,
SSRF-Schutz inklusive). Kein Klon, kein Arbeitsverzeichnis.
Warum Issues und nicht eine Datei im Repo: **dort verlinkt Git ohnehin.**
Ein `fixes #12` in der Commit-Nachricht stellt die Verbindung von selbst
her — die Kette Aufgabe → Commit → PR gibt es geschenkt, statt sie zu bauen.
Damit erledigt sich auch „Aufgabe ↔ Commit verknüpfen" aus Abschnitt 3.
**Stufe 1 — eine Richtung, ausdrücklich** `M`
Ein Knopf „als Issue anlegen" an der Aufgabe. Nummer und URL wandern in die
vorhandenen Felder. Kein Automatismus: was nach außen geht, wird
angestoßen, nicht nebenbei mitgeschrieben.
**Stufe 2 — Rückweg über Webhooks** `M`
Setzt 1.1 voraus. Geschlossenes Issue → Aufgabe nach `DONE`. Erst damit ist
„synchron" ehrlich, ohne im Minutentakt fremde APIs zu fragen.
**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
`TODO/DOING/BLOCKED/DONE`, ein Issue nur offen/geschlossen. Ein Rundlauf
verliert also `DOING` und `BLOCKED` — bewusst hingenommen: die feine
Abstufung bleibt in Nebula, nach Git geht nur offen/zu. Labels wie
`nebula:doing` wären die Alternative, würden aber den Issue-Tracker mit
Zuständen zumüllen, die dort niemand pflegt.
**Echte beidseitige Synchronisation mit Konfliktauflösung ist nicht
vorgesehen.** Das ist der klassische Ort für stillen Datenverlust, und für
ein persönliches Dashboard steht der Aufwand in keinem Verhältnis.
Für Projekte ohne Issue-Tracker wäre eine `.nebula/tasks.md` über die
Contents-API denkbar — aber nur als **einseitiger Spiegel**, nicht als
Synchronisation: zwei Schreiber auf einer Datei, und jede Statusänderung
ein Commit.
---
## 2 · Größere Brocken
### 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.2 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 |
| 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 — fällt weitgehend mit 1.4 ab |
| 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` |
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
| 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.
@@ -94,6 +167,11 @@ Damit klar ist, was **nicht** kommt und warum:
| **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 | ✓ |
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
| **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ |
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
+23
View File
@@ -0,0 +1,23 @@
# ─────────────────────────────────────────────────────────────
# Overlay fuer die Entwicklung: macht die Datenbank auf dem Host erreichbar.
#
# Gebraucht wird das nur, wenn die Anwendung ausserhalb des Containers
# laeuft – "npm run dev" verbindet sich dann ueber localhost:5432. Im
# Vollbetrieb spricht die Anwendung "db:5432" im Docker-Netz an und braucht
# diese Datei nicht.
#
# Aufruf:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db
# npm run dev
#
# Kuerzer: npm run docker:db
#
# Gebunden wird auf 127.0.0.1, nicht auf 0.0.0.0. Docker traegt Freigaben
# direkt in iptables ein und umgeht dabei ufw – eine offene Bindung stuende
# also auch bei aktiver Firewall im Netz.
# ─────────────────────────────────────────────────────────────
services:
db:
ports:
- "${POSTGRES_PORT:-127.0.0.1:5432}:5432"
+46
View File
@@ -0,0 +1,46 @@
# ─────────────────────────────────────────────────────────────
# Overlay fuer den Serverbetrieb: kein einziger Port geht zum Host.
#
# Gedacht fuer einen Docker-Host, auf dem mehrere Stacks liegen und ein
# gemeinsamer Reverse Proxy die einzige Tuer nach draussen ist. Die
# Datenbank veroeffentlicht ohnehin nichts; diese Datei nimmt zusaetzlich
# die Freigabe der Anwendung zurueck und haengt sie in ein geteiltes Netz,
# ueber das der Proxy sie erreicht.
#
# Aufruf:
# docker compose -f docker-compose.yml -f docker-compose.server.yml \
# --profile full up -d --build
#
# Bequemer ist, das dauerhaft in die .env zu schreiben:
# COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml
# COMPOSE_PROFILES=full
# Danach genuegt ueberall "docker compose up -d" – auch in update.sh.
# (Trennzeichen: Doppelpunkt unter Linux, Semikolon unter Windows.)
#
# Das Netz muss es vorher geben (der Proxy-Stack legt es ueblicherweise an):
# docker network create proxy
#
# Im Proxy zeigt das Ziel dann auf http://nebula:3000 bzw. nebula-app:3000.
# ─────────────────────────────────────────────────────────────
services:
app:
# !reset loescht die Portfreigabe aus der Basisdatei. Ohne dieses Tag
# wuerden die Listen zusammengefuegt statt ersetzt – Compose behandelt
# "ports" beim Zusammenfuehren als anzuhaengende Liste. Braucht
# Compose 2.24 oder neuer.
ports: !reset []
networks:
# Das stackeigene Netz: darueber spricht die Anwendung mit der Datenbank.
default:
# Das geteilte Netz zum Reverse Proxy. Der Alias ist der Name, unter dem
# der Proxy Nebula anspricht – kurz und eindeutig, denn "app" hiessen in
# einem geteilten Netz schnell mehrere Container.
proxy:
aliases:
- ${PROXY_ALIAS:-nebula}
networks:
proxy:
external: true
name: ${PROXY_NETWORK:-proxy}
+21 -9
View File
@@ -1,14 +1,20 @@
name: nebula
# ─────────────────────────────────────────────────────────────
# Zwei Betriebsarten aus einer Datei:
# Diese Datei veroeffentlicht nur einen einzigen Port, und den auf dem
# Loopback: den der Anwendung. Die Datenbank bleibt im Docker-Netz.
#
# 1) Standalone-Entwicklung – nur die Datenbank im Container:
# docker compose up -d db
# 1) Alles im Container (Server wie Entwicklung):
# docker compose --profile full up -d --build
#
# 2) Standalone-Entwicklung – Datenbank im Container, App auf dem Host.
# Dafuer muss Postgres auf dem Host erreichbar sein, also mit dem
# Entwicklungs-Overlay (oder kurz: npm run docker:db):
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db
# npm run dev
#
# 2) Alles im Container:
# docker compose --profile full up -d --build
# 3) Server ohne jede Portfreigabe, hinter einem Reverse Proxy im
# gemeinsamen Docker-Netz: siehe docker-compose.server.yml
#
# Optionales DB-Webinterface:
# docker compose --profile tools up -d adminer → http://localhost:8080
@@ -25,8 +31,10 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-nebula}
# Deterministische Sortierung unabhaengig vom Host-Locale
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
ports:
- "${POSTGRES_PORT:-5432}:5432"
# Absichtlich keine Portfreigabe: die Anwendung erreicht die Datenbank im
# Container ueber "db:5432", und backup.sh geht ueber "docker exec". Nach
# aussen muss hier nichts. Wer die Datenbank vom Host aus braucht – fuer
# "npm run dev" oder psql – nimmt docker-compose.dev.yml dazu.
volumes:
- nebula-db-data:/var/lib/postgresql/data
healthcheck:
@@ -73,7 +81,11 @@ services:
PORT: 3000
HOSTNAME: 0.0.0.0
ports:
- "${APP_PORT:-3000}:3000"
# Nur auf dem Loopback: nach draussen kommt man ueber den Reverse Proxy.
# Docker schreibt seine Freigaben an ufw vorbei direkt in iptables – eine
# Bindung auf 0.0.0.0 stuende also im Netz, auch bei aktiver Firewall.
# Ganz ohne Host-Port: docker-compose.server.yml.
- "${APP_PORT:-127.0.0.1:3000}:3000"
adminer:
profiles: ["tools"]
@@ -87,7 +99,7 @@ services:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: dracula
ports:
- "${ADMINER_PORT:-8080}:8080"
- "${ADMINER_PORT:-127.0.0.1:8080}:8080"
volumes:
nebula-db-data:
Regular → Executable
View File
+171
View File
@@ -374,6 +374,177 @@ test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) =
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("Der Bildoptimierer ist zu", async ({ playwright }) => {
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
// nirgends – der Endpunkt darf ohne Anmeldung nicht antworten.
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
maxRedirects: 0,
});
const antwort = await anonym.get("/_next/image?url=%2Ficon-192.png&w=64&q=75");
expect(antwort.status(), "kein 200 ohne Anmeldung").not.toBe(200);
await anonym.dispose();
});
test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
await login(request, ADMIN);
+77
View File
@@ -106,6 +106,83 @@ test("Volltextsuche findet Notizen", async ({ page }) => {
await expect(page.getByText("Treffer in Notizen")).toBeVisible();
});
test("Listenansicht auf Telefonbreite ohne seitliches Scrollen", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Liste" }).click();
await expect(page.getByRole("link", { name: /Projekt Oberflaechentest/ })).toBeVisible();
// Der eigentliche Punkt der Ansicht: sie passt in die Breite. Frueher schob
// die Steuerleiste den Knopf "Neues Projekt" aus dem Bild und die ganze
// Seite liess sich seitwaerts schieben.
const masse = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(masse.scrollWidth).toBeLessThanOrEqual(masse.clientWidth + 1);
// Auch der Knopf muss erreichbar bleiben – auf dieser Breite traegt er nur
// noch das Pluszeichen und lebt von seinem aria-label. Geprueft wird die
// rechte Kante: genau die lag vorher ausserhalb des Bildschirms.
const knopf = page.getByRole("button", { name: "Neues Projekt" });
await expect(knopf).toBeVisible();
const kasten = await knopf.boundingBox();
expect(kasten).not.toBeNull();
expect(kasten!.x + kasten!.width).toBeLessThanOrEqual(masse.clientWidth);
});
test("Schnellerfassung legt Idee und Aufgabe mit einem Feld an", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Schnell erfassen" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Idee: nur der Name, alles andere kommt aus den Vorgaben des Schemas.
await page.fill("#quick-title", "Aus der Bahn notiert");
await dialog.getByRole("button", { name: "Anlegen" }).click();
// Der Dialog bleibt offen und bestaetigt – das ist der Punkt der
// Schnellerfassung: die naechste Idee soll ohne Umweg hineingehen.
await expect(dialog.getByText("Angelegt")).toBeVisible();
await expect(dialog.getByRole("link", { name: "Aus der Bahn notiert" })).toBeVisible();
await expect(page.locator("#quick-title")).toHaveValue("");
// Aufgabe: derselbe Dialog, ein Feld mehr.
await dialog.getByRole("button", { name: "Aufgabe", exact: true }).click();
await page.fill("#quick-title", "Unterwegs eingefallen");
await expect(page.locator("#quick-project")).toBeVisible();
await dialog.getByRole("button", { name: "Anlegen" }).click();
await expect(dialog.getByRole("link", { name: "Unterwegs eingefallen" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
// Die Idee steht als Projekt im Dashboard.
await page.goto("/");
await expect(page.getByText("Aus der Bahn notiert")).toBeVisible();
});
test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page }) => {
await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
// Die zuvor per Schnellerfassung angelegte Aufgabe taucht hier auf,
// obwohl sie in einem anderen Projekt haengt.
const zeile = page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" });
await expect(zeile).toBeVisible();
// Abhaken aus der Uebersicht heraus.
await zeile.getByRole("button", { name: /abhaken/ }).click();
// Ohne "Erledigte einblenden" verschwindet sie damit aus der Liste.
await expect(zeile).toBeHidden();
await page.getByLabel("Erledigte einblenden").check();
await expect(
page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" }),
).toBeVisible();
});
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
+15
View File
@@ -9,6 +9,21 @@ const nextConfig: NextConfig = {
poweredByHeader: false,
// Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht.
serverExternalPackages: ["@prisma/client", "prisma"],
images: {
/**
* Der Bildoptimierer ist abgeschaltet.
*
* Nebula benutzt next/image nirgends – die paar Symbole sind SVG und
* PNG in fester Groesse. Der Endpunkt /_next/image waere aber trotzdem
* da, und zwar ohne Anmeldung: die Middleware nimmt ihn aus, weil dort
* sonst nur Rechenzeit verbraucht wuerde.
*
* Er reicht jedes Bild durch sharp und damit durch libvips, fuer das es
* regelmaessig Schwachstellen gibt (aktuell CVE-2026-33327 und drei
* weitere). Eine Angriffsflaeche ohne Gegenwert – also weg damit.
*/
unoptimized: true,
},
};
export default nextConfig;
+13 -2
View File
@@ -1,12 +1,12 @@
{
"name": "nebula",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nebula",
"version": "1.0.0",
"version": "1.2.0",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -34,6 +34,7 @@
"@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",
@@ -2536,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",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nebula",
"version": "1.1.0",
"version": "1.6.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": {
@@ -16,7 +16,7 @@
"db:studio": "prisma studio",
"db:seed": "prisma db seed",
"db:reset": "prisma migrate reset",
"docker:db": "docker compose up -d db",
"docker:db": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db",
"docker:db:down": "docker compose stop db",
"docker:up": "docker compose --profile full up -d --build",
"docker:down": "docker compose --profile full down",
@@ -56,6 +56,7 @@
"@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",
@@ -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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totpLastCounter" INTEGER;
+76
View File
@@ -71,6 +71,16 @@ enum ActivityKind {
SYNC_FAILED
}
/// Wie oft sich eine Aufgabe wiederholt. Bewusst grob: feinere Regeln
/// (jeden zweiten Dienstag im Monat) braucht ein Projektdashboard nicht,
/// und sie waeren ohne Kalenderansicht kaum nachvollziehbar.
enum Recurrence {
DAILY
WEEKLY
BIWEEKLY
MONTHLY
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
@@ -118,6 +128,10 @@ model User {
/// sperrt sich aus, wer die Einrichtung abbricht.
totpSecretCipher String?
totpEnabledAt DateTime?
/// Zuletzt eingeloestes Zeitfenster. RFC 6238 verlangt, denselben Code
/// kein zweites Mal anzunehmen – ohne diesen Merker liesse sich ein
/// abgefangener Code die restliche Gueltigkeit ueber wiederverwenden.
totpLastCounter Int?
sessions Session[]
credentials Credential[]
@@ -127,6 +141,7 @@ model User {
activities Activity[]
recoveryCodes RecoveryCode[]
pendingLogins PendingLogin[]
templates ProjectTemplate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -449,6 +464,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?
@@ -466,6 +490,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())
@@ -550,6 +609,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?
Regular → Executable
View File
Regular → Executable
View File
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────
# Legt die .env an und wuerfelt ein APP_SECRET.
#
# Dasselbe wie scripts/setup.mjs, aber ohne Node: auf einem reinen
# Docker-Host ist keins installiert, und nur zum Erzeugen einer Textdatei
# eines zu verlangen waere albern. Braucht nichts ausser einer POSIX-Shell.
#
# Aufruf: ./scripts/setup.sh
# ./scripts/setup.sh --force (vorhandene .env ueberschreiben)
# ─────────────────────────────────────────────────────────────
set -eu
root="$(cd "$(dirname "$0")/.." && pwd)"
env_datei="$root/.env"
vorlage="$root/.env.example"
force=0
[ "${1:-}" = "--force" ] && force=1
if [ -t 1 ]; then
gruen=$'\033[32m'; gelb=$'\033[33m'; cyan=$'\033[36m'; dim=$'\033[2m'; aus=$'\033[0m'
else
gruen=""; gelb=""; cyan=""; dim=""; aus=""
fi
[ -f "$vorlage" ] || { echo "${gelb}.env.example nicht gefunden – abgebrochen.${aus}" >&2; exit 1; }
# ── Zufall ───────────────────────────────────────────────────
# openssl ist auf den meisten Systemen da, aber nicht auf allen. /dev/urandom
# gibt es ueberall, wo diese Shell laeuft – und base64 ebenso.
zufall() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 32
else
head -c 32 /dev/urandom | base64
fi | tr -d '\n'
}
if [ -f "$env_datei" ] && [ "$force" -eq 0 ]; then
echo "${gelb}·${aus} .env existiert bereits – sie wird nicht ueberschrieben."
echo " ${dim}Mit --force neu anlegen (die alte wird gesichert).${aus}"
else
if [ -f "$env_datei" ]; then
sicherung="$env_datei.$(date +%Y%m%d%H%M%S).bak"
cp "$env_datei" "$sicherung"
echo "${gelb}·${aus} Alte .env gesichert: $(basename "$sicherung")"
fi
secret="$(zufall)"
# Das Geheimnis kann / und + enthalten; als sed-Ersetzung waeren das
# Sonderzeichen. Deshalb zeilenweise ersetzen statt mit einem Ausdruck.
: > "$env_datei"
while IFS= read -r zeile || [ -n "$zeile" ]; do
case "$zeile" in
APP_SECRET=*) printf 'APP_SECRET="%s"\n' "$secret" >> "$env_datei" ;;
*) printf '%s\n' "$zeile" >> "$env_datei" ;;
esac
done < "$vorlage"
chmod 600 "$env_datei"
echo "${gruen}✓${aus} .env angelegt, APP_SECRET zufaellig erzeugt, Rechte auf 600."
fi
echo
echo "${cyan}Noch anzupassen${aus}"
echo "${dim}─────────────────────────────────────────────${aus}"
echo " APP_URL die Adresse, unter der du Nebula aufrufst"
echo " ${dim}bestimmt die WebAuthn-rpID – passt sie nicht, scheitern Passkeys${aus}"
echo " POSTGRES_PASSWORD die Voreinstellung ist fuer Entwicklung gedacht"
echo " APP_PORT auf einem Server: 127.0.0.1:3000 hinter den Reverse Proxy"
echo
echo "${cyan}Danach${aus}"
echo "${dim}─────────────────────────────────────────────${aus}"
echo " docker compose --profile full up -d --build"
echo " ${dim}Der Container spielt die Migrationen beim Start selbst ein.${aus}"
echo
echo " Ausfuehrlich: DEPLOYMENT.md"
echo
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# Nebula auf einem Server aktualisieren.
#
# Holt den neuen Stand, sichert die Datenbank, baut das Abbild, spielt die
# Migrationen ein und startet neu. Bricht ab, sobald ein Schritt scheitert –
# und sagt dann, wie zurueckzukommen ist.
#
# Aufruf: ./scripts/update.sh
# ./scripts/update.sh --no-pull (nur neu bauen und starten)
# ./scripts/update.sh --ref v1.4.0 (auf einen Stand festlegen)
# ./scripts/update.sh --yes (ohne Rueckfrage)
#
# Rueckgabe 0 heisst: die neue Fassung laeuft und antwortet gesund.
# ─────────────────────────────────────────────────────────────
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
pull=1
ref=""
zusage=0
while [ $# -gt 0 ]; do
case "$1" in
--no-pull) pull=0 ;;
--ref) ref="${2:-}"; shift ;;
--yes|-y) zusage=1 ;;
-h|--help) sed -n '3,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; exit 2 ;;
esac
shift
done
# ── Farben, aber nur wenn ein Terminal zusieht ───────────────
if [ -t 1 ]; then
rot=$'\033[31m'; gruen=$'\033[32m'; gelb=$'\033[33m'; fett=$'\033[1m'; aus=$'\033[0m'
else
rot=""; gruen=""; gelb=""; fett=""; aus=""
fi
# Die Version aus der package.json lesen – ohne Node. Auf einem reinen
# Docker-Host ist keins installiert, und dieses Skript soll dort laufen.
paket_version() {
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$root/package.json" \
| head -n 1
}
schritt() { printf '\n%s▸ %s%s\n' "$fett" "$1" "$aus"; }
ok() { printf ' %s✓%s %s\n' "$gruen" "$aus" "$1"; }
warnung() { printf ' %s!%s %s\n' "$gelb" "$aus" "$1"; }
abbruch() { printf '\n%s✗ %s%s\n' "$rot" "$1" "$aus" >&2; exit 1; }
# ── Voraussetzungen ──────────────────────────────────────────
schritt "Voraussetzungen"
command -v docker >/dev/null || abbruch "docker nicht gefunden."
docker compose version >/dev/null 2>&1 || abbruch "docker compose (v2) nicht gefunden."
[ -f "$root/.env" ] || abbruch "Keine .env in $root. Zuerst './scripts/setup.sh' ausfuehren."
[ -f "$root/docker-compose.yml" ] || abbruch "Kein docker-compose.yml in $root."
ok "docker, compose und .env vorhanden"
# APP_SECRET muss gesetzt sein – sonst liegen die Git-Tokens faktisch offen.
if grep -qE '^\s*APP_SECRET\s*=\s*("?)(nebula-dev-secret[^"]*|)\1\s*$' "$root/.env"; then
abbruch "APP_SECRET ist leer oder noch der Entwicklungswert. Bitte in der .env setzen."
fi
ok "APP_SECRET gesetzt"
vorher="$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")"
version_vorher="$(paket_version)"
printf ' Aktuell: v%s (%s)\n' "$version_vorher" "$vorher"
# ── Neuen Stand holen ────────────────────────────────────────
if [ "$pull" -eq 1 ]; then
schritt "Neuen Stand holen"
schmutz="$(git status --porcelain)"
if [ -n "$schmutz" ]; then
# Nicht nur meckern, sondern zeigen, worum es geht – sonst muss man
# raten, was man da eigentlich verwerfen soll.
printf '\n%sGeaendert gegenueber dem Stand im Git:%s\n' "$fett" "$aus"
printf '%s\n' "$schmutz" | sed 's/^/ /'
printf '\n Verwerfen: git checkout -- .\n'
printf ' Behalten: git stash\n'
abbruch "Das Arbeitsverzeichnis ist nicht sauber."
fi
git fetch --all --tags --quiet
if [ -n "$ref" ]; then
git checkout --quiet "$ref"
ok "Auf $ref gewechselt"
else
zweig="$(git rev-parse --abbrev-ref HEAD)"
git pull --ff-only --quiet origin "$zweig"
ok "$zweig aktualisiert"
fi
nachher="$(git rev-parse --short HEAD)"
version_nachher="$(paket_version)"
if [ "$vorher" = "$nachher" ]; then
warnung "Kein neuer Stand – es wird trotzdem neu gebaut."
else
printf ' Neu: v%s (%s)\n' "$version_nachher" "$nachher"
printf '\n Was dazukam:\n'
git --no-pager log --oneline --no-decorate "$vorher..$nachher" 2>/dev/null | sed 's/^/ /' || true
fi
else
warnung "Ohne --pull: der vorhandene Stand wird neu gebaut."
fi
# ── Rueckfrage ───────────────────────────────────────────────
if [ "$zusage" -eq 0 ] && [ -t 0 ]; then
printf '\n%sWeiter? Die Datenbank wird vorher gesichert. [j/N] %s' "$fett" "$aus"
read -r antwort
case "$antwort" in [jJyY]*) ;; *) abbruch "Abgebrochen." ;; esac
fi
# ── Datenbank sichern ────────────────────────────────────────
schritt "Datenbank sichern"
# Die Datenbank muss laufen, sonst gibt es nichts zu sichern.
docker compose up -d db >/dev/null
sicherung=""
if [ -x "$root/scripts/backup.sh" ]; then
ausgabe="$("$root/scripts/backup.sh" 2>&1)" || abbruch "Sicherung fehlgeschlagen:\n$ausgabe"
# backup.sh setzt den Pfad in Anfuehrungszeichen – die gehoeren nicht in
# den Namen, sonst laesst sich der Vorschlag am Ende nicht kopieren.
sicherung="$(printf '%s' "$ausgabe" | grep -oE '[^ "]*backups/[^ "]*\.sql[^ "]*' | tail -n 1 || true)"
ok "Gesichert${sicherung:+: $sicherung}"
else
warnung "scripts/backup.sh fehlt – es wird ohne Sicherung fortgefahren."
fi
# ── Bauen ────────────────────────────────────────────────────
schritt "Abbild bauen"
# Erst bauen, dann umschalten: ein Fehlschlag laesst die alte Fassung laufen.
docker compose --profile full build app || abbruch "Der Bau ist gescheitert. Die laufende Fassung ist unveraendert."
ok "Abbild gebaut"
# ── Starten ──────────────────────────────────────────────────
schritt "Starten"
# Die Migrationen laufen im entrypoint.sh, bevor der Server hochkommt.
docker compose --profile full up -d || abbruch "Der Start ist gescheitert."
ok "Container gestartet"
# ── Gesundheitspruefung ──────────────────────────────────────
schritt "Gesundheitspruefung"
# Gefragt wird der Container selbst, nicht der Host. Das Abbild bringt einen
# HEALTHCHECK mit, und so ist es einerlei, ob APP_PORT nur eine Nummer ist
# oder wie auf einem Server "127.0.0.1:3000" – und curl braucht es auch nicht.
gesund=0
for _ in $(seq 1 40); do
zustand="$(docker inspect --format '{{.State.Health.Status}}' nebula-app 2>/dev/null || echo "weg")"
case "$zustand" in
healthy) gesund=1; break ;;
unhealthy) break ;;
esac
sleep 3
done
if [ "$gesund" -ne 1 ]; then
printf '\n%sLetzte Zeilen aus dem Log:%s\n' "$fett" "$aus"
docker compose --profile full logs --tail 40 app || true
printf '\n%sZurueck zur vorigen Fassung:%s\n' "$fett" "$aus"
printf ' git checkout %s && ./scripts/update.sh --no-pull --yes\n' "$vorher"
[ -n "$sicherung" ] && printf ' Datenbank notfalls: ./scripts/restore.sh %s\n' "$sicherung"
abbruch "Der Container meldet sich nach zwei Minuten nicht gesund (Zustand: ${zustand})."
fi
ok "Container meldet sich gesund"
# Die Anzeige der Adresse. Nicht die .env raten, sondern Docker fragen: mit
# docker-compose.server.yml veroeffentlicht der Container gar keinen Port und
# haengt nur im Netz des Reverse Proxy. Dann gibt "port" nichts zurueck.
adresse="$(docker compose --profile full port app 3000 2>/dev/null | tail -n 1 || true)"
if [ -n "$adresse" ]; then
port="${adresse##*:}"
if command -v curl >/dev/null 2>&1; then
antwort_json="$(curl -fsS "http://127.0.0.1:${port}/api/health" 2>/dev/null || true)"
[ -n "$antwort_json" ] && ok "Antwort: $antwort_json"
fi
wo="$adresse"
else
wo="im Docker-Netz (kein Host-Port veroeffentlicht)"
fi
# ── Fertig ───────────────────────────────────────────────────
version_jetzt="$(paket_version)"
printf '\n%s%s✓ Nebula v%s laeuft auf %s%s\n' "$gruen" "$fett" "$version_jetzt" "$wo" "$aus"
[ -n "$sicherung" ] && printf ' Sicherung vor dem Update: %s\n' "$sicherung"
printf ' Log mitlesen: docker compose --profile full logs -f app\n\n'
+7 -4
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password";
import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery";
import { generateTotpSecret, groupSecret, otpauthUri, verifyTotp } from "@/lib/auth/totp";
import { generateTotpSecret, groupSecret, matchTotp, otpauthUri } from "@/lib/auth/totp";
import { APP_NAME } from "@/lib/config";
import { encryptSecret, tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db";
@@ -109,7 +109,8 @@ export async function PUT(request: Request) {
return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409);
}
if (!verifyTotp(secret, parsed.data.code)) {
const fenster = matchTotp(secret, parsed.data.code);
if (fenster === null) {
return jsonError(
"Der Code stimmt nicht. Geht die Uhr des Geraets richtig?",
401,
@@ -118,7 +119,9 @@ export async function PUT(request: Request) {
await prisma.user.update({
where: { id: auth.user.id },
data: { totpEnabledAt: new Date() },
// Das Fenster gleich mitverbrennen: der Code, der eben aktiviert hat,
// soll nicht Sekunden spaeter noch zum Anmelden taugen.
data: { totpEnabledAt: new Date(), totpLastCounter: fenster },
});
return NextResponse.json({
@@ -157,7 +160,7 @@ export async function DELETE(request: Request) {
await prisma.$transaction([
prisma.user.update({
where: { id: auth.user.id },
data: { totpSecretCipher: null, totpEnabledAt: null },
data: { totpSecretCipher: null, totpEnabledAt: null, totpLastCounter: null },
}),
prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }),
prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }),
+23 -4
View File
@@ -8,8 +8,9 @@ import {
} from "@/lib/auth/mfa";
import { redeemRecoveryCode } from "@/lib/auth/recovery";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { verifyTotp } from "@/lib/auth/totp";
import { matchTotp } from "@/lib/auth/totp";
import { noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
@@ -63,9 +64,19 @@ export async function POST(request: Request) {
// Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die
// Formate ueberschneiden sich nicht.
const istZiffern = /^[\d\s]+$/.test(eingabe);
const erfolg = istZiffern
? Boolean(pending.user.totpSecret) && verifyTotp(pending.user.totpSecret!, eingabe)
: await redeemRecoveryCode(pending.user.id, eingabe);
// Bei den Ziffern zaehlt zusaetzlich, dass das Zeitfenster neuer ist als
// das zuletzt eingeloeste. Sonst liesse sich ein abgefangener Code die
// restliche Gueltigkeitsdauer ueber wiederverwenden – RFC 6238, 5.2.
const fenster =
istZiffern && pending.user.totpSecret
? matchTotp(pending.user.totpSecret, eingabe)
: null;
const frisch =
fenster !== null &&
(pending.user.totpLastCounter === null || fenster > pending.user.totpLastCounter);
const erfolg = istZiffern ? frisch : await redeemRecoveryCode(pending.user.id, eingabe);
if (!erfolg) {
const weiterMoeglich = await noteMfaFailure(pending.id);
@@ -83,6 +94,14 @@ export async function POST(request: Request) {
await discardPendingLogin(pending.id);
await noteSuccessfulLogin(pending.user.id);
// Das eingeloeste Fenster merken, damit derselbe Code verbrannt ist.
if (fenster !== null) {
await prisma.user.update({
where: { id: pending.user.id },
data: { totpLastCounter: fenster },
});
}
const session = await createSession(pending.user.id, {
userAgent: request.headers.get("user-agent"),
ip,
@@ -3,7 +3,7 @@ import type { Prisma, TaskStatus } from "@prisma/client";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { ownedProject, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
import { formatZodError, taskReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -52,11 +52,14 @@ export async function PATCH(request: Request, { params }: Params) {
const now = new Date();
const updates: Prisma.PrismaPromise<unknown>[] = [];
/** Wer eben erst nach ERLEDIGT gewandert ist – nicht, wer dort schon lag. */
const frischErledigt: string[] = [];
for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) {
ids.forEach((taskId, index) => {
if (!ownedIds.has(taskId)) return;
const before = previousStatus.get(taskId);
if (status === "DONE" && before !== "DONE") frischErledigt.push(taskId);
updates.push(
prisma.task.update({
where: { id: taskId },
@@ -77,6 +80,14 @@ export async function PATCH(request: Request, { params }: Params) {
if (updates.length > 0) {
await prisma.$transaction(updates);
// Wiederkehrende Aufgaben, die eben nach ERLEDIGT gezogen wurden,
// bekommen ihre naechste Fassung. Erst nach der Transaktion: vorher
// stuende doneAt noch nicht, aus dem der Folgetermin gerechnet wird.
for (const taskId of frischErledigt) {
await spawnRecurrence(taskId);
}
await refreshDerivedProgress(id);
}
+2 -1
View File
@@ -46,7 +46,7 @@ export async function POST(request: Request, { params }: Params) {
);
}
const { title, description, status, dueDate, labels } = parsed.data;
const { title, description, status, dueDate, labels, recurrence } = parsed.data;
const task = await prisma.task.create({
data: {
@@ -58,6 +58,7 @@ export async function POST(request: Request, { params }: Params) {
position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null,
labels,
recurrence: recurrence ?? null,
doneAt: status === "DONE" ? new Date() : null,
},
});
+9 -1
View File
@@ -3,7 +3,8 @@ 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 { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, projectReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -20,6 +21,13 @@ export async function PATCH(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;
+64 -3
View File
@@ -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,8 +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 },
});
@@ -111,12 +128,56 @@ export async function POST(request: Request) {
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_CREATED",
summary:
data.status === "IDEA"
summary: vorlage
? `Aus Vorlage „${vorlage.name}“ angelegt`
: data.status === "IDEA"
? "Als Idee angelegt"
: `Angelegt mit Status „${statusOf(data.status).label}“`,
meta: { status: data.status },
meta: {
status: data.status,
...(vorlage ? { template: vorlage.name, notes: notes.length, tasks: tasks.length } : {}),
},
});
return NextResponse.json({ project }, { status: 201 });
}
/**
* Die Vorlage kommt als JSON aus der Datenbank und ist damit fuer TypeScript
* unbekannt. Hier wird sie auf das Noetige zurechtgeschnitten – lieber einen
* Eintrag auslassen als am Anlegen scheitern.
*/
function templateNotes(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
.filter((entry) => typeof entry.content === "string" && entry.content.trim().length > 0)
.slice(0, 50)
.map((entry) => ({
title: typeof entry.title === "string" ? entry.title : null,
content: entry.content as string,
pinned: entry.pinned === true,
}));
}
function templateTasks(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
.filter((entry) => typeof entry.title === "string" && entry.title.trim().length > 0)
.slice(0, 100)
.map((entry, index) => ({
title: entry.title as string,
description: typeof entry.description === "string" ? entry.description : null,
status: (["TODO", "DOING", "BLOCKED"] as const).includes(
entry.status as "TODO" | "DOING" | "BLOCKED",
)
? (entry.status as "TODO" | "DOING" | "BLOCKED")
: ("TODO" as const),
// Reihenfolge wie in der Vorlage.
position: index,
labels: Array.isArray(entry.labels)
? entry.labels.filter((label): label is string => typeof label === "string").slice(0, 8)
: [],
}));
}
+21 -4
View File
@@ -4,7 +4,12 @@ import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { TASK_STATUS_META, nextPosition, refreshDerivedProgress } from "@/lib/tasks";
import {
TASK_STATUS_META,
nextPosition,
refreshDerivedProgress,
spawnRecurrence,
} from "@/lib/tasks";
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -44,6 +49,7 @@ export async function PATCH(request: Request, { params }: Params) {
if (data.title !== undefined) update.title = data.title;
if (data.description !== undefined) update.description = data.description;
if (data.labels !== undefined) update.labels = data.labels;
if (data.recurrence !== undefined) update.recurrence = data.recurrence;
if (data.dueDate !== undefined) {
update.dueDate = data.dueDate ? new Date(data.dueDate) : null;
}
@@ -57,6 +63,15 @@ export async function PATCH(request: Request, { params }: Params) {
}
const task = await prisma.task.update({ where: { id }, data: update });
// Genau beim Uebergang nach ERLEDIGT entsteht die naechste Fassung. Ein
// erneutes Speichern einer schon erledigten Aufgabe darf keine zweite
// erzeugen – deshalb haengt es an update.status und nicht an data.status.
const folge =
update.status === "DONE" && existing.status !== "DONE"
? await spawnRecurrence(id)
: null;
await refreshDerivedProgress(existing.projectId);
// Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder
@@ -68,12 +83,14 @@ export async function PATCH(request: Request, { params }: Params) {
projectId: existing.projectId,
userId: auth.user.id,
kind: "TASK_MOVED",
summary: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
meta: { taskId: id, from: existing.status, to: nach },
summary: folge
? `Aufgabe „${shorten(task.title, 40)}“ erledigt, nächste angelegt`
: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
meta: { taskId: id, from: existing.status, to: nach, ...(folge ? { next: folge.id } : {}) },
});
}
return NextResponse.json({ task });
return NextResponse.json({ task, next: folge });
}
export async function DELETE(_request: Request, { params }: Params) {
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, templateUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await prisma.projectTemplate.findFirst({
where: { id, ownerId: auth.user.id },
select: { id: true },
});
if (!existing) return jsonError("Vorlage nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = templateUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
if (data.name) {
const doppelt = await prisma.projectTemplate.findFirst({
where: { ownerId: auth.user.id, name: data.name, id: { not: id } },
select: { id: true },
});
if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409);
}
const template = await prisma.projectTemplate.update({ where: { id }, data });
return NextResponse.json({ template });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// deleteMany mit Besitzerbedingung: trifft fremde Vorlagen gar nicht erst.
const result = await prisma.projectTemplate.deleteMany({
where: { id, ownerId: auth.user.id },
});
if (result.count === 0) return jsonError("Vorlage nicht gefunden", 404);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } 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 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 = 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 });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } 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 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 = 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 });
}
+70
View File
@@ -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; }
@@ -253,6 +277,21 @@
border: 1px solid transparent;
}
/**
* Auf Zeigergeraeten reichen die 36px, die sich aus Schriftgroesse und
* Innenabstand ergeben. Ein Finger trifft das nicht zuverlaessig – dort
* gilt die uebliche Untergrenze von 44px.
*
* Ueber "pointer: coarse" statt einer Breitenabfrage: entscheidend ist das
* Eingabegeraet, nicht die Fenstergroesse. Ein Tablet im Querformat ist
* breit und wird trotzdem mit dem Finger bedient.
*/
@media (pointer: coarse) {
.btn {
min-height: 2.75rem;
}
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -311,6 +350,19 @@
box-shadow 0.2s ease;
}
/**
* Unter 16px zoomt iOS Safari beim Hineintippen die ganze Seite heran und
* kehrt danach nicht von selbst zurueck – aus einem Formular wird so eine
* Schiebepartie. Deshalb auf Beruehrungsgeraeten volle 16px, unabhaengig
* von der Fensterbreite.
*/
@media (pointer: coarse) {
.field {
font-size: 1rem;
padding: 0.65rem 0.85rem;
}
}
.field::placeholder {
color: rgba(148, 163, 184, 0.6);
}
@@ -392,4 +444,22 @@
.range::-moz-range-track {
background: transparent;
}
/* Ein 16px-Griff ist mit dem Finger kaum zu fassen. Die Spur bleibt
schmal, damit der Regler nicht ploetzlich klobig wirkt. */
@media (pointer: coarse) {
.range {
height: 10px;
}
.range::-webkit-slider-thumb {
height: 24px;
width: 24px;
}
.range::-moz-range-thumb {
height: 22px;
width: 22px;
}
}
}
+30
View File
@@ -16,12 +16,35 @@
--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,
Consolas, "DejaVu Sans Mono", monospace;
--radius-card: 1.25rem;
/* Eine Stufe unterhalb von Tailwinds "sm" (40rem). Zwischen 320px und
640px liegt der gesamte Telefonbereich, und dort ist der Unterschied
zwischen "Beschriftung passt noch" und "passt nicht mehr" oft nur ein
Zentimeter breit. 22rem trennt die wirklich schmalen Geraete (iPhone SE
der ersten Reihe, 320px) vom Rest, der bei 360px und mehr anfaengt. */
--breakpoint-xs: 22rem;
}
:root {
@@ -47,7 +70,14 @@
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Ein Notnagel gegen seitliches Scrollen, kein Ersatz fuer richtiges
Umbrechen: was hier abgeschnitten wird, ist auf dem Telefon einfach
nicht mehr erreichbar. Die Ursachen gehoeren in die Komponenten. */
overflow-x: hidden;
/* Mit viewportFit: "cover" reicht die Seite bis in die Ecken; ohne diese
Einrueckung stuende der Inhalt im Querformat unter der Kameraleiste. */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
::selection {
+5
View File
@@ -42,6 +42,11 @@ export const viewport: Viewport = {
themeColor: "#05060f",
width: "device-width",
initialScale: 1,
// Die Seite reicht bis unter Statusleiste und Home-Anzeige; das ist noetig,
// damit der Hintergrund im installierten Betrieb nicht an einem weissen
// Balken endet. Was dadurch verdeckt wuerde, holen die env(safe-area-*)
// in globals.css wieder heraus.
viewportFit: "cover",
};
export default async function RootLayout({
+5 -1
View File
@@ -52,13 +52,17 @@ export default async function DashboardPage() {
</p>
</header>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf
Kacheln dort den ganzen ersten Bildschirm, und die Projekte – der
eigentliche Inhalt – begannen erst nach dem zweiten Wischen. */}
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-5">
<StatCard
label="Projekte"
value={active.length}
hint={`Ø Fortschritt ${averageProgress}%`}
icon={CircleDot}
color="#a78bfa"
className="col-span-2 lg:col-span-1"
/>
<StatCard
label="Ideen"
+26
View File
@@ -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>
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { TaskOverview } from "@/components/tasks/TaskOverview";
import { requireUserPage } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { dueBucket } from "@/lib/tasks";
import type { TaskOverviewItem } from "@/types";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Aufgaben" };
export default async function TasksPage() {
const user = await requireUserPage();
/**
* Die Aufgabe kennt keinen Besitzer – die Trennung laeuft ueber das
* Projekt. Die Bedingung steht deshalb als einzige und erste in der
* where-Klausel; eine fremde Aufgabe kann hier gar nicht auftauchen.
*/
const tasks = await prisma.task.findMany({
where: { project: { ownerId: user.id } },
include: {
project: { select: { id: true, name: true, accent: true, status: true } },
},
// NULL-Termine sortiert Postgres bei ASC ans Ende – genau richtig,
// Aufgaben ohne Termin gehoeren nicht nach oben.
orderBy: [{ dueDate: "asc" }, { position: "asc" }],
});
/**
* Das Fach wird auf dem Server bestimmt, nicht im Browser. "Heute" haengt
* an der Uhr, und zwei verschiedene Antworten zwischen Server-Render und
* Hydration waeren genau der Fehler, den die feste Zeitzone in lib/utils
* sonst vermeidet.
*/
const jetzt = new Date();
const items: TaskOverviewItem[] = tasks.map((task) => ({
id: task.id,
title: task.title,
description: task.description,
status: task.status,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
labels: task.labels,
recurrence: task.recurrence,
sourceUrl: task.sourceUrl,
bucket: dueBucket(task.dueDate, jetzt),
project: task.project,
}));
return <TaskOverview tasks={items} />;
}
+18 -4
View File
@@ -66,8 +66,13 @@ export function BulkBar({
}
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">
// Der Abstand nach unten beruecksichtigt die Home-Anzeige des iPhones;
// ohne das liegt die Leiste im installierten Betrieb darunter.
<div
className="pointer-events-none fixed inset-x-0 bottom-4 z-[90] flex justify-center px-3"
style={{ bottom: "calc(1rem + env(safe-area-inset-bottom))" }}
>
<div className="popover pointer-events-auto flex max-w-full flex-wrap items-center justify-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>
@@ -122,8 +127,17 @@ export function BulkBar({
<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">
{/* Die Breite gehoert an den Popover, nicht an sein Kind: der Popover
setzt overflow-hidden, ein breiteres Kind wird schlicht
abgeschnitten – frueher um die Haelfte des Abbrechen-Knopfs. */}
<Popover
open={tagsOpen}
anchorRef={tagsRef}
onClose={() => setTagsOpen(false)}
align="start"
width={288}
>
<div className="space-y-2 p-3">
<label className="field-label" htmlFor="bulk-tags">
Tags (kommagetrennt)
</label>
+8
View File
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import {
Bell,
CheckSquare,
CornerDownLeft,
GitBranch,
LayoutDashboard,
@@ -55,6 +56,13 @@ interface Action {
const ACTIONS: Action[] = [
{ id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" },
{
id: "tasks",
label: "Aufgaben",
hint: "Über alle Projekte, nach Fälligkeit",
icon: CheckSquare,
href: "/tasks",
},
{
id: "connections",
label: "Git-Verbindungen",
+6 -1
View File
@@ -24,10 +24,11 @@ import {
Unlink,
} from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { Heatmap } from "@/components/Heatmap";
import { RepositoryDialog } from "@/components/RepositoryDialog";
import { Sparkline } from "@/components/Sparkline";
import { repoTitle } from "@/lib/git/meta";
import { asArray, asRecord } from "@/types";
import { asArray, asCalendar, asRecord } from "@/types";
import type {
BranchInfo,
CommitInfo,
@@ -109,6 +110,7 @@ export function GitPanel({
const pulls = asArray<TicketInfo>(snapshot?.pullRequests);
const issues = asArray<TicketInfo>(snapshot?.issues);
const activity = asArray<number>(snapshot?.commitActivity);
const calendar = asCalendar(snapshot?.commitCalendar);
/** Ein einzelnes Repository oder – ohne Argument – alle. */
async function sync(nurDieses?: string) {
@@ -416,6 +418,9 @@ export function GitPanel({
</div>
) : null}
{/* Der Jahresueberblick, sobald er einmal geholt wurde. */}
{calendar ? <Heatmap calendar={calendar} /> : null}
{/* Sprachen */}
{languageTotal > 0 ? (
<div className="glass p-4">
+190
View File
@@ -0,0 +1,190 @@
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="-mx-1 overflow-x-auto px-1">
{/*
Kein max-w-full: zusammen mit der festen Hoehe staucht das die
Wochen auf schmalen Anzeigen zu einem unlesbaren Streifen, statt
den Elter seitlich scrollen zu lassen. Die Kalenderfelder haben
eine feste Groesse – die Karte gehoert geschoben, nicht gequetscht.
*/}
<svg
viewBox={`0 0 ${breite + 22} ${hoehe}`}
width={breite + 22}
height={hoehe}
className="block shrink-0"
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>
);
}
+5 -1
View File
@@ -253,7 +253,11 @@ function Column({
// Eindeutiger Anker fuer Tests und Automatisierung.
data-column={status}
className={cn(
"flex w-[17rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors",
// Schmaler auf dem Telefon: bei 17rem stand die zweite Spalte fast
// vollstaendig ausserhalb des Bildschirms, und ohne einen sichtbaren
// Rest der naechsten Spalte sieht man dem Brett nicht an, dass es
// seitlich weitergeht.
"flex w-[14rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors sm:w-[17rem]",
isOver && "border-violet-400/40 bg-violet-500/6",
)}
>
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Markdown } from "./Markdown";
/**
* Markdown kommt aus zwei Quellen, denen nicht zu trauen ist: eigenen
* Notizen (die zwar der Benutzer schreibt, aber ohne Bereinigung ein
* XSS-Loch waeren) und dem README eines fremden Repositories.
*
* Diese Tests halten die Bereinigung fest. Sie rendern serverseitig, was
* fuer die Frage genuegt – es geht um das erzeugte HTML, nicht um
* Verhalten im Browser.
*/
const html = (text: string, baseUrl?: string) =>
renderToStaticMarkup(<Markdown baseUrl={baseUrl}>{text}</Markdown>);
describe("Bereinigung", () => {
it("laesst kein script durch", () => {
const ausgabe = html("<script>alert(1)</script>\n\nText danach.");
expect(ausgabe).not.toContain("<script");
expect(ausgabe).not.toContain("alert(1)");
expect(ausgabe).toContain("Text danach.");
});
it("entfernt Ereignis-Attribute aus rohem HTML", () => {
const ausgabe = html('<img src="x" onerror="alert(1)">');
expect(ausgabe).not.toContain("onerror");
expect(ausgabe).not.toContain("alert");
});
it("laesst kein javascript: als Link durch", () => {
const ausgabe = html("[klick](javascript:alert(1))");
expect(ausgabe).not.toContain("javascript:");
});
it("laesst kein javascript: ueber ein Bild durch", () => {
// Das Bild wird zu einem Link umgebaut – die Quelle darf dabei nicht
// unbesehen zum href werden.
const ausgabe = html("![x](javascript:alert(1))");
expect(ausgabe).not.toContain("javascript:");
});
it("laesst kein data: mit HTML durch", () => {
const ausgabe = html("[klick](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)");
expect(ausgabe).not.toContain("data:text/html");
});
it("haengt an jeden Link noopener und noreferrer", () => {
const ausgabe = html("[Ziel](https://example.com)");
expect(ausgabe).toContain('target="_blank"');
expect(ausgabe).toContain("noopener");
expect(ausgabe).toContain("noreferrer");
});
it("laedt keine Bilder, sondern zeigt eine Marke", () => {
// Die Content-Security-Policy erlaubt img-src nur von der eigenen
// Instanz; ein fremdes README duerfte sonst Zaehlpixel nachladen.
const ausgabe = html("![Abzeichen](https://example.com/badge.svg)");
expect(ausgabe).not.toContain("<img");
expect(ausgabe).toContain("md-image");
expect(ausgabe).toContain("Abzeichen");
});
});
describe("Relative Verweise", () => {
const BASIS = "https://git.example.com/o/r/blob/main/";
it("loest relative Links gegen die Basis auf", () => {
expect(html("[Doku](docs/x.md)", BASIS)).toContain(`${BASIS}docs/x.md`);
});
it("laesst absolute Links und Anker in Ruhe", () => {
expect(html("[Extern](https://example.com/a)", BASIS)).toContain("https://example.com/a");
expect(html("[Anker](#titel)", BASIS)).toContain('href="#titel"');
});
it("laesst ohne Basis alles unveraendert", () => {
expect(html("[Doku](docs/x.md)")).toContain('href="docs/x.md"');
});
it("erzeugt aus einem Bild im Link keinen Link im Link", () => {
// "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel. Ein a im a ist
// ungueltiges HTML; der Browser zieht es auseinander, was zu einem
// Hydrationsfehler fuehrt – genau daran ist der erste Versuch gescheitert.
const ausgabe = html("[![Abzeichen](img/b.svg)](https://example.com/ziel)", BASIS);
expect(ausgabe).toContain("<span");
expect(ausgabe.match(/<a /g) ?? []).toHaveLength(1);
});
});
+94 -3
View File
@@ -24,12 +24,93 @@ const schema = {
},
};
/** Loest relative Verweise gegen die Basis auf; laesst absolute in Ruhe. */
function absolut(href: string | undefined, base: string | null | undefined): string | undefined {
if (!href || !base) return href;
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("#")) {
return href;
}
try {
return new URL(href, base).toString();
} catch {
return href;
}
}
type HastNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: HastNode[];
value?: string;
};
/**
* Ersetzt Bilder durch eine anklickbare Marke – siehe die Begruendung
* unten am `.md-image`-Stil.
*
* Das passiert im Baum und nicht ueber eine Komponente, weil es auf den
* Elternknoten ankommt: `[![Abzeichen](bild)](ziel)` ist in READMEs die
* Regel, und ein `<a>` in einem `<a>` ist ungueltiges HTML. Der Browser
* zieht es beim Parsen auseinander, was zu einem Hydrationsfehler fuehrt –
* genau daran ist es beim ersten Versuch gescheitert. Innerhalb eines Links
* bleibt deshalb nur der Text stehen; der umgebende Link ist ohnehin das,
* was man anklicken will.
*/
function bilderAlsMarke(base: string | null | undefined) {
return () => (tree: HastNode) => {
const gehe = (knoten: HastNode, inLink: boolean) => {
const kinder = knoten.children;
if (!kinder) return;
for (let index = 0; index < kinder.length; index += 1) {
const kind = kinder[index];
if (kind.type === "element" && kind.tagName === "img") {
const quelle = typeof kind.properties?.src === "string" ? kind.properties.src : undefined;
const alt = typeof kind.properties?.alt === "string" ? kind.properties.alt.trim() : "";
const text: HastNode = { type: "text", value: alt || "Bild" };
const ziel = absolut(quelle, base);
kinder[index] =
inLink || !ziel
? { type: "element", tagName: "span", properties: { className: ["md-image"] }, children: [text] }
: {
type: "element",
tagName: "a",
properties: {
className: ["md-image"],
href: ziel,
title: ziel,
target: "_blank",
rel: "noreferrer noopener nofollow",
},
children: [text],
};
continue;
}
gehe(kind, inLink || (kind.type === "element" && kind.tagName === "a"));
}
};
gehe(tree, false);
};
}
export function Markdown({
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>
),
+66 -48
View File
@@ -7,6 +7,7 @@ import {
Database,
Download,
LayoutGrid,
List,
ListFilter,
NotebookPen,
Plus,
@@ -21,6 +22,7 @@ import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard";
import { ProjectCard } from "@/components/ProjectCard";
import { ProjectDialog } from "@/components/ProjectDialog";
import { ProjectList } from "@/components/ProjectList";
import { Popover } from "@/components/ui/Popover";
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn } from "@/lib/utils";
@@ -53,9 +55,17 @@ function highlight(snippet: string): React.ReactNode[] {
}
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped" | "kanban";
type ViewMode = "grid" | "list" | "grouped" | "kanban";
const VIEWS: ViewMode[] = ["grid", "grouped", "kanban"];
const VIEWS: ViewMode[] = ["grid", "list", "grouped", "kanban"];
/** Reihenfolge der Umschalter – zugleich die Beschriftung der Knoepfe. */
const VIEW_META: Record<ViewMode, { label: string; title: string; icon: typeof LayoutGrid }> = {
grid: { label: "Raster", title: "Rasteransicht", icon: LayoutGrid },
list: { label: "Liste", title: "Kompakte Liste", icon: List },
grouped: { label: "Gruppiert", title: "Nach Status gruppiert", icon: Rows3 },
kanban: { label: "Brett", title: "Kanban – Status per Ziehen aendern", icon: Columns3 },
};
/** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */
const ANSICHT_SCHLUESSEL = "nebula.board";
@@ -255,10 +265,14 @@ export function ProjectBoard({
return (
<section className="space-y-5">
{/* Steuerleiste */}
<div className="glass flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[220px] flex-1">
{/* Steuerleiste.
Auf dem Telefon steht die Suche allein in der ersten Zeile und die
Werkzeuge darunter. Frueher lagen beide in einer Zeile, die nicht
umbrechen konnte – der Knopf "Neues Projekt" wurde dabei aus dem
Bild geschoben und die ganze Seite seitlich scrollbar. */}
<div className="glass flex flex-col gap-3 p-3 sm:gap-4 sm:p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="relative min-w-0 sm:min-w-[220px] sm:flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
className="field pl-9"
@@ -282,9 +296,12 @@ export function ProjectBoard({
<label className="sr-only" htmlFor="sort">
Sortierung
</label>
{/* min-w-0 ist hier entscheidend: ohne das weigert sich ein
Flex-Kind, unter seine Inhaltsbreite zu schrumpfen, und
drueckt stattdessen die Nachbarn hinaus. */}
<select
id="sort"
className="field w-auto min-w-[11rem]"
className="field w-auto min-w-0 flex-1 truncate sm:flex-none sm:min-w-[11rem]"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
// Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier
@@ -303,50 +320,11 @@ export function ProjectBoard({
))}
</select>
<div className="flex overflow-hidden rounded-xl border border-white/9">
<button
type="button"
onClick={() => setView("grid")}
className={cn(
"p-2 transition-colors",
view === "grid" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Rasteransicht"
title="Rasteransicht"
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setView("grouped")}
className={cn(
"p-2 transition-colors",
view === "grouped" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Nach Status gruppiert"
title="Nach Status gruppiert"
>
<Rows3 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setView("kanban")}
className={cn(
"p-2 transition-colors",
view === "kanban" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Kanban-Spalten"
title="Kanban – Status per Ziehen aendern"
>
<Columns3 className="h-4 w-4" />
</button>
</div>
<button
ref={dataButtonRef}
type="button"
onClick={() => setDataMenuOpen((value) => !value)}
className="rounded-xl border border-white/9 p-2 text-slate-500 transition-colors hover:text-white"
className="shrink-0 rounded-xl border border-white/9 p-2.5 text-slate-500 transition-colors hover:text-white"
aria-label="Daten exportieren oder importieren"
title="Export und Import"
aria-haspopup="menu"
@@ -355,13 +333,51 @@ export function ProjectBoard({
<Database className="h-4 w-4" />
</button>
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}>
<button
type="button"
className="btn btn-primary shrink-0"
onClick={() => setDialogOpen(true)}
// Unterhalb von sm bleibt nur das Pluszeichen stehen. Ohne
// eigene Beschriftung liest ein Screenreader dann gar nichts vor.
aria-label="Neues Projekt"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Neues Projekt</span>
</button>
</div>
</div>
{/* Ansichtsumschalter: auf schmalen Anzeigen ueber die volle Breite,
damit die vier Knoepfe gross genug zum Treffen bleiben. */}
<div
className="flex overflow-hidden rounded-xl border border-white/9 sm:w-auto sm:self-start"
role="group"
aria-label="Ansicht"
>
{VIEWS.map((mode) => {
const { label, title, icon: Icon } = VIEW_META[mode];
const active = view === mode;
return (
<button
key={mode}
type="button"
onClick={() => setView(mode)}
className={cn(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors sm:flex-none",
active ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-pressed={active}
title={title}
>
<Icon className="h-4 w-4 shrink-0" />
{/* Nur auf den schmalsten Anzeigen bleiben die Symbole allein –
dort waeren vier Beschriftungen enger als die Fingerkuppe. */}
<span className="hidden xs:inline">{label}</span>
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 inline-flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-slate-500">
<ListFilter className="h-3.5 w-3.5" />
@@ -471,6 +487,8 @@ export function ProjectBoard({
selection={selection}
onToggleSelect={toggleSelect}
/>
) : view === "list" ? (
<ProjectList projects={visible} 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) => (
+140 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, RefreshCw, Search } from "lucide-react";
import { Loader2, RefreshCw, Search, Trash2 } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status";
@@ -17,6 +17,33 @@ interface ProjectDialogProps {
project?: ProjectListItem | null;
}
/** Eine Vorlage, so wie sie /api/templates liefert. */
export interface ProjectTemplateEntry {
id: string;
name: string;
description: string | null;
summary: string | null;
projectDescription: string | null;
status: ProjectStatus;
priority: number;
accent: string;
tags: string[];
progressFromTasks: boolean;
notes: unknown[];
tasks: unknown[];
}
/** "3 Notizen, 5 Aufgaben" – oder was davon zutrifft. */
function beschreibeVorlage(vorlage: ProjectTemplateEntry): string {
const teile: string[] = [];
const notizen = Array.isArray(vorlage.notes) ? vorlage.notes.length : 0;
const aufgaben = Array.isArray(vorlage.tasks) ? vorlage.tasks.length : 0;
if (notizen > 0) teile.push(`${notizen} ${notizen === 1 ? "Notiz" : "Notizen"}`);
if (aufgaben > 0) teile.push(`${aufgaben} ${aufgaben === 1 ? "Aufgabe" : "Aufgaben"}`);
if (vorlage.tags.length > 0) teile.push(vorlage.tags.map((tag) => `#${tag}`).join(" "));
return teile.length > 0 ? teile.join(" · ") : "nur Stammdaten";
}
interface FormState {
name: string;
summary: string;
@@ -33,6 +60,8 @@ interface FormState {
repoName: string;
autoSync: boolean;
progressFromTasks: boolean;
/** Nur beim Anlegen: aus welcher Vorlage Notizen und Aufgaben kommen. */
templateId: string;
}
const EMPTY: FormState = {
@@ -51,6 +80,7 @@ const EMPTY: FormState = {
repoName: "",
autoSync: true,
progressFromTasks: false,
templateId: "",
};
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -60,14 +90,72 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const isEdit = Boolean(project);
const [templates, setTemplates] = useState<ProjectTemplateEntry[]>([]);
const gewaehlteVorlage = templates.find((entry) => entry.id === form.templateId) ?? null;
/**
* Die Vorlagen werden erst geladen, wenn der Dialog aufgeht – und nur zum
* Anlegen. Beim Bearbeiten waeren sie nur Ballast.
*/
useEffect(() => {
if (!open || isEdit) return;
let abgebrochen = false;
fetch("/api/templates")
.then((response) => (response.ok ? response.json() : { templates: [] }))
.then((payload) => {
if (!abgebrochen) setTemplates(payload.templates ?? []);
})
.catch(() => undefined);
return () => {
abgebrochen = true;
};
}, [open, isEdit]);
/**
* Uebernimmt die Stammdaten sichtbar in die Felder. Notizen und Aufgaben
* bleiben der Serverseite ueberlassen: sie liessen sich hier nicht
* darstellen, und wer die Vorlage wieder abwaehlt, soll sie auch nicht
* mitgeschleppt bekommen.
*
* Bereits Getipptes wird nicht ueberschrieben – nur leere Felder werden
* gefuellt.
*/
function applyTemplate(templateId: string) {
const vorlage = templates.find((entry) => entry.id === templateId);
if (!vorlage) {
update("templateId", "");
return;
}
setForm((previous) => ({
...previous,
templateId,
name: previous.name || vorlage.name,
summary: previous.summary || (vorlage.summary ?? ""),
description: previous.description || (vorlage.projectDescription ?? ""),
status: vorlage.status,
priority: vorlage.priority,
accent: vorlage.accent,
tags: previous.tags || vorlage.tags.join(", "),
progressFromTasks: vorlage.progressFromTasks,
}));
}
async function removeTemplate(vorlage: ProjectTemplateEntry) {
if (!window.confirm(`Vorlage „${vorlage.name}“ loeschen?`)) return;
await fetch(`/api/templates/${vorlage.id}`, { method: "DELETE" }).catch(() => undefined);
setTemplates((current) => current.filter((entry) => entry.id !== vorlage.id));
update("templateId", "");
}
const [repoPickerOpen, setRepoPickerOpen] = useState(false);
const [repos, setRepos] = useState<RepoListEntry[]>([]);
const [reposLoading, setReposLoading] = useState(false);
const [repoFilter, setRepoFilter] = useState("");
const [repoError, setRepoError] = useState<string | null>(null);
const isEdit = Boolean(project);
useEffect(() => {
if (!open) return;
setError(null);
@@ -96,6 +184,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoName: project.repositories[0]?.repoName ?? "",
autoSync: project.repositories[0]?.autoSync ?? true,
progressFromTasks: project.progressFromTasks,
templateId: "",
}
: EMPTY,
);
@@ -165,6 +254,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoName: form.repoName,
autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks,
// Nur beim Anlegen; beim Bearbeiten kennt das Schema das Feld nicht.
...(isEdit ? {} : { templateId: form.templateId || null }),
};
try {
@@ -203,6 +294,52 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
}
>
<form onSubmit={submit} className="space-y-5">
{/* Nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus einer
Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln. */}
{!isEdit && templates.length > 0 ? (
<div className="glass-soft p-4">
<label className="field-label" htmlFor="project-template">
Aus Vorlage
</label>
<div className="flex flex-wrap items-center gap-2">
<select
id="project-template"
className="field w-auto min-w-[14rem] flex-1"
value={form.templateId}
onChange={(event) => applyTemplate(event.target.value)}
>
<option value="">– ohne Vorlage –</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
{gewaehlteVorlage ? (
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs text-rose-200"
onClick={() => removeTemplate(gewaehlteVorlage)}
title="Diese Vorlage loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{gewaehlteVorlage ? (
<p className="mt-1.5 text-xs text-slate-500">
{gewaehlteVorlage.description ? `${gewaehlteVorlage.description} · ` : ""}
{beschreibeVorlage(gewaehlteVorlage)}
</p>
) : (
<p className="mt-1.5 text-xs text-slate-500">
Vorlagen entstehen aus einem bestehenden Projekt – im Projektkopf unter
„Als Vorlage sichern“.
</p>
)}
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-name">
+30 -3
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
BookmarkPlus,
CalendarDays,
CheckSquare,
Download,
@@ -17,6 +18,7 @@ import {
Trash2,
} from "lucide-react";
import { ProjectDialog } from "@/components/ProjectDialog";
import { SaveTemplateDialog } from "@/components/SaveTemplateDialog";
import { StatusSelect } from "@/components/StatusSelect";
import { Popover } from "@/components/ui/Popover";
import { repoTitle } from "@/lib/git/meta";
@@ -36,6 +38,7 @@ export function ProjectHeader({
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const [editOpen, setEditOpen] = useState(false);
const [templateOpen, setTemplateOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const [favorite, setFavorite] = useState(project.favorite);
@@ -71,7 +74,7 @@ export function ProjectHeader({
return (
<header
className="glass relative overflow-hidden p-6"
className="glass relative overflow-hidden p-4 sm:p-6"
style={
{
"--accent-from": accent.from,
@@ -165,9 +168,13 @@ export function ProjectHeader({
) : null}
</div>
<div className="flex flex-col items-end gap-3">
{/* Auf dem Telefon steht dieser Block unter dem Titel und faengt
links an; erst ab sm rueckt er nach rechts neben ihn. Vorher
standen vier Knoepfe in einer Zeile, die nicht umbrechen durfte –
"Loeschen" lag damit ausserhalb des Bildschirms. */}
<div className="flex flex-wrap items-center gap-2 sm:flex-col sm:items-end sm:gap-3">
<StatusSelect projectId={project.id} status={project.status} />
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button
ref={exportButtonRef}
type="button"
@@ -176,9 +183,19 @@ export function ProjectHeader({
aria-haspopup="menu"
aria-expanded={exportOpen}
title="Exportieren"
aria-label="Exportieren"
>
<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"
aria-label="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 +301,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>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
AlertTriangle,
CheckSquare,
GitBranch,
GitCommitHorizontal,
NotebookPen,
Star,
} from "lucide-react";
import { StatusSelect } from "@/components/StatusSelect";
import { PRIORITY_META, accentOf, statusOf } from "@/lib/status";
import { cn, formatRelative } from "@/lib/utils";
import type { ProjectListItem } from "@/types";
/**
* Listenansicht des Boards.
*
* Warum zusaetzlich zum Raster: eine Karte braucht rund 260px Hoehe, auf dem
* Telefon steht sie zwangslaeufig allein in der Zeile. Fuenf Projekte fuellen
* damit zwei Bildschirme, bevor man ueberhaupt etwas vergleichen kann. Eine
* Zeile kommt mit einem Viertel davon aus, und weil alle Zeilen gleich
* aufgebaut sind, liegen Name, Fortschritt und Status untereinander – genau
* das, was das Raster auf schmalen Anzeigen verliert.
*/
export function ProjectList({
projects,
selection,
onToggleSelect,
}: {
projects: ProjectListItem[];
selection: Set<string>;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
return (
<ul className="glass fade-up divide-y divide-white/6 overflow-hidden">
{projects.map((project) => (
<ProjectRow
key={project.id}
project={project}
selected={selection.has(project.id)}
onToggleSelect={onToggleSelect}
/>
))}
</ul>
);
}
function ProjectRow({
project,
selected,
onToggleSelect,
}: {
project: ProjectListItem;
selected: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent);
const meta = statusOf(project.status);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const snapshot = project.repositories[0]?.snapshot ?? null;
const syncError = project.repositories.find((repo) => repo.syncError)?.syncError ?? null;
const [favorite, setFavorite] = useState(project.favorite);
async function toggleFavorite() {
const next = !favorite;
setFavorite(next);
try {
const response = await fetch(`/api/projects/${project.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ favorite: next }),
});
if (!response.ok) throw new Error("failed");
} catch {
setFavorite(!next);
}
}
return (
<li
className={cn("group relative transition-colors", selected && "bg-violet-500/8")}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
} as React.CSSProperties
}
>
{/* Die ganze Zeile ist der Link; was darueber bedienbar sein soll,
bekommt weiter unten die Zeigerereignisse zurueck. */}
<Link
href={`/projects/${project.id}`}
className="absolute inset-0 z-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-400/60"
aria-label={`Projekt ${project.name} oeffnen`}
/>
<div className="pointer-events-none relative z-10 flex items-center gap-2.5 px-3 py-2.5 transition-colors group-hover:bg-white/4 sm:gap-3 sm:px-4 sm:py-3">
{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-4 w-4 shrink-0 accent-violet-500 transition-opacity",
!selected && "opacity-35 group-hover:opacity-100 focus-visible:opacity-100",
)}
/>
) : null}
{/* Statusfarbe als Streifen: auf einen Blick erkennbar, ohne Platz
fuer eine Beschriftung zu verbrauchen. */}
<span
className="h-8 w-1 shrink-0 rounded-full"
style={{ backgroundColor: meta.color }}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<h3 className="truncate text-sm font-medium text-white group-hover:text-violet-200">
{project.name}
</h3>
{favorite ? (
<Star className="h-3 w-3 shrink-0 fill-amber-300 text-amber-300" />
) : null}
{syncError ? (
<AlertTriangle className="h-3 w-3 shrink-0 text-rose-300/80" aria-label="Sync-Fehler" />
) : null}
</div>
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-white/8 sm:w-24">
<span
className="accent-bar block h-full rounded-full"
style={{ width: `${Math.max(2, project.progress)}%` }}
/>
</span>
<span className="shrink-0 tabular-nums">{project.progress}%</span>
{/* Was nicht mehr in die Zeile passt, faellt der Reihe nach weg,
statt sie zu sprengen. */}
{project._count.tasks > 0 ? (
<span className="hidden shrink-0 items-center gap-1 xs:inline-flex">
<CheckSquare className="h-3 w-3" />
{project.tasks.length}/{project._count.tasks}
</span>
) : null}
{project._count.notes > 0 ? (
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
<NotebookPen className="h-3 w-3" />
{project._count.notes}
</span>
) : null}
{snapshot?.defaultBranch ? (
<span className="hidden shrink-0 items-center gap-1 font-mono md:inline-flex">
<GitBranch className="h-3 w-3" />
{snapshot.defaultBranch}
</span>
) : null}
{snapshot?.lastCommitAt ? (
<span
className="hidden shrink-0 items-center gap-1 lg:inline-flex"
suppressHydrationWarning
>
<GitCommitHorizontal className="h-3 w-3" />
{formatRelative(snapshot.lastCommitAt)}
</span>
) : null}
<span
className="ml-auto hidden shrink-0 uppercase tracking-wider md:inline"
style={{ color: priority.color }}
title={`Prioritaet: ${priority.label}`}
>
{priority.label}
</span>
</div>
</div>
<button
type="button"
onClick={toggleFavorite}
aria-label={favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={cn(
"pointer-events-auto hidden shrink-0 rounded-lg p-2 transition-colors sm:block",
favorite
? "text-amber-300 hover:bg-amber-400/10"
: "text-slate-600 hover:bg-white/8 hover:text-amber-300",
)}
>
<Star className={cn("h-4 w-4", favorite && "fill-current")} />
</button>
<div className="pointer-events-auto shrink-0">
<StatusSelect projectId={project.id} status={project.status} variant="compact" />
</div>
</div>
</li>
);
}
+282
View File
@@ -0,0 +1,282 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { CheckSquare, Lightbulb, Loader2, Zap } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { cn } from "@/lib/utils";
/**
* Schnellerfassung: ein Feld, Enter, fertig.
*
* „Ideen festhalten" ist der erste Satz auf dem Dashboard – der Weg dorthin
* fuehrte aber ueber einen Dialog mit sieben Feldern. Unterwegs auf dem
* Telefon ist das der Unterschied zwischen „schreib ich schnell auf" und
* „mach ich spaeter", und spaeter heisst gar nicht.
*
* Deshalb bleibt hier alles weg, was sich nachziehen laesst: Projekte
* entstehen als Idee, Aufgaben als offen. Beschreibung, Status, Termin und
* der ganze Rest kommen am Rechner dazu, wenn ueberhaupt.
*
* Der Dialog bleibt nach dem Anlegen offen und leert nur das Feld. Wer eine
* Idee notiert, hat meistens gleich die naechste – ihn jedes Mal neu zu
* oeffnen waere genau die Huerde, die hier wegsoll.
*/
interface ProjektHit {
id: string;
name: string;
}
/** Zuletzt gewaehltes Projekt, damit die Aufgabenerfassung nicht jedes Mal fragt. */
const PROJEKT_SCHLUESSEL = "nebula.quickcapture.projekt";
type Modus = "idee" | "aufgabe";
interface Angelegt {
art: Modus;
titel: string;
href: string;
}
export function QuickCapture({
open,
onClose,
initialMode = "idee",
}: {
open: boolean;
onClose: () => void;
/** Aus der Aufgabenuebersicht heraus faengt der Dialog bei "aufgabe" an. */
initialMode?: Modus;
}) {
const router = useRouter();
const eingabeRef = useRef<HTMLInputElement>(null);
const [modus, setModus] = useState<Modus>(initialMode);
const [titel, setTitel] = useState("");
const [projekte, setProjekte] = useState<ProjektHit[]>([]);
const [projektId, setProjektId] = useState("");
const [busy, setBusy] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
const [angelegt, setAngelegt] = useState<Angelegt[]>([]);
// Beim Oeffnen zuruecksetzen – ein Dialog, der noch die Eingabe von
// vorgestern zeigt, ist schlimmer als einer, der leer ist.
useEffect(() => {
if (!open) return;
setTitel("");
setFehler(null);
setAngelegt([]);
setModus(initialMode);
}, [open, initialMode]);
/**
* Die Projektliste wird erst gebraucht, wenn jemand eine Aufgabe erfasst.
* Sie beim Oeffnen zu holen waere eine Abfrage fuer den haeufigeren Fall,
* in dem sie niemand ansieht.
*/
useEffect(() => {
if (!open || modus !== "aufgabe" || projekte.length > 0) return;
let abgebrochen = false;
(async () => {
try {
const antwort = await fetch("/api/projects");
if (!antwort.ok) return;
const daten = (await antwort.json()) as { projects?: ProjektHit[] };
if (abgebrochen) return;
const liste = (daten.projects ?? []).map(({ id, name }) => ({ id, name }));
setProjekte(liste);
const gemerkt = window.localStorage.getItem(PROJEKT_SCHLUESSEL);
const gueltig = gemerkt && liste.some((eintrag) => eintrag.id === gemerkt);
setProjektId(gueltig ? gemerkt : (liste[0]?.id ?? ""));
} catch {
// Ohne Liste bleibt der Waehler leer; der Hinweis darunter erklaert es.
}
})();
return () => {
abgebrochen = true;
};
}, [open, modus, projekte.length]);
async function absenden(event: React.FormEvent) {
event.preventDefault();
const wert = titel.trim();
if (!wert || busy) return;
setBusy(true);
setFehler(null);
try {
if (modus === "idee") {
const antwort = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
// Nur der Name. Alles andere setzt das Schema: Status IDEA,
// Prioritaet normal, Fortschritt 0.
body: JSON.stringify({ name: wert }),
});
const nutzlast = await antwort.json().catch(() => ({}));
if (!antwort.ok) throw new Error(nutzlast.error ?? "Anlegen fehlgeschlagen.");
setAngelegt((bisher) => [
{ art: "idee", titel: wert, href: `/projects/${nutzlast.project.id}` },
...bisher,
]);
} else {
if (!projektId) throw new Error("Kein Projekt gewaehlt.");
const antwort = await fetch(`/api/projects/${projektId}/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: wert }),
});
const nutzlast = await antwort.json().catch(() => ({}));
if (!antwort.ok) throw new Error(nutzlast.error ?? "Anlegen fehlgeschlagen.");
window.localStorage.setItem(PROJEKT_SCHLUESSEL, projektId);
setAngelegt((bisher) => [
{ art: "aufgabe", titel: wert, href: `/projects/${projektId}` },
...bisher,
]);
}
setTitel("");
eingabeRef.current?.focus();
router.refresh();
} catch (gefangen) {
setFehler(gefangen instanceof Error ? gefangen.message : String(gefangen));
} finally {
setBusy(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title="Schnell erfassen"
description="Ein Feld genuegt. Beschreibung, Status und Termin lassen sich spaeter nachziehen."
>
<form onSubmit={absenden} className="space-y-4">
<div className="flex overflow-hidden rounded-xl border border-white/9" role="group">
{(
[
{ wert: "idee", label: "Idee", hinweis: "Neues Projekt", icon: Lightbulb },
{ wert: "aufgabe", label: "Aufgabe", hinweis: "In einem Projekt", icon: CheckSquare },
] as const
).map(({ wert, label, hinweis, icon: Icon }) => (
<button
key={wert}
type="button"
onClick={() => setModus(wert)}
aria-pressed={modus === wert}
className={cn(
"flex flex-1 items-center justify-center gap-2 px-3 py-2.5 text-sm transition-colors",
modus === wert ? "bg-white/10 text-white" : "text-slate-400 hover:text-white",
)}
title={hinweis}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</button>
))}
</div>
<div>
<label className="field-label" htmlFor="quick-title">
{modus === "idee" ? "Wie heisst die Idee?" : "Was ist zu tun?"}
</label>
<input
id="quick-title"
ref={eingabeRef}
className="field"
value={titel}
onChange={(event) => setTitel(event.target.value)}
placeholder={modus === "idee" ? "z. B. Pixelgarten" : "z. B. Favicon austauschen"}
maxLength={modus === "idee" ? 120 : 200}
required
autoFocus
// enterKeyHint stellt auf dem Telefon die Absendetaste um – sonst
// steht dort „Weiter", obwohl es nichts weiter gibt.
enterKeyHint="done"
/>
</div>
{modus === "aufgabe" ? (
<div>
<label className="field-label" htmlFor="quick-project">
Projekt
</label>
{projekte.length > 0 ? (
<select
id="quick-project"
className="field"
value={projektId}
onChange={(event) => setProjektId(event.target.value)}
>
{projekte.map((projekt) => (
<option key={projekt.id} value={projekt.id}>
{projekt.name}
</option>
))}
</select>
) : (
<p className="text-sm text-slate-500">
Noch kein Projekt vorhanden – leg zuerst eine Idee an.
</p>
)}
</div>
) : null}
{fehler ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{fehler}
</p>
) : null}
{angelegt.length > 0 ? (
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/8 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-emerald-300/80">
Angelegt
</p>
<ul className="mt-1 space-y-0.5">
{angelegt.slice(0, 5).map((eintrag, index) => (
<li key={`${eintrag.href}-${index}`} className="truncate text-sm">
<Link
href={eintrag.href}
onClick={onClose}
className="text-emerald-100 underline-offset-2 hover:underline"
>
{eintrag.titel}
</Link>
<span className="ml-1.5 text-[11px] text-emerald-300/60">
{eintrag.art === "idee" ? "Projekt" : "Aufgabe"}
</span>
</li>
))}
</ul>
</div>
) : null}
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
{angelegt.length > 0 ? "Fertig" : "Abbrechen"}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={busy || !titel.trim() || (modus === "aufgabe" && !projektId)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
Anlegen
</button>
</div>
</form>
</Modal>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { BookOpen, ChevronDown, ExternalLink } from "lucide-react";
import { Markdown } from "@/components/Markdown";
import { cn } from "@/lib/utils";
/**
* Das README des verknuepften Repositories.
*
* Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
* Projekt eigentlich ging. Nicht zum Lesen ganzer Handbuecher gedacht:
* eingeklappt zeigt es die ersten Zeilen, ausgeklappt alles.
*/
export function ReadmePanel({
content,
path,
baseUrl,
fileUrl,
}: {
content: string;
path: string | null;
baseUrl: string | null;
/** Direktlink auf die Datei im Git-System. */
fileUrl: string | null;
}) {
const [offen, setOffen] = useState(false);
// Kurze READMEs vollstaendig zu zeigen ist einfacher, als sie mit einem
// Knopf zu versehen, der nichts freigibt.
const kurz = content.length < 900;
return (
<section className="glass p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<BookOpen className="h-4 w-4 text-cyan-300" />
{path ?? "README"}
</h2>
{fileUrl ? (
<a
href={fileUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-violet-300"
>
Im Git öffnen
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<div className="relative">
<div
className={cn(
"overflow-hidden transition-[max-height] duration-300",
!kurz && !offen && "max-h-72",
)}
>
<Markdown baseUrl={baseUrl}>{content}</Markdown>
</div>
{/* Verlauf ueber den abgeschnittenen Rand, damit erkennbar ist, dass
es weitergeht. Nur wenn wirklich etwas verdeckt wird. */}
{!kurz && !offen ? (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#0b0d1c] to-transparent"
/>
) : null}
</div>
{!kurz ? (
<button
type="button"
onClick={() => setOffen((wert) => !wert)}
className="btn btn-ghost mt-2 w-full text-xs"
aria-expanded={offen}
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", offen && "rotate-180")} />
{offen ? "Weniger" : "Ganzes README anzeigen"}
</button>
) : null}
</section>
);
}
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useEffect, useState } from "react";
import { BookmarkPlus, Check, Loader2 } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
/**
* Aus dem offenen Projekt eine Vorlage machen.
*
* Bewusst hier und nicht in einer eigenen Verwaltungsseite: Vorlagen
* entstehen aus einem Projekt, das sich bewaehrt hat. Sie von Grund auf zu
* tippen kann man ueber die API, taeglich braucht das niemand.
*/
export function SaveTemplateDialog({
open,
onClose,
projectId,
projectName,
noteCount,
taskCount,
}: {
open: boolean;
onClose: () => void;
projectId: string;
projectName: string;
noteCount: number;
taskCount: number;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [withNotes, setWithNotes] = useState(true);
const [withTasks, setWithTasks] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
if (!open) return;
setName(projectName);
setDescription("");
setWithNotes(true);
setWithTasks(true);
setError(null);
setDone(false);
}, [open, projectName]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
try {
const response = await fetch("/api/templates/from-project", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, name, description, withNotes, withTasks }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
setDone(true);
// Kurz stehen lassen, damit die Bestaetigung ankommt.
setTimeout(onClose, 900);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title="Als Vorlage sichern"
description="Neue Projekte starten damit mit denselben Tags, Notizen und Aufgaben."
>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="field-label" htmlFor="template-name">
Name der Vorlage *
</label>
<input
id="template-name"
className="field"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={80}
required
autoFocus
/>
</div>
<div>
<label className="field-label" htmlFor="template-description">
Wofür ist sie gut? (optional)
</label>
<input
id="template-description"
className="field"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="z. B. Kleine Web-App mit Docker und CI"
maxLength={280}
/>
</div>
<div className="space-y-2">
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={withNotes}
onChange={(event) => setWithNotes(event.target.checked)}
disabled={noteCount === 0}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Notizen übernehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
{noteCount === 0
? "Dieses Projekt hat keine Notizen."
: `${noteCount} ${noteCount === 1 ? "Notiz" : "Notizen"}`}
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={withTasks}
onChange={(event) => setWithTasks(event.target.checked)}
disabled={taskCount === 0}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Aufgaben übernehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
{taskCount === 0
? "Dieses Projekt hat keine offenen Aufgaben."
: `${taskCount} offene ${taskCount === 1 ? "Aufgabe" : "Aufgaben"} — erledigte kommen nicht mit`}
</span>
</span>
</label>
</div>
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-xs text-slate-500">
Repositories gehen <strong>nicht</strong> mit. Eine Vorlage, die jedes neue Projekt
an dasselbe Repository hängt, wäre eine Falle.
</p>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving || done}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : done ? (
<Check className="h-4 w-4" />
) : (
<BookmarkPlus className="h-4 w-4" />
)}
{done ? "Gesichert" : "Sichern"}
</button>
</div>
</form>
</Modal>
);
}
+15 -7
View File
@@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface StatCardProps {
label: string;
@@ -6,26 +7,33 @@ interface StatCardProps {
hint?: string;
icon: LucideIcon;
color: string;
className?: string;
}
export function StatCard({ label, value, hint, icon: Icon, color }: StatCardProps) {
export function StatCard({ label, value, hint, icon: Icon, color, className }: StatCardProps) {
return (
<div className="glass relative overflow-hidden p-4">
<div className={cn("glass relative overflow-hidden p-3 sm:p-4", className)}>
<div
className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-2 sm:gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
{/* Umbrechen statt abschneiden: in der zweispaltigen Anordnung auf
dem Telefon endeten "In Entwicklung" und "Repos verknuepft"
sonst beide als "In Entwickl…" – zwei Kacheln, die sich nicht
mehr unterscheiden liessen. */}
<p className="text-[11px] font-semibold uppercase leading-tight tracking-[0.14em] text-slate-500">
{label}
</p>
<p className="mt-2 text-3xl font-semibold tabular-nums text-white">{value}</p>
{hint ? <p className="mt-1 truncate text-xs text-slate-500">{hint}</p> : null}
<p className="mt-1.5 text-2xl font-semibold tabular-nums text-white sm:mt-2 sm:text-3xl">
{value}
</p>
{hint ? <p className="mt-1 text-xs leading-tight text-slate-500">{hint}</p> : null}
</div>
<span
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
className="grid h-8 w-8 shrink-0 place-items-center rounded-xl sm:h-9 sm:w-9"
style={{ backgroundColor: `${color}1f`, color }}
>
<Icon className="h-4 w-4" />
+52 -7
View File
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
CheckSquare,
GitBranch,
LayoutDashboard,
LogOut,
@@ -11,13 +12,16 @@ import {
ShieldCheck,
Sparkles,
UserRound,
Zap,
} from "lucide-react";
import { QuickCapture } from "@/components/QuickCapture";
import { Popover } from "@/components/ui/Popover";
import type { SessionUser } from "@/lib/auth/session";
import { cn } from "@/lib/utils";
const LINKS = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/tasks", label: "Aufgaben", icon: CheckSquare },
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
];
@@ -34,16 +38,20 @@ export function TopNav({
return (
<header className="sticky top-0 z-40 border-b border-white/5 bg-[#05060f]/70 backdrop-blur-xl">
<div className="mx-auto flex w-full max-w-[1400px] items-center gap-6 px-4 py-3 sm:px-6 lg:px-8">
<Link href="/" className="group flex items-center gap-3">
<div className="mx-auto flex w-full max-w-[1400px] items-center gap-3 px-4 py-3 sm:gap-6 sm:px-6 lg:px-8">
<Link href="/" className="group flex min-w-0 items-center gap-2 sm:gap-3">
<span className="relative grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400 shadow-[0_8px_28px_-10px_rgba(168,85,247,0.9)] transition-transform group-hover:scale-105">
<Sparkles className="h-4 w-4 text-white" strokeWidth={2.4} />
</span>
<span className="flex flex-col leading-none">
<span className="text-gradient text-lg font-semibold tracking-tight">
<span className="flex min-w-0 flex-col leading-none">
<span className="text-gradient truncate text-lg font-semibold tracking-tight">
{appName}
</span>
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500">
{/* Der weite Zeichenabstand kostet mehr Platz, als die Zeile wert
ist. Seit die Aufgaben als dritter Punkt danebenstehen, brach
sie auf 375px mitten im Wort ab – unterhalb von sm bleibt
deshalb nur der Name. */}
<span className="mt-0.5 hidden truncate text-[10px] uppercase tracking-[0.2em] text-slate-500 sm:block">
{tagline}
</span>
</span>
@@ -51,13 +59,20 @@ export function TopNav({
{user ? (
<>
<nav className="flex items-center gap-1">
<nav className="flex shrink-0 items-center gap-1">
{LINKS.map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
// Unterhalb von sm bleibt nur das Symbol stehen. Ohne
// eigene Beschriftung ist der Verweis dann fuer einen
// Screenreader namenlos – axe-core meldet das als
// "link-name", und zwar zu Recht: vorgelesen wird nichts.
aria-label={label}
title={label}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
active
@@ -72,7 +87,8 @@ export function TopNav({
})}
</nav>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-2">
<QuickCaptureButton />
<UserMenu user={user} />
</div>
</>
@@ -82,6 +98,35 @@ export function TopNav({
);
}
/**
* Der Einstieg in die Schnellerfassung sitzt in der Kopfleiste, nicht als
* schwebender Knopf unten rechts: dort waere er auf schmalen Anzeigen mit
* der Auswahlleiste kollidiert, die beim Markieren von Projekten einfaehrt.
* In der Kopfleiste ist er auf jeder Seite erreichbar.
*/
function QuickCaptureButton() {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="btn btn-ghost px-2.5 py-1.5 sm:px-3"
// Unterhalb von sm bleibt nur das Symbol – ohne Beschriftung waere
// der Knopf fuer einen Screenreader namenlos.
aria-label="Schnell erfassen"
title="Schnell erfassen – Idee oder Aufgabe"
>
<Zap className="h-4 w-4" />
<span className="hidden lg:inline">Erfassen</span>
</button>
<QuickCapture open={open} onClose={() => setOpen(false)} />
</>
);
}
function UserMenu({ user }: { user: SessionUser }) {
const router = useRouter();
const [open, setOpen] = useState(false);
+13 -2
View File
@@ -1,7 +1,8 @@
"use client";
import { AlignLeft, CalendarClock, GripVertical } from "lucide-react";
import { AlignLeft, CalendarClock, GripVertical, Repeat } from "lucide-react";
import type { Task } from "@prisma/client";
import { RECURRENCE_META } from "@/lib/tasks";
import { cn, formatDate } from "@/lib/utils";
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
@@ -69,12 +70,22 @@ export function TaskCard({
{task.title}
</p>
{(task.description || task.dueDate || task.labels.length > 0) && (
{(task.description || task.dueDate || task.recurrence || task.labels.length > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{task.description ? (
<AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" />
) : null}
{task.recurrence ? (
<span
className="inline-flex items-center gap-1 text-slate-500"
title={`Wiederholt sich: ${RECURRENCE_META[task.recurrence].label}`}
>
<Repeat className="h-2.5 w-2.5" />
{RECURRENCE_META[task.recurrence].short}
</span>
) : null}
{task.dueDate ? (
<span className={cn("inline-flex items-center gap-1", due?.className)}>
<CalendarClock className="h-2.5 w-2.5" />
+31 -2
View File
@@ -3,9 +3,9 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client";
import type { Recurrence, Task, TaskStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn, parseTags } from "@/lib/utils";
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
@@ -36,6 +36,7 @@ export function TaskDialog({
const [status, setStatus] = useState<TaskStatus>(initialStatus);
const [dueDate, setDueDate] = useState("");
const [labels, setLabels] = useState("");
const [recurrence, setRecurrence] = useState<string>("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -48,6 +49,7 @@ export function TaskDialog({
setStatus(task?.status ?? initialStatus);
setDueDate(toDateInput(task?.dueDate ?? null));
setLabels(task?.labels.join(", ") ?? "");
setRecurrence(task?.recurrence ?? "");
setError(null);
}, [open, task, initialStatus]);
@@ -64,6 +66,7 @@ export function TaskDialog({
// Datum nicht auf den Vortag ziehen.
dueDate: dueDate ? `${dueDate}T12:00:00` : null,
labels: parseTags(labels),
recurrence: recurrence || null,
};
try {
@@ -194,6 +197,32 @@ export function TaskDialog({
placeholder="bug, ui"
/>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="task-recurrence">
Wiederholung
</label>
<select
id="task-recurrence"
className="field"
value={recurrence}
onChange={(event) => setRecurrence(event.target.value)}
>
<option value="">– einmalig –</option>
{(Object.keys(RECURRENCE_META) as Recurrence[]).map((key) => (
<option key={key} value={key}>
{RECURRENCE_META[key].label}
</option>
))}
</select>
{recurrence ? (
<p className="mt-1 text-xs text-slate-500">
Sobald die Aufgabe nach „Erledigt“ wandert, entsteht die nächste. Gerechnet
wird vom Fälligkeitsdatum, nicht vom Abhaken — eine wöchentliche Aufgabe
bleibt also auf ihrem Wochentag.
</p>
) : null}
</div>
</div>
{error ? (
+370
View File
@@ -0,0 +1,370 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
CalendarClock,
CheckSquare,
ExternalLink,
Loader2,
Plus,
Repeat,
Square,
} from "lucide-react";
import type { TaskStatus } from "@prisma/client";
import { QuickCapture } from "@/components/QuickCapture";
import {
DUE_BUCKET_META,
DUE_BUCKET_ORDER,
RECURRENCE_META,
TASK_STATUS_META,
TASK_STATUS_ORDER,
} from "@/lib/tasks";
import { cn, formatDate } from "@/lib/utils";
import type { TaskOverviewItem } from "@/types";
/** Wo Filter und Schalter im Browser liegen. */
const ANSICHT_SCHLUESSEL = "nebula.tasks";
/**
* Aufgaben ueber alle Projekte hinweg.
*
* Geordnet wird nach Faelligkeit, nicht nach Spalte: projektuebergreifend
* lautet die Frage "was ist ueberfaellig", nicht "was steht wo im Brett".
* Aus demselben Grund laesst sich hier bewusst **nichts ziehen** – die
* Position einer Aufgabe gilt innerhalb ihrer Spalte in ihrem Projekt und
* waere hier ohne Bedeutung. Eine Reihenfolge anzubieten, die nirgends
* ankommt, waere ein Versprechen, das die Seite nicht halten kann.
*/
export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
const router = useRouter();
const [statusFilter, setStatusFilter] = useState<TaskStatus[]>([]);
const [projektFilter, setProjektFilter] = useState("");
const [zeigeErledigte, setZeigeErledigte] = useState(false);
const [erfassenOffen, setErfassenOffen] = useState(false);
const [laufend, setLaufend] = useState<Set<string>>(new Set());
const projekte = useMemo(() => {
const map = new Map<string, string>();
for (const task of tasks) map.set(task.project.id, task.project.name);
return [...map.entries()]
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, "de"));
}, [tasks]);
const zaehler = useMemo(() => {
const map = new Map<TaskStatus, number>();
for (const task of tasks) map.set(task.status, (map.get(task.status) ?? 0) + 1);
return map;
}, [tasks]);
const sichtbar = useMemo(() => {
return tasks.filter((task) => {
// Erledigtes fuellt die Liste sonst mit dem, was gerade nicht ansteht.
if (!zeigeErledigte && task.status === "DONE" && !statusFilter.includes("DONE")) {
return false;
}
if (statusFilter.length > 0 && !statusFilter.includes(task.status)) return false;
if (projektFilter && task.project.id !== projektFilter) return false;
return true;
});
}, [tasks, statusFilter, projektFilter, zeigeErledigte]);
const faecher = useMemo(() => {
return DUE_BUCKET_ORDER.map((bucket) => ({
bucket,
items: sichtbar.filter((task) => task.bucket === bucket),
})).filter((fach) => fach.items.length > 0);
}, [sichtbar]);
const offen = sichtbar.filter((task) => task.status !== "DONE").length;
const ueberfaellig = sichtbar.filter(
(task) => task.bucket === "overdue" && task.status !== "DONE",
).length;
function toggleStatus(status: TaskStatus) {
setStatusFilter((current) =>
current.includes(status)
? current.filter((entry) => entry !== status)
: [...current, status],
);
}
/**
* Abhaken direkt aus der Uebersicht. Der Endpunkt erledigt dabei den Rest:
* doneAt, abgeleiteter Fortschritt und – bei wiederkehrenden Aufgaben –
* die naechste Fassung.
*/
async function abhaken(task: TaskOverviewItem) {
const ziel: TaskStatus = task.status === "DONE" ? "TODO" : "DONE";
setLaufend((current) => new Set(current).add(task.id));
try {
const antwort = await fetch(`/api/tasks/${task.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: ziel }),
});
if (!antwort.ok) throw new Error("fehlgeschlagen");
router.refresh();
} catch {
// Beim naechsten Laden steht wieder der echte Stand da.
} finally {
setLaufend((current) => {
const next = new Set(current);
next.delete(task.id);
return next;
});
}
}
return (
<div className="space-y-5">
<header className="fade-up flex flex-wrap items-end justify-between gap-3 pt-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Über alle Projekte
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
Aufgaben
</h1>
<p className="mt-1.5 text-sm text-slate-400">
{offen} offen
{ueberfaellig > 0 ? (
<>
{" · "}
<span className="text-rose-300">{ueberfaellig} überfällig</span>
</>
) : null}
</p>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => setErfassenOffen(true)}
aria-label="Neue Aufgabe"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Neue Aufgabe</span>
</button>
</header>
{/* Steuerleiste */}
<div className="glass flex flex-col gap-3 p-3 sm:p-4">
<div className="flex flex-wrap items-center gap-2">
{TASK_STATUS_ORDER.map((status) => {
const meta = TASK_STATUS_META[status];
const aktiv = statusFilter.includes(status);
return (
<button
key={status}
type="button"
onClick={() => toggleStatus(status)}
aria-pressed={aktiv}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ring-1 ring-inset transition-all",
aktiv
? meta.className
: "text-slate-400 ring-white/10 hover:bg-white/5 hover:text-slate-200",
)}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color, opacity: aktiv ? 1 : 0.55 }}
/>
{meta.label}
<span className="tabular-nums text-slate-500">{zaehler.get(status) ?? 0}</span>
</button>
);
})}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="sr-only" htmlFor="task-project-filter">
Projekt
</label>
<select
id="task-project-filter"
className="field w-auto min-w-0 sm:min-w-[14rem] sm:flex-none"
value={projektFilter}
onChange={(event) => setProjektFilter(event.target.value)}
>
<option value="">Alle Projekte</option>
{projekte.map((projekt) => (
<option key={projekt.id} value={projekt.id}>
{projekt.name}
</option>
))}
</select>
<label className="flex cursor-pointer items-center gap-2 text-xs text-slate-500 sm:ml-auto">
<input
type="checkbox"
checked={zeigeErledigte}
onChange={(event) => setZeigeErledigte(event.target.checked)}
className="h-4 w-4 accent-violet-500"
/>
Erledigte einblenden
</label>
</div>
</div>
{faecher.length === 0 ? (
<div className="glass fade-up flex flex-col items-center justify-center gap-4 px-6 py-16 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<CheckSquare className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">
{tasks.length === 0 ? "Noch keine Aufgaben" : "Nichts offen"}
</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
{tasks.length === 0
? "Aufgaben entstehen im Projekt oder hier über „Neue Aufgabe“."
: "Alles abgehakt, was zu den Filtern passt."}
</p>
</div>
</div>
) : (
<div className="space-y-6">
{faecher.map((fach) => {
const meta = DUE_BUCKET_META[fach.bucket];
return (
<section key={fach.bucket}>
<div className="mb-2.5 flex items-center gap-2.5">
<span
className="accent-dot h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color, color: meta.color }}
/>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{meta.label}
</h2>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{fach.items.length}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-white/10 to-transparent" />
</div>
<ul className="glass divide-y divide-white/6 overflow-hidden">
{fach.items.map((task) => (
<TaskRow
key={task.id}
task={task}
busy={laufend.has(task.id)}
onToggle={() => abhaken(task)}
/>
))}
</ul>
</section>
);
})}
</div>
)}
<QuickCapture
open={erfassenOffen}
onClose={() => setErfassenOffen(false)}
initialMode="aufgabe"
/>
</div>
);
}
function TaskRow({
task,
busy,
onToggle,
}: {
task: TaskOverviewItem;
busy: boolean;
onToggle: () => void;
}) {
const meta = TASK_STATUS_META[task.status];
const erledigt = task.status === "DONE";
return (
<li className="flex items-center gap-2.5 px-3 py-2.5 transition-colors hover:bg-white/4 sm:gap-3 sm:px-4">
<button
type="button"
onClick={onToggle}
disabled={busy}
aria-label={erledigt ? `„${task.title}“ wieder öffnen` : `„${task.title}“ abhaken`}
className="shrink-0 rounded-lg p-1.5 text-slate-500 transition-colors hover:text-emerald-300 disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : erledigt ? (
<CheckSquare className="h-4 w-4 text-emerald-300" />
) : (
<Square className="h-4 w-4" />
)}
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className={cn(
"truncate text-sm",
erledigt ? "text-slate-500 line-through" : "text-slate-100",
)}
>
{task.title}
</span>
{task.recurrence ? (
<Repeat
className="h-3 w-3 shrink-0 text-slate-500"
aria-label={RECURRENCE_META[task.recurrence].label}
/>
) : null}
{task.sourceUrl ? (
<a
href={task.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-slate-500 transition-colors hover:text-violet-300"
aria-label="Zugehöriges Issue öffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
{/* Das Projekt gehoert in jede Zeile – ohne es ist eine
projektuebergreifende Liste nicht zu lesen. */}
<Link
href={`/projects/${task.project.id}`}
className="max-w-[10rem] truncate text-slate-400 underline-offset-2 hover:text-violet-300 hover:underline"
>
{task.project.name}
</Link>
<span
className={cn("inline-flex items-center gap-1 rounded-full px-1.5 ring-1 ring-inset", meta.className)}
>
{meta.label}
</span>
{task.dueDate ? (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1",
task.bucket === "overdue" && !erledigt && "text-rose-300",
)}
>
<CalendarClock className="h-3 w-3" />
{formatDate(task.dueDate)}
</span>
) : null}
{task.labels.slice(0, 2).map((label) => (
<span key={label} className="hidden rounded bg-white/5 px-1.5 text-slate-400 xs:inline">
#{label}
</span>
))}
</div>
</div>
</li>
);
}
+2 -2
View File
@@ -53,7 +53,7 @@ export function Modal({
if (!mounted || !open) return null;
return createPortal(
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-4 sm:p-8">
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-3 sm:p-8">
<button
type="button"
aria-label="Dialog schliessen"
@@ -65,7 +65,7 @@ export function Modal({
aria-modal="true"
aria-label={title}
className={cn(
"popover fade-up relative z-10 my-auto w-full rounded-2xl p-6",
"popover fade-up relative z-10 my-auto w-full rounded-2xl p-4 sm:p-6",
width === "md" && "max-w-lg",
width === "lg" && "max-w-2xl",
width === "xl" && "max-w-4xl",
+15 -6
View File
@@ -44,7 +44,11 @@ export function Popover({
}) {
const panelRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const [position, setPosition] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
useEffect(() => setMounted(true), []);
@@ -56,14 +60,19 @@ export function Popover({
const margin = 8;
const gap = 6;
// Passt die Wunschbreite nicht ins Fenster, gilt das Fenster. Sonst
// liefert die Klemmung unten eine negative linke Kante und das Menue
// stuende zur Haelfte ausserhalb des Bildschirms.
const effektiv = Math.min(width, window.innerWidth - 2 * margin);
// Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln.
// Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit
// links stuende das Menue sonst quer ueber der Seite statt an seinem
// Ausloeser.
let left = align === "end" ? rect.right - width : rect.left;
let left = align === "end" ? rect.right - effektiv : rect.left;
if (left < margin) left = rect.left;
if (left + width > window.innerWidth - margin) left = rect.right - width;
left = Math.min(Math.max(margin, left), window.innerWidth - width - margin);
if (left + effektiv > window.innerWidth - margin) left = rect.right - effektiv;
left = Math.min(Math.max(margin, left), window.innerWidth - effektiv - margin);
const panelHeight = panelRef.current?.offsetHeight ?? 0;
let top = rect.bottom + gap;
@@ -73,7 +82,7 @@ export function Popover({
const fitsAbove = rect.top - gap - panelHeight >= margin;
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
setPosition({ top, left });
setPosition({ top, left, width: effektiv });
}, [anchorRef, align, width]);
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
@@ -127,7 +136,7 @@ export function Popover({
position: "fixed",
top: position?.top ?? -9999,
left: position?.left ?? -9999,
width,
width: position?.width ?? width,
// Bis die Position steht, unsichtbar halten – sonst blitzt das
// Menue kurz an der falschen Stelle auf.
visibility: position ? "visible" : "hidden",
+3
View File
@@ -75,6 +75,8 @@ export interface PendingLoginState {
isActive: boolean;
/** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */
totpSecret: string | null;
/** Zuletzt eingeloestes Zeitfenster – gegen die Wiederverwendung. */
totpLastCounter: number | null;
};
}
@@ -104,6 +106,7 @@ export async function readPendingLogin(): Promise<PendingLoginState | null> {
role: pending.user.role,
isActive: pending.user.isActive,
totpSecret: tryDecryptSecret(pending.user.totpSecretCipher),
totpLastCounter: pending.user.totpLastCounter,
},
};
}
+31
View File
@@ -6,6 +6,7 @@ import {
counterFor,
generateTotpSecret,
groupSecret,
matchTotp,
otpauthUri,
totpCode,
verifyTotp,
@@ -124,3 +125,33 @@ describe("Einrichtung", () => {
expect(groupSecret("ABCDEFGH")).toBe("ABCD EFGH");
});
});
describe("matchTotp", () => {
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
const JETZT = new Date(1111111109_000);
/**
* Der Zaehler ist noetig, um einen verbrauchten Code zu entwerten. RFC 6238
* verlangt das in Abschnitt 5.2 ausdruecklich – sonst laesst sich ein
* abgefangener Code die restliche Gueltigkeitsdauer ueber wiederverwenden.
*/
it("gibt das Zeitfenster zurueck, das gepasst hat", () => {
expect(matchTotp(SECRET, "081804", JETZT)).toBe(37037036);
});
it("nennt bei den Nachbarfenstern deren eigene Nummer", () => {
// Genau daran haengt die Entwertung: der Code aus dem vorigen Fenster
// darf den Merker nicht auf einen hoeheren Stand setzen.
const naechstes = new Date(JETZT.getTime() + TOTP_PERIOD * 1000);
expect(matchTotp(SECRET, "081804", naechstes)).toBe(37037036);
});
it("liefert null bei falschem Code", () => {
expect(matchTotp(SECRET, "000000", JETZT)).toBeNull();
});
it("stimmt mit verifyTotp ueberein", () => {
expect(matchTotp(SECRET, "081804", JETZT) !== null).toBe(verifyTotp(SECRET, "081804", JETZT));
expect(matchTotp(SECRET, "000000", JETZT) !== null).toBe(verifyTotp(SECRET, "000000", JETZT));
});
});
+31 -13
View File
@@ -102,26 +102,44 @@ export function counterFor(at: Date = new Date()): number {
}
/**
* Prueft einen eingegebenen Code. Vergleicht in konstanter Zeit und probiert
* die Nachbarfenster mit, damit eine leicht abweichende Uhr nicht stoert.
* Prueft einen eingegebenen Code und gibt das Zeitfenster zurueck, das
* gepasst hat – oder null.
*
* Das Fenster wird gebraucht, um einen verbrauchten Code zu entwerten:
* RFC 6238 verlangt in Abschnitt 5.2 ausdruecklich, dass derselbe Code kein
* zweites Mal angenommen wird. Ohne diese Zahl liesse sich ein abgefangener
* Code die restliche Gueltigkeitsdauer ueber wiederverwenden.
*
* Verglichen wird in konstanter Zeit, und die Nachbarfenster werden
* mitprobiert, damit eine leicht abweichende Uhr nicht stoert.
*/
export function matchTotp(
secret: string,
code: string,
at: Date = new Date(),
window: number = TOTP_WINDOW,
): number | null {
const eingabe = code.replace(/\s/g, "");
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return null;
const jetzt = counterFor(at);
let treffer: number | null = null;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = jetzt + schritt;
}
return treffer;
}
/** Wie matchTotp, wenn nur interessiert *ob* der Code stimmt. */
export function verifyTotp(
secret: string,
code: string,
at: Date = new Date(),
window: number = TOTP_WINDOW,
): boolean {
const eingabe = code.replace(/\s/g, "");
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return false;
const jetzt = counterFor(at);
let treffer = false;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = true;
}
return treffer;
return matchTotp(secret, code, at, window) !== null;
}
function constantEquals(a: string, b: string): boolean {
+192
View File
@@ -41,6 +41,198 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
};
export const CHANGELOG: Release[] = [
{
version: "1.6.0",
date: "2026-08-14",
headline: "Unterwegs erfassen — und eine Oberfläche, die aufs Telefon passt.",
changes: [
{
kind: "behoben",
text:
"Auf dem Telefon war das Dashboard breiter als der Bildschirm: die Steuerleiste " +
"konnte nicht umbrechen und schob den Knopf „Neues Projekt“ aus dem Bild. " +
"Dasselbe im Projektkopf, wo „Löschen“ außerhalb lag. Beide Zeilen brechen " +
"jetzt um.",
},
{
kind: "neu",
text:
"Schnellerfassung: ein Blitzsymbol in der Kopfleiste, auf jeder Seite " +
"erreichbar. Ein Feld, Enter, fertig — als Idee entsteht ein Projekt, als " +
"Aufgabe eine Aufgabe im gewählten Projekt. Der Dialog bleibt danach offen, " +
"weil auf eine Idee meist gleich die nächste folgt.",
},
{
kind: "neu",
text:
"Eine Aufgabenseite über alle Projekte hinweg, geordnet nach Fälligkeit: " +
"überfällig, heute, diese Woche, später, ohne Termin. Abhaken geht direkt " +
"aus der Liste.",
},
{
kind: "neu",
text:
"Vierte Ansicht im Dashboard: eine kompakte Liste. Auf dem Telefon passen " +
"damit fünf Projekte auf einen Bildschirm statt auf vier.",
},
{
kind: "besser",
text:
"Eingabefelder sind auf Berührungsgeräten 16 Pixel groß — darunter zoomt " +
"iOS Safari beim Hineintippen die Seite heran und kehrt nicht von selbst " +
"zurück. Knöpfe und der Fortschrittsregler sind mit dem Finger zu treffen.",
},
{
kind: "behoben",
text:
"Die Verweise in der Kopfleiste und „Neues Projekt“ sind auf schmalen " +
"Anzeigen reine Symbole und hießen für einen Screenreader gar nichts. " +
"Jetzt tragen sie eine Beschriftung.",
},
{
kind: "behoben",
text:
"Das Tag-Formular der Auswahlleiste war breiter als sein Menü und wurde " +
"beschnitten — auf jeder Bildschirmbreite.",
},
],
},
{
version: "1.5.0",
date: "2026-07-27",
headline: "Sicherheitsdurchsicht und eine Anleitung für den Server.",
changes: [
{
kind: "behoben",
text:
"Ein Einmalkennwort ließ sich innerhalb seiner Gültigkeit mehrfach verwenden. " +
"Das eingelöste Zeitfenster wird jetzt gemerkt — RFC 6238 verlangt das " +
"ausdrücklich.",
},
{
kind: "behoben",
text:
"Der Bildoptimierer von Next war ohne Anmeldung erreichbar und reichte Bilder " +
"durch libvips, für das es offene Schwachstellen gibt. Nebula benutzt ihn " +
"nirgends — jetzt ist er abgeschaltet.",
},
{
kind: "neu",
text:
"DEPLOYMENT.md: Schritt für Schritt vom leeren Server bis zur Instanz hinter " +
"TLS, mit Firewall, Sicherungen und Betrieb.",
},
{
kind: "neu",
text:
"scripts/update.sh rollt ein Update aus: prüfen, holen, sichern, bauen, " +
"starten, Gesundheit prüfen — und nennt bei einem Fehlschlag den Weg zurück.",
},
{
kind: "besser",
text:
"Umsortieren im Brett und das Anlegen von Vorlagen sind jetzt ebenfalls " +
"ratenbegrenzt.",
},
{
kind: "besser",
text:
"Die Bereinigung des Markdowns ist mit elf Tests festgehalten: Skripte, " +
"Ereignis-Attribute, javascript: und data: kommen nicht durch.",
},
],
},
{
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",
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import {
CALENDAR_MAX_PAGES,
CALENDAR_PAGE_SIZE,
calendarByPaging,
calendarSince,
calendarTotal,
dayKey,
} from "./calendar";
/**
* Der Kalender darf ueber den Zeitraum nicht luegen. Reichen die geholten
* Seiten nur ein paar Monate zurueck, faengt die Heatmap dort an – sie zeigt
* dann keine leeren Felder fuer Zeiten, ueber die schlicht nichts bekannt ist.
* Genau diesen Fehler hatte die Verlaufslinie schon einmal.
*/
const JETZT = new Date("2026-07-26T12:00:00.000Z");
const TAG = 86_400_000;
/** Erzeugt n Commit-Zeitpunkte, beginnend vor `abTagen` Tagen, einer je Tag. */
function commits(anzahl: number, abTagen: number): string[] {
return Array.from({ length: anzahl }, (_, index) =>
new Date(JETZT.getTime() - (abTagen + index) * TAG).toISOString(),
);
}
describe("dayKey", () => {
it("liefert das Datum ohne Uhrzeit", () => {
expect(dayKey(new Date("2026-07-26T23:59:00.000Z"))).toBe("2026-07-26");
});
});
describe("calendarSince", () => {
it("liegt ein Jahr zurueck", () => {
const seit = calendarSince(JETZT);
expect(Math.round((JETZT.getTime() - seit.getTime()) / TAG)).toBe(365);
});
});
describe("calendarByPaging", () => {
it("liefert null, wenn es keine Commits gibt", async () => {
expect(await calendarByPaging(async () => [], JETZT)).toBeNull();
});
it("zaehlt mehrere Commits desselben Tages zusammen", async () => {
const heute = JETZT.toISOString();
const kalender = await calendarByPaging(async () => [heute, heute, heute], JETZT);
expect(kalender?.days["2026-07-26"]).toBe(3);
expect(calendarTotal(kalender!)).toBe(3);
});
it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => {
// Nur eine halbe Seite, zehn Tage zurueck: alles davor ist unbekannt.
const kalender = await calendarByPaging(async () => commits(10, 0), JETZT);
expect(kalender?.from).toBe("2026-07-17");
expect(kalender?.to).toBe("2026-07-26");
expect(kalender?.truncated).toBe(false);
});
it("hoert auf, sobald eine Seite nicht mehr voll ist", async () => {
const ladeSeite = vi.fn(async (seite: number) =>
seite === 1 ? commits(CALENDAR_PAGE_SIZE, 0) : commits(5, CALENDAR_PAGE_SIZE),
);
await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(2);
});
it("deckelt die Zahl der Seiten und sagt es", async () => {
// Immer volle Seiten innerhalb des Zeitraums: ohne Deckel liefe das ewig.
const ladeSeite = vi.fn(async () =>
Array.from({ length: CALENDAR_PAGE_SIZE }, () =>
new Date(JETZT.getTime() - 3 * TAG).toISOString(),
),
);
const kalender = await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(CALENDAR_MAX_PAGES);
expect(kalender?.truncated).toBe(true);
});
it("bricht ab, wenn eine ganze Seite aelter ist als der Zeitraum", async () => {
const ladeSeite = vi.fn(async (seite: number) =>
seite === 1
? commits(CALENDAR_PAGE_SIZE, 0)
: Array.from({ length: CALENDAR_PAGE_SIZE }, () =>
new Date(JETZT.getTime() - 400 * TAG).toISOString(),
),
);
await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(2);
});
it("uebergeht Commits mit Datum in der Zukunft", async () => {
// Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern.
const kalender = await calendarByPaging(
async () => [
new Date(JETZT.getTime() + 5 * TAG).toISOString(),
JETZT.toISOString(),
],
JETZT,
);
expect(kalender?.to).toBe("2026-07-26");
expect(calendarTotal(kalender!)).toBe(1);
});
it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => {
const kalender = await calendarByPaging(
async () => [null, "gestern", "", JETZT.toISOString()],
JETZT,
);
expect(calendarTotal(kalender!)).toBe(1);
});
it("laesst Tage ohne Commits weg", async () => {
// Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen.
const kalender = await calendarByPaging(
async () => [JETZT.toISOString(), new Date(JETZT.getTime() - 30 * TAG).toISOString()],
JETZT,
);
expect(Object.keys(kalender!.days)).toHaveLength(2);
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Commits je Tag ueber ein Jahr – die Grundlage der Heatmap.
*
* Getrennt vom uebrigen Abgleich, weil es zusaetzliche Aufrufe kostet: der
* Kalender wird hoechstens einmal am Tag geholt. Von Stunde zu Stunde
* aendert sich an einem Jahresueberblick ohnehin nichts Sichtbares.
*
* Wichtig ist die Ehrlichkeit ueber den Zeitraum. Wo es keine
* Jahresstatistik gibt, reichen die Daten nur so weit zurueck wie die
* abgerufenen Seiten – dann faengt die Heatmap dort an und tut nicht so, als
* waere ein Jahr lang nichts passiert. Diesen Fehler hatte die Verlaufslinie
* schon einmal.
*/
/** Wie weit die Heatmap hoechstens zurueckreicht. */
export const CALENDAR_DAYS = 365;
/**
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Fuenf Seiten sind
* 500 Commits – genug fuer die allermeisten Repositories und wenig genug,
* um kein fremdes Kontingent zu sprengen.
*/
export const CALENDAR_MAX_PAGES = 5;
export const CALENDAR_PAGE_SIZE = 100;
export interface CommitCalendar {
/** Erster Tag, fuer den wirklich Daten vorliegen (yyyy-mm-dd). */
from: string;
/** Letzter beruecksichtigter Tag (yyyy-mm-dd). */
to: string;
/** Nur Tage mit Commits: { "2026-07-26": 3 }. Die Nullen ergaenzt die Anzeige. */
days: Record<string, number>;
/**
* "stats" = Jahresstatistik des Anbieters, der Zeitraum ist vollstaendig.
* "commits" = so weit, wie die Seiten zurueckreichten.
*/
source: "stats" | "commits";
/** Der Deckel hat gegriffen; aeltere Commits fehlen. */
truncated: boolean;
}
/** Tag im Format yyyy-mm-dd, in UTC – so liefern es die Anbieter. */
export function dayKey(value: Date): string {
return value.toISOString().slice(0, 10);
}
/** Der Tag, ab dem der Kalender hoechstens reicht. */
export function calendarSince(now: Date = new Date()): Date {
return new Date(now.getTime() - CALENDAR_DAYS * 86_400_000);
}
/**
* Holt seitenweise Commit-Zeitpunkte und zaehlt sie zu Tagen zusammen.
*
* Abgebrochen wird, sobald eine Seite nicht mehr voll ist (dann gibt es
* nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem
* Zeitraum stammt, oder wenn der Deckel greift.
*/
export async function calendarByPaging(
ladeSeite: (seite: number) => Promise<Array<string | null>>,
now: Date = new Date(),
): Promise<CommitCalendar | null> {
const grenze = calendarSince(now).getTime();
const days: Record<string, number> = {};
let aeltester: number | null = null;
let gedeckelt = false;
for (let seite = 1; seite <= CALENDAR_MAX_PAGES; seite += 1) {
const daten = await ladeSeite(seite);
if (daten.length === 0) break;
let innerhalb = 0;
for (const value of daten) {
if (!value) continue;
const time = Date.parse(value);
if (Number.isNaN(time)) continue;
// Ein Commit-Datum in der Zukunft kommt vor – falsch gestellte Uhren.
if (time > now.getTime()) continue;
if (time < grenze) continue;
innerhalb += 1;
const key = dayKey(new Date(time));
days[key] = (days[key] ?? 0) + 1;
if (aeltester === null || time < aeltester) aeltester = time;
}
// Nicht mehr voll: das war die letzte Seite.
if (daten.length < CALENDAR_PAGE_SIZE) break;
// Die ganze Seite lag schon vor dem Zeitraum – weiter zurueck lohnt nicht.
if (innerhalb === 0) break;
if (seite === CALENDAR_MAX_PAGES) gedeckelt = true;
}
if (aeltester === null) return null;
return {
from: dayKey(new Date(aeltester)),
to: dayKey(now),
days,
source: "commits",
truncated: gedeckelt,
};
}
/** Wie viele Commits der Kalender insgesamt kennt. */
export function calendarTotal(calendar: CommitCalendar): number {
return Object.values(calendar.days).reduce((sum, value) => sum + value, 0);
}
+65
View File
@@ -1,4 +1,11 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import {
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 {
+92
View File
@@ -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
View File
@@ -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 {
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { README_MAX_CHARS, capReadme, decodeBase64, pickReadme } from "./readme";
describe("pickReadme", () => {
it("findet die uebliche Schreibweise", () => {
expect(pickReadme(["src", "README.md", "package.json"])).toBe("README.md");
});
it("ist bei der Gross- und Kleinschreibung nachsichtig", () => {
expect(pickReadme(["readme.md"])).toBe("readme.md");
expect(pickReadme(["ReadMe.MD"])).toBe("ReadMe.MD");
});
it("zieht Markdown den uebrigen Formaten vor", () => {
// Beides kommt vor; nur das erste laesst sich wirklich darstellen.
expect(pickReadme(["README.rst", "README.md"])).toBe("README.md");
});
it("nimmt auch ein README ohne Endung", () => {
expect(pickReadme(["LICENSE", "README"])).toBe("README");
});
it("liefert null, wenn keines dabei ist", () => {
expect(pickReadme(["src", "package.json"])).toBeNull();
expect(pickReadme([])).toBeNull();
});
it("verwechselt Aehnliches nicht mit dem README", () => {
expect(pickReadme(["README-CONTRIBUTING.md", "readme.md.bak"])).toBeNull();
});
});
describe("capReadme", () => {
it("laesst Kurzes unangetastet", () => {
const { content, truncated } = capReadme("# Titel\n\nEin Satz.");
expect(content).toBe("# Titel\n\nEin Satz.");
expect(truncated).toBe(false);
});
it("kuerzt Langes an einer Zeilengrenze", () => {
// Sonst bliebe womoeglich ein halber Codeblock stehen.
const zeile = "x".repeat(80);
const lang = Array.from({ length: 2000 }, () => zeile).join("\n");
const { content, truncated } = capReadme(lang);
expect(truncated).toBe(true);
expect(content.length).toBeLessThanOrEqual(README_MAX_CHARS);
expect(content.endsWith("x")).toBe(true);
expect(content.split("\n").at(-1)).toBe(zeile);
});
it("kuerzt notfalls hart, wenn es gar keine Zeilengrenze gibt", () => {
// Eine einzige riesige Zeile: hier waere der letzte Umbruch bei -1.
const { content, truncated } = capReadme("y".repeat(README_MAX_CHARS + 500));
expect(truncated).toBe(true);
expect(content).toHaveLength(README_MAX_CHARS);
});
});
describe("decodeBase64", () => {
it("dekodiert UTF-8 richtig", () => {
// Ueber Bytes gedacht kaeme hier Kauderwelsch heraus.
const kodiert = Buffer.from("# Nebula – Übersicht", "utf8").toString("base64");
expect(decodeBase64(kodiert)).toBe("# Nebula – Übersicht");
});
it("uebergeht Zeilenumbrueche in der Kodierung", () => {
// GitHub bricht den base64-Block alle 60 Zeichen um.
const roh = Buffer.from("Hallo Welt", "utf8").toString("base64");
const umgebrochen = `${roh.slice(0, 4)}\n${roh.slice(4)}`;
expect(decodeBase64(umgebrochen)).toBe("Hallo Welt");
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Das README eines Repositories – gemeinsame Teile fuer alle drei Anbieter.
*
* Gezeigt wird es auf der Projektseite, damit man nicht ins Git wechseln
* muss, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging.
*/
export interface RepoReadme {
/** Roher Markdown-Text. */
content: string;
/** Dateiname im Repository, etwa "README.md". */
path: string;
/**
* Basis, gegen die relative Links im Text aufzuloesen sind – die
* Web-Ansicht des Repositories im Verzeichnis des READMEs.
*/
baseUrl: string | null;
/** Wurde am Deckel abgeschnitten? */
truncated: boolean;
}
/**
* Hoechstlaenge. Ein README von einem Megabyte gibt es, und es waere in
* einer Vorschau ohnehin unlesbar – abgeschnitten wird an einer
* Zeilengrenze, damit kein halber Codeblock stehen bleibt.
*/
export const README_MAX_CHARS = 60_000;
/**
* Dateinamen, die als README gelten – in dieser Reihenfolge. Markdown zuerst,
* weil nur das dargestellt werden kann; die uebrigen landen als Klartext in
* einem Codeblock, was immer noch besser ist als nichts.
*/
const KANDIDATEN = [
"readme.md",
"readme.markdown",
"readme.mdown",
"readme",
"readme.txt",
"readme.rst",
];
/**
* Sucht aus einer Verzeichnisauflistung die README-Datei heraus.
* Gross- und Kleinschreibung ist egal; die Reihenfolge oben entscheidet.
*/
export function pickReadme(namen: string[]): string | null {
const klein = new Map(namen.map((name) => [name.toLowerCase(), name]));
for (const kandidat of KANDIDATEN) {
const treffer = klein.get(kandidat);
if (treffer) return treffer;
}
return null;
}
/** Kuerzt am Deckel, aber an einer Zeilengrenze. */
export function capReadme(text: string): { content: string; truncated: boolean } {
if (text.length <= README_MAX_CHARS) return { content: text, truncated: false };
const roh = text.slice(0, README_MAX_CHARS);
const letzterUmbruch = roh.lastIndexOf("\n");
return {
// Nur wenn der Umbruch nicht ganz am Anfang liegt – sonst bliebe fast
// nichts uebrig.
content: letzterUmbruch > README_MAX_CHARS / 2 ? roh.slice(0, letzterUmbruch) : roh,
truncated: true,
};
}
/**
* Dekodiert, was die Anbieter als base64 liefern.
*
* Bewusst ueber Buffer und nicht ueber atob: der Inhalt ist UTF-8, und atob
* gaebe Bytes zurueck, aus denen Umlaute als Kauderwelsch herauskaemen.
*/
export function decodeBase64(value: string): string {
return Buffer.from(value.replace(/\s/g, ""), "base64").toString("utf8");
}
+17
View File
@@ -1,4 +1,8 @@
import type { GitProvider } from "@prisma/client";
import type { 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. */
+97
View File
@@ -2,6 +2,7 @@ import type { Repository } from "@prisma/client";
import { recordActivity, shorten } from "@/lib/activity";
import { prisma } from "@/lib/db";
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import type { GitClient } from "@/lib/git/types";
import { notifyAfterSync } from "@/lib/notify";
import { repoTitle } from "@/lib/repositories";
import type { RepoSnapshotData } from "@/lib/git/types";
@@ -57,6 +58,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">,
+103 -1
View File
@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { TASK_STATUS_META, TASK_STATUS_ORDER, progressFromTasks } from "./tasks";
import {
DUE_BUCKET_META,
DUE_BUCKET_ORDER,
TASK_STATUS_META,
TASK_STATUS_ORDER,
dueBucket,
nextDueDate,
progressFromTasks,
} from "./tasks";
describe("progressFromTasks", () => {
it("rechnet den Anteil erledigter Aufgaben in Prozent um", () => {
@@ -34,3 +42,97 @@ 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);
});
});
describe("dueBucket", () => {
/** 14.08.2026, 17:30 Berliner Zeit – bewusst spaeter Nachmittag. */
const JETZT = new Date("2026-08-14T15:30:00Z");
it("ordnet eine Aufgabe ohne Termin nach hinten", () => {
expect(dueBucket(null, JETZT)).toBe("someday");
});
it("zaehlt den heutigen Tag als heute, auch wenn die Uhrzeit vorbei ist", () => {
// Der Punkt der Funktion: gerechnet wird auf Kalendertage. Um 9 Uhr
// faellig und jetzt ist es halb sechs – die Aufgabe steht trotzdem
// unter "Heute" und nicht unter "Ueberfaellig".
expect(dueBucket(new Date("2026-08-14T07:00:00Z"), JETZT)).toBe("today");
});
it("erkennt den Vortag als ueberfaellig", () => {
expect(dueBucket(new Date("2026-08-13T21:00:00Z"), JETZT)).toBe("overdue");
});
it("fasst die kommenden sieben Tage als diese Woche", () => {
expect(dueBucket(new Date("2026-08-15T06:00:00Z"), JETZT)).toBe("week");
expect(dueBucket(new Date("2026-08-21T06:00:00Z"), JETZT)).toBe("week");
});
it("schiebt alles danach nach spaeter", () => {
expect(dueBucket(new Date("2026-08-22T06:00:00Z"), JETZT)).toBe("later");
});
it("richtet sich nach Berliner Kalendertagen, nicht nach UTC", () => {
// 21:30 UTC ist in Berlin schon 23:30 desselben Tages – im Container
// laeuft die Anwendung aber nach UTC. Ohne feste Zeitzone kippte die
// Einteilung hier um einen Tag.
const spaetAbends = new Date("2026-08-15T21:30:00Z");
expect(dueBucket(new Date("2026-08-15T10:00:00Z"), spaetAbends)).toBe("today");
expect(dueBucket(new Date("2026-08-16T10:00:00Z"), spaetAbends)).toBe("week");
});
it("hat zu jedem Fach eine Beschriftung und eine Farbe", () => {
for (const bucket of DUE_BUCKET_ORDER) {
expect(DUE_BUCKET_META[bucket].label).toBeTruthy();
expect(DUE_BUCKET_META[bucket].color).toMatch(/^#[0-9a-f]{6}$/i);
}
});
});
+160 -1
View File
@@ -1,4 +1,4 @@
import type { TaskStatus } from "@prisma/client";
import type { Recurrence, TaskStatus } from "@prisma/client";
import { prisma } from "@/lib/db";
/** Spaltenreihenfolge des Aufgabenbretts. */
@@ -64,6 +64,165 @@ 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.
},
});
}
// ── Faelligkeit in der Gesamtuebersicht ──────────────────────
/**
* Faecher der Aufgabenuebersicht. Anders als im Projektbrett ordnet dort
* nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist
* ueberfaellig" die Frage, nicht "was steht in welcher Spalte".
*/
export const DUE_BUCKET_ORDER = ["overdue", "today", "week", "later", "someday"] as const;
export type DueBucket = (typeof DUE_BUCKET_ORDER)[number];
export const DUE_BUCKET_META: Record<DueBucket, { label: string; color: string }> = {
overdue: { label: "Überfällig", color: "#fb7185" },
today: { label: "Heute", color: "#fbbf24" },
week: { label: "Diese Woche", color: "#a78bfa" },
later: { label: "Später", color: "#38bdf8" },
someday: { label: "Ohne Termin", color: "#8a98b0" },
};
/**
* Kalendertag in fester Zeitzone, als fortlaufende Tagesnummer.
*
* Die Serverzeit taugt dafuer nicht: im Container laeuft die Anwendung nach
* UTC. Um 23:30 Berliner Zeit ist dort noch der Vortag, und eine heute
* faellige Aufgabe stuende unter "Ueberfaellig". Dieselbe Zeitzone benutzt
* auch die Datumsausgabe in lib/utils.
*/
const TAG_FORMAT = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
function tagesnummer(wert: Date): number {
// en-CA liefert "2026-08-14" – und damit etwas, das sich ohne weiteres
// Zerlegen sortieren und rechnen laesst.
const [jahr, monat, tag] = TAG_FORMAT.format(wert).split("-").map(Number);
return Date.UTC(jahr, monat - 1, tag) / 86_400_000;
}
/**
* In welches Fach eine Aufgabe faellt.
*
* Gerechnet wird auf Kalendertage, nicht auf Stunden: eine Aufgabe, die
* heute um 9 Uhr faellig war, ist um 17 Uhr nicht "ueberfaellig", sondern
* immer noch "heute". Sonst wanderte die halbe Liste im Lauf des Tages
* nach oben.
*
* Bewusst als reine Funktion mit uebergebenem "jetzt" – das macht sie
* pruefbar.
*/
export function dueBucket(dueDate: Date | null, now: Date = new Date()): DueBucket {
if (!dueDate) return "someday";
const abstand = tagesnummer(dueDate) - tagesnummer(now);
if (abstand < 0) return "overdue";
if (abstand === 0) return "today";
if (abstand <= 7) return "week";
return "later";
}
/** Naechste freie Position am Ende einer Spalte. */
export async function nextPosition(
projectId: string,
+57 -3
View File
@@ -3,6 +3,8 @@ import { z } from "zod";
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
const 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,17 @@ 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
@@ -70,6 +80,50 @@ export const projectBulkSchema = z
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 ─────────────────────────────────────────────
export const repositoryCreateSchema = z.object({
@@ -88,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
@@ -106,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();
+4 -1
View File
@@ -147,7 +147,10 @@ export const config = {
* weder CSRF-Pruefung noch Nonce und wuerden nur Rechenzeit kosten.
*/
{
source: "/((?!_next/static|_next/image|favicon.ico|icon.svg).*)",
// _next/image ist bewusst *nicht* ausgenommen: der Bildoptimierer ist
// abgeschaltet (siehe next.config.ts), und was von ihm uebrig bleibt,
// soll nicht ohne Sitzung erreichbar sein.
source: "/((?!_next/static|favicon.ico|icon.svg).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
+44
View File
@@ -4,11 +4,14 @@ import type {
Note,
Project,
ProjectStatus,
Recurrence,
RepoSnapshot,
Repository,
Task,
TaskStatus,
} from "@prisma/client";
import type { CommitCalendar } from "@/lib/git/calendar";
import type { DueBucket } from "@/lib/tasks";
import type {
BranchInfo,
CiInfo,
@@ -69,6 +72,27 @@ export interface ProjectDetail extends Project {
_count: { notes: number; tasks: number };
}
/**
* Eine Aufgabe in der projektuebergreifenden Uebersicht.
*
* Bewusst kein `extends Task`: die Seite braucht das Projekt dazu, das Fach
* fuer die Faelligkeit kommt vom Server, und Datumsangaben gehen als
* Zeichenkette an den Browser – ein Date ueberlebt die Serialisierung nicht.
*/
export interface TaskOverviewItem {
id: string;
title: string;
description: string | null;
status: TaskStatus;
dueDate: string | null;
labels: string[];
recurrence: Recurrence | null;
/** Gesetzt, wenn die Aufgabe aus einem Git-Issue stammt. */
sourceUrl: string | null;
bucket: DueBucket;
project: { id: string; name: string; accent: string; status: ProjectStatus };
}
export interface RepoListEntry {
fullPath: string;
owner: string;
@@ -83,6 +107,7 @@ export interface RepoListEntry {
export type {
ActivityKind,
BranchInfo,
CommitCalendar,
CiInfo,
CommitInfo,
Note,
@@ -101,6 +126,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>;
+9 -1
View File
@@ -5,9 +5,17 @@ export default defineConfig({
test: {
// Reine Logik, kein DOM und keine Datenbank – die Tests laufen in
// Millisekunden und brauchen weder Server noch Container.
//
// .tsx ist dabei, weil die Bereinigung des Markdowns nur am erzeugten
// HTML zu pruefen ist. Dafuer genuegt renderToStaticMarkup; ein DOM
// braucht es dazu nicht.
environment: "node",
include: ["src/**/*.test.ts"],
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
// Die tsconfig setzt jsx auf "preserve", weil Next das selbst uebersetzt.
// Hier gibt es kein Next, also muss der Transformer es tun. Vite nimmt
// inzwischen oxc statt esbuild – die esbuild-Option wuerde ignoriert.
oxc: { jsx: { runtime: "automatic" } },
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),