1import type { UsageProviderKind, UsageSummary } from "@t3tools/contracts";
2
3interface UsageProgressEnvironment {
4 readonly label: string;
5 readonly isConnected: boolean;
6 readonly isPending: boolean;
7 readonly error: string | null;
8 readonly summary: UsageSummary | null;
9}
10
11/**
12 * Where one environment's answer stands for the usage on screen.
13 *
14 * - `inactive`: disconnected or failed, so waiting on it changes nothing.
15 * - `loading`: no summary yet.
16 * - `stale`: its summary on screen predates the current request.
17 * - `partway`: answered this round while slow sources finish refreshing.
18 * - `ready`: answered.
19 *
20 * While a manual `refreshing` runs, every summary counts as stale.
21 */
22export function usageEnvironmentProgress(
23 environment: UsageProgressEnvironment,
24 refreshing = false,
25) {
26 const { summary } = environment;
27 if (!environment.isConnected || environment.error !== null) return { phase: "inactive" } as const;
28 if (summary === null) return { phase: "loading" } as const;
29 if (refreshing) return { phase: "stale" } as const;
30 // A refreshing source without a pending follow-up has nothing left to wait on.
31 if (!environment.isPending) return { phase: "ready" } as const;
32 const providers = [
33 ...new Set(
34 summary.sources.flatMap((source) => (source.refreshing ? [source.fingerprint.provider] : [])),
35 ),
36 ];
37 return providers.length > 0
38 ? ({ phase: "partway", providers } as const)
39 : ({ phase: "stale" } as const);
40}
41
42export function updatingProvidersLabel(
43 providers: readonly UsageProviderKind[],
44 providerLabel: (provider: UsageProviderKind) => string,
45) {
46 return providers.length === 1
47 ? `Updating ${providerLabel(providers[0]!)}…`
48 : `Updating ${providers.length} providers…`;
49}
50
51/**
52 * Loading state for the selected environments' merged usage.
53 *
54 * The page dims only while old numbers are all that is on screen. Once any
55 * environment answers, the label carries what is still coming, so one slow
56 * device never holds the page dimmed.
57 */
58export function usageProgress(
59 environments: readonly UsageProgressEnvironment[],
60 {
61 refreshing = false,
62 providerLabel,
63 }: {
64 readonly refreshing?: boolean;
65 readonly providerLabel: (provider: UsageProviderKind) => string;
66 },
67) {
68 const progress = environments.map((environment) => ({
69 label: environment.label,
70 ...usageEnvironmentProgress(environment, refreshing),
71 }));
72 const waiting = progress.filter(({ phase }) => phase === "loading" || phase === "stale");
73 const providers = [
74 ...new Set(progress.flatMap((entry) => (entry.phase === "partway" ? entry.providers : []))),
75 ];
76 const answered = progress.some(({ phase }) => phase === "partway" || phase === "ready");
77 return {
78 dimmed: !answered && progress.some(({ phase }) => phase === "stale"),
79 label:
80 waiting.length === 0
81 ? providers.length === 0
82 ? null
83 : updatingProvidersLabel(providers, providerLabel)
84 : environments.length === 1
85 ? "Updating…"
86 : waiting.length === 1
87 ? `Updating ${waiting[0]!.label}…`
88 : `Updating ${waiting.length} environments…`,
89 };
90}
91
92/**
93 * Which figures in the merged usage on screen are still coming in, so clients
94 * can mute just those.
95 *
96 * - `partial`: totals will change because some environment is still answering.
97 * - `providers`: providers whose own figures will change. An environment that
98 * has not answered yet, or whose whole summary is stale, could change any
99 * provider, so it sets `everyProvider`.
100 */
101export function usageLoadingState(
102 environments: readonly UsageProgressEnvironment[],
103 refreshing = false,
104) {
105 const providers = new Set<UsageProviderKind>();
106 let everyProvider = false;
107 for (const environment of environments) {
108 const progress = usageEnvironmentProgress(environment, refreshing);
109 if (progress.phase === "loading" || progress.phase === "stale") everyProvider = true;
110 if (progress.phase === "partway")
111 for (const provider of progress.providers) providers.add(provider);
112 }
113 return { partial: everyProvider || providers.size > 0, everyProvider, providers };
114}