Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
cursorUsageWindowDetails,
displayLimitWindows,
formatDuration,
formatPaceHeadroom,
formatResetsIn,
paceRemainingPercent,
remainingPercent,
type LimitAccount,
type LimitPoolWindow,
Expand All @@ -28,7 +30,6 @@ 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;
Expand All @@ -37,6 +38,21 @@ function accountName(account: LimitAccount) {
return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account";
}

/** Where even spending would sit. Stacked dashes: React Native cannot dash a single border side on iOS. */
function PaceMark({ left }: { readonly left: number }) {
return (
<View
pointerEvents="none"
className="absolute top-0 bottom-0 w-px justify-between py-px"
style={{ left: `${left}%`, opacity: 0.6 }}
>
{[0, 1, 2, 3, 4].map((dash) => (
<View key={dash} className="h-[3px] w-px bg-foreground" />
))}
</View>
);
}

/** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */
function AccountSegment({
remaining,
Expand Down Expand Up @@ -84,6 +100,7 @@ function PoolWindowCard({
readonly description?: string;
}) {
const navigation = useNavigation();
// The soonest reset that hands anything back; an untouched account resets to no effect.
const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
const openAccount = (account: LimitAccount) =>
navigation.navigate("SettingsSheet", {
Expand All @@ -109,27 +126,32 @@ function PoolWindowCard({
{pool.remainingPercent}%
</Text>
<Text className="text-sm text-foreground-muted">left</Text>
{nextRefill ? (
<Text
accessibilityLabel={`Pooled usage +${nextRefill.restoresPercent}% ${nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`}`}
className="text-xs tabular-nums text-foreground-muted"
>
↻ +{nextRefill.restoresPercent}%
</Text>
) : null}
</View>
</View>
{pool.pace ? (
<Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>
{pool.paceHeadroomPercent !== null ? (
<Text className="text-xs tabular-nums text-foreground-tertiary">
{formatPaceHeadroom(pool.paceHeadroomPercent)}
</Text>
) : null}
</View>
{description ? <Text className="text-xs text-foreground-muted">{description}</Text> : null}
{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" />;
const timeLeft = paceRemainingPercent(window, now);
return (
<Pressable
key={account.key}
accessibilityRole="button"
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${timeLeft === null ? "" : `, ${timeLeft}% of the window left`}`}
accessibilityHint="Show account details"
onPress={() => openAccount(account)}
className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
Expand All @@ -139,6 +161,7 @@ function PoolWindowCard({
color={color}
pending={Boolean(window.resetsAt)}
/>
{timeLeft !== null ? <PaceMark left={timeLeft} /> : null}
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Text className="text-xs font-t3-medium tabular-nums text-foreground">
{index + 1}
Expand Down Expand Up @@ -343,6 +366,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
.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 (
Expand Down Expand Up @@ -397,6 +421,11 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
})}
</Text>
) : null}
{evenPace !== null ? (
<Text className="text-sm text-foreground-muted">
Even pace: {evenPace}% left by now
</Text>
) : null}
{reset && reset.restoresPercent > 0 ? (
<Text className="text-sm text-foreground-muted">
Restores {reset.restoresPercent}% of the pool
Expand Down
5 changes: 2 additions & 3 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import type {
UsageProviderKind,
} from "@t3tools/contracts";
import {
elapsedShare,
formatDuration,
formatResetsIn,
limitsNotice,
paceOf,
paceRemainingPercent,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
Expand Down Expand Up @@ -52,8 +52,7 @@ function WindowRow(props: {
}) {
const { window, now } = props;
const remaining = remainingPercent(window);
const elapsed = elapsedShare(window, now);
const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100);
const timeLeft = paceRemainingPercent(window, now);
const pace = paceOf(window, now);
const resetsIn = formatResetsIn(window, now);
return (
Expand Down
7 changes: 3 additions & 4 deletions apps/web/src/components/usage/UsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import {
} from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import {
elapsedShare,
formatDuration,
formatResetsIn,
type LimitPace,
paceOf,
paceRemainingPercent,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
Expand Down Expand Up @@ -52,7 +52,7 @@ export function barColor(driver: ServerProvider["driver"]): string {
}

/** Pace as a glyph with the words on hover. */
export function PaceIcon({ pace }: { readonly pace: LimitPace }) {
function PaceIcon({ pace }: { readonly pace: LimitPace }) {
const Icon = PACE[pace].icon;
return (
<Tooltip>
Expand Down Expand Up @@ -89,9 +89,8 @@ function WindowBar({
}) {
const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
const remaining = remainingPercent(window);
const elapsed = elapsedShare(window, now);
// The fill is quota left, so the even-spending mark is the time left.
const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100);
const timeLeft = paceRemainingPercent(window, now);
const resetsIn = formatResetsIn(window, now);
const resetsAt = window.resetsAt
? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)
Expand Down
59 changes: 45 additions & 14 deletions apps/web/src/components/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import {
collectLimitPools,
cursorUsageWindowDetails,
displayLimitWindows,
formatDuration,
formatPaceHeadroom,
formatResetsIn,
type LimitAccount,
type LimitPool,
type LimitPoolMember,
type LimitPoolWindow,
paceRemainingPercent,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react";
Expand All @@ -27,13 +30,8 @@ import { Button } from "../ui/button";
import { OpenAI } from "../Icons";
import { Alert, AlertTitle } from "../ui/alert";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import {
PaceIcon,
ResetCreditDialog,
barColor,
resetCreditsSummary,
useResetCredit,
} from "./UsageLimits";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { ResetCreditDialog, barColor, resetCreditsSummary, useResetCredit } from "./UsageLimits";

/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */
function accountInitials(email: string): string {
Expand Down Expand Up @@ -153,6 +151,7 @@ function SegmentPopover({
}) {
const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
const remaining = remainingPercent(window);
const timeLeft = paceRemainingPercent(window, now);
const resetsIn = formatResetsIn(window, now);
const where =
account.environments.length > 0
Expand Down Expand Up @@ -193,6 +192,7 @@ function SegmentPopover({
{resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""}
</Row>
) : null}
{timeLeft !== null ? <Row label="Even pace">{timeLeft}% left by now</Row> : null}
{reset && reset.restoresPercent > 0 ? (
<Row label="Restores">+{reset.restoresPercent}% of pool</Row>
) : null}
Expand Down Expand Up @@ -242,6 +242,7 @@ function PoolSegment({
}) {
const [open, setOpen] = useState(false);
const remaining = remainingPercent(window);
const timeLeft = paceRemainingPercent(window, now);
const resetsIn = formatResetsIn(window, now);
const credits = account.limits.resetCredits?.availableCount ?? 0;
return (
Expand All @@ -252,7 +253,7 @@ function PoolSegment({
<button
type="button"
style={{ gridColumn: index, gridRow: 1 }}
aria-label={`${account.displayName ?? (account.email ? accountInitials(account.email) : account.driver)}: ${remaining}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset ${credits === 1 ? "credit" : "credits"} banked` : ""}`}
aria-label={`${account.displayName ?? (account.email ? accountInitials(account.email) : account.driver)}: ${remaining}% left${timeLeft === null ? "" : `, ${timeLeft}% of the window left`}${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset ${credits === 1 ? "credit" : "credits"} banked` : ""}`}
className="relative h-5 min-w-0 cursor-pointer overflow-hidden rounded-md bg-muted text-start outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[popup-open]:ring-1 data-[popup-open]:ring-border @2xl/pool:h-8"
/>
}
Expand All @@ -274,6 +275,18 @@ function PoolSegment({
}}
/>
) : null}
{/* Dashed so it reads as a reference, not a boundary. */}
{timeLeft !== null ? (
<span
aria-hidden
className="absolute inset-y-0 w-px -translate-x-1/2 text-foreground opacity-50"
style={{
left: `${timeLeft}%`,
backgroundImage:
"repeating-linear-gradient(to bottom, currentColor 0 3px, transparent 3px 6px)",
}}
Comment thread
flamboh marked this conversation as resolved.
Outdated
/>
) : null}
<span
aria-hidden
className="absolute inset-0 flex items-center justify-center text-3xs leading-none font-semibold text-foreground/80 tabular-nums @2xl/pool:hidden"
Expand Down Expand Up @@ -486,7 +499,8 @@ function PoolBar({

/**
* Big pooled number and the segment bar. Accounts keep the same column across
* windows; each segment's popover shows its own reset time and share restored.
* windows; each segment's popover shows its own reset time and share restored,
* and the refill beside the number says what the next reset hands back.
*/
function PoolWindowCard({
pool,
Expand All @@ -507,16 +521,33 @@ function PoolWindowCard({
<div className="grid items-center gap-x-6 gap-y-3 rounded-lg border border-border/60 p-4 md:grid-cols-[11rem_minmax(0,1fr)]">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-foreground">{label ?? pool.label}</span>
<span className="flex items-baseline gap-2">
<span className="flex flex-wrap items-baseline gap-x-2">
<span className="text-3xl font-semibold text-foreground tabular-nums">
{pool.remainingPercent}%
</span>
<span className="text-sm text-muted-foreground">left</span>
{pool.pace ? <PaceIcon pace={pool.pace} /> : null}
{nextRefill ? (
<Tooltip>
<TooltipTrigger
render={
<span
tabIndex={0}
className="rounded-sm text-xs text-muted-foreground tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
}
>
↻ +{nextRefill.restoresPercent}%
</TooltipTrigger>
<TooltipPopup side="top">
Pooled usage +{nextRefill.restoresPercent}%{" "}
{nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`}
</TooltipPopup>
</Tooltip>
) : null}
</span>
{nextRefill && pool.columns.length > 1 ? (
<span className="text-xs font-medium text-foreground tabular-nums">
↻ +{nextRefill.restoresPercent}%
{pool.paceHeadroomPercent !== null ? (
<span className="text-xs text-muted-foreground tabular-nums">
{formatPaceHeadroom(pool.paceHeadroomPercent)}
</span>
) : null}
</div>
Expand Down
14 changes: 12 additions & 2 deletions packages/shared/src/usageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ import {
collectLimitPools,
displayLimitWindows,
elapsedShare,
formatPaceHeadroom,
formatResetsIn,
limitsNotice,
paceOf,
paceRemainingPercent,
providersWithLimits,
remainingPercent,
isChatGptUsageLimitError,
Expand Down Expand Up @@ -61,6 +63,7 @@ function provider(overrides: Partial<ServerProvider>): ServerProvider {
describe("pace", () => {
it("places the clock three fifths through a five-hour window with two hours left", () => {
expect(elapsedShare(window, now)).toBeCloseTo(0.6);
expect(paceRemainingPercent(window, now)).toBe(40);
expect(paceOf(window, now)).toBe("under");
expect(paceOf({ ...window, usedPercent: 62 }, now)).toBe("on");
expect(paceOf({ ...window, usedPercent: 80 }, now)).toBe("ahead");
Expand All @@ -69,9 +72,16 @@ describe("pace", () => {
it("has no pace without a reset or a duration", () => {
expect(paceOf({ ...window, resetsAt: undefined }, now)).toBeNull();
expect(paceOf({ ...window, windowDurationMins: undefined }, now)).toBeNull();
expect(paceRemainingPercent({ ...window, resetsAt: undefined }, now)).toBeNull();
expect(formatResetsIn({ ...window, resetsAt: undefined }, now)).toBeNull();
});

it("phrases distance from even spending", () => {
expect(formatPaceHeadroom(24)).toBe("24% under pace");
expect(formatPaceHeadroom(-7)).toBe("7% over pace");
expect(formatPaceHeadroom(0)).toBe("On pace");
});

it("phrases the reset as a countdown", () => {
expect(formatResetsIn(window, now)).toBe("resets in 2h 0m");
expect(formatResetsIn({ ...window, resetsAt: "2026-09-06T15:30:00.000Z" }, now)).toBe(
Expand Down Expand Up @@ -613,13 +623,13 @@ describe("pools", () => {
now,
);
// Only a votes: 80% used, 80% elapsed.
expect(untimed[0]?.windows[0]?.pace).toBe("on");
expect(untimed[0]?.windows[0]?.paceHeadroomPercent).toBe(0);
// a is 80% through its window and b 60%: the pool is 70% elapsed, 60% used.
expect(session).toMatchObject({
id: "five_hour",
remainingPercent: 40,
usedPercent: 60,
pace: "under",
paceHeadroomPercent: 10,
});
expect(
session?.resets.map((reset) => [reset.member.account.key, reset.restoresPercent]),
Expand Down
Loading
Loading