Skip to content

Commit c8d104e

Browse files
authored
feat(billing): segment the usage bar into included vs spend-limit portions
The 1px notch marking where the included allowance ends was barely visible in either theme. Replace it with a physically segmented bar: the $20 included allowance and the org spend limit render as separate gap-divided segments, usage fills the included segment green before pouring into the accent spend-limit segment, and a dot legend names each amount. Exceeded turns both segments and dots red; a $0 spend limit collapses to the included segment alone; the free tier keeps the plain single-track bar. Generated-By: PostHog Code Task-Id: b24f71e2-3751-4a6b-b5d8-a24147c4c067
1 parent 44ab278 commit c8d104e

3 files changed

Lines changed: 134 additions & 36 deletions

File tree

packages/ui/src/features/billing/UsageMeter.stories.tsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,46 @@ const meta: Meta<typeof UsageMeter> = {
1616
export default meta;
1717
type Story = StoryObj<typeof UsageMeter>;
1818

19-
// A subscribed org on default settings: the $70 limit is $20 included
20-
// allowance + the $50 default spend limit, spelled out in the detail line
21-
// with the notch marking where the included allowance ends.
19+
// A subscribed org on default settings: the $70 limit renders as two
20+
// segments — the $20 included allowance (green) and the $50 default spend
21+
// limit (accent) — with a dot legend naming each amount. Usage still inside
22+
// the included allowance only fills the green segment.
2223
export const SubscribedWithBreakdown: Story = {
2324
args: {
2425
label: "Usage this period",
2526
percent: 18,
2627
valueLabel: "$12.40 of $70",
27-
detail: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT",
28-
markerPercent: (20 / 70) * 100,
28+
detail: "Resets Jul 31 at 2:00 PM PDT",
29+
breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 12.4 },
2930
},
3031
};
3132

33+
// Past the allowance: the green segment is full and billable usage fills the
34+
// accent segment.
3235
export const SubscribedPastIncluded: Story = {
3336
args: {
3437
label: "Usage this period",
3538
percent: 66,
3639
valueLabel: "$46.20 of $70",
37-
detail: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT",
38-
markerPercent: (20 / 70) * 100,
40+
detail: "Resets Jul 31 at 2:00 PM PDT",
41+
breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 46.2 },
42+
},
43+
};
44+
45+
// A subscribed org that set its spend limit to $0: only the included segment
46+
// (and its legend entry) renders.
47+
export const ZeroSpendLimit: Story = {
48+
args: {
49+
label: "Usage this period",
50+
percent: 65,
51+
valueLabel: "$13 of $20",
52+
detail: "Resets Jul 31 at 2:00 PM PDT",
53+
breakdown: { includedUsd: 20, spendLimitUsd: 0, usedUsd: 13 },
3954
},
4055
};
4156

57+
// Free tier has no breakdown — its limit IS the allowance, so the plain
58+
// single-track bar renders.
4259
export const FreeTier: Story = {
4360
args: {
4461
label: "Monthly free usage",
@@ -53,9 +70,8 @@ export const Exceeded: Story = {
5370
label: "Usage this period",
5471
percent: 100,
5572
valueLabel: "$70 of $70",
56-
detail:
57-
"Limit exceeded. $20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT",
58-
markerPercent: (20 / 70) * 100,
73+
detail: "Limit exceeded. Resets Jul 31 at 2:00 PM PDT",
74+
breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 70 },
5975
color: "red",
6076
},
6177
};
Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,37 @@
1+
import { formatUsdAmount } from "@posthog/core/billing/usageDisplay";
2+
import { cn } from "@posthog/quill";
13
import { Flex, Progress, Text } from "@radix-ui/themes";
24

5+
// How the merged limit splits for the segmented bar: the included allowance
6+
// and the configured spend limit render as physically separate segments so a
7+
// "$70" total is visibly $20 + $50, with usage drawn across both.
8+
interface UsageMeterBreakdown {
9+
includedUsd: number;
10+
spendLimitUsd: number;
11+
usedUsd: number;
12+
}
13+
314
interface UsageMeterProps {
415
label: string;
516
percent: number;
617
valueLabel: string;
718
detail: string;
819
color?: "red";
9-
// Where a boundary inside the limit falls (e.g. the end of the included
10-
// allowance), as a percent of the bar. Rendered as a notch over the track.
11-
markerPercent?: number;
20+
breakdown?: UsageMeterBreakdown;
1221
}
1322

23+
const clampPercent = (value: number): number =>
24+
Math.min(100, Math.max(0, value));
25+
1426
export function UsageMeter({
1527
label,
1628
percent,
1729
valueLabel,
1830
detail,
1931
color,
20-
markerPercent,
32+
breakdown,
2133
}: UsageMeterProps) {
2234
const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)";
23-
const showMarker =
24-
markerPercent != null && markerPercent > 0 && markerPercent < 100;
2535

