feat(tasks): Karten je Spalte begrenzen, Rest hinter einen Knopf
CI / pruefen (pull_request) Canceled after 0s
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:
+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