Skip to content

Commit 4840746

Browse files
talissoncostaclaude
andcommitted
docs(usage): make the dashboard stories working pages
The stories were fixed snapshots with stub selects, so choosing a period or a project moved the control and nothing else. That hid a real bug: the contribution note divided one period by another and could read past 100%. Each story is now a starting subscription and limit, rendered through a harness that calls the same utils and hook the page does. The period, project and dimension menus all work, and the copy comes from planSectionCopy rather than being typed into args, so the stories cannot drift from the page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4eb4ede commit 4840746

1 file changed

Lines changed: 199 additions & 102 deletions

File tree

Lines changed: 199 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,237 @@
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+
basisExplanation,
10+
contributionNote,
11+
isBilledOnAPeriod,
12+
isBillingPeriodSelected,
13+
periodLabel,
14+
periodsFor,
15+
planSectionCopy,
16+
resolvePeriod,
17+
showsPlanCeiling,
18+
usageBasisOf,
19+
} from 'components/pages/usage/utils'
20+
import { BillingPeriod, PeriodOption } from 'common/types/requests'
21+
import { PlanLimit } from 'components/shared/UsageBar/utils'
22+
import { Subscription } from 'common/types/responses'
23+
import { toUsageResponse, USAGE_SCENARIOS } from './fixtures/usage'
424

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

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.
181+
type Story = StoryObj<typeof UsagePage>
182+
183+
const billed = subscriptionOf({ has_active_billing_periods: true })
184+
55185
export const PaidWithABillingPeriod: Story = {
56-
args: {
57-
data: paid,
58-
hasBillingPeriod: true,
59-
limit: 2000000,
60-
total: paid.totals.total,
61-
},
186+
args: { limit: 2000000, subscription: billed },
62187
}
63188

64189
export const PaidApproachingTheLimit: Story = {
65-
args: {
66-
data: paidApproaching,
67-
hasBillingPeriod: true,
68-
limit: 2000000,
69-
total: paidApproaching.totals.total,
70-
},
190+
args: { limit: 1400000, subscription: billed },
71191
}
72192

73193
export const PaidOverTheLimit: Story = {
74-
args: {
75-
data: paidOver,
76-
hasBillingPeriod: true,
77-
limit: 2000000,
78-
total: paidOver.totals.total,
79-
},
194+
args: { limit: 900000, subscription: billed },
80195
}
81196

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

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.
201+
// Enterprise agreements are invoiced outside Chargebee, so they have a real
202+
// limit and no period. The meter works; the chart falls back to daily volume.
94203
export const EnterpriseWithoutABillingPeriod: Story = {
95204
args: {
96-
data: paid,
97-
hasBillingPeriod: false,
98205
limit: 50000000,
99-
total: paid.totals.total,
206+
subscription: subscriptionOf({
207+
payment_method: 'XERO',
208+
plan: 'enterprise',
209+
}),
100210
},
101211
}
102212

103-
// Self-hosted has no subscription data at all, so there is nothing to be a
104-
// percentage of.
105-
export const WithoutAPlanLimit: Story = {
213+
// On Chargebee, but no period has arrived. Reads differently from invoiced,
214+
// because this one may resolve itself.
215+
export const ChargebeeWithoutAPeriodYet: Story = {
106216
args: {
107-
data: paid,
108-
hasBillingPeriod: false,
109-
limit: null,
110-
total: paid.totals.total,
217+
limit: 2000000,
218+
subscription: subscriptionOf({ payment_method: 'CHARGEBEE' }),
111219
},
112220
}
113221

222+
// Self-hosted has no subscription data at all, so nothing to be a percentage of.
223+
export const WithoutAPlanLimit: Story = {
224+
args: { limit: null, subscription: subscriptionOf({ plan: 'enterprise' }) },
225+
}
226+
114227
export const NoUsageYet: Story = {
115-
args: {
116-
data: usage(0, 0),
117-
hasBillingPeriod: true,
118-
limit: 2000000,
119-
total: 0,
120-
},
228+
args: { empty: true, limit: 2000000, subscription: billed },
121229
}
122230

123231
export const Loading: Story = {
124-
args: {
125-
hasBillingPeriod: true,
126-
isLoading: true,
127-
limit: 2000000,
128-
total: 0,
129-
},
232+
args: { isLoading: true, limit: 2000000, subscription: billed },
130233
}
131234

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

0 commit comments

Comments
 (0)