import { useAtomValue } from "@effect/atom-react"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { EnvironmentId } from "@t3tools/contracts"; import { providerClients } from "@t3tools/client-runtime/provider-clients"; import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display"; import { collectLimitAccounts, collectExternalUsageLinks, collectLimitNotices, collectLimitPools, cursorUsageWindowDetails, displayLimitWindows, formatResetsIn, paceRemainingPercent, remainingPercent, type LimitAccount, type LimitPoolWindow, } from "@t3tools/shared/usageLimits"; import { Fragment, type ReactNode, useId, useState } from "react"; import { Linking, Pressable, View } from "react-native"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { environmentPresentations } from "../../state/presentation"; import { ResetCredits } from "./UsageLimitsSection"; import { usageProviderKind, useProviderColors } from "./usageProviders"; 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 providerClients.get(account.driver)?.label ?? account.driver; return emailInitials(account.email) || "Account"; } function emailInitials(email: string) { const [local = "", domain = ""] = email.split("@"); return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase(); } function accountHue(email: string) { let hash = 0; for (let index = 0; index < email.length; index += 1) { hash = (hash * 31 + email.charCodeAt(index)) | 0; } return Math.abs(hash) % 360; } // Same hashed hue as the web account chip, so an account keeps its color across clients. function AccountChip({ email }: { readonly email: string }) { const hue = accountHue(email); return ( {emailInitials(email)} ); } // Same gap-and-tick as the Limits rows: two clipped copies of the segment leave a see-through gap. function PaceGap({ timeLeft, children, }: { readonly timeLeft: number | null; readonly children: ReactNode; }) { const [width, setWidth] = useState(0); const tickX = timeLeft === null || width < 6 ? null : Math.min(Math.max((width * timeLeft) / 100, 3), width - 3); return ( setWidth(event.nativeEvent.layout.width)} > {tickX === null ? children : ( [ [0, tickX - 3], [tickX + 3, width], ] as const ).map(([start, end]) => ( {children} ))} {tickX !== null ? ( ) : null} ); } function AccountSegment({ remaining, color, refillPercent, }: { readonly remaining: number; readonly color: string; readonly refillPercent: number; }) { const patternId = useId().replace(/:/g, ""); const [size, setSize] = useState({ width: 0, height: 0 }); return ( { const { width, height } = event.nativeEvent.layout; setSize((previous) => previous.width === width && previous.height === height ? previous : { width, height }, ); }} > {size.width > 0 && size.height > 0 ? ( // Numeric canvas dimensions clear Android SVG geometry cached before a segment resized. {refillPercent > 0 ? ( ) : null} ) : null} ); } function PoolWindowCard({ pool, color, now, environmentIds, label, description, wide, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; readonly environmentIds: readonly string[] | null; readonly label?: string; readonly description?: string; readonly wide: boolean; }) { const navigation = useNavigation(); const [barWidth, setBarWidth] = useState(0); // Each segment needs room for its account, percent and reset to label itself. const segmentWidth = barWidth / Math.max(pool.columns.length, 1); const inlineLabels = wide && segmentWidth >= 96; const inlineReset = wide && segmentWidth >= 150; const inlineName = wide && segmentWidth >= 220; const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); const openAccount = (account: LimitAccount) => navigation.navigate("SettingsUsageAccount", { accountKey: account.key, windowId: pool.id, windowKind: pool.kind, environmentIds, now, }); const summary = ( <> {label ?? pool.label} {pool.remainingPercent}% left {pool.pace ? ( {PACE_LABEL[pool.pace]} ) : null} {description ? {description} : null} {nextRefill ? ( ↻ +{nextRefill.restoresPercent}%{" "} {formatResetsIn(nextRefill.member.window, now) ?.replace("resets in ", "in ") .replace("resets now", "now")} ) : null} ); const bar = ( setBarWidth(event.nativeEvent.layout.width)} > {pool.columns.map((member, index) => { if (!member.window) return ( ); const { account, window } = member; const timeLeft = paceRemainingPercent(window, now); const credits = account.limits.resetCredits?.availableCount ?? 0; const resetsIn = formatResetsIn(window, now)?.replace("resets in ", ""); return ( openAccount(account)} className={wide ? "h-9 min-w-0 flex-1 rounded-md" : "h-7 min-w-0 flex-1 rounded-md"} > {inlineLabels ? ( {account.email ? : null} {account.email && !(account.displayName && inlineName) ? null : ( {accountName(account)} )} {remainingPercent(window)}% {inlineReset && (resetsIn || credits) ? ( {resetsIn ? ( <> {resetsIn} ) : null} {credits ? ( <> {credits} ) : null} ) : null} ) : ( {index + 1} )} ); })} ); const legend = ( {pool.columns.map(({ account, window }, index) => { if (!window) return null; const segmentColor = normalizeProviderAccentColor(account.accentColor) ?? color; const credits = account.limits.resetCredits?.availableCount ?? 0; const resetsIn = formatResetsIn(window, now); return ( openAccount(account)} className="min-h-[44px] flex-row items-center gap-2 active:opacity-60" > {index + 1} {accountName(account)} {remainingPercent(window)}% {resetsIn ? ( <> {resetsIn.replace("resets in ", "")} ) : null} {credits ? ( <> {resetsIn ? · : null} {credits} ) : null} ); })} ); if (wide) { return ( {summary} {bar} {inlineLabels ? null : legend} ); } return ( {summary} {bar} {legend} ); } export function UsageLimitsSection({ now, failedLabels, selectedEnvironmentIds, wide = false, }: { readonly now: number; readonly failedLabels: readonly string[]; readonly selectedEnvironmentIds: ReadonlySet | null; readonly wide?: boolean; }) { const presentations = useAtomValue(environmentPresentations.presentationsWithProvidersAtom); const selected = selectedEnvironmentIds === null ? presentations : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); const pools = collectLimitPools(collectLimitAccounts(selected), now); const notices = collectLimitNotices(selected); const externalLinks = collectExternalUsageLinks(selected); const colors = useProviderColors(); return ( {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 && externalLinks.length === 0 ? ( {selected.size === 0 ? "Select an environment to see limits." : "No provider on the selected environments reports subscription limits."} ) : null} {pools.map((pool) => { const windows = displayLimitWindows(pool); return ( {providerClients.get(pool.driver)?.label ?? pool.driver} {pool.accounts .filter((account) => account.limits.windows.length === 0) .map((account) => ( {account.email ? : null} {accountName(account)} Limits unavailable ))} {windows.map((window) => { const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; return ( ); })} ); })} {externalLinks.map((link) => ( {link.label} {link.accounts.join(", ")} {link.message ? ( {link.message} ) : null} void Linking.openURL(link.url).catch(() => undefined)} > Manage usage ))} {notices.length > 0 || failedLabels.length > 0 ? ( {notices.map((notice) => ( {notice} ))} {failedLabels.length > 0 ? ( {failedLabels.join(", ")} could not refresh limits. Showing the last known values. ) : null} ) : null} ); } type AccountScreenProps = StaticScreenProps<{ accountKey: string; windowId: string; windowKind: LimitPoolWindow["kind"]; environmentIds: readonly string[] | null; now: number; }>; export function UsageLimitAccountScreen({ route }: AccountScreenProps) { const insets = useSafeAreaInsets(); const presentations = useAtomValue(environmentPresentations.presentationsWithProvidersAtom); 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 evenPace = window ? paceRemainingPercent(window, now) : null; const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey); const [revealed, setRevealed] = useState(false); return ( {!account || !window ? ( This account is no longer reporting limits on the selected environments. ) : ( <> {account.displayName ?? providerClients.get(account.driver)?.label ?? account.driver} {account.email ? ( setRevealed((value) => !value)} className="min-h-[44px] justify-center" > {revealed ? account.email : "••••••@••••••"} ) : null} {account.plan ? ( {account.plan} ) : null} {window.label} {remainingPercent(window)}% left {window.resetsAt ? ( Resets{" "} {new Date(window.resetsAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", })} ) : null} {evenPace !== null ? ( Even pace: {evenPace}% left by now ) : null} {reset && reset.restoresPercent > 0 ? ( Restores {reset.restoresPercent}% of the pool ) : null} {account.environments.length ? "Signed in" : "Source"} {account.environments.length ? ( account.environments.map((environment) => ( {environment.label} )) ) : ( {account.sourceLabel} )} {account.redeem && account.limits.resetCredits ? ( Reset credits ) : null} )} ); }