Skip to content

Commit 82b3e9c

Browse files
committed
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 927acb0 commit 82b3e9c

6 files changed

Lines changed: 121 additions & 65 deletions

File tree

packages/core/src/billing/usageDisplay.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
11
import type { UsageBucket, UsageOutput } from "../usage/schemas";
22

3-
/**
4-
* The monthly usage allowance included for every org under Code usage
5-
* billing — the "first $20 each month is included" from the billing product
6-
* config. The gateway has no field for it: a subscribed org's
7-
* `ai_credits.limit_usd` arrives as one merged number (allowance + configured
8-
* spend limit), so display code peels the allowance back off with this
9-
* constant. If billing ever changes the allowance or starts sending a
10-
* breakdown, this constant (and the copy quoting $20) is the seam.
11-
*/
123
export const CODE_INCLUDED_USAGE_USD = 20;
134

145
/** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */
@@ -18,14 +9,6 @@ export function isCodeUsageFreeTier(
189
return usage?.code_usage_subscribed === false;
1910
}
2011

21-
/**
22-
* The spend limit the org actually configured in billing settings ($50 by
23-
* default), recovered from the merged `limit_usd`. Null when it can't be
24-
* recovered: unconfirmed/free orgs (their limit IS the allowance), missing
25-
* numbers, or a merged limit below the allowance. Zero is a real answer — a
26-
* subscribed org whose merged limit equals the allowance set its spend limit
27-
* to $0.
28-
*/
2912
export function codeOrgSpendLimitUsd(
3013
usage:
3114
| Pick<UsageOutput, "code_usage_subscribed" | "ai_credits">
@@ -35,7 +18,6 @@ export function codeOrgSpendLimitUsd(
3518
if (usage?.code_usage_subscribed !== true) return null;
3619
const limitUsd = usage.ai_credits?.limit_usd;
3720
if (limitUsd == null || limitUsd < CODE_INCLUDED_USAGE_USD) return null;
38-
// Round away float dust — the wire amounts are already cent-rounded.
3921
return Math.round((limitUsd - CODE_INCLUDED_USAGE_USD) * 100) / 100;
4022
}
4123

@@ -52,9 +34,6 @@ export type CodeUsageMeter =
5234
percent: number;
5335
exceeded: boolean;
5436
resetAt: string;
55-
// How the merged limit decomposes for a subscribed org, so the meter
56-
// can explain "$70" as $20 included + $50 spend limit. Null when the
57-
// split is unknown (free tier, or a limit below the allowance).
5837
breakdown: CodeUsageBreakdown | null;
5938
}
6039
| { kind: "bucket"; bucket: UsageBucket }
@@ -97,7 +76,6 @@ export function formatUsdAmount(amount: number): string {
9776
return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`;
9877
}
9978

100-
/** One shared phrasing for the merged-limit explanation, e.g. "$20 included + $50 org spend limit". */
10179
export function formatUsageBreakdown(breakdown: CodeUsageBreakdown): string {
10280
return `${formatUsdAmount(breakdown.includedUsd)} included + ${formatUsdAmount(breakdown.spendLimitUsd)} org spend limit`;
10381
}

packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,6 @@ export function UsageBillingAnnouncementModal() {
2525
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
2626
const { usage } = useUsage({ enabled: isOpen });
2727

28-
// A subscribed org's limit_usd merges the included $20 with the configured
29-
// spend limit — quote the recovered spend limit, the number the org set.
30-
// Free orgs (whose limit_usd is just the first bullet's $20) get the
31-
// default-limit line instead.
3228
const spendLimitUsd = codeOrgSpendLimitUsd(usage);
3329

3430
useEffect(() => {

packages/ui/src/features/billing/UsageButton.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,6 @@ export function UsageButton() {
7171
const resetLabel = formatResetTime(
7272
meter.kind === "dollars" ? meter.resetAt : meter.bucket.reset_at,
7373
);
74-
// The subscribed meter's limit is a merged number (included allowance +
75-
// configured spend limit); without the split, "$70" matches nothing the
76-
// user ever set.
7774
const breakdownLabel =
7875
meter.kind === "dollars" && meter.breakdown
7976
? formatUsageBreakdown(meter.breakdown)

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: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,34 @@
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+
interface UsageMeterBreakdown {
6+
includedUsd: number;
7+
spendLimitUsd: number;
8+
usedUsd: number;
9+
}
10+
311
interface UsageMeterProps {
412
label: string;
513
percent: number;
614
valueLabel: string;
715
detail: string;
816
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;
17+
breakdown?: UsageMeterBreakdown;
1218
}
1319

20+
const clampPercent = (value: number): number =>
21+
Math.min(100, Math.max(0, value));
22+
1423
export function UsageMeter({
1524
label,
1625
percent,
1726
valueLabel,
1827
detail,
1928
color,
20-
markerPercent,
29+
breakdown,
2130
}: UsageMeterProps) {
2231
const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)";
23-
const showMarker =
24-
markerPercent != null && markerPercent > 0 && markerPercent < 100;
2532

2633
return (
2734
<Flex
@@ -37,21 +44,94 @@ export function UsageMeter({
3744
<Text className="font-medium text-sm">{label}</Text>
3845
<Text className="font-medium text-sm">{valueLabel}</Text>
3946
</Flex>
40-
<div className="relative">
47+
{breakdown ? (
48+
<SegmentedUsageBar
49+
percent={percent}
50+
breakdown={breakdown}
51+
exceeded={color === "red"}
52+
/>
53+
) : (
4154
<Progress
4255
value={percent}
4356
size="2"
4457
color={color === "red" ? "red" : undefined}
4558
/>
46-
{showMarker && (
59+
)}
60+
<Text className="text-(--gray-9) text-[13px]">{detail}</Text>
61+
</Flex>
62+
);
63+
}
64+
65+
function SegmentedUsageBar({
66+
percent,
67+
breakdown,
68+
exceeded,
69+
}: {
70+
percent: number;
71+
breakdown: UsageMeterBreakdown;
72+
exceeded: boolean;
73+
}) {
74+
const { includedUsd, spendLimitUsd, usedUsd } = breakdown;
75+
const totalUsd = includedUsd + spendLimitUsd;
76+
const hasPaidSegment = spendLimitUsd > 0 && totalUsd > 0;
77+
const includedFill =
78+
includedUsd > 0 ? clampPercent((usedUsd / includedUsd) * 100) : 100;
79+
const paidFill = hasPaidSegment
80+
? clampPercent(((usedUsd - includedUsd) / spendLimitUsd) * 100)
81+
: 0;
82+
const includedDot = exceeded ? "bg-(--red-9)" : "bg-(--green-9)";
83+
const paidDot = exceeded ? "bg-(--red-9)" : "bg-(--accent-9)";
84+
85+
return (
86+
<Flex direction="column" gap="2">
87+
<div
88+
role="progressbar"
89+
aria-valuemin={0}
90+
aria-valuemax={100}
91+
aria-valuenow={Math.round(clampPercent(percent))}
92+
className="flex h-2 w-full gap-[3px]"
93+
>
94+
<div
95+
className="h-full overflow-hidden rounded-full bg-(--gray-a3)"
96+
style={{
97+
width: hasPaidSegment
98+
? `${(includedUsd / totalUsd) * 100}%`
99+
: "100%",
100+
}}
101+
>
47102
<div
48-
aria-hidden
49-
className="absolute inset-y-0 w-px bg-(--gray-1)"
50-
style={{ left: `${markerPercent}%` }}
103+
className={cn(
104+
"h-full transition-[width] duration-300",
105+
includedDot,
106+
)}
107+
style={{ width: `${includedFill}%` }}
51108
/>
109+
</div>
110+
{hasPaidSegment && (
111+
<div className="h-full flex-1 overflow-hidden rounded-full bg-(--gray-a3)">
112+
<div
113+
className={cn("h-full transition-[width] duration-300", paidDot)}
114+
style={{ width: `${paidFill}%` }}
115+
/>
116+
</div>
52117
)}
53118
</div>
54-
<Text className="text-(--gray-9) text-[13px]">{detail}</Text>
119+
<Flex align="center" gap="4" wrap="wrap">
120+
<Flex align="center" gap="2">
121+
<span className={cn("size-2 rounded-full", includedDot)} />
122+
<Text className="text-(--gray-9) text-[13px]">
123+
{formatUsdAmount(includedUsd)} included
124+
</Text>
125+
</Flex>
126+
{hasPaidSegment && (
127+
<Flex align="center" gap="2">
128+
<span className={cn("size-2 rounded-full", paidDot)} />
129+
<Text className="text-(--gray-9) text-[13px]">
130+
{formatUsdAmount(spendLimitUsd)} org spend limit
131+
</Text>
132+
</Flex>
133+
)}
134+
</Flex>
55135
</Flex>
56136
);
57137
}

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

Lines changed: 3 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,10 @@ 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+
breakdown={
186183
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
184+
? { ...meter.breakdown, usedUsd: meter.usedUsd }
196185
: undefined
197186
}
198187
color={meter.exceeded ? "red" : undefined}

0 commit comments

Comments
 (0)