6 Commits
Author SHA1 Message Date
florian.wilde 4ce26e1e8d Merge: Banner als Titelbild der README
CI / pruefen (push) Canceled after 0s
2026-08-19 06:58:25 +00:00
florian.wilde 9fb86fcc61 docs(readme): Banner als Titelbild
CI / pruefen (pull_request) Canceled after 0s
Der Textkopf sagte dasselbe in duerr: Name, Zeile darunter, Technikliste.
Das Bild traegt beides zugleich und nennt obendrein die vier Punkte, um
die es geht - drei Git-Provider, Aufgaben und Issues, Passkeys, eigene
Datenbank.

Die Zeile "Ideen festhalten - Status pflegen - ..." faellt weg, weil der
Banner sie woertlich enthaelt. Ueberschrift und Technikzeile bleiben: die
uebrigen Dokumente beginnen alle mit einer H1, und wer Bilder abschaltet
oder die Datei im Editor liest, soll trotzdem sehen, worum es geht. Aus
demselben Grund steht der ganze Inhalt des Bildes im Alternativtext.

Volle Breite statt fester Pixelzahl - so skaliert das Bild in Gitea wie
auf dem Telefon mit dem Textkoerper mit.
2026-08-19 08:57:42 +02:00
florian.wilde d11dd9c4ac Merge #16: Karten je Spalte begrenzen
CI / pruefen (push) Canceled after 0s
2026-08-18 21:27:05 +00:00
florian.wilde 3fd6e41e16 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
2026-08-18 23:19:56 +02:00
florian.wilde 2d191a54bc Merge: Roadmap auf 19 Releases
CI / pruefen (push) Canceled after 0s
2026-08-18 12:48:35 +00:00
florian.wilde 27b4dd83b7 docs(roadmap): Zahl der Releases auf 19 ziehen
CI / pruefen (pull_request) Canceled after 0s
Mit v1.10.1 ist eine Fassung dazugekommen. Die Zahl steht in 3.5 als
Gegenstueck zu den beiden Fassungen ohne Release - laeuft sie mit der
Wirklichkeit auseinander, entwertet das die ganze Notiz.
2026-08-18 14:48:17 +02:00
14 changed files with 290 additions and 9 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.
+3 -2
View File
@@ -1,11 +1,11 @@
![Nebula — Projekt-Kontrollzentrum, self-hosted. Alle deine halbfertigen Projekte, ein Kontrollzentrum: Ideen, Status, Notizen und Live-Daten aus deinen Git-Repos, gebündelt an einem Ort auf deinem eigenen Server. Drei Git-Provider (GitHub, GitLab, Gitea), Aufgaben und Issues in beide Richtungen, Passkeys und Zwei-Faktor, Daten in deiner eigenen PostgreSQL.](docs/banner.png)
<div align="center">
# ✦ Nebula
**Das Kontrollzentrum für deine Vibe-Coding Projekte.**
Ideen festhalten · Status pflegen · Notizen sammeln · Git-Repos live im Blick
<sub>Next.js 15 · TypeScript · Tailwind CSS 4 · Prisma · PostgreSQL · Docker</sub>
</div>
@@ -390,6 +390,7 @@ Container aus — auf dem Rechner selbst muss nichts installiert sein.
```
nebula/
├── docs/banner.png Titelbild dieser Datei
├── docker/entrypoint.sh Migrationen + Serverstart im Container
├── docker-compose.yml db (immer) · app (--profile full) · adminer (--profile tools)
├── Dockerfile Multi-Stage-Build auf Basis des Next-Standalone-Outputs
+1 -1
View File
@@ -108,7 +108,7 @@ Beide stehen im Änderungsverlauf, existierten aber nie in der
`package.json` — dort springt die Historie von 0.1.0 auf 0.4.0. Nachgetragen
wurden sie in dem Commit, der den Verlauf einführte, und der liegt *nach*
0.4.0. Es gibt also keinen ehrlichen Commit, an dem ein Tag hängen könnte.
Die übrigen 18 Fassungen haben seit dem 18.08.2026 ein Release in Gitea.
Die übrigen 19 Fassungen haben seit dem 18.08.2026 ein Release in Gitea.
---
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

@@ -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({