feat: strengthen module contracts and shared WebUI runtime

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 53e947935a
commit 68328f3d8e
57 changed files with 4358 additions and 371 deletions
+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type SharedNotificationSummary = {
total: number;
unread: number;
pending: number;
failed: number;
show_unread_badge?: boolean;
};
export type SharedNotificationSummarySnapshot = {
summary: SharedNotificationSummary | null;
loading: boolean;
error: unknown;
};
type Listener = (snapshot: SharedNotificationSummarySnapshot) => void;
type SummaryStore = {
settings: ApiSettings;
listeners: Map<Listener, number>;
snapshot: SharedNotificationSummarySnapshot;
inFlight: Promise<void> | null;
lastFetchedAt: number;
timerId: number | null;
focusListener: () => void;
changeListener: () => void;
};
const stores = new Map<string, SummaryStore>();
const EMPTY_SNAPSHOT: SharedNotificationSummarySnapshot = {
summary: null,
loading: false,
error: null
};
function storeKey(settings: ApiSettings, scopeKey: string): string {
return [
settings.apiBaseUrl,
settings.apiKey,
settings.accessToken,
scopeKey
].join("\u0000");
}
function notify(store: SummaryStore): void {
for (const listener of store.listeners.keys()) {
listener(store.snapshot);
}
}
function minimumInterval(store: SummaryStore): number {
return Math.max(
5_000,
Math.min(...Array.from(store.listeners.values()))
);
}
function refresh(store: SummaryStore, force = false): Promise<void> {
if (store.inFlight) return store.inFlight;
if (
!force &&
store.lastFetchedAt > 0 &&
Date.now() - store.lastFetchedAt < Math.min(minimumInterval(store), 5_000)
) {
return Promise.resolve();
}
if (store.snapshot.summary === null) {
store.snapshot = { ...store.snapshot, loading: true, error: null };
notify(store);
}
const request = apiFetch<SharedNotificationSummary>(
store.settings,
"/api/v1/notifications/summary"
)
.then((summary) => {
store.lastFetchedAt = Date.now();
store.snapshot = { summary, loading: false, error: null };
notify(store);
})
.catch((error) => {
store.snapshot = {
...store.snapshot,
loading: false,
error
};
notify(store);
})
.finally(() => {
if (store.inFlight === request) store.inFlight = null;
});
store.inFlight = request;
return request;
}
function schedule(store: SummaryStore): void {
if (store.timerId !== null) window.clearInterval(store.timerId);
if (store.listeners.size === 0) {
store.timerId = null;
return;
}
store.timerId = window.setInterval(
() => void refresh(store, true),
minimumInterval(store)
);
}
function createStore(settings: ApiSettings): SummaryStore {
const store = {} as SummaryStore;
store.settings = settings;
store.listeners = new Map();
store.snapshot = { ...EMPTY_SNAPSHOT };
store.inFlight = null;
store.lastFetchedAt = 0;
store.timerId = null;
store.focusListener = () => void refresh(store);
store.changeListener = () => void refresh(store, true);
return store;
}
function subscribe(
settings: ApiSettings,
scopeKey: string,
intervalMs: number,
listener: Listener
): { store: SummaryStore; unsubscribe: () => void } {
const key = storeKey(settings, scopeKey);
let store = stores.get(key);
if (!store) {
store = createStore(settings);
stores.set(key, store);
}
const firstListener = store.listeners.size === 0;
store.listeners.set(listener, Math.max(5_000, intervalMs));
if (firstListener) {
window.addEventListener("focus", store.focusListener);
window.addEventListener(
"govoplan:notifications-changed",
store.changeListener
);
}
listener(store.snapshot);
schedule(store);
void refresh(store);
return {
store,
unsubscribe: () => {
store?.listeners.delete(listener);
if (store?.listeners.size === 0) {
schedule(store);
window.removeEventListener("focus", store.focusListener);
window.removeEventListener(
"govoplan:notifications-changed",
store.changeListener
);
stores.delete(key);
} else if (store) {
schedule(store);
}
}
};
}
export function useSharedNotificationSummary(
settings: ApiSettings,
options: {
enabled: boolean;
scopeKey: string;
intervalMs?: number;
refreshKey?: string | number;
}
): SharedNotificationSummarySnapshot {
const [snapshot, setSnapshot] =
useState<SharedNotificationSummarySnapshot>(EMPTY_SNAPSHOT);
const activeStore = useRef<SummaryStore | null>(null);
const previousRefreshKey = useRef(options.refreshKey);
useEffect(() => {
if (!options.enabled) {
activeStore.current = null;
setSnapshot(EMPTY_SNAPSHOT);
return;
}
const subscription = subscribe(
settings,
options.scopeKey,
options.intervalMs ?? 60_000,
setSnapshot
);
activeStore.current = subscription.store;
return () => {
activeStore.current = null;
subscription.unsubscribe();
};
}, [
options.enabled,
options.intervalMs,
options.scopeKey,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => {
if (previousRefreshKey.current === options.refreshKey) return;
previousRefreshKey.current = options.refreshKey;
if (activeStore.current) void refresh(activeStore.current, true);
}, [options.refreshKey]);
return snapshot;
}