378 lines · 15.0 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import { useNavigation, type StaticScreenProps } from "@react-navigation/native";import { EnvironmentId } from "@t3tools/contracts";import {  collectLimitAccounts,  collectLimitNotices,  collectLimitPools,  formatDuration,  formatResetsIn,  remainingPercent,  type LimitAccount,  type LimitPoolWindow,} from "@t3tools/shared/usageLimits";import { useId, useState } from "react";import { Platform, Pressable, ScrollView, View } from "react-native";import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";import { SymbolView } from "../../components/AppSymbol";import { AppText as Text } from "../../components/AppText";import { ProviderIcon } from "../../components/ProviderIcon";import { NativeStackScreenOptions } from "../../native/StackHeader";import { environmentPresentations } from "../../state/presentation";import { ResetCredits } from "./UsageLimitsSection";import { useProviderColors } from "./usageProviders"; const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; function accountName(account: LimitAccount) {  if (account.displayName) return account.displayName;  if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver);  const [local = "", domain = ""] = account.email.split("@");  return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account";} /** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */function AccountSegment({  remaining,  color,  pending,}: {  readonly remaining: number;  readonly color: string;  readonly pending: boolean;}) {  const patternId = useId().replace(/:/g, "");  return (    <Svg width="100%" height="100%" accessible={false}>      <Defs>        <Pattern id={patternId} width={6} height={6} patternUnits="userSpaceOnUse">          <Path d="M-1 1L1 -1M0 6L6 0M5 7L7 5" stroke={color} strokeWidth={1} opacity={0.22} />        </Pattern>      </Defs>      {pending ? (        <Rect          x={`${remaining}%`}          width={`${100 - remaining}%`}          height="100%"          fill={`url(#${patternId})`}        />      ) : null}      <Rect width={`${remaining}%`} height="100%" fill={color} opacity={0.35} />    </Svg>  );} function PoolWindowCard({  pool,  color,  now,  environmentIds,}: {  readonly pool: LimitPoolWindow;  readonly color: string;  readonly now: number;  readonly environmentIds: readonly string[] | null;}) {  const navigation = useNavigation();  const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);  const openAccount = (account: LimitAccount) =>    navigation.navigate("SettingsSheet", {      screen: "SettingsContent",      params: {        screen: "SettingsUsageAccount",        params: {          accountKey: account.key,          windowId: pool.id,          windowKind: pool.kind,          environmentIds,          now,        },      },    });  return (    <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">      <View className="flex-row items-start justify-between gap-3">        <View className="gap-1">          <Text className="text-sm font-t3-medium text-foreground">{pool.label}</Text>          <View className="flex-row items-baseline gap-1.5">            <Text className="text-3xl font-t3-bold tabular-nums text-foreground">              {pool.remainingPercent}%            </Text>            <Text className="text-sm text-foreground-muted">left</Text>          </View>        </View>        {pool.pace ? (          <Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>        ) : null}      </View>      {nextRefill ? (        <Text className="text-xs tabular-nums text-foreground-muted">          ↻ +{nextRefill.restoresPercent}%{" "}          {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`}        </Text>      ) : null}      <View className="flex-row gap-1">        {pool.columns.map(({ account, window }, index) => {          if (!window) return <View key={account.key} className="h-7 min-w-0 flex-1" />;          return (            <Pressable              key={account.key}              accessibilityRole="button"              accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}              accessibilityHint="Show account details"              onPress={() => openAccount(account)}              className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"            >              <AccountSegment                remaining={remainingPercent(window)}                color={color}                pending={Boolean(window.resetsAt)}              />              <View pointerEvents="none" className="absolute inset-0 items-center justify-center">                <Text className="text-xs font-t3-medium tabular-nums text-foreground">                  {index + 1}                </Text>              </View>            </Pressable>          );        })}      </View>      <View>        {pool.columns.map(({ account, window }, index) => {          if (!window) return null;          const credits = account.limits.resetCredits?.availableCount ?? 0;          const resetsIn = formatResetsIn(window, now);          return (            <Pressable              key={account.key}              accessibilityRole="button"              accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset credits banked` : ""}`}              accessibilityHint="Show account details"              onPress={() => openAccount(account)}              className="min-h-[44px] flex-row items-center gap-2 active:opacity-60"            >              <View className="size-5 items-center justify-center overflow-hidden rounded-md bg-subtle-strong">                <Text className="text-xs font-t3-medium tabular-nums text-foreground">                  {index + 1}                </Text>              </View>              <Text                numberOfLines={1}                className="min-w-0 flex-1 text-sm font-t3-medium text-foreground"              >                {accountName(account)}              </Text>              <Text className="text-sm font-t3-medium tabular-nums text-foreground">                {remainingPercent(window)}%              </Text>              <View className="flex-row items-center gap-1">                {resetsIn ? (                  <Text className="text-xs tabular-nums text-foreground-muted">                    {resetsIn.replace("resets in ", "↻ ")}                  </Text>                ) : null}                {credits ? (                  <>                    {resetsIn ? <Text className="text-xs text-foreground-tertiary">·</Text> : null}                    <SymbolView name="ticket" size={13} tintColorClassName="accent-icon" />                    <Text className="text-xs font-t3-medium tabular-nums text-foreground">                      {credits}                    </Text>                  </>                ) : null}              </View>            </Pressable>          );        })}      </View>    </View>  );} export function UsageLimitsSection({  now,  failedLabels,  selectedEnvironmentIds,}: {  readonly now: number;  readonly failedLabels: readonly string[];  readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;}) {  const presentations = useAtomValue(environmentPresentations.presentationsAtom);  const selected =    selectedEnvironmentIds === null      ? presentations      : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));  const pools = collectLimitPools(collectLimitAccounts(selected), now);  const notices = collectLimitNotices(selected);  const colors = useProviderColors();  return (    <View className="gap-6">      {failedLabels.length ? (        <Text className="text-sm text-foreground-muted">          {failedLabels.join(", ")} could not refresh limits. Showing the last known values.        </Text>      ) : null}      {pools.length === 0 ? (        <Text className="py-12 text-center text-base text-foreground-muted">          {selected.size === 0            ? "Select an environment to see limits."            : "No provider on the selected environments reports subscription limits."}        </Text>      ) : null}      {pools.map((pool) => (        <View key={pool.driver} className="gap-3">          <View className="flex-row items-center gap-2 px-1">            <ProviderIcon provider={pool.driver} size={18} />            <Text className="text-base font-t3-medium text-foreground">              {DRIVER_LABEL[pool.driver] ?? pool.driver}            </Text>          </View>          {pool.windows.map((window) => (            <PoolWindowCard              key={`${window.kind}:${window.id}`}              pool={window}              color={pool.driver === "claudeAgent" ? colors.claude : colors.codex}              now={now}              environmentIds={selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]}            />          ))}        </View>      ))}      {notices.map((notice) => (        <Text key={notice} className="text-sm text-foreground-muted">          {notice}        </Text>      ))}    </View>  );} type AccountScreenProps = StaticScreenProps<{  accountKey: string;  windowId: string;  windowKind: LimitPoolWindow["kind"];  environmentIds: readonly string[] | null;  now: number;}>; /** Resolve the account again so live quota and credit updates reach the open detail screen. */export function UsageLimitAccountScreen({ route }: AccountScreenProps) {  const navigation = useNavigation();  const insets = useSafeAreaInsets();  const presentations = useAtomValue(environmentPresentations.presentationsAtom);  const { accountKey, windowId, windowKind, environmentIds, now } = route.params;  const selectedIds =    environmentIds === null ? null : new Set(environmentIds.map((id) => EnvironmentId.make(id)));  const selected =    selectedIds === null      ? presentations      : new Map([...presentations].filter(([id]) => selectedIds.has(id)));  const accounts = collectLimitAccounts(selected);  const account = accounts.find((candidate) => candidate.key === accountKey);  const pool = collectLimitPools(accounts, now)    .find((candidate) => candidate.driver === account?.driver)    ?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind);  const window = pool?.members.find((member) => member.account.key === accountKey)?.window;  const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey);  const [revealed, setRevealed] = useState(false);  return (    <View collapsable={false} className="flex-1 bg-sheet">      {Platform.OS === "android" ? (        <>          <NativeStackScreenOptions options={{ headerShown: false }} />          <AndroidScreenHeader title="Account" onBack={() => navigation.goBack()} />        </>      ) : null}      <ScrollView        contentInsetAdjustmentBehavior="automatic"        contentContainerClassName="gap-5 p-5"        contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}      >        {!account || !window ? (          <Text className="text-base text-foreground-muted">            This account is no longer reporting limits on the selected environments.          </Text>        ) : (          <>            <View className="gap-2">              <View className="flex-row items-center gap-2">                <ProviderIcon provider={account.driver} size={24} />                <Text className="flex-1 text-xl font-t3-bold text-foreground">                  {account.displayName ?? DRIVER_LABEL[account.driver] ?? account.driver}                </Text>              </View>              {account.email ? (                <Pressable                  accessibilityRole="button"                  accessibilityLabel={revealed ? "Hide account email" : "Reveal account email"}                  onPress={() => setRevealed((value) => !value)}                  className="min-h-[44px] justify-center"                >                  <Text className="text-sm text-foreground-muted">                    {revealed ? account.email : "••••••@••••••"}                  </Text>                </Pressable>              ) : null}              {account.plan ? (                <Text selectable className="text-sm text-foreground-muted">                  {account.plan}                </Text>              ) : null}            </View>            <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">              <Text className="text-sm font-t3-medium text-foreground">{window.label}</Text>              <Text className="text-3xl font-t3-bold tabular-nums text-foreground">                {remainingPercent(window)}% left              </Text>              {window.resetsAt ? (                <Text selectable className="text-sm text-foreground-muted">                  Resets{" "}                  {new Date(window.resetsAt).toLocaleString(undefined, {                    dateStyle: "medium",                    timeStyle: "short",                  })}                </Text>              ) : null}              {reset && reset.restoresPercent > 0 ? (                <Text className="text-sm text-foreground-muted">                  Restores {reset.restoresPercent}% of the pool                </Text>              ) : null}            </View>            <View className="gap-2 rounded-[24px] border-continuous bg-card p-4">              <Text className="text-sm font-t3-medium text-foreground">                {account.environments.length ? "Signed in" : "Source"}              </Text>              {account.environments.length ? (                account.environments.map((environment) => (                  <Text key={environment.environmentId} className="text-sm text-foreground-muted">                    {environment.label}                  </Text>                ))              ) : (                <Text className="text-sm text-foreground-muted">{account.sourceLabel}</Text>              )}            </View>            {account.redeem && account.limits.resetCredits ? (              <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">                <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text>                <ResetCredits                  key={account.key}                  environmentId={account.redeem.environmentId}                  input={account.redeem.input}                  credits={account.limits.resetCredits}                  now={now}                />              </View>            ) : null}          </>        )}      </ScrollView>    </View>  );}