Feat #16: Karten je Spalte begrenzen #17
+13
@@ -259,6 +259,19 @@ Blockiert · Erledigt**.
|
|||||||
vor Beschreibung, Wiederholung, Frist und Labels, damit sich eine Spalte danach
|
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),
|
ü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.
|
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
|
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
|
||||||
wieder gelöscht.
|
wieder gelöscht.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "AppSettings" ADD COLUMN "taskColumnLimit" INTEGER NOT NULL DEFAULT 15;
|
||||||
@@ -266,6 +266,11 @@ model AppSettings {
|
|||||||
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
|
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
|
||||||
lastAutoSyncReport Json?
|
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 ─────────────────────────────────────────────
|
// ── Stillstand ─────────────────────────────────────────────
|
||||||
/// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt.
|
/// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt.
|
||||||
staleAfterDays Int @default(60)
|
staleAfterDays Int @default(60)
|
||||||
|
|||||||
+17
-1
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const admin = await requireAdminPage();
|
const admin = await requireAdminPage();
|
||||||
|
|
||||||
const [settings, users, eligible, staleListeners] = await Promise.all([
|
const [settings, users, eligible, staleListeners, laengsteSpalte] = await Promise.all([
|
||||||
getAppSettings(),
|
getAppSettings(),
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
select: userSafeSelect,
|
select: userSafeSelect,
|
||||||
@@ -41,6 +41,18 @@ export default async function AdminPage() {
|
|||||||
prisma.notificationTarget.count({
|
prisma.notificationTarget.count({
|
||||||
where: { isActive: true, events: { has: "PROJECT_STALE" } },
|
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 = {
|
const data: AdminData = {
|
||||||
@@ -64,6 +76,10 @@ export default async function AdminPage() {
|
|||||||
lastCheckAt: settings.lastStaleCheckAt,
|
lastCheckAt: settings.lastStaleCheckAt,
|
||||||
listeners: staleListeners,
|
listeners: staleListeners,
|
||||||
},
|
},
|
||||||
|
tasks: {
|
||||||
|
columnLimit: settings.taskColumnLimit,
|
||||||
|
laengsteSpalte: laengsteSpalte[0]?._count._all ?? 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export async function PATCH(request: Request) {
|
|||||||
autoSyncActiveOnly,
|
autoSyncActiveOnly,
|
||||||
autoSyncBatchSize,
|
autoSyncBatchSize,
|
||||||
staleAfterDays,
|
staleAfterDays,
|
||||||
|
taskColumnLimit,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
||||||
@@ -63,6 +64,7 @@ export async function PATCH(request: Request) {
|
|||||||
...(staleAfterDays !== undefined
|
...(staleAfterDays !== undefined
|
||||||
? { staleAfterDays, lastStaleCheckAt: null }
|
? { staleAfterDays, lastStaleCheckAt: null }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(taskColumnLimit !== undefined ? { taskColumnLimit } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const settings = await prisma.appSettings.upsert({
|
const settings = await prisma.appSettings.upsert({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ProjectHeader } from "@/components/ProjectHeader";
|
|||||||
import { TaskBoard } from "@/components/tasks/TaskBoard";
|
import { TaskBoard } from "@/components/tasks/TaskBoard";
|
||||||
import { requireUserPage } from "@/lib/auth/guard";
|
import { requireUserPage } from "@/lib/auth/guard";
|
||||||
import { getSessionUser } from "@/lib/auth/session";
|
import { getSessionUser } from "@/lib/auth/session";
|
||||||
|
import { getAppSettings } from "@/lib/auth/settings";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects";
|
import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects";
|
||||||
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
||||||
@@ -37,7 +38,7 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
const user = await requireUserPage();
|
const user = await requireUserPage();
|
||||||
const { id } = await params;
|
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,
|
// findFirst mit Besitzerbedingung: ein fremdes Projekt fuehrt zu 404,
|
||||||
// nicht zu einer Fehlermeldung, die seine Existenz bestaetigt.
|
// nicht zu einer Fehlermeldung, die seine Existenz bestaetigt.
|
||||||
prisma.project.findFirst({
|
prisma.project.findFirst({
|
||||||
@@ -49,6 +50,7 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
select: connectionSafeSelect,
|
select: connectionSafeSelect,
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
}),
|
}),
|
||||||
|
getAppSettings(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!project) notFound();
|
if (!project) notFound();
|
||||||
@@ -83,6 +85,7 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
projectId={detail.id}
|
projectId={detail.id}
|
||||||
tasks={detail.tasks}
|
tasks={detail.tasks}
|
||||||
progressFromTasks={detail.progressFromTasks}
|
progressFromTasks={detail.progressFromTasks}
|
||||||
|
columnLimit={settings.taskColumnLimit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Columns3,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
@@ -74,6 +75,12 @@ export interface AdminData {
|
|||||||
/** Wie viele Konten ueberhaupt auf Stillstand hoeren. */
|
/** Wie viele Konten ueberhaupt auf Stillstand hoeren. */
|
||||||
listeners: number;
|
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;
|
type Notice = { ok: boolean; text: string } | null;
|
||||||
@@ -195,6 +202,7 @@ export function AdminManager({ data }: { data: AdminData }) {
|
|||||||
|
|
||||||
<AutoSyncSection data={data.autoSync} />
|
<AutoSyncSection data={data.autoSync} />
|
||||||
<StaleSection data={data.stale} />
|
<StaleSection data={data.stale} />
|
||||||
|
<TaskLimitSection data={data.tasks} />
|
||||||
|
|
||||||
{/* ── Instanz ─────────────────────────────────────── */}
|
{/* ── Instanz ─────────────────────────────────────── */}
|
||||||
<section className="glass p-5">
|
<section className="glass p-5">
|
||||||
@@ -1099,3 +1107,128 @@ function UserDialog({
|
|||||||
</Modal>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import {
|
|||||||
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
|
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
|
||||||
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
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 type { Task, TaskStatus } from "@prisma/client";
|
||||||
import { TaskCard } from "@/components/tasks/TaskCard";
|
import { TaskCard } from "@/components/tasks/TaskCard";
|
||||||
import { TaskDialog } from "@/components/tasks/TaskDialog";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Columns = Record<TaskStatus, Task[]>;
|
type Columns = Record<TaskStatus, Task[]>;
|
||||||
@@ -48,10 +48,13 @@ export function TaskBoard({
|
|||||||
projectId,
|
projectId,
|
||||||
tasks,
|
tasks,
|
||||||
progressFromTasks,
|
progressFromTasks,
|
||||||
|
columnLimit = 0,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
progressFromTasks: boolean;
|
progressFromTasks: boolean;
|
||||||
|
/** Karten je Spalte; 0 = ohne Begrenzung. Kommt aus den Admin-Einstellungen. */
|
||||||
|
columnLimit?: number;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [columns, setColumns] = useState<Columns>(() => group(tasks));
|
const [columns, setColumns] = useState<Columns>(() => group(tasks));
|
||||||
@@ -224,6 +227,7 @@ export function TaskBoard({
|
|||||||
key={status}
|
key={status}
|
||||||
status={status}
|
status={status}
|
||||||
tasks={columns[status]}
|
tasks={columns[status]}
|
||||||
|
limit={columnLimit}
|
||||||
onAdd={() => setCreatingIn(status)}
|
onAdd={() => setCreatingIn(status)}
|
||||||
onEdit={setEditing}
|
onEdit={setEditing}
|
||||||
/>
|
/>
|
||||||
@@ -252,16 +256,24 @@ export function TaskBoard({
|
|||||||
function TaskColumn({
|
function TaskColumn({
|
||||||
status,
|
status,
|
||||||
tasks,
|
tasks,
|
||||||
|
limit,
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
|
limit: number;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (task: Task) => void;
|
onEdit: (task: Task) => void;
|
||||||
}) {
|
}) {
|
||||||
const meta = TASK_STATUS_META[status];
|
const meta = TASK_STATUS_META[status];
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -284,12 +296,14 @@ function TaskColumn({
|
|||||||
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
|
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Nur die gezeigten Karten stehen im SortableContext – was nicht im
|
||||||
|
DOM ist, kann dnd-kit ohnehin nicht treffen. */}
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={tasks.map((task) => task.id)}
|
items={gezeigt.map((task) => task.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
{tasks.map((task) => (
|
{gezeigt.map((task) => (
|
||||||
<SortableTask key={task.id} task={task} onEdit={onEdit} />
|
<SortableTask key={task.id} task={task} onEdit={onEdit} />
|
||||||
))}
|
))}
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
@@ -300,6 +314,27 @@ function TaskColumn({
|
|||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAdd}
|
onClick={onAdd}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
dueBucket,
|
dueBucket,
|
||||||
nextDueDate,
|
nextDueDate,
|
||||||
progressFromTasks,
|
progressFromTasks,
|
||||||
|
spaltenAnzeige,
|
||||||
} from "./tasks";
|
} from "./tasks";
|
||||||
|
|
||||||
describe("progressFromTasks", () => {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -168,6 +168,29 @@ export async function spawnRecurrence(taskId: string) {
|
|||||||
* nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist
|
* nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist
|
||||||
* ueberfaellig" die Frage, nicht "was steht in welcher Spalte".
|
* 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 const DUE_BUCKET_ORDER = ["overdue", "today", "week", "later", "someday"] as const;
|
||||||
|
|
||||||
export type DueBucket = (typeof DUE_BUCKET_ORDER)[number];
|
export type DueBucket = (typeof DUE_BUCKET_ORDER)[number];
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ export const adminSettingsSchema = z.object({
|
|||||||
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
|
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
|
||||||
autoSyncActiveOnly: z.boolean().optional(),
|
autoSyncActiveOnly: z.boolean().optional(),
|
||||||
autoSyncBatchSize: z.coerce.number().int().min(1).max(200).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({
|
export const adminUserCreateSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user