Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
73 changes: 58 additions & 15 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,53 @@ function accountName(account: LimitAccount) {
return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account";
}

// 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 (
<View
pointerEvents="none"
className="absolute inset-0"
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
>
{tickX === null
? children
: (
[
[0, tickX - 3],
[tickX + 3, width],
] as const
).map(([start, end]) => (
<View
key={start}
className="absolute top-0 bottom-0 overflow-hidden"
style={{ left: start, width: end - start }}
>
<View className="absolute top-0 bottom-0" style={{ left: -start, width }}>
{children}
</View>
</View>
))}
{tickX !== null ? (
<View
className="absolute top-0.5 bottom-0.5 w-0.5 rounded-full bg-foreground"
style={{ left: tickX - 1 }}
/>
) : null}
</View>
);
}

function AccountSegment({
remaining,
color,
Expand Down Expand Up @@ -155,22 +202,18 @@ function PoolWindowCard({
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 rounded-md bg-subtle"
className="h-7 min-w-0 flex-1 rounded-md"
>
<View className="absolute inset-0 overflow-hidden rounded-md">
<AccountSegment
remaining={remainingPercent(window)}
color={color}
refillPercent={window.resetsAt ? member.refillPercent : 0}
/>
</View>
{timeLeft !== null ? (
<View
pointerEvents="none"
className="absolute -top-1 -bottom-1 w-0.5 -translate-x-px rounded-full bg-foreground-muted"
style={{ left: `${timeLeft}%` }}
/>
) : null}
<PaceGap timeLeft={timeLeft}>
<View className="absolute inset-0 rounded-md bg-subtle" />
<View className="absolute inset-0 overflow-hidden rounded-md">
<AccountSegment
remaining={remainingPercent(window)}
color={color}
refillPercent={window.resetsAt ? member.refillPercent : 0}
/>
</View>
</PaceGap>
<View
pointerEvents="none"
className="absolute inset-0 items-center justify-center overflow-hidden rounded-md"
Expand Down
74 changes: 54 additions & 20 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,32 @@ function WindowRow(props: {
const timeLeft = paceRemainingPercent(window, now);
const pace = paceOf(window, now);
const resetsIn = formatResetsIn(window, now);
const [barWidth, setBarWidth] = useState(0);
const tickX =
timeLeft === null || barWidth < 6
? null
: Math.min(Math.max((barWidth * timeLeft) / 100, 3), barWidth - 3);
const bar = (
<View
className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle"
style={barWidth > 0 ? { width: barWidth } : undefined}
>
<View
className={
remaining <= 10
? "h-full rounded-full bg-red-500"
: remaining <= 30
? "h-full rounded-full bg-amber-500"
: "h-full rounded-full bg-foreground"
}
style={[
{ flex: remaining },
remaining > 30 && props.color ? { backgroundColor: props.color } : null,
]}
/>
<View style={{ flex: 100 - remaining }} />
</View>
);
return (
<View className="gap-1">
<View className="flex-row items-baseline justify-between gap-3">
Expand All @@ -57,27 +83,35 @@ function WindowRow(props: {
{remaining}% left
</Text>
</View>
<View className="h-3 justify-center">
<View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle">
<View
className={
remaining <= 10
? "h-full rounded-full bg-red-500"
: remaining <= 30
? "h-full rounded-full bg-amber-500"
: "h-full rounded-full bg-foreground"
}
style={[
{ flex: remaining },
remaining > 30 && props.color ? { backgroundColor: props.color } : null,
]}
/>
<View style={{ flex: 100 - remaining }} />
</View>
{timeLeft !== null ? (
<View
className="h-3 justify-center"
onLayout={(event) => setBarWidth(event.nativeEvent.layout.width)}
>
{tickX === null ? (
bar
) : (
<View className="h-1.5">
{/* Clip the same full-width bar so neither fill proportions nor corner radii change. */}
{(
[
[0, tickX - 3],
[tickX + 3, barWidth],
] as const
).map(([start, end]) => (
<View
key={start}
className="absolute top-0 bottom-0 overflow-hidden"
style={{ left: start, width: end - start }}
>
<View style={{ marginLeft: -start }}>{bar}</View>
</View>
))}
</View>
)}
{tickX !== null ? (
<View
className="absolute top-0 bottom-0 w-px bg-foreground"
style={{ left: `${timeLeft}%`, opacity: 0.6 }}
className="absolute top-px bottom-px w-0.5 rounded-full bg-foreground"
style={{ left: tickX - 1 }}
/>
) : null}
</View>
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/components/usage/UsageLimits.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { render } from "../../../test/render";
import { ProviderDriverKind, type ServerProviderUsageWindow } from "@t3tools/contracts";
import { expect, it, vi } from "vite-plus/test";
import { page } from "vite-plus/test/browser";

import "~/index.css";

vi.mock("../../state/session", () => ({
useEnvironmentScope: () => true,
readEnvironmentScope: () => true,
}));
vi.mock("../../state/server", () => ({ serverEnvironment: { consumeResetCredit: null } }));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" }));

import { LimitWindows } from "./UsageLimits";

const codex = ProviderDriverKind.make("codex");
const now = Date.parse("2026-09-03T12:00:00Z");

const sessionWindow = (minutesUntilReset: number | null): ServerProviderUsageWindow => ({
id: "five_hour",
kind: "session",
label: "Session",
usedPercent: 40,
...(minutesUntilReset === null
? {}
: {
windowDurationMins: 300,
resetsAt: new Date(now + minutesUntilReset * 60_000).toISOString(),
}),
});

const sized = (width: string) => {
const container = document.createElement("div");
container.style.width = width;
return container;
};

const bar = () => {
const element = page.getByRole("img", { name: /^Session:/ }).element();
const mark = element.querySelector(":scope > span[aria-hidden]");
return {
rect: element.getBoundingClientRect(),
mark: mark?.getBoundingClientRect(),
clip: getComputedStyle(element.querySelector(":scope > div")!).clipPath,
};
};

it("places the even-pace tick in a gap at the share of the window left", async () => {
await using _screen = await render(
<LimitWindows driver={codex} windows={[sessionWindow(90)]} now={now} />,
{ container: sized("640px") },
);
const { rect, mark, clip } = bar();
expect(mark!.left + mark!.width / 2 - rect.left).toBeCloseTo(rect.width * 0.3, 0);
expect(clip).toMatch(/^polygon/);
});

it("keeps the tick inside the bar at both ends of the window", async () => {
for (const minutesUntilReset of [0, 300]) {
await using _screen = await render(
<LimitWindows driver={codex} windows={[sessionWindow(minutesUntilReset)]} now={now} />,
{ container: sized("640px") },
);
const { rect, mark } = bar();
expect(mark!.left).toBeGreaterThanOrEqual(rect.left);
expect(mark!.right).toBeLessThanOrEqual(rect.right);
}
});

it("draws a continuous bar when the window timing is unknown", async () => {
await using _screen = await render(
<LimitWindows driver={codex} windows={[sessionWindow(null)]} now={now} />,
{ container: sized("640px") },
);
expect(bar()).toMatchObject({ mark: undefined, clip: "none" });
});

it("draws neither gap nor tick on a bar narrower than 6px", async () => {
// 11rem + 1rem + 3.5px + 1rem + 7rem: the bar column gets 3.5px.
await using _screen = await render(
<LimitWindows driver={codex} windows={[sessionWindow(300)]} now={now} />,
{ container: sized("323.5px") },
);
const { rect, mark, clip } = bar();
expect(rect.width).toBeCloseTo(3.5, 1);
expect({ mark, clip }).toEqual({ mark: undefined, clip: "none" });
});
65 changes: 54 additions & 11 deletions apps/web/src/components/usage/UsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import {
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
import { Fragment, type ReactNode, useState } from "react";
import { Fragment, type ReactNode, useLayoutEffect, useState } from "react";

import { usePrimarySettings } from "../../hooks/useSettings";
import { observeResize } from "../../lib/observeResize";
import { environmentPresentations } from "../../state/presentation";
import { useEnvironmentScope, readEnvironmentScope } from "../../state/session";
import { serverEnvironment } from "../../state/server";
Expand Down Expand Up @@ -73,6 +74,43 @@ export function PaceIcon({ pace }: { readonly pace: LimitPace }) {
);
}

// The tick sits in a transparent gap cut from the bar, clamped so it stays inside at 0% and 100%.
// The gap is a clip-path slot rather than a mask: a mask composites its layer and re-antialiases
// the bar's rounded ends, and the polygon reaches 1px past the box so only the slot edges clip.
function paceMarkStyles(timeLeft: number | null) {
if (timeLeft === null) return null;
const at = `clamp(3px, ${timeLeft}%, calc(100% - 3px))`;
const [left, right] = [`calc(${at} - 3px)`, `calc(${at} + 3px)`];
const [near, far] = ["-1px", "calc(100% + 1px)"];
return {
tick: { left: at },
gap: {
clipPath: `polygon(${near} ${near}, ${left} ${near}, ${left} ${far}, ${right} ${far}, ${right} ${near}, ${far} ${near}, ${far} ${far}, ${near} ${far})`,
},
// For a track painted on an element that also draws focus rings, which a clip would cut.
gappedTrack: {
backgroundColor: "transparent",
backgroundImage: `linear-gradient(to right, var(--color-muted) ${left}, transparent ${left}, transparent ${right}, var(--color-muted) ${right})`,
},
};
}

/**
* Pace mark styles for the bar given the returned ref. Below 6px there is no room for the gap
* and tick, so the bar draws neither, as the mobile bars do.
*/
export function usePaceMark(timeLeft: number | null) {
const [bar, setBar] = useState<HTMLElement | null>(null);
const [roomy, setRoomy] = useState(false);
useLayoutEffect(() => {
if (!bar) return;
const measure = () => setRoomy(bar.getBoundingClientRect().width >= 6);
measure();
return observeResize(bar, measure);
}, [bar]);
return [setBar, roomy ? paceMarkStyles(timeLeft) : null] as const;
}

function WindowBar({
color,
window,
Expand All @@ -85,6 +123,7 @@ function WindowBar({
const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
const remaining = remainingPercent(window);
const timeLeft = paceRemainingPercent(window, now);
const [barRef, paceMark] = usePaceMark(timeLeft);
const resetsIn = formatResetsIn(window, now);
const resetsAt = window.resetsAt
? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)
Expand All @@ -98,25 +137,29 @@ function WindowBar({
<TooltipTrigger
render={
<div
ref={barRef}
role="img"
aria-label={summary}
tabIndex={0}
className="relative h-6 cursor-default rounded-full outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
}
>
<div className="absolute inset-x-0 inset-y-1.5 rounded-full bg-muted" />
{remaining > 0 ? (
<div
className="absolute inset-y-1.5 left-0 rounded-full"
style={{ width: `${remaining}%`, backgroundColor: color }}
/>
) : null}
{timeLeft !== null ? (
{/* Clip both layers so the gap exposes even a translucent composer surface. */}
<div className="absolute inset-0" style={paceMark?.gap}>
<div className="absolute inset-x-0 inset-y-1.5 rounded-full bg-muted" />
{remaining > 0 ? (
<div
className="absolute inset-y-1.5 left-0 rounded-full"
style={{ width: `${remaining}%`, backgroundColor: color }}
/>
) : null}
</div>
{paceMark ? (
<span
aria-hidden
className="absolute inset-y-0.5 w-px -translate-x-1/2 bg-foreground/60"
style={{ left: `${timeLeft}%` }}
className="absolute inset-y-1 w-0.5 -translate-x-1/2 rounded-full bg-foreground"
style={paceMark.tick}
/>
) : null}
</TooltipTrigger>
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,23 @@ it("omits an empty reset plate but keeps countdowns and banked credits visible",
expect(plate()?.textContent).toBe("1");
});

it("cuts the pace gap through the segment track and keeps the tick inside it", async () => {
setup();
// Five hours before the reset is the start of the window, the right-hand end of the bar.
await using _screen = await render(
<UsageLimitsPooled
presentations={presentations("2026-09-03T14:00:00Z", 0)}
now={Date.parse("2026-09-03T09:00:00Z")}
/>,
);
const segment = page.getByRole("button", { name: /^Alpha:/ }).element();
const tick = segment.querySelector(':scope > span[style*="left"]')!.getBoundingClientRect();
const rect = segment.getBoundingClientRect();
expect(getComputedStyle(segment).backgroundImage).toMatch(/^linear-gradient/);
expect(tick.left).toBeGreaterThanOrEqual(rect.left);
expect(tick.right).toBeLessThanOrEqual(rect.right);
});

it("moves the one limit popover between windows on hover", async () => {
setup();
await using screen = await render(initial());
Expand Down
Loading
Loading