feat(tasks): Karten je Spalte begrenzen, Rest hinter einen Knopf
CI / pruefen (pull_request) Canceled after 0s

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
This commit is contained in:
2026-08-18 23:19:56 +02:00
parent 2d191a54bc
commit 3fd6e41e16
11 changed files with 286 additions and 6 deletions
+13
View File
@@ -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 `<a>` 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.
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "AppSettings" ADD COLUMN "taskColumnLimit" INTEGER NOT NULL DEFAULT 15;
+5
View File
@@ -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)
+17 -1
View File
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
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 (
+2
View File
@@ -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({
+4 -1
View File
@@ -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}
/>
{/*
+133
View File
@@ -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 }) {
<AutoSyncSection data={data.autoSync} />
<StaleSection data={data.stale} />
<TaskLimitSection data={data.tasks} />
{/* ── Instanz ─────────────────────────────────────── */}
<section className="glass p-5">
@@ -1099,3 +1107,128 @@ function UserDialog({
</Modal>
);
}
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<Notice>(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 (
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<Columns3 className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Aufgabenbrett
</h2>
<p className="mt-1 max-w-xl text-sm text-slate-400">
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.
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="task-column-limit">
Karten je Spalte
</label>
<select
id="task-column-limit"
className="field"
value={limit}
disabled={busy}
onChange={(event) => {
const value = Number(event.target.value);
setLimit(value);
void save(value);
}}
>
{SPALTEN_OPTIONEN.map((value) => (
<option key={value} value={value}>
{value === 0 ? "ohne Begrenzung" : `hoechstens ${value}`}
</option>
))}
</select>
</div>
<dl className="grid grid-cols-2 gap-3">
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Laengste Spalte
</dt>
<dd className="mt-1 text-sm tabular-nums text-slate-200">
{data.laengsteSpalte}
</dd>
</div>
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Wirkung
</dt>
<dd className="mt-1 text-sm text-slate-200">
{limit === 0 ? "aus" : greift ? "greift" : "noch nicht"}
</dd>
</div>
</dl>
</div>
<p className="mt-3 text-[11px] text-slate-600">
{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."}
</p>
{notice ? (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
) : null}
</section>
);
}
+39 -4
View File
@@ -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<TaskStatus, Task[]>;
@@ -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<Columns>(() => 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 (
<div
@@ -284,12 +296,14 @@ function TaskColumn({
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
</div>
{/* Nur die gezeigten Karten stehen im SortableContext – was nicht im
DOM ist, kann dnd-kit ohnehin nicht treffen. */}
<SortableContext
items={tasks.map((task) => task.id)}
items={gezeigt.map((task) => task.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-1 flex-col gap-1.5">
{tasks.map((task) => (
{gezeigt.map((task) => (
<SortableTask key={task.id} task={task} onEdit={onEdit} />
))}
{tasks.length === 0 ? (
@@ -300,6 +314,27 @@ function TaskColumn({
</div>
</SortableContext>
{verborgen > 0 || entfaltet ? (
<button
type="button"
onClick={() => setEntfaltet((vorher) => !vorher)}
aria-expanded={entfaltet}
className="mt-1.5 flex items-center justify-center gap-1 rounded-lg border border-dashed border-white/10 px-2 py-1.5 text-[11px] text-slate-500 transition-colors hover:border-white/20 hover:text-slate-300"
>
{entfaltet ? (
<>
<ChevronUp className="h-3 w-3" />
weniger anzeigen
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
{verborgen} weitere anzeigen
</>
)}
</button>
) : null}
<button
type="button"
onClick={onAdd}
+46
View File
@@ -7,6 +7,7 @@ import {
dueBucket,
nextDueDate,
progressFromTasks,
spaltenAnzeige,
} from "./tasks";
describe("progressFromTasks", () => {
@@ -136,3 +137,48 @@ describe("dueBucket", () => {
}
});
});
describe("spaltenAnzeige", () => {
it("zeigt alles, solange die Spalte unter der Grenze bleibt", () => {
expect(spaltenAnzeige(3, 15, false)).toEqual({ sichtbar: 3, verborgen: 0 });
// Genau auf der Grenze ist noch nichts zu verbergen – sonst stuende
// "0 weitere anzeigen" an der Spalte.
expect(spaltenAnzeige(15, 15, false)).toEqual({ sichtbar: 15, verborgen: 0 });
});
it("kappt darueber und sagt, wie viele dahinter warten", () => {
expect(spaltenAnzeige(40, 15, false)).toEqual({ sichtbar: 15, verborgen: 25 });
});
it("nimmt 0 als 'ohne Begrenzung'", () => {
// Dieselbe Lesart wie SESSION_IDLE_HOURS. Waere 0 woertlich gemeint,
// zeigte das Brett gar nichts mehr.
expect(spaltenAnzeige(200, 0, false)).toEqual({ sichtbar: 200, verborgen: 0 });
});
it("behandelt eine negative Grenze wie keine", () => {
// Die API laesst sie nicht durch; ein alter Datenbankstand oder ein
// Handgriff in psql koennte sie aber liefern.
expect(spaltenAnzeige(9, -5, false)).toEqual({ sichtbar: 9, verborgen: 0 });
});
it("zeigt entfaltet wieder alles", () => {
expect(spaltenAnzeige(40, 15, true)).toEqual({ sichtbar: 40, verborgen: 0 });
});
it("meldet nie mehr Sichtbares als vorhanden", () => {
// Sonst liefe das slice() in der Spalte ins Leere und die Zahl am Knopf
// waere negativ.
for (const anzahl of [0, 1, 7, 15, 16, 99]) {
for (const limit of [0, 1, 15, 100]) {
for (const entfaltet of [true, false]) {
const { sichtbar, verborgen } = spaltenAnzeige(anzahl, limit, entfaltet);
expect(sichtbar, `${anzahl}/${limit}/${entfaltet}`).toBeLessThanOrEqual(anzahl);
expect(verborgen).toBeGreaterThanOrEqual(0);
// Zusammen ergeben sie immer die ganze Spalte – nichts faellt weg.
expect(sichtbar + verborgen).toBe(anzahl);
}
}
}
});
});
+23
View File
@@ -168,6 +168,29 @@ export async function spawnRecurrence(taskId: string) {
* nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist
* ueberfaellig" die Frage, nicht "was steht in welcher Spalte".
*/
/**
* Wie viele Karten eine Spalte des Aufgabenbretts zeigt – und wie viele
* dahinter warten.
*
* Begrenzt wird ausschliesslich die *Anzeige*. Der Zustand im Brett bleibt
* vollstaendig, weil das Umsortieren je Spalte die ganze Liste an den Server
* schickt: waere sie hier schon gekuerzt, wuerden die verborgenen Aufgaben
* beim naechsten Ziehen aus dem Brett fallen.
*
* `limit` 0 heisst "ohne Begrenzung" – dieselbe Lesart wie bei
* SESSION_IDLE_HOURS.
*/
export function spaltenAnzeige(
anzahl: number,
limit: number,
entfaltet: boolean,
): { sichtbar: number; verborgen: number } {
if (limit <= 0 || entfaltet || anzahl <= limit) {
return { sichtbar: anzahl, verborgen: 0 };
}
return { sichtbar: limit, verborgen: anzahl - limit };
}
export const DUE_BUCKET_ORDER = ["overdue", "today", "week", "later", "someday"] as const;
export type DueBucket = (typeof DUE_BUCKET_ORDER)[number];
+2
View File
@@ -264,6 +264,8 @@ export const adminSettingsSchema = z.object({
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
autoSyncActiveOnly: z.boolean().optional(),
autoSyncBatchSize: z.coerce.number().int().min(1).max(200).optional(),
/** Karten je Spalte im Aufgabenbrett; 0 schaltet die Begrenzung ab. */
taskColumnLimit: z.coerce.number().int().min(0).max(500).optional(),
});
export const adminUserCreateSchema = z.object({