Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
36 changes: 29 additions & 7 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
cursorUsageWindowDetails,
displayLimitWindows,
formatResetsIn,
paceRemainingPercent,
remainingPercent,
type LimitAccount,
type LimitPoolWindow,
Expand Down Expand Up @@ -40,6 +41,17 @@ function accountName(account: LimitAccount) {
return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account";
}

/** Where even spending would sit: a solid tick taller than the bar, so it reads as a target rather than the fill's edge. */
function PaceMark({ left }: { readonly left: number }) {
return (
<View
pointerEvents="none"
className="absolute -top-1 -bottom-1 w-0.5 -translate-x-px rounded-full bg-foreground-muted"
style={{ left: `${left}%` }}
/>
);
}

/** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */
function AccountSegment({
remaining,
Expand Down Expand Up @@ -130,20 +142,24 @@ function PoolWindowCard({
<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"
className="h-7 min-w-0 flex-1 rounded-md bg-subtle"
>
<AccountSegment
remaining={remainingPercent(window)}
color={color}
pending={Boolean(window.resetsAt)}
/>
<View className="absolute inset-0 overflow-hidden rounded-md">
<AccountSegment
remaining={remainingPercent(window)}
color={color}
pending={Boolean(window.resetsAt)}
/>
</View>
{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 @@ -361,6 +377,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 @@ -415,6 +432,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 @@ -10,11 +10,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 @@ -54,8 +54,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
5 changes: 2 additions & 3 deletions apps/web/src/components/usage/UsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,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 @@ -97,9 +97,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
44 changes: 29 additions & 15 deletions apps/web/src/components/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type LimitPool,
type LimitPoolMember,
type LimitPoolWindow,
paceRemainingPercent,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react";
Expand Down Expand Up @@ -155,6 +156,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 @@ -195,6 +197,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 @@ -244,6 +247,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 @@ -254,26 +258,36 @@ 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` : ""}`}
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-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[popup-open]:ring-1 data-[popup-open]:ring-inset data-[popup-open]:ring-border @2xl/pool:h-8"
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 rounded-md bg-muted text-start outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[popup-open]:ring-1 data-[popup-open]:ring-inset data-[popup-open]:ring-border @2xl/pool:h-8"
/>
}
>
{/* Translucent so the label reads over the fill for any provider colour and theme. */}
<div
aria-hidden
className="absolute inset-y-0 left-0 rounded-md opacity-35"
style={{ width: `${remaining}%`, backgroundColor: color }}
/>
{/* The spent share is hatched, not blank: it is what the countdown restores. */}
{remaining < 100 && reset ? (
<div aria-hidden className="absolute inset-0 overflow-hidden rounded-md">
{/* Translucent so the label reads over the fill for any provider colour and theme. */}
<div
aria-hidden
className="absolute inset-y-0 right-0 opacity-20"
style={{
width: `${100 - remaining}%`,
backgroundImage: `repeating-linear-gradient(135deg, ${color} 0 1px, transparent 1px 5px)`,
}}
className="absolute inset-y-0 left-0 rounded-md opacity-35"
style={{ width: `${remaining}%`, backgroundColor: color }}
/>
{/* The spent share is hatched, not blank: it is what the countdown restores. */}
{remaining < 100 && reset ? (
<div
aria-hidden
className="absolute inset-y-0 right-0 opacity-20"
style={{
width: `${100 - remaining}%`,
backgroundImage: `repeating-linear-gradient(135deg, ${color} 0 1px, transparent 1px 5px)`,
}}
/>
) : null}
</div>
{/* A solid tick taller than the bar, so it reads as a target rather than the fill's edge. */}
{timeLeft !== null ? (
<span
aria-hidden
className="absolute -inset-y-1 w-0.5 -translate-x-1/2 rounded-full bg-muted-foreground"
style={{ left: `${timeLeft}%` }}
/>
) : null}
<span
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/usageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
formatResetsIn,
limitsNotice,
paceOf,
paceRemainingPercent,
providersWithLimits,
remainingPercent,
usesChatGptSharing,
Expand Down Expand Up @@ -72,6 +73,7 @@ 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 @@ -80,6 +82,7 @@ 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();
});

Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/usageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,15 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu
return Math.max(0, Math.min(1, (length - (resetsAt - now)) / length));
}

/** Quota even spending would leave by now, 0..100: where a bar's pace mark sits. */
export function paceRemainingPercent(
window: ServerProviderUsageWindow,
now: number,
): number | null {
const elapsed = elapsedShare(window, now);
return elapsed === null ? null : Math.round((1 - elapsed) * 100);
}

export type LimitPace = "ahead" | "on" | "under";

/**
Expand Down
Loading