import { create } from "zustand"; import { apiFetch } from "../lib/api"; export type BlockerStats = { current: number; weeklyAdded: number; monthlyAdded: number; history: { label: string; count: number }[]; submissions: { inReview: number; approved: number; rejected: number; }; mySubmissions: { inReview: number; approved: number; rejected: number; }; avgPerUser: number; avgApprovalWaitDays: number; }; type RawStatsResponse = { current?: unknown; weeklyAdded?: unknown; monthlyAdded?: unknown; history?: Array<{ label?: unknown; count?: unknown }>; submissions?: { inVote?: unknown; inReview?: unknown; pending?: unknown; approved?: unknown; rejected?: unknown; }; mySubmissions?: { active?: unknown; inVote?: unknown; inReview?: unknown; pending?: unknown; approved?: unknown; rejected?: unknown; }; avgPerUser?: unknown; avgApprovalWaitDays?: unknown; }; type BlockerStatsState = { stats: BlockerStats | null; loading: boolean; error: string | null; fetchedAt: number | null; refresh: () => Promise; refreshIfStale: (maxAgeMs?: number) => Promise; /** Optimistische lokale Erhöhung von mySubmissions.inReview — damit das Half-Donut * im ProtectionDetailsSheet sofort die neue Freigabe zeigt, ohne auf den * 60s-Cache-Refresh zu warten. Der nächste echte refresh() überschreibt den Wert * ohnehin mit dem Server-State. */ bumpMyInReview: (delta?: number) => void; }; let inFlight: Promise | null = null; function asNumber(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } function normalizeStats(raw: RawStatsResponse): BlockerStats { const inReviewGlobal = asNumber(raw.submissions?.inReview) + asNumber(raw.submissions?.inVote) + asNumber(raw.submissions?.pending); const inReviewMine = asNumber(raw.mySubmissions?.inReview) + asNumber(raw.mySubmissions?.inVote) + asNumber(raw.mySubmissions?.pending); const approvedMine = asNumber(raw.mySubmissions?.approved) + asNumber(raw.mySubmissions?.active); const history = Array.isArray(raw.history) ? raw.history.map((h) => ({ label: typeof h?.label === "string" ? h.label : "", count: asNumber(h?.count), })) : []; return { current: asNumber(raw.current), weeklyAdded: asNumber(raw.weeklyAdded), monthlyAdded: asNumber(raw.monthlyAdded), history, submissions: { inReview: inReviewGlobal, approved: asNumber(raw.submissions?.approved), rejected: asNumber(raw.submissions?.rejected), }, mySubmissions: { inReview: inReviewMine, approved: approvedMine, rejected: asNumber(raw.mySubmissions?.rejected), }, avgPerUser: asNumber(raw.avgPerUser), avgApprovalWaitDays: asNumber(raw.avgApprovalWaitDays), }; } export const useBlockerStatsStore = create((set, get) => ({ stats: null, loading: false, error: null, fetchedAt: null, refresh: async () => { if (inFlight) return inFlight; inFlight = (async () => { set((s) => ({ ...s, loading: true, error: null })); try { const raw = await apiFetch("/api/blocklist/stats"); const stats = normalizeStats(raw ?? {}); set({ stats, loading: false, error: null, fetchedAt: Date.now() }); } catch (e: any) { set((s) => ({ ...s, loading: false, error: e?.message ?? "stats_fetch_failed", })); } finally { inFlight = null; } })(); return inFlight; }, refreshIfStale: async (maxAgeMs = 60_000) => { const { fetchedAt, stats, refresh } = get(); if (!stats || !fetchedAt || Date.now() - fetchedAt > maxAgeMs) { await refresh(); } }, bumpMyInReview: (delta = 1) => { const { stats } = get(); if (!stats) return; set({ stats: { ...stats, mySubmissions: { ...stats.mySubmissions, inReview: Math.max(0, stats.mySubmissions.inReview + delta), }, submissions: { ...stats.submissions, inReview: Math.max(0, stats.submissions.inReview + delta), }, }, }); }, }));