From 18ed4de9f9a85b0237f02a1a3c1d5282838be897 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 10:55:15 +0200 Subject: [PATCH] feat(admin): Schwelle fuer den Stillstand einstellbar Eigener Abschnitt mit der Schwelle von 14 Tagen bis zu einem Jahr, dem Zeitpunkt der letzten Pruefung und der Zahl der Empfaenger. Steht die auf null, sagt der Abschnitt das und verweist auf "Mein Konto". Eine Aenderung der Schwelle setzt lastStaleCheckAt zurueck, damit sie beim naechsten Tick greift und nicht erst in 24 Stunden. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/admin/page.tsx | 10 +- src/app/api/admin/settings/route.ts | 6 ++ src/components/admin/AdminManager.tsx | 126 ++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5054736..e9e2f1e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -19,7 +19,7 @@ export async function generateMetadata(): Promise { export default async function AdminPage() { const admin = await requireAdminPage(); - const [settings, users, eligible] = await Promise.all([ + const [settings, users, eligible, staleListeners] = await Promise.all([ getAppSettings(), prisma.user.findMany({ select: userSafeSelect, @@ -36,6 +36,9 @@ export default async function AdminPage() { ], }, }), + prisma.notificationTarget.count({ + where: { isActive: true, events: { has: "PROJECT_STALE" } }, + }), ]); const data: AdminData = { @@ -54,6 +57,11 @@ export default async function AdminPage() { lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null, eligible, }, + stale: { + afterDays: settings.staleAfterDays, + lastCheckAt: settings.lastStaleCheckAt, + listeners: staleListeners, + }, }; return ( diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 72a707d..a1c3ecd 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -37,6 +37,7 @@ export async function PATCH(request: Request) { autoSyncIntervalMinutes, autoSyncActiveOnly, autoSyncBatchSize, + staleAfterDays, } = parsed.data; // Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren – @@ -57,6 +58,11 @@ export async function PATCH(request: Request) { ...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}), ...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}), ...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}), + // Neue Schwelle: die Pruefung soll beim naechsten Tick greifen, nicht + // erst in 24 Stunden. + ...(staleAfterDays !== undefined + ? { staleAfterDays, lastStaleCheckAt: null } + : {}), }; const settings = await prisma.appSettings.upsert({ diff --git a/src/components/admin/AdminManager.tsx b/src/components/admin/AdminManager.tsx index d07d989..fdeb0d0 100644 --- a/src/components/admin/AdminManager.tsx +++ b/src/components/admin/AdminManager.tsx @@ -9,6 +9,7 @@ import { Download, Fingerprint, FolderGit2, + Hourglass, Loader2, Lock, Plus, @@ -67,6 +68,12 @@ export interface AdminData { /** Projekte mit Repository, die grundsaetzlich in Frage kommen. */ eligible: number; }; + stale: { + afterDays: number; + lastCheckAt: string | Date | null; + /** Wie viele Konten ueberhaupt auf Stillstand hoeren. */ + listeners: number; + }; } type Notice = { ok: boolean; text: string } | null; @@ -187,6 +194,7 @@ export function AdminManager({ data }: { data: AdminData }) { + {/* ── Instanz ─────────────────────────────────────── */}
@@ -574,6 +582,124 @@ function AutoSyncSection({ data }: { data: AdminData["autoSync"] }) { ); } +const STALE_OPTIONS = [14, 30, 60, 90, 180, 365]; + +function StaleSection({ data }: { data: AdminData["stale"] }) { + const router = useRouter(); + const [days, setDays] = useState(data.afterDays); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(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({ staleAfterDays: 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) { + setDays(data.afterDays); + setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + + +
+

+ Stillstand +

+

+ Ab wann ein Projekt als eingeschlafen gilt. Massstab ist der letzte Commit im + verknuepften Repository – das Aenderungsdatum taugt nicht, weil jeder Abgleich + es anfasst. Projekte ohne Repository bleiben aussen vor, ebenso fertige und + archivierte. +

+
+
+ +
+
+ + +
+ +
+
+
+ Letzte Pruefung +
+
+ {data.lastCheckAt ? formatRelative(data.lastCheckAt) : "noch keine"} +
+
+
+
+ Empfaenger +
+
{data.listeners}
+
+
+
+ + {data.listeners === 0 ? ( +

+ Niemand hoert derzeit auf diesen Anlass – die Pruefung laeuft dann gar nicht + erst. Einzurichten unter „Mein Konto → Benachrichtigungen“. +

+ ) : ( +

+ Die Pruefung laeuft hoechstens einmal am Tag. Je Projekt wird einmal gemeldet; + erneut erst, wenn danach wieder ein Commit kam und es wieder still wurde. +

+ )} + + {notice ? ( +

+ {notice.text} +

+ ) : null} +
+ ); +} + function ModeCard({ active, disabled,