1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { EnvironmentId } from "@t3tools/contracts";
4import {
5 collectLimitAccounts,
6 collectExternalUsageLinks,
7 collectLimitNotices,
8 collectLimitPools,
9 cursorUsageWindowDetails,
10 displayLimitWindows,
11 formatResetsIn,
12 remainingPercent,
13 type LimitAccount,
14 type LimitPoolWindow,
15} from "@t3tools/shared/usageLimits";
16import { Fragment, type ReactNode, useId, useState } from "react";
17import { Linking, Pressable, ScrollView, View } from "react-native";
18import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";
19import { useSafeAreaInsets } from "react-native-safe-area-context";
20
21import { SymbolView } from "../../components/AppSymbol";
22import { AppText as Text } from "../../components/AppText";
23import { ProviderIcon } from "../../components/ProviderIcon";
24import { SettingsScreen } from "../settings/components/SettingsScreen";
25import { environmentPresentations } from "../../state/presentation";
26import { ResetCredits } from "./UsageLimitsSection";
27import { useProviderColors } from "./usageProviders";
28
29const DRIVER_LABEL: Partial<Record<string, string>> = {
30 codex: "Codex",
31 claudeAgent: "Claude",
32 antigravity: "Antigravity",
33};
34const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const;
35
36function accountName(account: LimitAccount) {
37 if (account.displayName) return account.displayName;
38 if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver);
39 const [local = "", domain = ""] = account.email.split("@");
40 return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account";
41}
42
43/** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */
44function AccountSegment({
45 remaining,
46 color,
47 pending,
48}: {
49 readonly remaining: number;
50 readonly color: string;
51 readonly pending: boolean;
52}) {
53 const patternId = useId().replace(/:/g, "");
54 return (
55 <Svg width="100%" height="100%" accessible={false}>
56 <Defs>
57 <Pattern id={patternId} width={6} height={6} patternUnits="userSpaceOnUse">
58 <Path d="M-1 1L1 -1M0 6L6 0M5 7L7 5" stroke={color} strokeWidth={1} opacity={0.22} />
59 </Pattern>
60 </Defs>
61 {pending ? (
62 <Rect
63 x={`${remaining}%`}
64 width={`${100 - remaining}%`}
65 height="100%"
66 fill={`url(#${patternId})`}
67 />
68 ) : null}
69 <Rect width={`${remaining}%`} height="100%" fill={color} opacity={0.35} />
70 </Svg>
71 );
72}
73
74function PoolWindowCard({
75 pool,
76 color,
77 now,
78 environmentIds,
79 label,
80 description,
81}: {
82 readonly pool: LimitPoolWindow;
83 readonly color: string;
84 readonly now: number;
85 readonly environmentIds: readonly string[] | null;
86 readonly label?: string;
87 readonly description?: string;
88}) {
89 const navigation = useNavigation();
90 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
91 const openAccount = (account: LimitAccount) =>
92 navigation.navigate("SettingsSheet", {
93 screen: "SettingsContent",
94 params: {
95 screen: "SettingsUsageAccount",
96 params: {
97 accountKey: account.key,
98 windowId: pool.id,
99 windowKind: pool.kind,
100 environmentIds,
101 now,
102 },
103 },
104 });
105 return (
106 <View className="gap-3 rounded-[24px] border-continuous bg-grouped-card p-4">
107 <View className="flex-row items-start justify-between gap-3">
108 <View className="gap-1">
109 <Text className="text-sm font-t3-medium text-foreground">{label ?? pool.label}</Text>
110 <View className="flex-row items-baseline gap-1.5">
111 <Text className="text-3xl font-t3-bold tabular-nums text-foreground">
112 {pool.remainingPercent}%
113 </Text>
114 <Text className="text-sm text-foreground-muted">left</Text>
115 </View>
116 </View>
117 {pool.pace ? (
118 <Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>
119 ) : null}
120 </View>
121 {description ? <Text className="text-xs text-foreground-muted">{description}</Text> : null}
122 {nextRefill ? (
123 <Text className="text-xs tabular-nums text-foreground-muted">
124 ↻ +{nextRefill.restoresPercent}%{" "}
125 {formatResetsIn(nextRefill.member.window, now)
126 ?.replace("resets in ", "in ")
127 .replace("resets now", "now")}
128 </Text>
129 ) : null}
130 <View className="flex-row gap-1">
131 {pool.columns.map(({ account, window }, index) => {
132 if (!window) return <View key={account.key} className="h-7 min-w-0 flex-1" />;
133 return (
134 <Pressable
135 key={account.key}
136 accessibilityRole="button"
137 accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
138 accessibilityHint="Show account details"
139 onPress={() => openAccount(account)}
140 className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
141 >
142 <AccountSegment
143 remaining={remainingPercent(window)}
144 color={color}
145 pending={Boolean(window.resetsAt)}
146 />
147 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
148 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
149 {index + 1}
150 </Text>
151 </View>
152 </Pressable>
153 );
154 })}
155 </View>
156 <View>
157 {pool.columns.map(({ account, window }, index) => {
158 if (!window) return null;
159 const credits = account.limits.resetCredits?.availableCount ?? 0;
160 const resetsIn = formatResetsIn(window, now);
161 return (
162 <Pressable
163 key={account.key}
164 accessibilityRole="button"
165 accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset credits banked` : ""}`}
166 accessibilityHint="Show account details"
167 onPress={() => openAccount(account)}
168 className="min-h-[44px] flex-row items-center gap-2 active:opacity-60"
169 >
170 <View className="size-5 items-center justify-center overflow-hidden rounded-md bg-subtle-strong">
171 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
172 {index + 1}
173 </Text>
174 </View>
175 <Text
176 numberOfLines={1}
177 className="min-w-0 flex-1 text-sm font-t3-medium text-foreground"
178 >
179 {accountName(account)}
180 </Text>
181 <Text className="text-sm font-t3-medium tabular-nums text-foreground">
182 {remainingPercent(window)}%
183 </Text>
184 <View className="flex-row items-center gap-1">
185 {resetsIn ? (
186 <>
187 <SymbolView
188 name="arrow.clockwise"
189 size={12}
190 tintColorClassName="accent-foreground-muted"
191 />
192 <Text className="text-xs tabular-nums text-foreground-muted">
193 {resetsIn.replace("resets in ", "")}
194 </Text>
195 </>
196 ) : null}
197 {credits ? (
198 <>
199 {resetsIn ? <Text className="text-xs text-foreground-tertiary">·</Text> : null}
200 <SymbolView name="ticket" size={13} tintColorClassName="accent-icon" />
201 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
202 {credits}
203 </Text>
204 </>
205 ) : null}
206 </View>
207 </Pressable>
208 );
209 })}
210 </View>
211 </View>
212 );
213}
214
215export function UsageLimitsSection({
216 now,
217 failedLabels,
218 selectedEnvironmentIds,
219 cursorPrompt,
220}: {
221 readonly now: number;
222 readonly failedLabels: readonly string[];
223 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
224 readonly cursorPrompt?: ReactNode;
225}) {
226 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
227 const selected =
228 selectedEnvironmentIds === null
229 ? presentations
230 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
231 const pools = collectLimitPools(collectLimitAccounts(selected), now);
232 const notices = collectLimitNotices(selected);
233 const externalLinks = collectExternalUsageLinks(selected);
234 const colors = useProviderColors();
235 const cursorPromptAt =
236 Math.max(
237 pools.findIndex((pool) => pool.driver === "codex"),
238 pools.findIndex((pool) => pool.driver === "claudeAgent"),
239 ) + 1;
240 return (
241 <View className="gap-6">
242 {pools.length === 0 &&
243 notices.length === 0 &&
244 failedLabels.length === 0 &&
245 !cursorPrompt &&
246 externalLinks.length === 0 ? (
247 <Text className="py-12 text-center text-base text-foreground-muted">
248 {selected.size === 0
249 ? "Select an environment to see limits."
250 : "No provider on the selected environments reports subscription limits."}
251 </Text>
252 ) : null}
253 {pools.map((pool, index) => {
254 const windows = displayLimitWindows(pool);
255 return (
256 <Fragment key={pool.driver}>
257 {index === cursorPromptAt ? cursorPrompt : null}
258 <View className="gap-3">
259 <View className="flex-row items-center gap-2 px-1">
260 <ProviderIcon provider={pool.driver} size={18} />
261 <Text className="text-base font-t3-medium text-foreground">
262 {DRIVER_LABEL[pool.driver] ?? pool.driver}
263 </Text>
264 </View>
265 {windows.map((window) => {
266 const details =
267 pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined;
268 return (
269 <PoolWindowCard
270 key={`${window.kind}:${window.id}`}
271 pool={window}
272 color={
273 pool.driver === "claudeAgent"
274 ? colors.claude
275 : pool.driver === "antigravity"
276 ? colors.antigravity
277 : colors.codex
278 }
279 now={now}
280 environmentIds={
281 selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]
282 }
283 label={details?.label}
284 description={details?.description}
285 />
286 );
287 })}
288 </View>
289 </Fragment>
290 );
291 })}
292 {cursorPromptAt === pools.length ? cursorPrompt : null}
293 {externalLinks.map((link) => (
294 <View key={link.url} className="gap-3 rounded-xl border border-border-subtle p-4">
295 <Text className="text-base font-t3-medium text-foreground">{link.label}</Text>
296 <Text className="text-xs text-foreground-muted">{link.accounts.join(", ")}</Text>
297 {link.message ? (
298 <Text className="text-sm text-foreground-muted">{link.message}</Text>
299 ) : null}
300 <Pressable
301 accessibilityRole="link"
302 className="min-h-11 justify-center"
303 onPress={() => void Linking.openURL(link.url).catch(() => undefined)}
304 >
305 <Text className="text-sm font-t3-medium text-primary">Manage usage</Text>
306 </Pressable>
307 </View>
308 ))}
309 {notices.length > 0 || failedLabels.length > 0 ? (
310 <View
311 accessible
312 accessibilityRole="alert"
313 accessibilityLiveRegion="polite"
314 className="flex-row items-start gap-2 rounded-xl border border-warning-border bg-warning px-3.5 py-3"
315 >
316 <SymbolView
317 name="exclamationmark.triangle"
318 size={16}
319 tintColorClassName="accent-warning-foreground"
320 />
321 <View className="min-w-0 flex-1 gap-0.5">
322 {notices.map((notice) => (
323 <Text key={notice} className="text-sm font-t3-medium text-warning-foreground">
324 {notice}
325 </Text>
326 ))}
327 {failedLabels.length > 0 ? (
328 <Text className="text-sm font-t3-medium text-warning-foreground">
329 {failedLabels.join(", ")} could not refresh limits. Showing the last known values.
330 </Text>
331 ) : null}
332 </View>
333 </View>
334 ) : null}
335 </View>
336 );
337}
338
339type AccountScreenProps = StaticScreenProps<{
340 accountKey: string;
341 windowId: string;
342 windowKind: LimitPoolWindow["kind"];
343 environmentIds: readonly string[] | null;
344 now: number;
345}>;
346
347/** Resolve the account again so live quota and credit updates reach the open detail screen. */
348export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
349 const insets = useSafeAreaInsets();
350 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
351 const { accountKey, windowId, windowKind, environmentIds, now } = route.params;
352 const selectedIds =
353 environmentIds === null ? null : new Set(environmentIds.map((id) => EnvironmentId.make(id)));
354 const selected =
355 selectedIds === null
356 ? presentations
357 : new Map([...presentations].filter(([id]) => selectedIds.has(id)));
358 const accounts = collectLimitAccounts(selected);
359 const account = accounts.find((candidate) => candidate.key === accountKey);
360 const pool = collectLimitPools(accounts, now)
361 .find((candidate) => candidate.driver === account?.driver)
362 ?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind);
363 const window = pool?.members.find((member) => member.account.key === accountKey)?.window;
364 const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey);
365 const [revealed, setRevealed] = useState(false);
366 return (
367 <SettingsScreen title="Account">
368 <ScrollView
369 contentInsetAdjustmentBehavior="automatic"
370 contentContainerClassName="gap-5 p-5"
371 contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
372 >
373 {!account || !window ? (
374 <Text className="text-base text-foreground-muted">
375 This account is no longer reporting limits on the selected environments.
376 </Text>
377 ) : (
378 <>
379 <View className="gap-2">
380 <View className="flex-row items-center gap-2">
381 <ProviderIcon provider={account.driver} size={24} />
382 <Text className="flex-1 text-xl font-t3-bold text-foreground">
383 {account.displayName ?? DRIVER_LABEL[account.driver] ?? account.driver}
384 </Text>
385 </View>
386 {account.email ? (
387 <Pressable
388 accessibilityRole="button"
389 accessibilityLabel={revealed ? "Hide account email" : "Reveal account email"}
390 onPress={() => setRevealed((value) => !value)}
391 className="min-h-[44px] justify-center"
392 >
393 <Text className="text-sm text-foreground-muted">
394 {revealed ? account.email : "••••••@••••••"}
395 </Text>
396 </Pressable>
397 ) : null}
398 {account.plan ? (
399 <Text selectable className="text-sm text-foreground-muted">
400 {account.plan}
401 </Text>
402 ) : null}
403 </View>
404 <View className="gap-3 rounded-[24px] border-continuous bg-grouped-card p-4">
405 <Text className="text-sm font-t3-medium text-foreground">{window.label}</Text>
406 <Text className="text-3xl font-t3-bold tabular-nums text-foreground">
407 {remainingPercent(window)}% left
408 </Text>
409 {window.resetsAt ? (
410 <Text selectable className="text-sm text-foreground-muted">
411 Resets{" "}
412 {new Date(window.resetsAt).toLocaleString(undefined, {
413 dateStyle: "medium",
414 timeStyle: "short",
415 })}
416 </Text>
417 ) : null}
418 {reset && reset.restoresPercent > 0 ? (
419 <Text className="text-sm text-foreground-muted">
420 Restores {reset.restoresPercent}% of the pool
421 </Text>
422 ) : null}
423 </View>
424 <View className="gap-2 rounded-[24px] border-continuous bg-grouped-card p-4">
425 <Text className="text-sm font-t3-medium text-foreground">
426 {account.environments.length ? "Signed in" : "Source"}
427 </Text>
428 {account.environments.length ? (
429 account.environments.map((environment) => (
430 <Text key={environment.environmentId} className="text-sm text-foreground-muted">
431 {environment.label}
432 </Text>
433 ))
434 ) : (
435 <Text className="text-sm text-foreground-muted">{account.sourceLabel}</Text>
436 )}
437 </View>
438 {account.redeem && account.limits.resetCredits ? (
439 <View className="gap-3 rounded-[24px] border-continuous bg-grouped-card p-4">
440 <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text>
441 <ResetCredits
442 key={account.key}
443 environmentId={account.redeem.environmentId}
444 input={account.redeem.input}
445 credits={account.limits.resetCredits}
446 now={now}
447 />
448 </View>
449 ) : null}
450 </>
451 )}
452 </ScrollView>
453 </SettingsScreen>
454 );
455}