2636
return (
2737
<Flex
@@ -37,21 +47,101 @@ export function UsageMeter({
3747
<Text className="font-medium text-sm">{label}</Text>
3848
<Text className="font-medium text-sm">{valueLabel}</Text>
3949
</Flex>
40-
<div className="relative">
50+
{breakdown ? (
51+
<SegmentedUsageBar
52+
percent={percent}
53+
breakdown={breakdown}
54+
exceeded={color === "red"}
55+
/>
56+
) : (
4157
<Progress
4258
value={percent}
4359
size="2"
4460
color={color === "red" ? "red" : undefined}
4561
/>
46-
{showMarker && (
62+
)}
63+
<Text className="text-(--gray-9) text-[13px]">{detail}</Text>
64+
</Flex>
65+
);
66+
}
67+
68+
/**
69+
* Two-segment usage bar: the included allowance fills green, the org spend
70+
* limit fills accent, split by a gap sized to each side's share of the merged
71+
* limit. Usage pours through the included segment first — matching how
72+
* billing spends the allowance before billable credits — and a dot legend
73+
* names each segment's amount. Exceeded turns both fills (and dots) red.
74+
*/
75+
function SegmentedUsageBar({
76+
percent,
77+
breakdown,
78+
exceeded,
79+
}: {
80+
percent: number;
81+
breakdown: UsageMeterBreakdown;
82+
exceeded: boolean;
83+
}) {
84+
const { includedUsd, spendLimitUsd, usedUsd } = breakdown;
85+
const totalUsd = includedUsd + spendLimitUsd;
86+
const hasPaidSegment = spendLimitUsd > 0 && totalUsd > 0;
87+
const includedFill =
88+
includedUsd > 0 ? clampPercent((usedUsd / includedUsd) * 100) : 100;
89+
const paidFill = hasPaidSegment
90+
? clampPercent(((usedUsd - includedUsd) / spendLimitUsd) * 100)
91+
: 0;
92+
const includedDot = exceeded ? "bg-(--red-9)" : "bg-(--green-9)";
93+
const paidDot = exceeded ? "bg-(--red-9)" : "bg-(--accent-9)";
94+
95+
return (
96+
<Flex direction="column" gap="2">
97+
<div
98+
role="progressbar"
99+
aria-valuemin={0}
100+
aria-valuemax={100}
101+
aria-valuenow={Math.round(clampPercent(percent))}
102+
className="flex h-2 w-full gap-[3px]"
103+
>
104+
<div
105+
className="h-full overflow-hidden rounded-full bg-(--gray-a3)"
106+
style={{
107+
width: hasPaidSegment
108+
? `${(includedUsd / totalUsd) * 100}%`
109+
: "100%",
110+
}}
111+
>
47112
<div
48-
aria-hidden
49-
className="absolute inset-y-0 w-px bg-(--gray-1)"
50-
style={{ left: `${markerPercent}%` }}
113+
className={cn(
114+
"h-full transition-[width] duration-300",
115+
includedDot,
116+
)}
117+
style={{ width: `${includedFill}%` }}
51118
/>
119+
</div>
120+
{hasPaidSegment && (
121+
<div className="h-full flex-1 overflow-hidden rounded-full bg-(--gray-a3)">
122+
<div
123+
className={cn("h-full transition-[width] duration-300", paidDot)}
124+
style={{ width: `${paidFill}%` }}
125+
/>
126+
</div>
52127
)}
53128
</div>
54-
<Text className="text-(--gray-9) text-[13px]">{detail}</Text>
129+
<Flex align="center" gap="4" wrap="wrap">
130+
<Flex align="center" gap="2">
131+
<span className={cn("size-2 rounded-full", includedDot)} />
132+
<Text className="text-(--gray-9) text-[13px]">
133+
{formatUsdAmount(includedUsd)} included
134+
</Text>
135+
</Flex>
136+
{hasPaidSegment && (
137+
<Flex align="center" gap="2">
138+
<span className={cn("size-2 rounded-full", paidDot)} />
139+
<Text className="text-(--gray-9) text-[13px]">
140+
{formatUsdAmount(spendLimitUsd)} org spend limit
141+
</Text>
142+
</Flex>
143+
)}
144+
</Flex>
55145
</Flex>
56146
);
57147
}

packages/ui/src/features/settings/sections/PlanUsageSettings.tsx

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
import {
77
codeUsageMeter,
88
formatResetTime,
9-
formatUsageBreakdown,
109
formatUsdAmount,
1110
isCodeUsageFreeTier,
1211
} from "@posthog/core/billing/usageDisplay";
@@ -179,20 +178,13 @@ export function PlanUsageSettings() {
179178
label={freeTier ? "Monthly free usage" : "Usage this period"}
180179
percent={meter.percent}
181180
valueLabel={`${formatUsdAmount(meter.usedUsd)} of ${formatUsdAmount(meter.limitUsd)}${freeTier ? " included" : ""}`}
182-
detail={[
183-
meter.exceeded ? "Limit exceeded." : null,
184-
// Spell out how the merged limit is composed — "$70" on its
185-
// own reads as a mystery number to an org that set $50.
181+
detail={`${meter.exceeded ? "Limit exceeded. " : ""}${formatResetTime(meter.resetAt)}`}
182+
// Splits the bar so "$70" is visibly $20 included + $50 spend
183+
// limit — a merged total reads as a mystery number to an org
184+
// that set $50.
185+
breakdown={
186186
meter.breakdown
187-
? `${formatUsageBreakdown(meter.breakdown)}.`
188-
: null,
189-
formatResetTime(meter.resetAt),
190-
]
191-
.filter(Boolean)
192-
.join(" ")}
193-
markerPercent={
194-
meter.breakdown
195-
? (meter.breakdown.includedUsd / meter.limitUsd) * 100
187+
? { ...meter.breakdown, usedUsd: meter.usedUsd }
196188
: undefined
197189
}
198190
color={meter.exceeded ? "red" : undefined}

0 commit comments

Comments
 (0)