From 3fd6e41e163e962e10480d8afc65b53d15e8264f Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 18 Aug 2026 23:19:56 +0200 Subject: [PATCH] feat(tasks): Karten je Spalte begrenzen, Rest hinter einen Knopf Ein Projekt mit vierzig offenen Aufgaben macht das Brett unbrauchbar: die Spalte laeuft ueber die halbe Seite, und die Bloecke darunter - Notizen, Repository, Verlauf - rutschen aus dem Blick. Die Grenze steht in den Admin-Einstellungen und gilt fuer alle Projekte, wie autoSyncBatchSize und staleAfterDays. Kein Feld am Projekt: das haette eine Migration am Project-Modell gekostet und eine Einstellung, die man vierzig Mal pflegt. 0 heisst "ohne Begrenzung", dieselbe Lesart wie bei SESSION_IDLE_HOURS. Begrenzt wird ausschliesslich die Anzeige. Das ist der Kern: persist() schickt je Spalte die vollstaendige Liste an /tasks/reorder, und eine dort schon gekuerzte Liste wuerde beim ersten Ziehen die verborgenen Aufgaben aus dem Brett loeschen. Der Zustand im Brett bleibt deshalb ganz, gekuerzt wird erst beim Rendern der Spalte. Gegengeprobt am echten PATCH-Rumpf: 40 IDs bei 15 sichtbaren Karten. Die Zahl neben der Spaltenueberschrift nennt weiter den vollen Bestand. Nur die sichtbaren Karten stehen im SortableContext - was nicht im DOM ist, kann dnd-kit ohnehin nicht treffen. Die Admin-Sektion zeigt neben der Einstellung die laengste Spalte im Bestand. Ohne die steht die Zahl im Leeren: eine Grenze von 15 sagt nichts, wenn die groesste Spalte aus drei Karten besteht. Refs #16 --- FEATURES.md | 13 ++ .../migration.sql | 2 + prisma/schema.prisma | 5 + src/app/admin/page.tsx | 18 ++- src/app/api/admin/settings/route.ts | 2 + src/app/projects/[id]/page.tsx | 5 +- src/components/admin/AdminManager.tsx | 133 ++++++++++++++++++ src/components/tasks/TaskBoard.tsx | 43 +++++- src/lib/tasks.test.ts | 46 ++++++ src/lib/tasks.ts | 23 +++ src/lib/validation.ts | 2 + 11 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20260818230000_task_column_limit/migration.sql diff --git a/FEATURES.md b/FEATURES.md index d316107..f053c33 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -259,6 +259,19 @@ Blockiert · Erledigt**. vor Beschreibung, Wiederholung, Frist und Labels, damit sich eine Spalte danach überfliegen lässt. Bewusst kein Link: der Weg zum Issue führt über den Dialog (4.6), weil ein `` innerhalb der anklickbaren Karte ungültiges Markup wäre. + +#### Wie viele Karten eine Spalte zeigt + +Eine Spalte zeigt höchstens so viele Karten, wie unter *Admin-Einstellungen → +Aufgabenbrett* eingestellt ist (Voreinstellung 15, `0` schaltet die Begrenzung ab). Was +darüber hinausgeht, steht hinter einem Knopf „*n* weitere anzeigen"; entfaltet wird je +Spalte einzeln. Die Zahl neben der Spaltenüberschrift nennt weiterhin den **vollen** +Bestand — sonst wäre nicht zu erkennen, dass etwas fehlt. + +**Begrenzt wird nur die Anzeige.** Das Umsortieren schickt je Spalte die vollständige +Liste an den Server; würde hier schon gekürzt, fielen die verborgenen Aufgaben beim +nächsten Ziehen aus dem Brett. Die Einstellung ist deshalb bewusst eine Sache der +Darstellung und keine der Abfrage — geladen wird ohnehin das ganze Projekt. - Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen wieder gelöscht. diff --git a/prisma/migrations/20260818230000_task_column_limit/migration.sql b/prisma/migrations/20260818230000_task_column_limit/migration.sql new file mode 100644 index 0000000..c20a4b5 --- /dev/null +++ b/prisma/migrations/20260818230000_task_column_limit/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "AppSettings" ADD COLUMN "taskColumnLimit" INTEGER NOT NULL DEFAULT 15; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5eaed02..d865db8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -266,6 +266,11 @@ model AppSettings { /// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms } lastAutoSyncReport Json? + // ── Aufgabenbrett ────────────────────────────────────────── + /// Hoechstzahl Karten je Spalte. Was darueber hinausgeht, kommt hinter + /// einen Knopf – verborgen wird nichts. 0 schaltet die Begrenzung ab. + taskColumnLimit Int @default(15) + // ── Stillstand ───────────────────────────────────────────── /// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt. staleAfterDays Int @default(60) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a8654ef..6bf2af3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -19,7 +19,7 @@ export async function generateMetadata(): Promise { export default async function AdminPage() { const admin = await requireAdminPage(); - const [settings, users, eligible, staleListeners] = await Promise.all([ + const [settings, users, eligible, staleListeners, laengsteSpalte] = await Promise.all([ getAppSettings(), prisma.user.findMany({ select: userSafeSelect, @@ -41,6 +41,18 @@ export default async function AdminPage() { prisma.notificationTarget.count({ where: { isActive: true, events: { has: "PROJECT_STALE" } }, }), + // Laengste Spalte ueber alle Projekte. Ohne diese Zahl steht die + // Einstellung im Leeren: eine Grenze von 15 sagt nichts, wenn die + // groesste Spalte im Bestand aus drei Karten besteht. + // + // Wie beim Abgleich bewusst ueber alle Konten – gezaehlt wird nur, es + // wird nichts gelesen und nichts zugeordnet. + prisma.task.groupBy({ + by: ["projectId", "status"], + _count: { _all: true }, + orderBy: { _count: { projectId: "desc" } }, + take: 1, + }), ]); const data: AdminData = { @@ -64,6 +76,10 @@ export default async function AdminPage() { lastCheckAt: settings.lastStaleCheckAt, listeners: staleListeners, }, + tasks: { + columnLimit: settings.taskColumnLimit, + laengsteSpalte: laengsteSpalte[0]?._count._all ?? 0, + }, }; return ( diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index a1c3ecd..2251033 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -38,6 +38,7 @@ export async function PATCH(request: Request) { autoSyncActiveOnly, autoSyncBatchSize, staleAfterDays, + taskColumnLimit, } = parsed.data; // Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren – @@ -63,6 +64,7 @@ export async function PATCH(request: Request) { ...(staleAfterDays !== undefined ? { staleAfterDays, lastStaleCheckAt: null } : {}), + ...(taskColumnLimit !== undefined ? { taskColumnLimit } : {}), }; const settings = await prisma.appSettings.upsert({ diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index f615808..4977305 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -10,6 +10,7 @@ import { ProjectHeader } from "@/components/ProjectHeader"; import { TaskBoard } from "@/components/tasks/TaskBoard"; import { requireUserPage } from "@/lib/auth/guard"; import { getSessionUser } from "@/lib/auth/session"; +import { getAppSettings } from "@/lib/auth/settings"; import { prisma } from "@/lib/db"; import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects"; import type { ConnectionSafe, ProjectDetail } from "@/types"; @@ -37,7 +38,7 @@ export default async function ProjectPage({ params }: PageProps) { const user = await requireUserPage(); const { id } = await params; - const [project, connections] = await Promise.all([ + const [project, connections, settings] = await Promise.all([ // findFirst mit Besitzerbedingung: ein fremdes Projekt fuehrt zu 404, // nicht zu einer Fehlermeldung, die seine Existenz bestaetigt. prisma.project.findFirst({ @@ -49,6 +50,7 @@ export default async function ProjectPage({ params }: PageProps) { select: connectionSafeSelect, orderBy: { name: "asc" }, }), + getAppSettings(), ]); if (!project) notFound(); @@ -83,6 +85,7 @@ export default async function ProjectPage({ params }: PageProps) { projectId={detail.id} tasks={detail.tasks} progressFromTasks={detail.progressFromTasks} + columnLimit={settings.taskColumnLimit} /> {/* diff --git a/src/components/admin/AdminManager.tsx b/src/components/admin/AdminManager.tsx index fdeb0d0..b1be23e 100644 --- a/src/components/admin/AdminManager.tsx +++ b/src/components/admin/AdminManager.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { Ban, CheckCircle2, + Columns3, Database, Download, Fingerprint, @@ -74,6 +75,12 @@ export interface AdminData { /** Wie viele Konten ueberhaupt auf Stillstand hoeren. */ listeners: number; }; + tasks: { + /** Karten je Spalte im Aufgabenbrett; 0 = ohne Begrenzung. */ + columnLimit: number; + /** Laengste Spalte ueber alle Projekte – ohne die ist die Zahl blind. */ + laengsteSpalte: number; + }; } type Notice = { ok: boolean; text: string } | null; @@ -195,6 +202,7 @@ export function AdminManager({ data }: { data: AdminData }) { + {/* ── Instanz ─────────────────────────────────────── */}
@@ -1099,3 +1107,128 @@ function UserDialog({ ); } + +const SPALTEN_OPTIONEN = [0, 5, 10, 15, 20, 30, 50]; + +/** + * Wie viele Karten eine Spalte des Aufgabenbretts hoechstens zeigt. + * + * Begrenzt wird nur die Anzeige: der Rest steht hinter einem Knopf, und die + * Reihenfolge wird weiterhin ueber die vollstaendige Spalte gespeichert. + * Eine Aufgabe verschwindet hier also nirgends – sie ist nur einen Klick + * weiter weg. + */ +function TaskLimitSection({ data }: { data: AdminData["tasks"] }) { + const router = useRouter(); + const [limit, setLimit] = useState(data.columnLimit); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + async function save(value: number) { + setBusy(true); + setNotice(null); + try { + const response = await fetch("/api/admin/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ taskColumnLimit: value }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen."); + setNotice({ ok: true, text: "Gespeichert." }); + router.refresh(); + } catch (caught) { + setLimit(data.columnLimit); + setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setBusy(false); + } + } + + const greift = limit > 0 && data.laengsteSpalte > limit; + + return ( +
+
+ + + +
+

+ Aufgabenbrett +

+

+ Wie viele Karten eine Spalte hoechstens zeigt. Was darueber hinausgeht, kommt + hinter einen Knopf – verborgen wird nichts, und die Reihenfolge bleibt + vollstaendig gespeichert. Gilt fuer alle Projekte. +

+
+
+ +
+
+ + +
+ +
+
+
+ Laengste Spalte +
+
+ {data.laengsteSpalte} +
+
+
+
+ Wirkung +
+
+ {limit === 0 ? "aus" : greift ? "greift" : "noch nicht"} +
+
+
+
+ +

+ {limit === 0 + ? "Jede Spalte zeigt alles. Bei langen Listen wird das Brett unuebersichtlich." + : greift + ? "Mindestens eine Spalte ist laenger als die Grenze – dort erscheint der Knopf." + : "Derzeit reicht keine Spalte an die Grenze heran; sichtbar wird die Einstellung erst danach."} +

+ + {notice ? ( +

+ {notice.text} +

+ ) : null} +
+ ); +} diff --git a/src/components/tasks/TaskBoard.tsx b/src/components/tasks/TaskBoard.tsx index da61210..36b87a6 100644 --- a/src/components/tasks/TaskBoard.tsx +++ b/src/components/tasks/TaskBoard.tsx @@ -18,11 +18,11 @@ import { import { restrictToWindowEdges } from "@dnd-kit/modifiers"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { CheckSquare, Loader2, Plus } from "lucide-react"; +import { CheckSquare, ChevronDown, ChevronUp, Loader2, Plus } from "lucide-react"; import type { Task, TaskStatus } from "@prisma/client"; import { TaskCard } from "@/components/tasks/TaskCard"; import { TaskDialog } from "@/components/tasks/TaskDialog"; -import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; +import { TASK_STATUS_META, TASK_STATUS_ORDER, spaltenAnzeige } from "@/lib/tasks"; import { cn } from "@/lib/utils"; type Columns = Record; @@ -48,10 +48,13 @@ export function TaskBoard({ projectId, tasks, progressFromTasks, + columnLimit = 0, }: { projectId: string; tasks: Task[]; progressFromTasks: boolean; + /** Karten je Spalte; 0 = ohne Begrenzung. Kommt aus den Admin-Einstellungen. */ + columnLimit?: number; }) { const router = useRouter(); const [columns, setColumns] = useState(() => group(tasks)); @@ -224,6 +227,7 @@ export function TaskBoard({ key={status} status={status} tasks={columns[status]} + limit={columnLimit} onAdd={() => setCreatingIn(status)} onEdit={setEditing} /> @@ -252,16 +256,24 @@ export function TaskBoard({ function TaskColumn({ status, tasks, + limit, onAdd, onEdit, }: { status: TaskStatus; tasks: Task[]; + limit: number; onAdd: () => void; onEdit: (task: Task) => void; }) { const meta = TASK_STATUS_META[status]; const { setNodeRef, isOver } = useDroppable({ id: status }); + const [entfaltet, setEntfaltet] = useState(false); + + // Nur die Anzeige wird gekuerzt – `tasks` bleibt oben vollstaendig, sonst + // wuerde das Umsortieren die verborgenen Aufgaben aus dem Brett loeschen. + const { sichtbar, verborgen } = spaltenAnzeige(tasks.length, limit, entfaltet); + const gezeigt = tasks.slice(0, sichtbar); return (
{tasks.length}
+ {/* Nur die gezeigten Karten stehen im SortableContext – was nicht im + DOM ist, kann dnd-kit ohnehin nicht treffen. */} task.id)} + items={gezeigt.map((task) => task.id)} strategy={verticalListSortingStrategy} >
- {tasks.map((task) => ( + {gezeigt.map((task) => ( ))} {tasks.length === 0 ? ( @@ -300,6 +314,27 @@ function TaskColumn({
+ {verborgen > 0 || entfaltet ? ( + + ) : null} +