/** * Multi-environment usage state. * * Every connected environment answers the same typed query; the client merges * the results. Raw transcripts never leave the machine that produced them. * * @module state/usage */import { useAtomValue } from "@effect/atom-react";import { USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageSummary, type UsageSummaryInput,} from "@t3tools/contracts";import { refreshUsage } from "@t3tools/client-runtime/state/usage";import * as Option from "effect/Option";import { AsyncResult, Atom } from "effect/unstable/reactivity";import { useCallback, useMemo } from "react"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";import { appAtomRegistry } from "../rpc/atomRegistry";import { environmentPresentations } from "./presentation";import { serverEnvironment } from "./server"; export interface EnvironmentUsageStatus { readonly environmentId: EnvironmentId; readonly label: string; readonly isPending: boolean; readonly error: string | null; readonly summary: UsageSummary | null;} /** * Reads every environment's summary for one window. * * Keyed by the serialised window so switching ranges does not thrash the atom * cache, and so each environment's query is shared with any other reader of the * same window. */const usageByWindowAtom = Atom.family((windowKey: string) => Atom.make((get): readonly EnvironmentUsageStatus[] => { const input = JSON.parse(windowKey) as UsageSummaryInput; const presentations = get(environmentPresentations.presentationsAtom); const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { const result = get(serverEnvironment.usageSummary({ environmentId, input })); statuses.push({ environmentId, label: presentation.entry.target.label, isPending: result.waiting, error: result._tag === "Failure" ? "This environment could not report usage." : null, summary: Option.getOrNull(AsyncResult.value(result)), }); } return statuses; }).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)),); export interface UsageView { readonly merged: MergedUsage; readonly environments: readonly EnvironmentUsageStatus[]; readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; /** True until at least one selected environment has answered. */ readonly isPending: boolean; /** * True while environments that have not failed are still answering. Failed * environments are reported through their own error rows: totals will not * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; readonly refresh: (input?: UsageSummaryInput) => Promise<void>;} export function useUsage( input: UsageSummaryInput, selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,): UsageView { const windowKey = useMemo( () => JSON.stringify({ sinceDay: input.sinceDay, untilDay: input.untilDay, timeZone: input.timeZone, resolution: input.resolution, sinceTime: input.sinceTime, untilTime: input.untilTime, }), [ input.sinceDay, input.untilDay, input.timeZone, input.resolution, input.sinceTime, input.untilTime, ], ); const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); const selectedEnvironments = useMemo( () => selectedEnvironmentIds === null ? environments : environments.filter((environment) => selectedEnvironmentIds.has(environment.environmentId), ), [environments, selectedEnvironmentIds], ); const refresh = useCallback( (nextInput?: UsageSummaryInput) => refreshUsage({ registry: appAtomRegistry, server: serverEnvironment, presentations: environmentPresentations, environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), }), [selectedEnvironments, windowKey], ); const merged = useMemo(() => { const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => environment.summary === null ? [] : [ { environmentId: environment.environmentId, label: environment.label, summary: environment.summary, }, ], ); return mergeUsage(answered, USAGE_CONTRACT_VERSION); }, [selectedEnvironments]); const answeredCount = selectedEnvironments.filter( (environment) => environment.summary !== null, ).length; const stillReporting = selectedEnvironments.filter( (environment) => environment.summary === null && environment.error === null, ).length; return { merged, environments, selectedEnvironments, isPending: answeredCount === 0 && stillReporting > 0, isPartial: answeredCount > 0 && stillReporting > 0, refresh, };}
/**
* Multi-environment usage state.
*
* Every connected environment answers the same typed query; the client merges
* the results. Raw transcripts never leave the machine that produced them.
*
* @module state/usage
*/
import { useAtomValue } from "@effect/atom-react";
import {
USAGE_CONTRACT_VERSION,
type EnvironmentId,
type UsageSummary,
type UsageSummaryInput,
} from "@t3tools/contracts";
import { refreshUsage } from "@t3tools/client-runtime/state/usage";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useCallback, useMemo } from "react";
import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentPresentations } from "./presentation";
import { serverEnvironment } from "./server";
export interface EnvironmentUsageStatus {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly isPending: boolean;
readonly error: string | null;
readonly summary: UsageSummary | null;
}
/**
* Reads every environment's summary for one window.
*
* Keyed by the serialised window so switching ranges does not thrash the atom
* cache, and so each environment's query is shared with any other reader of the
* same window.
*/
const usageByWindowAtom = Atom.family((windowKey: string) =>
Atom.make((get): readonly EnvironmentUsageStatus[] => {
const input = JSON.parse(windowKey) as UsageSummaryInput;
const presentations = get(environmentPresentations.presentationsAtom);
const statuses: EnvironmentUsageStatus[] = [];
for (const [environmentId, presentation] of presentations) {
const result = get(serverEnvironment.usageSummary({ environmentId, input }));
statuses.push({
environmentId,
label: presentation.entry.target.label,
isPending: result.waiting,
error: result._tag === "Failure" ? "This environment could not report usage." : null,
summary: Option.getOrNull(AsyncResult.value(result)),
});
}
return statuses;
}).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)),
);
export interface UsageView {
readonly merged: MergedUsage;
readonly environments: readonly EnvironmentUsageStatus[];
readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
/** True until at least one selected environment has answered. */
readonly isPending: boolean;
/**
* True while environments that have not failed are still answering. Failed
* environments are reported through their own error rows: totals will not
* improve by waiting on them, so they must not read as "still reporting".
*/
readonly isPartial: boolean;
readonly refresh: (input?: UsageSummaryInput) => Promise<void>;
}
export function useUsage(
input: UsageSummaryInput,
selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
): UsageView {
const windowKey = useMemo(
() =>
JSON.stringify({
sinceDay: input.sinceDay,
untilDay: input.untilDay,
timeZone: input.timeZone,
resolution: input.resolution,
sinceTime: input.sinceTime,
untilTime: input.untilTime,
}),
[
input.sinceDay,
input.untilDay,
input.timeZone,
input.resolution,
input.sinceTime,
input.untilTime,
],
);
const atom = usageByWindowAtom(windowKey);
const environments = useAtomValue(atom);
const selectedEnvironments = useMemo(
() =>
selectedEnvironmentIds === null
? environments
: environments.filter((environment) =>
selectedEnvironmentIds.has(environment.environmentId),
),
[environments, selectedEnvironmentIds],
);
const refresh = useCallback(
(nextInput?: UsageSummaryInput) =>
refreshUsage({
registry: appAtomRegistry,
server: serverEnvironment,
presentations: environmentPresentations,
environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId),
input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
}),
[selectedEnvironments, windowKey],
);
const merged = useMemo(() => {
const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) =>
environment.summary === null
? []
: [
{
environmentId: environment.environmentId,
label: environment.label,
summary: environment.summary,
},
],
);
return mergeUsage(answered, USAGE_CONTRACT_VERSION);
}, [selectedEnvironments]);
const answeredCount = selectedEnvironments.filter(
(environment) => environment.summary !== null,
).length;
const stillReporting = selectedEnvironments.filter(
(environment) => environment.summary === null && environment.error === null,
).length;
return {
merged,
environments,
selectedEnvironments,
isPending: answeredCount === 0 && stillReporting > 0,
isPartial: answeredCount > 0 && stillReporting > 0,
refresh,
};
}