Feat #16: Karten je Spalte begrenzen #17

Merged
florian.wilde merged 1 commits from feat/16-max-aufgaben-anzeigen into main 2026-08-18 21:27:06 +00:00
11 changed files with 286 additions and 6 deletions
Showing only changes of commit 3fd6e41e16 - Show all commits
+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({