diff --git a/FEATURES.md b/FEATURES.md index 398819d..fc4c275 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -138,6 +138,15 @@ 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. +### Mehrfachauswahl + +Ein Kästchen auf jeder Karte — es erscheint erst beim Überfahren, damit nicht auf jeder +Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Leiste ein: +**Status setzen**, **Tags ergänzen oder entfernen**, **als Favorit markieren**, **löschen**. + +Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte, +die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch. + ### Kanban Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und @@ -147,8 +156,15 @@ erst danach gespeichert; scheitert das Speichern, springt die Karte zurück. normaler Link. - Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren. Auf Touchgeräten nach kurzem Halten. -- Die Reihenfolge innerhalb einer Spalte folgt der gewählten Sortierung. Frei sortieren - ginge nur mit einer gespeicherten Position — bewusst nicht gebaut. +- **Freie Reihenfolge innerhalb einer Spalte.** Die Position wird am Projekt gespeichert, + wie beim Aufgabenbrett. Deshalb ist die Sortierung im Kopf in dieser Ansicht abgeschaltet + — sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend. +- Reines Umsortieren fasst das Änderungsdatum **nicht** an. Sonst schöbe ein Aufräumen im + Brett sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben. +- Neue, importierte und per Status verschobene Projekte landen am Ende ihrer Spalte. +- **Ansicht, Sortierung und Archivschalter überdauern das Neuladen** (im Browser + gespeichert, nicht am Konto). Der Suchbegriff bleibt bewusst außen vor — eine Suche von + gestern beim Öffnen wiederherzustellen wäre eher verwirrend als hilfreich. ### Projektkarte auf einen Blick @@ -461,6 +477,14 @@ letzten Zustellung stehen an der Karte. Fokusring, `prefers-reduced-motion` schaltet Animationen ab. - **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen. +- **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy + „Zum Startbildschirm hinzufügen“, danach läuft Nebula ohne Adressleiste. Die + maskierbare Fassung hat mehr Rand, weil Android Symbole je nach Gerät rund, eckig + oder als Tropfen zuschneidet. +- **Änderungsverlauf** hinter der Versionsnummer in der Fußzeile: was neu ist, was besser + wurde und welche Fehler behoben sind. Die Liste steht in `src/lib/changelog.ts` und ist + die einzige Quelle — ein Test hält sie und `package.json` zusammen, weil genau diese + beiden Angaben hier schon einmal auseinandergelaufen sind. --- diff --git a/ROADMAP.md b/ROADMAP.md index 88a1ee4..d356b3e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,11 +28,6 @@ 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. -### 2.2 Kanban mit freier Reihenfolge `M` - -Heute folgt die Reihenfolge innerhalb einer Spalte der gewählten Sortierung. -Frei sortieren hieße, eine Position je Projekt zu speichern. - --- ## 3 · Größere Brocken @@ -42,7 +37,7 @@ Frei sortieren hieße, eine Position je Projekt zu speichern. Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb. -### 3.4 Mehrsprachigkeit `L` +### 3.2 Mehrsprachigkeit `L` Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine Voraussetzung, falls du das Projekt veröffentlichen willst. @@ -54,14 +49,11 @@ Voraussetzung, falls du das Projekt veröffentlichen willst. | Idee | Aufwand | Nutzen | | --- | --- | --- | | Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste | -| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal | | Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" | | Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen | | Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel | | Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` | -| PWA-Manifest | `S` | Auf dem Handy als App installierbar | | Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste | -| `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7. Achtung: danach lädt Prisma die `.env` nicht mehr von selbst | | Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat | --- @@ -101,6 +93,7 @@ Damit klar ist, was **nicht** kommt und warum: | **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ | | **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ | | **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ | +| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ | 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 diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 598b4c6..77ffa0d 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -279,6 +279,101 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => { expect(zumSchluss.repositories[0].repoName).toBe("swr"); }); +test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => { + await login(request, ADMIN); + + const angelegt = []; + for (const name of ["Brett A", "Brett B", "Brett C"]) { + const response = await request.post("/api/projects", { data: { name, status: "IDEA" } }); + angelegt.push((await response.json()).project); + } + // Neue Projekte haengen sich hinten an ihre Spalte an – fortlaufend, nicht + // alle auf denselben Platz. Absolut zaehlen laesst sich nicht: in der + // Spalte stehen schon Projekte aus frueheren Tests. + const [a, b, c] = angelegt.map((p) => p.position); + expect([b, c]).toEqual([a + 1, a + 2]); + + const ids = angelegt.map((p) => p.id); + const vorher = angelegt[0].updatedAt; + + // Umdrehen und dabei eines in eine andere Spalte schieben. + const gedreht = [ids[2], ids[1]]; + const reorder = await request.patch("/api/projects/reorder", { + data: { columns: { IDEA: gedreht, IN_DEVELOPMENT: [ids[0]] } }, + }); + expect(reorder.ok()).toBeTruthy(); + + const alle = (await (await request.get("/api/projects")).json()).projects; + const finde = (id: string) => alle.find((p: { id: string }) => p.id === id); + expect(finde(ids[2]).position).toBe(0); + expect(finde(ids[1]).position).toBe(1); + expect(finde(ids[0]).status).toBe("IN_DEVELOPMENT"); + + // Reines Umsortieren darf das Aenderungsdatum nicht anfassen – sonst + // schoebe ein Aufraeumen im Brett alles in "Zuletzt geaendert" nach oben. + expect(new Date(finde(ids[1]).updatedAt).getTime()).toBe( + new Date(angelegt[1].updatedAt).getTime(), + ); + // Das verschobene dagegen hat sich wirklich geaendert. + expect(new Date(finde(ids[0]).updatedAt).getTime()).toBeGreaterThan( + new Date(vorher).getTime(), + ); + + // Mehrfachauswahl: Tags ergaenzen und wieder entfernen. + const ergaenzt = await request.post("/api/projects/bulk", { + data: { ids, action: "tags", tags: ["Sammel", "test"], tagMode: "add" }, + }); + expect((await ergaenzt.json()).changed).toBe(3); + const mitTags = (await (await request.get("/api/projects")).json()).projects; + // Kleingeschrieben abgelegt, wie ueberall. + expect(mitTags.find((p: { id: string }) => p.id === ids[0]).tags).toContain("sammel"); + + const entfernt = await request.post("/api/projects/bulk", { + data: { ids, action: "tags", tags: ["sammel"], tagMode: "remove" }, + }); + expect((await entfernt.json()).changed).toBe(3); + + // Status fuer mehrere: die Verschobenen landen hintereinander, nicht + // uebereinander auf derselben Position. + const verschoben = await request.post("/api/projects/bulk", { + data: { ids, action: "status", status: "PLANNING" }, + }); + expect((await verschoben.json()).changed).toBe(3); + const geplant = (await (await request.get("/api/projects?status=PLANNING")).json()).projects; + const positionen = geplant.map((p: { position: number }) => p.position).sort(); + expect(new Set(positionen).size).toBe(positionen.length); + + // Wer schon so steht, wird nicht noch einmal angefasst. + const nochmal = await request.post("/api/projects/bulk", { + data: { ids, action: "status", status: "PLANNING" }, + }); + expect((await nochmal.json()).changed).toBe(0); + + // Fremde IDs fallen still unter den Tisch, statt etwas anzurichten. + const fremd = await playwright.request.newContext({ + baseURL: test.info().project.use.baseURL, + }); + await login(fremd, ZWEITER); + const untergeschoben = await fremd.post("/api/projects/bulk", { + data: { ids, action: "delete" }, + }); + expect((await untergeschoben.json()).changed).toBe(0); + const uebrig = (await (await request.get("/api/projects")).json()).projects; + expect(uebrig.filter((p: { id: string }) => ids.includes(p.id))).toHaveLength(3); + + const fremdSortieren = await fremd.patch("/api/projects/reorder", { + data: { columns: { DONE: ids } }, + }); + expect((await fremdSortieren.json()).updated).toBe(0); + await fremd.dispose(); + + // Und zum Schluss die eigenen wirklich loeschen. + const geloescht = await request.post("/api/projects/bulk", { + data: { ids, action: "delete" }, + }); + expect((await geloescht.json()).changed).toBe(3); +}); + test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => { await login(request, ADMIN); diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts index c468b01..0937601 100644 --- a/e2e/ui.spec.ts +++ b/e2e/ui.spec.ts @@ -72,9 +72,17 @@ test("Aufgaben anlegen und per Ziehen verschieben", async ({ page }) => { // dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese // Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck. await page.waitForTimeout(250); + + // Auf die Antwort warten, bevor neu geladen wird. Sonst bricht das Neuladen + // den noch laufenden Aufruf ab, und der Test schlaegt gelegentlich fehl, + // obwohl die Anwendung in Ordnung ist – genau so ist es passiert. + const gespeichert = page.waitForResponse( + (response) => response.url().includes("/tasks/reorder") && response.request().method() === "PATCH", + ); await page.mouse.up(); await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible(); + expect((await gespeichert).ok()).toBeTruthy(); // Und der Server hat es auch so gespeichert. await page.reload(); @@ -119,6 +127,73 @@ test("Schnellsuche findet Projekt und Notiz", async ({ page }) => { await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden(); }); +test("Mehrfachauswahl aendert mehrere Projekte auf einmal", async ({ page }) => { + const kaestchen = page.locator('input[aria-label$="auswaehlen"]'); + const anzahl = await kaestchen.count(); + expect(anzahl).toBeGreaterThan(0); + + // Ohne Auswahl gibt es keine Leiste – sie soll nicht dauerhaft im Weg sein. + await expect(page.getByText(/ausgewählt/)).toBeHidden(); + + await kaestchen.first().check(); + await expect(page.getByText("1 ausgewählt")).toBeVisible(); + + await page.getByRole("button", { name: "Tags", exact: true }).click(); + await page.fill("#bulk-tags", "sammeltest"); + await page.getByRole("button", { name: "Hinzufügen" }).click(); + + await expect(page.getByText("#sammeltest").first()).toBeVisible(); + // Nach getaner Arbeit ist die Auswahl aufgehoben. + await expect(page.getByText(/ausgewählt/)).toBeHidden(); +}); + +test("Die Versionsnummer oeffnet den Aenderungsverlauf", async ({ page }) => { + await page.getByRole("button", { name: /^v\d+\.\d+\.\d+$/ }).click(); + + // Ueber die Rolle, nicht ueber den Text: das Wort steht auch in einem der + // Eintraege, und ein blosser Textvergleich waere mehrdeutig. + const dialog = page.getByRole("dialog", { name: "Änderungsverlauf" }); + await expect(dialog).toBeVisible(); + // Die laufende Fassung ist als solche gekennzeichnet. + await expect(dialog.getByText("installiert")).toBeVisible(); + // Und es steht wirklich etwas drin. + await expect(dialog.getByRole("heading", { level: 3 }).first()).toBeVisible(); + await expect(dialog.getByText("Neu").first()).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); +}); + +test("Das Manifest liegt bereit, auch ohne Anmeldung", async ({ page, playwright }) => { + const anonym = await playwright.request.newContext({ + baseURL: test.info().project.use.baseURL, + }); + + // Ohne diesen Weg an der Zugangsschranke vorbei liesse sich die Anwendung + // nicht installieren: der Browser holt das Manifest, bevor jemand angemeldet ist. + const antwort = await anonym.get("/manifest.webmanifest"); + expect(antwort.ok()).toBeTruthy(); + + const manifest = await antwort.json(); + expect(manifest.name).toContain("Nebula"); + expect(manifest.display).toBe("standalone"); + expect(manifest.icons.some((icon: { purpose: string }) => icon.purpose === "maskable")).toBe(true); + + for (const icon of manifest.icons) { + const bild = await anonym.get(icon.src); + expect(bild.ok(), icon.src).toBeTruthy(); + expect(bild.headers()["content-type"]).toContain("image/png"); + } + + await anonym.dispose(); + + // Die Seite muss ein Symbol verweisen. Fehlt es, sucht der Browser + // /favicon.ico – das es hier nicht gibt, und die Konsole fuellt sich mit + // 404ern. Genau das ist beim Ergaenzen des Apple-Symbols passiert. + await page.goto("/"); + await expect(page.locator('link[rel="icon"]').first()).toHaveAttribute("href", /icon/); +}); + test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => { await page.getByRole("button", { name: /Benutzermenue/ }).click(); await page.getByRole("menuitem", { name: "Abmelden" }).click(); diff --git a/package-lock.json b/package-lock.json index aac581d..2b48e5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nebula", - "version": "0.9.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nebula", - "version": "0.9.0", + "version": "1.0.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", + "dotenv": "^17.4.2", "prisma": "^6.8.2", "tailwindcss": "^4.1.7", "tsx": "^4.19.4", @@ -2574,6 +2575,19 @@ } } }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -2866,10 +2880,10 @@ "license": "MIT" }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" diff --git a/package.json b/package.json index a7c81da..872b2f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nebula", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "scripts": { @@ -30,9 +30,6 @@ "test:e2e:ui": "playwright test --ui", "test:all": "npm test && npm run test:e2e" }, - "prisma": { - "seed": "tsx prisma/seed.ts" - }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -59,6 +56,7 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.1.5", "@types/react-dom": "^19.1.5", + "dotenv": "^17.4.2", "prisma": "^6.8.2", "tailwindcss": "^4.1.7", "tsx": "^4.19.4", diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..4416848 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,24 @@ +import { config as loadEnv } from "dotenv"; +import { defineConfig } from "prisma/config"; + +/** + * Konfiguration der Prisma-CLI. + * + * Loest den Abschnitt `prisma` in der package.json ab, der mit Prisma 7 + * wegfaellt. + * + * **Der Aufruf von dotenv ist nicht optional.** Sobald diese Datei + * existiert, liest Prisma die .env nicht mehr von selbst ein – ohne die + * folgenden Zeilen stuende `migrate deploy` plotzlich ohne DATABASE_URL da, + * und zwar mit einer Fehlermeldung, die nicht darauf hindeutet. Erst .env, + * dann .env.local: eine oertliche Abweichung soll gewinnen. + */ +loadEnv({ path: ".env", quiet: true }); +loadEnv({ path: ".env.local", override: true, quiet: true }); + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + seed: "tsx prisma/seed.ts", + }, +}); diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index 3e91951..e5e5b69 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { NOTIFICATION_EVENTS } from "@/lib/notify/types"; -import { notificationTargetCreateSchema } from "./validation"; +import { + notificationTargetCreateSchema, + projectBulkSchema, + projectReorderSchema, +} from "./validation"; /** * Dieser Test entstand aus einem Fehler: beim Ergaenzen eines neuen @@ -70,3 +74,60 @@ describe("notificationTargetCreateSchema", () => { } }); }); + +describe("projectReorderSchema", () => { + it("nimmt Spalten mit Listen an", () => { + const parsed = projectReorderSchema.safeParse({ + columns: { IDEA: ["a", "b"], DONE: [] }, + }); + expect(parsed.success).toBe(true); + }); + + it("weist unbekannte Spalten ab", () => { + expect( + projectReorderSchema.safeParse({ columns: { PHANTASIE: ["a"] } }).success, + ).toBe(false); + }); + + it("weist einen leeren Aufruf ab, statt ihn wirkungslos durchzuwinken", () => { + expect(projectReorderSchema.safeParse({ columns: {} }).success).toBe(false); + }); +}); + +describe("projectBulkSchema", () => { + const ids = ["a", "b"]; + + it("verlangt zu jeder Aktion die noetige Angabe", () => { + // Ohne diese Pruefungen kaeme die Aktion durch und taete nichts – der + // schlimmste Fall, weil niemand merkt, dass nichts passiert ist. + expect(projectBulkSchema.safeParse({ ids, action: "status" }).success).toBe(false); + expect(projectBulkSchema.safeParse({ ids, action: "tags" }).success).toBe(false); + expect(projectBulkSchema.safeParse({ ids, action: "tags", tags: [] }).success).toBe(false); + expect(projectBulkSchema.safeParse({ ids, action: "favorite" }).success).toBe(false); + }); + + it("laesst vollstaendige Aktionen durch", () => { + expect( + projectBulkSchema.safeParse({ ids, action: "status", status: "DONE" }).success, + ).toBe(true); + expect( + projectBulkSchema.safeParse({ ids, action: "tags", tags: ["archiv"] }).success, + ).toBe(true); + expect( + projectBulkSchema.safeParse({ ids, action: "favorite", favorite: false }).success, + ).toBe(true); + // Loeschen braucht nichts ausser den IDs. + expect(projectBulkSchema.safeParse({ ids, action: "delete" }).success).toBe(true); + }); + + it("ergaenzt standardmaessig, statt zu entfernen", () => { + const parsed = projectBulkSchema.parse({ ids, action: "tags", tags: ["x"] }); + expect(parsed.tagMode).toBe("add"); + }); + + it("braucht mindestens eine ID und deckelt die Menge", () => { + expect(projectBulkSchema.safeParse({ ids: [], action: "delete" }).success).toBe(false); + const zuViele = Array.from({ length: 201 }, (_, index) => `id-${index}`); + expect(projectBulkSchema.safeParse({ ids: zuViele, action: "delete" }).success).toBe(false); + }); +});