Skip to content

Commit 93a6d02

Browse files
fix(usage): separate the plan meter from the filters (#8357)
1 parent 9dd6141 commit 93a6d02

27 files changed

Lines changed: 2541 additions & 225 deletions

frontend/.storybook/preview.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import ReactSelect, { components as selectComponents } from 'react-select'
1515
// Only import components that use the automatic JSX transform (TSX files).
1616
// Legacy .js files (Flex, Column, Input) use old JSX transform and crash here.
1717
import Tooltip from '../web/components/Tooltip'
18+
import Button from '../web/components/base/forms/Button'
19+
import Loader from '../web/components/Loader'
1820
import Row from '../web/components/base/grid/Row'
1921
import FormGroup from '../web/components/base/grid/FormGroup'
2022

@@ -53,6 +55,8 @@ global.Select = (props) =>
5355
}),
5456
)
5557
window.Tooltip = Tooltip
58+
window.Button = Button
59+
window.Loader = Loader
5660
window.Row = Row
5761
window.FormGroup = FormGroup
5862
// isMobile is set at app boot; stub it so components that read it render in Storybook.

frontend/common/types/responses.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,8 @@ export type AuditLogDetail = AuditLogItem & {
515515
new: FlagsmithValue
516516
}[]
517517
}
518+
export type PaymentMethod = 'CHARGEBEE' | 'XERO' | 'AWS_MARKETPLACE'
519+
518520
export type Subscription = {
519521
id: number
520522
uuid: string
@@ -525,7 +527,7 @@ export type Subscription = {
525527
max_api_calls: number
526528
cancellation_date: string | null
527529
customer_id: string
528-
payment_method: string
530+
payment_method: PaymentMethod | null
529531
notes: string | null
530532
has_active_billing_periods: boolean
531533
}
Lines changed: 192 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,230 @@
1+
import { FC, useState } from 'react'
12
import type { Meta, StoryObj } from 'storybook'
23
import { UsageDashboard } from 'components/pages/usage'
3-
import { Res } from 'common/types/responses'
4+
import UsageBreakdown, {
5+
useUsageBreakdown,
6+
} from 'components/pages/usage/components/UsageBreakdown'
7+
import {
8+
allowanceWindow,
9+
contributionNote,
10+
isBilledOnAPeriod,
11+
isBillingPeriodSelected,
12+
periodLabel,
13+
periodsFor,
14+
PeriodSelection,
15+
planSectionCopy,
16+
resolvePeriod,
17+
showsContribution,
18+
showsPlanCeiling,
19+
usageBasisOf,
20+
} from 'components/pages/usage/utils'
21+
import { BillingPeriod, PeriodOption } from 'common/types/requests'
22+
import { PlanLimit } from 'components/shared/UsageBar/utils'
23+
import { Subscription } from 'common/types/responses'
24+
import { toUsageResponse, USAGE_SCENARIOS } from './fixtures/usage'
425

5-
const meta: Meta<typeof UsageDashboard> = {
6-
component: UsageDashboard,
26+
const PROJECTS = [
27+
'All Projects',
28+
'Checkout',
29+
'Mobile app',
30+
'Internal tools',
31+
'Marketing site',
32+
]
33+
34+
// Roughly how much of the organisation each project accounts for.
35+
const PROJECT_SHARE: Record<string, number> = {
36+
'All Projects': 1,
37+
'Checkout': 0.38,
38+
'Internal tools': 0.07,
39+
'Marketing site': 0.13,
40+
'Mobile app': 0.42,
41+
}
42+
43+
const SCENARIO_FOR: Record<string, keyof typeof USAGE_SCENARIOS> = {
44+
'90_day_period': 'last90Days',
45+
current_billing_period: 'currentBillingPeriod',
46+
previous_billing_period: 'previousBillingPeriod',
47+
rolling: 'last30Days',
48+
}
49+
50+
const scenarioFor = (period: BillingPeriod, empty: boolean, free: boolean) => {
51+
if (empty) return []
52+
if (free) return USAGE_SCENARIOS.freePlan
53+
return USAGE_SCENARIOS[SCENARIO_FOR[period ?? 'rolling']]
54+
}
55+
56+
const subscriptionOf = (values: Partial<Subscription>): Subscription =>
57+
({
58+
has_active_billing_periods: false,
59+
payment_method: null,
60+
plan: 'scale-up',
61+
...values,
62+
} as Subscription)
63+
64+
type HarnessProps = {
65+
subscription: Subscription
66+
limit: PlanLimit
67+
/** Overrides the fixture so the meter reads a chosen percentage. */
68+
scale?: number
69+
empty?: boolean
70+
isLoading?: boolean
71+
isError?: boolean
72+
}
73+
74+
/**
75+
* Mirrors what UsageDashboardPage derives, using the real utils rather than a
76+
* copy of them, so the selects behave here as they do in the app.
77+
*/
78+
const UsagePage: FC<HarnessProps> = ({
79+
empty,
80+
isError,
81+
isLoading,
82+
limit,
83+
scale = 1,
84+
subscription,
85+
}) => {
86+
const isFreePlan = subscription.plan === 'free'
87+
const basis = usageBasisOf(subscription, isFreePlan)
88+
const planIsBilled = isBilledOnAPeriod(basis)
89+
const periods = periodsFor(planIsBilled)
90+
91+
const [chosenPeriod, setChosenPeriod] = useState<PeriodSelection>('default')
92+
const [project, setProject] = useState('All Projects')
93+
94+
const billingPeriod = resolvePeriod(chosenPeriod, planIsBilled)
95+
const share = PROJECT_SHARE[project] ?? 1
96+
const filtered = project !== 'All Projects'
97+
98+
const scoped = toUsageResponse(
99+
scenarioFor(billingPeriod, !!empty, isFreePlan),
100+
share * scale,
101+
)
102+
const allowanceTotal = toUsageResponse(
103+
scenarioFor(allowanceWindow(basis), !!empty, isFreePlan),
104+
scale,
105+
).totals.total
106+
107+
// The note needs the organisation over the period on screen, not over the
108+
// allowance window, or a project can read as more than all of it.
109+
const { setDimension, ...breakdown } = useUsageBreakdown({ data: scoped })
110+
111+
const scope = `${filtered ? project : 'All projects'} · ${periodLabel(
112+
periods,
113+
billingPeriod,
114+
)}`
115+
116+
return (
117+
<UsageDashboard
118+
breakdown={
119+
<UsageBreakdown
120+
{...breakdown}
121+
onChangeDimension={setDimension}
122+
scope={scope}
123+
/>
124+
}
125+
data={scoped}
126+
filters={
127+
<Row className='gap-2'>
128+
<div style={{ minWidth: 210 }}>
129+
<Select
130+
aria-label='Period'
131+
onChange={(option: PeriodOption) => setChosenPeriod(option.value)}
132+
options={periods}
133+
value={periods.find((option) => option.value === billingPeriod)}
134+
/>
135+
</div>
136+
<div style={{ minWidth: 210 }}>
137+
<Select
138+
aria-label='Project'
139+
onChange={(option: { value: string }) => setProject(option.value)}
140+
options={PROJECTS.map((name) => ({ label: name, value: name }))}
141+
value={{ label: project, value: project }}
142+
/>
143+
</div>
144+
</Row>
145+
}
146+
hasBillingPeriod={isBillingPeriodSelected(billingPeriod)}
147+
isError={isError}
148+
isLoading={isLoading}
149+
limit={limit}
150+
meterNote={
151+
showsContribution(basis, billingPeriod, filtered ? 1 : undefined)
152+
? contributionNote(project, scoped.totals.total, allowanceTotal)
153+
: undefined
154+
}
155+
onRetry={() => {}}
156+
periodLabel={periodLabel(periods, billingPeriod)}
157+
planCopy={planSectionCopy(basis, limit)}
158+
showPlanCeiling={showsPlanCeiling(
159+
billingPeriod,
160+
filtered ? 1 : undefined,
161+
)}
162+
total={allowanceTotal}
163+
/>
164+
)
165+
}
166+
167+
const meta: Meta<typeof UsagePage> = {
168+
component: UsagePage,
7169
parameters: { layout: 'fullscreen' },
8170
title: 'Pages/Usage Dashboard/Page',
9171
}
10172
export default meta
11173

12-
type Story = StoryObj<typeof UsageDashboard>
13-
14-
const DAY_WEIGHTS = [1.08, 1.12, 1.05, 1.1, 0.98, 0.62, 0.58]
15-
16-
const usage = (days: number, perDay: number): Res['organisationUsage'] => {
17-
const events = Array.from({ length: days }).map((_, index) => {
18-
const weight = DAY_WEIGHTS[index % DAY_WEIGHTS.length]
19-
return {
20-
day: `2026-08-${`${index + 1}`.padStart(2, '0')}`,
21-
environment_document: Math.round(perDay * weight * 0.04),
22-
flags: Math.round(perDay * weight * 0.63),
23-
identities: Math.round(perDay * weight * 0.24),
24-
labels: { user_agent: null },
25-
traits: Math.round(perDay * weight * 0.09),
26-
}
27-
})
28-
const sum = (
29-
key: 'flags' | 'identities' | 'traits' | 'environment_document',
30-
) => events.reduce((acc, event) => acc + event[key], 0)
31-
32-
return {
33-
events_list: events,
34-
totals: {
35-
environmentDocument: sum('environment_document'),
36-
flags: sum('flags'),
37-
identities: sum('identities'),
38-
total:
39-
sum('flags') +
40-
sum('identities') +
41-
sum('traits') +
42-
sum('environment_document'),
43-
traits: sum('traits'),
44-
},
45-
}
46-
}
47-
48-
const paid = usage(18, 70000)
49-
const free = usage(30, 1800)
50-
const paidApproaching = usage(26, 75000)
51-
const paidOver = usage(28, 92000)
52-
53-
// A plan with a billing term: usage climbs towards a reset, so it is drawn
54-
// cumulatively against the ceiling.
174+
type Story = StoryObj<typeof UsagePage>
175+
176+
const billed = subscriptionOf({ has_active_billing_periods: true })
177+
55178
export const PaidWithABillingPeriod: Story = {
56-
args: {
57-
data: paid,
58-
hasBillingPeriod: true,
59-
limit: 2000000,
60-
total: paid.totals.total,
61-
},
179+
args: { limit: 2000000, subscription: billed },
62180
}
63181

64182
export const PaidApproachingTheLimit: Story = {
65-
args: {
66-
data: paidApproaching,
67-
hasBillingPeriod: true,
68-
limit: 2000000,
69-
total: paidApproaching.totals.total,
70-
},
183+
args: { limit: 1400000, subscription: billed },
71184
}
72185

73186
export const PaidOverTheLimit: Story = {
74-
args: {
75-
data: paidOver,
76-
hasBillingPeriod: true,
77-
limit: 2000000,
78-
total: paidOver.totals.total,
79-
},
187+
args: { limit: 900000, subscription: billed },
80188
}
81189

82-
// No billing term, so no reset to accumulate towards: daily volume instead.
83190
export const FreeOnARollingWindow: Story = {
84-
args: {
85-
data: free,
86-
hasBillingPeriod: false,
87-
limit: 50000,
88-
total: free.totals.total,
89-
},
191+
args: { limit: 50000, subscription: subscriptionOf({ plan: 'free' }) },
90192
}
91193

92-
// Enterprise agreements are not billed through Chargebee, so they have a real
93-
// limit and no period. The meter still works; the chart falls back to volume.
194+
// Enterprise agreements are invoiced outside Chargebee, so they have a real
195+
// limit and no period. The meter works; the chart falls back to daily volume.
94196
export const EnterpriseWithoutABillingPeriod: Story = {
95197
args: {
96-
data: paid,
97-
hasBillingPeriod: false,
98198
limit: 50000000,
99-
total: paid.totals.total,
199+
subscription: subscriptionOf({
200+
payment_method: 'XERO',
201+
plan: 'enterprise',
202+
}),
100203
},
101204
}
102205

103-
// Self-hosted has no subscription data at all, so there is nothing to be a
104-
// percentage of.
105-
export const WithoutAPlanLimit: Story = {
206+
// On Chargebee, but no period has arrived. Reads differently from invoiced,
207+
// because this one may resolve itself.
208+
export const ChargebeeWithoutAPeriodYet: Story = {
106209
args: {
107-
data: paid,
108-
hasBillingPeriod: false,
109-
limit: null,
110-
total: paid.totals.total,
210+
limit: 2000000,
211+
subscription: subscriptionOf({ payment_method: 'CHARGEBEE' }),
111212
},
112213
}
113214

215+
// Self-hosted has no subscription data at all, so nothing to be a percentage of.
216+
export const WithoutAPlanLimit: Story = {
217+
args: { limit: null, subscription: subscriptionOf({ plan: 'enterprise' }) },
218+
}
219+
114220
export const NoUsageYet: Story = {
115-
args: {
116-
data: usage(0, 0),
117-
hasBillingPeriod: true,
118-
limit: 2000000,
119-
total: 0,
120-
},
221+
args: { empty: true, limit: 2000000, subscription: billed },
121222
}
122223

123224
export const Loading: Story = {
124-
args: {
125-
hasBillingPeriod: true,
126-
isLoading: true,
127-
limit: 2000000,
128-
total: 0,
129-
},
225+
args: { isLoading: true, limit: 2000000, subscription: billed },
130226
}
131227

132-
/** Distinct from NoUsageYet, which would otherwise look identical. */
133228
export const FailedToLoad: Story = {
134-
args: {
135-
hasBillingPeriod: true,
136-
isError: true,
137-
limit: 2000000,
138-
total: 0,
139-
},
229+
args: { isError: true, limit: 2000000, subscription: billed },
140230
}

frontend/documentation/components/UsageMeter.stories.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ export const OverTheLimit: Story = {
2828

2929
// Nothing to be a percentage of, so the total stands on its own.
3030
export const WithoutAPlanLimit: Story = {
31-
args: { limit: null, total: 340000 },
31+
args: {
32+
limit: null,
33+
total: 340000,
34+
},
3235
}
3336

3437
export const NoUsageYet: Story = {

frontend/documentation/components/UsageOverTime.stories.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import UsageOverTime from 'components/pages/usage/components/UsageOverTime'
33
import { Res } from 'common/types/responses'
44

55
const meta: Meta<typeof UsageOverTime> = {
6+
args: { periodLabel: 'Current billing period' },
67
component: UsageOverTime,
78
parameters: { layout: 'padded' },
89
title: 'Pages/Usage Dashboard/Components/UsageOverTime',
@@ -68,6 +69,7 @@ export const DailyVolumeOnARollingWindow: Story = {
6869
data: usage(30, 2000),
6970
isBillingPeriod: false,
7071
limit: 50000,
72+
periodLabel: 'Last 30 days',
7173
},
7274
}
7375

0 commit comments

Comments
 (0)