chahinebrini c1edef8abd feat(magic): RebreakMagic device-binding + DNS profile
- backend: /api/magic/{register,devices,profile,release} + AdGuard provisioning + 24h cooldown
- prisma: magic_binding_fields migration (additive on UserDevice)
- mac-app: Phase 2 - Login + MacRegistration + Profile install
- marketing: landing section + /download/rebreakmagic + DMG
- lyra: forbidden phrases + RebreakMagic coach guidance
2026-06-02 09:15:19 +02:00

163 lines
4.2 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>;
/** 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<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();
}
},
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),
},
},
});
},
}));