Open History to see the last commit
1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { EnvironmentId } from "@t3tools/contracts";
4import { providerClients } from "@t3tools/client-runtime/provider-clients";
5import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display";
6import {
7 collectLimitAccounts,
8 collectExternalUsageLinks,
9 collectLimitNotices,
10 collectLimitPools,
11 cursorUsageWindowDetails,
12 displayLimitWindows,
13 formatResetsIn,
14 paceRemainingPercent,
15 remainingPercent,
16 type LimitAccount,
17 type LimitPoolWindow,
18} from "@t3tools/shared/usageLimits";
19import { Fragment, type ReactNode, useId, useState } from "react";
20import { Linking, Pressable, View } from "react-native";
21import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
22import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";
23import { useSafeAreaInsets } from "react-native-safe-area-context";
24
25import { SymbolView } from "../../components/ui/AppSymbol";
26import { AppText as Text } from "../../components/ui/AppText";
27import { ProviderIcon } from "../../components/ProviderIcon";
28import { SettingsScreen } from "../settings/components/SettingsScreen";
29import { environmentPresentations } from "../../state/presentation";
30import { ResetCredits } from "./UsageLimitsSection";
31import { usageProviderKind, useProviderColors } from "./usageProviders";
32
33const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const;
34
35function accountName(account: LimitAccount) {
36 if (account.displayName) return account.displayName;
37 if (!account.email) return providerClients.get(account.driver)?.label ?? account.driver;
38 return emailInitials(account.email) || "Account";
39}
40
41function emailInitials(email: string) {
42 const [local = "", domain = ""] = email.split("@");
43 return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase();
44}
45
46function accountHue(email: string) {
47 let hash = 0;
48 for (let index = 0; index < email.length; index += 1) {
49 hash = (hash * 31 + email.charCodeAt(index)) | 0;
50 }
51 return Math.abs(hash) % 360;
52}
53
54// Same hashed hue as the web account chip, so an account keeps its color across clients.
55function AccountChip({ email }: { readonly email: string }) {
56 const hue = accountHue(email);
57 return (
58 <View
59 className="size-5 items-center justify-center rounded-full"
60 style={{ backgroundColor: `hsl(${hue}, 45%, 82%)` }}
61 >
62 <Text className="text-[9px] font-t3-bold" style={{ color: `hsl(${hue}, 45%, 28%)` }}>
63 {emailInitials(email)}
64 </Text>
65 </View>
66 );
67}
68
69// Same gap-and-tick as the Limits rows: two clipped copies of the segment leave a see-through gap.
70function PaceGap({
71 timeLeft,
72 children,
73}: {
74 readonly timeLeft: number | null;
75 readonly children: ReactNode;
76}) {
77 const [width, setWidth] = useState(0);
78 const tickX =
79 timeLeft === null || width < 6
80 ? null
81 : Math.min(Math.max((width * timeLeft) / 100, 3), width - 3);
82 return (
83 <View
84 pointerEvents="none"
85 className="absolute inset-0"
86 onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
87 >
88 {tickX === null
89 ? children
90 : (
91 [
92 [0, tickX - 3],
93 [tickX + 3, width],
94 ] as const
95 ).map(([start, end]) => (
96 <View
97 key={start}
98 className="absolute top-0 bottom-0 overflow-hidden"
99 style={{ left: start, width: end - start }}
100 >
101 <View className="absolute top-0 bottom-0" style={{ left: -start, width }}>
102 {children}
103 </View>
104 </View>
105 ))}
106 {tickX !== null ? (
107 <View
108 className="absolute top-0.5 bottom-0.5 w-0.5 rounded-full bg-foreground"
109 style={{ left: tickX - 1 }}
110 />
111 ) : null}
112 </View>
113 );
114}
115
116function AccountSegment({
117 remaining,
118 color,
119 refillPercent,
120}: {
121 readonly remaining: number;
122 readonly color: string;
123 readonly refillPercent: number;
124}) {
125 const patternId = useId().replace(/:/g, "");
126 const [size, setSize] = useState({ width: 0, height: 0 });
127 return (
128 <View
129 pointerEvents="none"
130 className="absolute inset-0"
131 onLayout={(event) => {
132 const { width, height } = event.nativeEvent.layout;
133 setSize((previous) =>
134 previous.width === width && previous.height === height ? previous : { width, height },
135 );
136 }}
137 >
138 {size.width > 0 && size.height > 0 ? (
139 // Numeric canvas dimensions clear Android SVG geometry cached before a segment resized.
140 <Svg width={size.width} height={size.height} accessible={false}>
141 <Defs>
142 <Pattern id={patternId} width={6} height={6} patternUnits="userSpaceOnUse">
143 <Path d="M-1 1L1 -1M0 6L6 0M5 7L7 5" stroke={color} strokeWidth={1} opacity={0.22} />
144 </Pattern>
145 </Defs>
146 {refillPercent > 0 ? (
147 <Rect
148 x={`${100 - Math.min(100 - remaining, refillPercent)}%`}
149 width={`${Math.min(100 - remaining, refillPercent)}%`}
150 height="100%"
151 fill={`url(#${patternId})`}
152 />
153 ) : null}
154 <Rect width={`${remaining}%`} height="100%" fill={color} opacity={0.35} />
155 </Svg>
156 ) : null}
157 </View>
158 );
159}
160
161function PoolWindowCard({
162 pool,
163 color,
164 now,
165 environmentIds,
166 label,
167 description,
168 wide,
169}: {
170 readonly pool: LimitPoolWindow;
171 readonly color: string;
172 readonly now: number;
173 readonly environmentIds: readonly string[] | null;
174 readonly label?: string | null;
175 readonly description?: string;
176 readonly wide: boolean;
177}) {
178 const navigation = useNavigation();
179 const [barWidth, setBarWidth] = useState(0);
180 // Each segment needs room for its account, percent and reset to label itself.
181 const segmentWidth = barWidth / Math.max(pool.columns.length, 1);
182 const inlineLabels = wide && segmentWidth >= 96;
183 const inlineReset = wide && segmentWidth >= 150;
184 const inlineName = wide && segmentWidth >= 220;
185 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
186 const openAccount = (account: LimitAccount) =>
187 navigation.navigate("SettingsUsageAccount", {
188 accountKey: account.key,
189 windowId: pool.id,
190 windowKind: pool.kind,
191 environmentIds,
192 now,
193 });
194 const summary = (
195 <>
196 <View className={wide ? "gap-1" : "flex-row items-start justify-between gap-3"}>
197 <View className="gap-1">
198 {label !== null ? (
199 <Text className="text-sm font-t3-medium text-foreground">{label ?? pool.label}</Text>
200 ) : null}
201 <View className="flex-row items-baseline gap-1.5">
202 <Text className="text-3xl font-t3-bold tabular-nums text-foreground">
203 {pool.remainingPercent}%
204 </Text>
205 <Text className="text-sm text-foreground-muted">left</Text>
206 </View>
207 </View>
208 {pool.pace ? (
209 <Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>
210 ) : null}
211 </View>
212 {description ? <Text className="text-xs text-foreground-muted">{description}</Text> : null}
213 {nextRefill ? (
214 <Text className="text-xs tabular-nums text-foreground-muted">
215 ↻ +{nextRefill.restoresPercent}%{" "}
216 {formatResetsIn(nextRefill.member.window, now)
217 ?.replace("resets in ", "in ")
218 .replace("resets now", "now")}
219 </Text>
220 ) : null}
221 </>
222 );
223 const bar = (
224 <View
225 className="flex-row gap-1"
226 onLayout={(event) => setBarWidth(event.nativeEvent.layout.width)}
227 >
228 {pool.columns.map((member, index) => {
229 if (!member.window)
230 return (
231 <View
232 key={member.account.key}
233 className={wide ? "h-9 min-w-0 flex-1" : "h-7 min-w-0 flex-1"}
234 />
235 );
236 const { account, window } = member;
237 const timeLeft = paceRemainingPercent(window, now);
238 const credits = account.limits.resetCredits?.availableCount ?? 0;
239 const resetsIn = formatResetsIn(window, now)?.replace("resets in ", "");
240 return (
241 <Pressable
242 key={account.key}
243 accessibilityRole="button"
244 accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${timeLeft === null ? "" : `, ${timeLeft}% of the window left`}`}
245 accessibilityHint="Show account details"
246 onPress={() => openAccount(account)}
247 className={wide ? "h-9 min-w-0 flex-1 rounded-md" : "h-7 min-w-0 flex-1 rounded-md"}
248 >
249 <PaceGap timeLeft={timeLeft}>
250 <View className="absolute inset-0 rounded-md bg-subtle" />
251 <View className="absolute inset-0 overflow-hidden rounded-md">
252 <AccountSegment
253 remaining={remainingPercent(window)}
254 color={normalizeProviderAccentColor(account.accentColor) ?? color}
255 refillPercent={window.resetsAt ? member.refillPercent : 0}
256 />
257 </View>
258 </PaceGap>
259 {inlineLabels ? (
260 <View
261 pointerEvents="none"
262 className="absolute inset-0 flex-row items-center justify-between gap-2 overflow-hidden rounded-md px-2"
263 >
264 <View className="min-w-0 shrink flex-row items-center gap-1.5">
265 {account.email ? <AccountChip email={account.email} /> : null}
266 {account.email && !(account.displayName && inlineName) ? null : (
267 <Text
268 numberOfLines={1}
269 className="min-w-0 shrink text-xs font-t3-medium text-foreground"
270 >
271 {accountName(account)}
272 </Text>
273 )}
274 <Text className="text-sm font-t3-bold tabular-nums text-foreground">
275 {remainingPercent(window)}%
276 </Text>
277 </View>
278 {inlineReset && (resetsIn || credits) ? (
279 <View className="shrink-0 flex-row items-center gap-1 rounded bg-grouped-card/85 px-1.5 py-0.5">
280 {resetsIn ? (
281 <>
282 <SymbolView
283 name="arrow.clockwise"
284 size={11}
285 tintColorClassName="accent-foreground-muted"
286 />
287 <Text className="text-xs tabular-nums text-foreground-muted">
288 {resetsIn}
289 </Text>
290 </>
291 ) : null}
292 {credits ? (
293 <>
294 <SymbolView name="ticket" size={12} tintColorClassName="accent-icon" />
295 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
296 {credits}
297 </Text>
298 </>
299 ) : null}
300 </View>
301 ) : null}
302 </View>
303 ) : (
304 <View
305 pointerEvents="none"
306 className="absolute inset-0 items-center justify-center overflow-hidden rounded-md"
307 >
308 <Text
309 numberOfLines={1}
310 className="max-w-full shrink text-xs font-t3-medium tabular-nums text-foreground"
311 >
312 {index + 1}
313 </Text>
314 </View>
315 )}
316 </Pressable>
317 );
318 })}
319 </View>
320 );
321 const legend = (
322 <View>
323 {pool.columns.map(({ account, window }, index) => {
324 if (!window) return null;
325 const segmentColor = normalizeProviderAccentColor(account.accentColor) ?? color;
326 const credits = account.limits.resetCredits?.availableCount ?? 0;
327 const resetsIn = formatResetsIn(window, now);
328 return (
329 <Pressable
330 key={account.key}
331 accessibilityRole="button"
332 accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset credits banked` : ""}`}
333 accessibilityHint="Show account details"
334 onPress={() => openAccount(account)}
335 className="min-h-[44px] flex-row items-center gap-2 active:opacity-60"
336 >
337 <View className="relative size-5 items-center justify-center overflow-hidden rounded-md bg-subtle-strong">
338 <View
339 pointerEvents="none"
340 className="absolute inset-0"
341 style={{ backgroundColor: segmentColor, opacity: 0.35 }}
342 />
343 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
344 {index + 1}
345 </Text>
346 </View>
347 <Text
348 numberOfLines={1}
349 className="min-w-0 flex-1 text-sm font-t3-medium text-foreground"
350 >
351 {accountName(account)}
352 </Text>
353 <Text className="text-sm font-t3-medium tabular-nums text-foreground">
354 {remainingPercent(window)}%
355 </Text>
356 <View className="flex-row items-center gap-1">
357 {resetsIn ? (
358 <>
359 <SymbolView
360 name="arrow.clockwise"
361 size={12}
362 tintColorClassName="accent-foreground-muted"
363 />
364 <Text className="text-xs tabular-nums text-foreground-muted">
365 {resetsIn.replace("resets in ", "")}
366 </Text>
367 </>
368 ) : null}
369 {credits ? (
370 <>
371 {resetsIn ? <Text className="text-xs text-foreground-tertiary">·</Text> : null}
372 <SymbolView name="ticket" size={13} tintColorClassName="accent-icon" />
373 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
374 {credits}
375 </Text>
376 </>
377 ) : null}
378 </View>
379 </Pressable>
380 );
381 })}
382 </View>
383 );
384 if (wide) {
385 return (
386 <View className="flex-row items-center gap-6 rounded-[24px] border-continuous bg-grouped-card p-4">
387 <View className="w-44 gap-1">{summary}</View>
388 <View className="min-w-0 flex-1 gap-1">
389 {bar}
390 {inlineLabels ? null : legend}
391 </View>
392 </View>
393 );
394 }
395 return (
396 <View className="gap-3 rounded-[24px] border-continuous bg-grouped-card p-4">
397 {summary}
398 {bar}
399 {legend}
400 </View>
401 );
402}
403
404export function UsageLimitsSection({
405 now,
406 failedLabels,
407 selectedEnvironmentIds,
408 wide = false,
409}: {
410 readonly now: number;
411 readonly failedLabels: readonly string[];
412 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
413 readonly wide?: boolean;
414}) {
415 const presentations = useAtomValue(environmentPresentations.presentationsWithProvidersAtom);
416 const selected =
417 selectedEnvironmentIds === null
418 ? presentations
419 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
420 const pools = collectLimitPools(collectLimitAccounts(selected), now);
421 const notices = collectLimitNotices(selected);
422 const externalLinks = collectExternalUsageLinks(selected);
423 const colors = useProviderColors();
424 return (
425 <View className="gap-6">
426 {pools.length === 0 &&
427 notices.length === 0 &&
428 failedLabels.length === 0 &&
429 externalLinks.length === 0 ? (
430 <Text className="py-12 text-center text-base text-foreground-muted">
431 {selected.size === 0
432 ? "Select an environment to see limits."
433 : "No provider on the selected environments reports subscription limits."}
434 </Text>
435 ) : null}
436 {pools.map((pool) => {
437 const windows = displayLimitWindows(pool);
438 return (
439 <Fragment key={pool.driver}>
440 <View className="gap-3">
441 <View className="flex-row items-center gap-2 px-1">
442 <ProviderIcon provider={pool.driver} size={18} />
443 <Text className="text-base font-t3-medium text-foreground">
444 {providerClients.get(pool.driver)?.label ?? pool.driver}
445 </Text>
446 </View>
447 {windows.map((window) => {
448 const details =
449 pool.driver === "cursor" ? cursorUsageWindowDetails(window.id, pool) : undefined;
450 return (
451 <PoolWindowCard
452 key={`${window.kind}:${window.id}`}
453 pool={window}
454 color={colors[usageProviderKind(pool.driver) ?? "codex"]}
455 now={now}
456 environmentIds={
457 selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]
458 }
459 label={details?.label}
460 description={details?.description}
461 wide={wide}
462 />
463 );
464 })}
465 </View>
466 </Fragment>
467 );
468 })}
469 {externalLinks.map((link) => (
470 <View key={link.url} className="gap-3 rounded-xl border border-border-subtle p-4">
471 <Text className="text-base font-t3-medium text-foreground">{link.label}</Text>
472 <Text className="text-xs text-foreground-muted">{link.accounts.join(", ")}</Text>
473 {link.message ? (
474 <Text className="text-sm text-foreground-muted">{link.message}</Text>
475 ) : null}
476 <Pressable
477 accessibilityRole="link"
478 className="min-h-11 justify-center"
479 onPress={() => void Linking.openURL(link.url).catch(() => undefined)}
480 >
481 <Text className="text-sm font-t3-medium text-primary">Manage usage</Text>
482 </Pressable>
483 </View>
484 ))}
485 {notices.length > 0 || failedLabels.length > 0 ? (
486 <View
487 accessible
488 accessibilityRole="alert"
489 accessibilityLiveRegion="polite"
490 className="flex-row items-start gap-2 rounded-xl border border-warning-border bg-warning px-3.5 py-3"
491 >
492 <SymbolView
493 name="exclamationmark.triangle"
494 size={16}
495 tintColorClassName="accent-warning-foreground"
496 />
497 <View className="min-w-0 flex-1 gap-0.5">
498 {notices.map((notice) => (
499 <Text key={notice} className="text-sm font-t3-medium text-warning-foreground">
500 {notice}
501 </Text>
502 ))}
503 {failedLabels.length > 0 ? (
504 <Text className="text-sm font-t3-medium text-warning-foreground">
505 {failedLabels.join(", ")} could not refresh limits. Showing the last known values.
506 </Text>
507 ) : null}
508 </View>
509 </View>
510 ) : null}
511 </View>
512 );
513}
514
515type AccountScreenProps = StaticScreenProps<{
516 accountKey: string;
517 windowId: string;
518 windowKind: LimitPoolWindow["kind"];
519 environmentIds: readonly string[] | null;
520 now: number;
521}>;
522
523export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
524 const insets = useSafeAreaInsets();
525 const presentations = useAtomValue(environmentPresentations.presentationsWithProvidersAtom);
526 const { accountKey, windowId, windowKind, environmentIds, now } = route.params;
527 const selectedIds =
528 environmentIds === null ? null : new Set(environmentIds.map((id) => EnvironmentId.make(id)));
529 const selected =
530 selectedIds === null
531 ? presentations
532 : new Map([...presentations].filter(([id]) => selectedIds.has(id)));
533 const accounts = collectLimitAccounts(selected);
534 const account = accounts.find((candidate) => candidate.key === accountKey);
535 const pool = collectLimitPools(accounts, now)
536 .find((candidate) => candidate.driver === account?.driver)
537 ?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind);
538 const window = pool?.members.find((member) => member.account.key === accountKey)?.window;
539 const evenPace = window ? paceRemainingPercent(window, now) : null;
540 const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey);
541 const [revealed, setRevealed] = useState(false);
542 return (
543 <SettingsScreen title="Account">
544 <ScrollView
545 contentInsetAdjustmentBehavior="automatic"
546 contentContainerClassName="gap-5 p-5"
547 contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
548 >
549 {!account || !window ? (
550 <Text className="text-base text-foreground-muted">
551 This account is no longer reporting limits on the selected environments.
552 </Text>
553 ) : (
554 <>
555 <View className="gap-2">
556 <View className="flex-row items-center gap-2">
557 <ProviderIcon provider={account.driver} size={24} />
558 <Text className="flex-1 text-xl font-t3-bold text-foreground">
559 {account.displayName ??
560 providerClients.get(account.driver)?.label ??
561 account.driver}
562 </Text>
563 </View>
564 {account.email ? (
565 <Pressable
566 accessibilityRole="button"
567 accessibilityLabel={revealed ? "Hide account email" : "Reveal account email"}
568 onPress={() => setRevealed((value) => !value)}
569 className="min-h-[44px] justify-center"
570 >
571 <Text className="text-sm text-foreground-muted">
572 {revealed ? account.email : "••••••@••••••"}
573 </Text>
574 </Pressable>
575 ) : null}
576 {account.plan ? (
577 <Text selectable className="text-sm text-foreground-muted">
578 {account.plan}
579 </Text>
580 ) : null}
581 </View>
582 <View className="gap-3 rounded-[24px] border-continuous bg-grouped-card p-4">
583 <Text className="text-sm font-t3-medium text-foreground">{window.label}</Text>
584 <Text className="text-3xl font-t3-bold tabular-nums text-foreground">
585 {remainingPercent(window)}% left
586 </Text>
587 {window.resetsAt ? (
588 <Text selectable className="text-sm text-foreground-muted">
589 Resets{" "}
590 {new Date(window.resetsAt).toLocaleString(undefined, {
591 dateStyle: "medium",
592 timeStyle: "short",
593 })}
594 </Text>
595 ) : null}
596 {evenPace !== null ? (
597 <Text className="text-sm text-foreground-muted">
598 Even pace: {evenPace}% left by now
599 </Text>
600 ) : null}
601 {reset && reset.restoresPercent > 0 ? (
602 <Text className="text-sm text-foreground-muted">
603 Restores {reset.restoresPercent}% of the pool
604 </Text>
605 ) : null}
606 </View>
607 <View className="gap-2 rounded-[24px] border-continuous bg-grouped-card p-4">
608 <Text className="text-sm font-t3-medium text-foreground">
609 {account.environments.length ? "Signed in" : "Source"}
610 </Text>
611 {account.environments.length ? (
612 account.environments.map((environment) => (
613 <Text key={environment.environmentId} className="text-sm text-foreground-muted">
614 {environment.label}
615 </Text>
616 ))
617 ) : (
618 <Text className="text-sm text-foreground-muted">{account.sourceLabel}</Text>
619 )}
620 </View>
621 {account.redeem && account.limits.resetCredits ? (
622 <View className="gap-3 rounded-[24px] border-continuous bg-grouped-card p-4">
623 <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text>
624 <ResetCredits
625 key={account.key}
626 environmentId={account.redeem.environmentId}
627 input={account.redeem.input}
628 credits={account.limits.resetCredits}
629 now={now}
630 />
631 </View>
632 ) : null}
633 </>
634 )}
635 </ScrollView>
636 </SettingsScreen>
637 );
638}