- devices: cleanupStaleDevices() purges phantoms >14d not bound; called from GET /api/devices + register limit-check. Deterministic sort (lastSeenAt, createdAt, id) so iPad+iPhone see identical order. Merge-cutoff tightened 30d -> 7d. - stats: rejected aggregates from notifications(type='domain_rejected') via Math.max — admin reject cascade-deletes submission row. - blocker.tsx: useDomainSubmissionRealtime triggers blockerStats.refresh() directly (not stale-check only) so info-sheet shows fresh rejected count.
140 lines
3.5 KiB
TypeScript
140 lines
3.5 KiB
TypeScript
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<void>;
|
|
refreshIfStale: (maxAgeMs?: number) => Promise<void>;
|
|
};
|
|
|
|
let inFlight: Promise<void> | 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<BlockerStatsState>((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<RawStatsResponse>('/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();
|
|
}
|
|
},
|
|
}));
|