Skip to content
Open
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
81 changes: 59 additions & 22 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ function useBarColor(driver: Driver): string | null {

/**
* One window as a bar spanning its whole duration: the fill is quota left,
* the hairline is how much of the window is left, so even spending keeps the
* fill on the line. Pace sits under the left edge, the countdown under the
* the tick is how much of the window is left, so even spending keeps the
* fill at the tick. Pace sits under the left edge, the countdown under the
* right, so a row reads in one glance.
*/
function WindowRow(props: {
Expand All @@ -58,6 +58,41 @@ function WindowRow(props: {
const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100);
const pace = paceOf(window, now);
const resetsIn = formatResetsIn(window, now);
// The tick sits in a gap cut 3px either side through the bar, so it reads over
// any fill; it stays 3px inside the ends so a fresh or spent window keeps its caps.
const [width, setWidth] = useState(0);
const tickX =
timeLeft === null || width === 0
? null
: Math.min(Math.max((width * timeLeft) / 100, 3), width - 3);
const gapSides: ReadonlyArray<readonly [number, number]> =
tickX === null
? []
: [
[0, tickX - 3],
[tickX + 3, width],
];
const bar = (
<View
className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle"
style={width > 0 ? { width } : 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 @@ -66,27 +101,29 @@ 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) => setWidth(event.nativeEvent.layout.width)}
>
{tickX === null ? (
bar
) : (
<View className="h-1.5">
{gapSides.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
40 changes: 29 additions & 11 deletions apps/web/src/components/usage/UsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,20 @@ export function PaceIcon({ pace }: { readonly pace: LimitPace }) {
);
}

/** Where the tick sits; kept 3px inside the ends so a fresh or spent window keeps its caps. */
function tickPosition(percent: number): string {
return `clamp(3px, ${percent}%, calc(100% - 3px))`;
}

/** A mask that hides the bar for 3px either side of the tick. */
function tickGap(percent: number): string {
const at = tickPosition(percent);
return `linear-gradient(to right, #000 calc(${at} - 3px), transparent calc(${at} - 3px), transparent calc(${at} + 3px), #000 calc(${at} + 3px))`;
}

/**
* One window as a full-width bar from the moment it opened to its reset.
* The fill is the share of quota spent; the hairline is how far into the
* The fill is the share of quota spent; the tick is how far into the
* window the clock is, which is also where even spending would have put the
* fill. Hover for the exact figures and reset time.
*/
Expand Down Expand Up @@ -114,18 +125,25 @@ function WindowBar({
/>
}
>
<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}
{/* The tick sits in a gap cut through the bar, so it reads over any fill
and on any surface, including the composer's translucent one. */}
<div
className="absolute inset-0"
style={timeLeft === null ? undefined : { maskImage: tickGap(timeLeft) }}
>
<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>
{timeLeft !== null ? (
<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={{ left: tickPosition(timeLeft) }}
/>
) : null}
</TooltipTrigger>
Expand All @@ -135,7 +153,7 @@ function WindowBar({
{remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""}
</span>
{timeLeft !== null ? (
<span className="text-muted-foreground">The line is where even spending would be.</span>
<span className="text-muted-foreground">The tick is where even spending would be.</span>
) : null}
{resetsAt ? (
<span className="text-muted-foreground">
Expand Down
Loading