From cb643d22cdac3b45d25998eea0a4616f5a3c0278 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 26 Aug 2026 09:38:59 -0300 Subject: [PATCH 1/6] fix(usage): only claim a percentage the allowance can support max_api_calls is a 30 day figure, so Last 90 days measured three months of usage against one month of allowance and read about three times too high. The meter reports a count for that window instead, names the allowance as a 30 day one, and the chart drops its ceiling with it. The headline also stopped repeating the fraction beside it, and stopped taking its colour from a percentage it was no longer showing. Fixes #8353 Co-Authored-By: Claude Opus 5 (1M context) --- .../components/pages/usage/UsageDashboard.tsx | 6 ++- .../pages/usage/UsageDashboardPage.tsx | 2 + .../pages/usage/__tests__/utils.test.ts | 12 +++++ .../components/UsageMeter/UsageMeter.tsx | 47 ++++++++++-------- .../UsageMeter/__tests__/utils.test.ts | 39 +++++++++++++-- .../usage/components/UsageMeter/utils.ts | 49 ++++++++++++++----- frontend/web/components/pages/usage/utils.ts | 3 ++ 7 files changed, 120 insertions(+), 38 deletions(-) diff --git a/frontend/web/components/pages/usage/UsageDashboard.tsx b/frontend/web/components/pages/usage/UsageDashboard.tsx index f094328aaad7..48018cfa7d5f 100644 --- a/frontend/web/components/pages/usage/UsageDashboard.tsx +++ b/frontend/web/components/pages/usage/UsageDashboard.tsx @@ -9,6 +9,7 @@ export type UsageDashboardProps = { data: Res['organisationUsage'] | undefined total: number limit: PlanLimit + comparable?: boolean hasBillingPeriod: boolean isError?: boolean isLoading?: boolean @@ -18,6 +19,7 @@ export type UsageDashboardProps = { const UsageDashboard: FC = ({ breakdown, + comparable, data, filters, hasBillingPeriod, @@ -45,11 +47,11 @@ const UsageDashboard: FC = ({ } else { content = ( <> - + diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.tsx b/frontend/web/components/pages/usage/UsageDashboardPage.tsx index c00d3e5e653a..8791eafd22f3 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -11,6 +11,7 @@ import UsageBreakdown, { useUsageBreakdown } from './components/UsageBreakdown' import UsageDashboard from './UsageDashboard' import { isBillingPeriodSelected, + isComparableToAllowance, periodLabel, periodsFor, PeriodSelection, @@ -84,6 +85,7 @@ const UsageDashboardPage: FC = ({ total={data?.totals?.total ?? 0} limit={subscriptionMeta?.max_api_calls} hasBillingPeriod={isBillingPeriodSelected(billingPeriod)} + comparable={isComparableToAllowance(billingPeriod)} breakdown={ { }) }) + describe('isComparableToAllowance', () => { + it('compares the 30 day window and the billing periods', () => { + expect(isComparableToAllowance(undefined)).toBe(true) + expect(isComparableToAllowance('current_billing_period')).toBe(true) + }) + + it('refuses the 90 day window against a 30 day allowance', () => { + expect(isComparableToAllowance('90_day_period')).toBe(false) + }) + }) + describe('periodsFor', () => { it('offers the billing periods only when there is a term', () => { expect(periodsFor(true).map((period) => period.value)).toContain( diff --git a/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx index 1643de2d4fef..11ad3c4016b6 100644 --- a/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx +++ b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx @@ -1,12 +1,7 @@ import { FC, ReactNode } from 'react' -import Format from 'common/utils/format' import UsageBar from 'components/shared/UsageBar' -import { - PlanLimit, - toneFor, - usagePercent, -} from 'components/shared/UsageBar/utils' -import { meterCopy } from './utils' +import { PlanLimit } from 'components/shared/UsageBar/utils' +import { meterCopy, meterTone } from './utils' import './UsageMeter.scss' const WARN_AT = 75 @@ -15,12 +10,18 @@ const NOTIFICATION_THRESHOLDS = [WARN_AT, 100] export type UsageMeterProps = { total: number limit: PlanLimit + comparable?: boolean note?: ReactNode } -const UsageMeter: FC = ({ limit, note, total }) => { - const copy = meterCopy(total, limit) - const tone = toneFor(usagePercent(total, limit), WARN_AT) +const UsageMeter: FC = ({ + comparable = true, + limit, + note, + total, +}) => { + const copy = meterCopy(total, limit, comparable) + const tone = meterTone(total, limit, comparable, WARN_AT) return (
@@ -28,7 +29,11 @@ const UsageMeter: FC = ({ limit, note, total }) => {

Plan usage

- + {copy.headline} @@ -36,18 +41,20 @@ const UsageMeter: FC = ({ limit, note, total }) => {
-
-
- {Format.shortenNumber(total)} - {copy.fractionSuffix} + {copy.fraction && ( +
+
+ {copy.fraction.value} + {copy.fraction.suffix} +
+
+ {copy.fraction.caption} +
-
- {copy.fractionCaption} -
-
+ )}
- {!!limit && ( + {!!limit && comparable && ( { describe('meterCopy', () => { it('reads as a percentage of the limit when there is one', () => { expect(meterCopy(1500000, 2000000)).toEqual({ - fractionCaption: 'API calls used / plan limit', - fractionSuffix: ' / 2M', + fraction: { + caption: 'API calls used / plan limit', + suffix: ' / 2M', + value: '1.5M', + }, headline: '75%', headlineCaption: 'of plan consumed', }) @@ -20,17 +26,40 @@ describe('UsageMeter utils', () => { 'falls back to the raw count when the limit is %p', (limit) => { expect(meterCopy(1500000, limit)).toEqual({ - fractionCaption: 'API calls used', - fractionSuffix: '', + fraction: undefined, headline: '1.5M', headlineCaption: 'API calls', }) }, ) + it('names the allowance window when the period rules a percentage out', () => { + expect(meterCopy(1500000, 2000000, false)).toEqual({ + fraction: { caption: 'allowance per 30 days', value: '2M' }, + headline: '1.5M', + headlineCaption: 'API calls', + }) + }) + it('shows zero rather than NaN for an organisation with no calls', () => { expect(meterCopy(0, null).headline).toBe('0') expect(meterCopy(0, 2000000).headline).toBe('0%') }) }) + + describe('meterTone', () => { + it('tracks the thresholds when the comparison holds', () => { + expect(meterTone(500000, 2000000, true, 75)).toBe('success') + expect(meterTone(1600000, 2000000, true, 75)).toBe('warning') + expect(meterTone(2000000, 2000000, true, 75)).toBe('danger') + }) + + it('has no tone to give when the comparison does not hold', () => { + expect(meterTone(9000000, 2000000, false, 75)).toBeUndefined() + }) + + it('has no tone to give without a limit', () => { + expect(meterTone(9000000, null, true, 75)).toBeUndefined() + }) + }) }) diff --git a/frontend/web/components/pages/usage/components/UsageMeter/utils.ts b/frontend/web/components/pages/usage/components/UsageMeter/utils.ts index 80ddc2a5e9a2..dee226aa4de5 100644 --- a/frontend/web/components/pages/usage/components/UsageMeter/utils.ts +++ b/frontend/web/components/pages/usage/components/UsageMeter/utils.ts @@ -1,26 +1,53 @@ import Format from 'common/utils/format' -import { PlanLimit, usagePercent } from 'components/shared/UsageBar/utils' +import { + PlanLimit, + toneFor, + usagePercent, + UsageTone, +} from 'components/shared/UsageBar/utils' -export type MeterCopy = { +type MeterCopy = { headline: string headlineCaption: string - fractionSuffix: string - fractionCaption: string + fraction?: { + value: string + suffix?: string + caption: string + } } const withLimit = (total: number, limit: number): MeterCopy => ({ - fractionCaption: 'API calls used / plan limit', - fractionSuffix: ` / ${Format.shortenNumber(limit)}`, + fraction: { + caption: 'API calls used / plan limit', + suffix: ` / ${Format.shortenNumber(limit)}`, + value: Format.shortenNumber(total), + }, headline: `${usagePercent(total, limit)}%`, headlineCaption: 'of plan consumed', }) -const withoutLimit = (total: number): MeterCopy => ({ - fractionCaption: 'API calls used', - fractionSuffix: '', +const withoutLimit = (total: number, limit: PlanLimit): MeterCopy => ({ + fraction: limit + ? { + caption: 'allowance per 30 days', + value: Format.shortenNumber(limit), + } + : undefined, headline: Format.shortenNumber(total), headlineCaption: 'API calls', }) -export const meterCopy = (total: number, limit: PlanLimit): MeterCopy => - limit ? withLimit(total, limit) : withoutLimit(total) +export const meterCopy = ( + total: number, + limit: PlanLimit, + comparable = true, +): MeterCopy => + limit && comparable ? withLimit(total, limit) : withoutLimit(total, limit) + +export const meterTone = ( + total: number, + limit: PlanLimit, + comparable: boolean, + warnAt: number, +): UsageTone | undefined => + limit && comparable ? toneFor(usagePercent(total, limit), warnAt) : undefined diff --git a/frontend/web/components/pages/usage/utils.ts b/frontend/web/components/pages/usage/utils.ts index 823b24d3b504..aec78f6c734e 100644 --- a/frontend/web/components/pages/usage/utils.ts +++ b/frontend/web/components/pages/usage/utils.ts @@ -26,6 +26,9 @@ export const resolvePeriod = ( export const isBillingPeriodSelected = (period: BillingPeriod): boolean => period === 'current_billing_period' || period === 'previous_billing_period' +export const isComparableToAllowance = (period: BillingPeriod): boolean => + period !== '90_day_period' + export const periodLabel = ( periods: PeriodOption[], period: BillingPeriod, From 63cbba17d87f7e0a4aa4c782b0b9825cf18ce58c Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 26 Aug 2026 09:40:01 -0300 Subject: [PATCH 2/6] fix(usage): keep the meter on the organisation Picking a project re-pointed the meter at that project while still calling it plan usage, so the same panel meant different things depending on a control at the far side of the page. The meter now always answers for the organisation, and a note underneath says what share of that usage the chosen project accounts for. Share of usage rather than share of allowance: a project on 4M of a 50M plan is 8% of the allowance but could be 40% of everything the organisation did, and the second number is the one that says whether it matters. The organisation query carries no projectId, so RTK fetches it once and reuses it across project changes, and dedupes it with the scoped query when no project is chosen. Fixes #8354 Co-Authored-By: Claude Opus 5 (1M context) --- .../components/pages/usage/UsageDashboard.tsx | 9 ++++++++- .../pages/usage/UsageDashboardPage.tsx | 18 +++++++++++++++++- .../pages/usage/__tests__/utils.test.ts | 13 +++++++++++++ .../usage/components/UsageMeter/UsageMeter.tsx | 4 +++- frontend/web/components/pages/usage/utils.ts | 14 ++++++++++++++ 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/frontend/web/components/pages/usage/UsageDashboard.tsx b/frontend/web/components/pages/usage/UsageDashboard.tsx index 48018cfa7d5f..51c3c3e90387 100644 --- a/frontend/web/components/pages/usage/UsageDashboard.tsx +++ b/frontend/web/components/pages/usage/UsageDashboard.tsx @@ -10,6 +10,7 @@ export type UsageDashboardProps = { total: number limit: PlanLimit comparable?: boolean + meterNote?: ReactNode hasBillingPeriod: boolean isError?: boolean isLoading?: boolean @@ -26,6 +27,7 @@ const UsageDashboard: FC = ({ isError, isLoading, limit, + meterNote, total, }) => { let content @@ -47,7 +49,12 @@ const UsageDashboard: FC = ({ } else { content = ( <> - + = ({ } : skipToken, ) + const { data: organisationData } = useGetOrganisationUsageQuery( + organisationId && organisation + ? { billing_period: billingPeriod, organisationId } + : skipToken, + ) + const { data: subscriptionMeta, isLoading: loadingLimit } = useGetSubscriptionMetadataQuery( organisationId ? { id: organisationId } : skipToken, @@ -82,10 +89,19 @@ const UsageDashboardPage: FC = ({ return ( { }) }) + describe('contributionNote', () => { + it('says what share of the organisation a project accounts for', () => { + expect(contributionNote('Checkout', 400000, 1000000)).toBe( + 'Checkout accounts for 40% of that usage.', + ) + }) + + it('has nothing to say when the organisation used nothing', () => { + expect(contributionNote('Checkout', 0, 0)).toBeUndefined() + }) + }) + describe('isComparableToAllowance', () => { it('compares the 30 day window and the billing periods', () => { expect(isComparableToAllowance(undefined)).toBe(true) diff --git a/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx index 11ad3c4016b6..c9af7c6c3059 100644 --- a/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx +++ b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx @@ -64,7 +64,9 @@ const UsageMeter: FC = ({ /> )} - {note} + {note && ( +

{note}

+ )}
) } diff --git a/frontend/web/components/pages/usage/utils.ts b/frontend/web/components/pages/usage/utils.ts index aec78f6c734e..2cd2c1b399c6 100644 --- a/frontend/web/components/pages/usage/utils.ts +++ b/frontend/web/components/pages/usage/utils.ts @@ -29,6 +29,20 @@ export const isBillingPeriodSelected = (period: BillingPeriod): boolean => export const isComparableToAllowance = (period: BillingPeriod): boolean => period !== '90_day_period' +export const contributionNote = ( + projectName: string, + scopedTotal: number, + organisationTotal: number, +): string | undefined => { + if (organisationTotal <= 0) { + return undefined + } + + const percent = Math.round((scopedTotal / organisationTotal) * 100) + + return `${projectName} accounts for ${percent}% of that usage.` +} + export const periodLabel = ( periods: PeriodOption[], period: BillingPeriod, From 1352bde9ab5bf2adb04b1393c244a47000dac8f7 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 26 Aug 2026 09:40:26 -0300 Subject: [PATCH 3/6] fix(usage): keep the chart's ceiling off a filtered view The meter and the chart answer different questions, so they need different rules. The meter reports the organisation, so a project filter does not touch it. The chart follows the filter, and a ceiling there implies a trajectory towards a limit one project will never reach, which is the point Matt made about the per-endpoint chart in July. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/pages/usage/UsageDashboard.tsx | 4 +++- .../pages/usage/UsageDashboardPage.tsx | 2 ++ .../pages/usage/__tests__/utils.test.ts | 16 ++++++++++++++++ frontend/web/components/pages/usage/utils.ts | 5 +++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/frontend/web/components/pages/usage/UsageDashboard.tsx b/frontend/web/components/pages/usage/UsageDashboard.tsx index 51c3c3e90387..e1c75a2458b7 100644 --- a/frontend/web/components/pages/usage/UsageDashboard.tsx +++ b/frontend/web/components/pages/usage/UsageDashboard.tsx @@ -11,6 +11,7 @@ export type UsageDashboardProps = { limit: PlanLimit comparable?: boolean meterNote?: ReactNode + showPlanCeiling?: boolean hasBillingPeriod: boolean isError?: boolean isLoading?: boolean @@ -28,6 +29,7 @@ const UsageDashboard: FC = ({ isLoading, limit, meterNote, + showPlanCeiling, total, }) => { let content @@ -58,7 +60,7 @@ const UsageDashboard: FC = ({ diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.tsx b/frontend/web/components/pages/usage/UsageDashboardPage.tsx index 0e1809af4ff9..f899861c82cd 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -13,6 +13,7 @@ import { isBillingPeriodSelected, contributionNote, isComparableToAllowance, + showsPlanCeiling, periodLabel, periodsFor, PeriodSelection, @@ -93,6 +94,7 @@ const UsageDashboardPage: FC = ({ limit={subscriptionMeta?.max_api_calls} hasBillingPeriod={isBillingPeriodSelected(billingPeriod)} comparable={isComparableToAllowance(billingPeriod)} + showPlanCeiling={showsPlanCeiling(billingPeriod, selectedProjectId)} meterNote={ selectedProjectId && projectName ? contributionNote( diff --git a/frontend/web/components/pages/usage/__tests__/utils.test.ts b/frontend/web/components/pages/usage/__tests__/utils.test.ts index 9bc442acfc41..81fe89fb57c0 100644 --- a/frontend/web/components/pages/usage/__tests__/utils.test.ts +++ b/frontend/web/components/pages/usage/__tests__/utils.test.ts @@ -3,6 +3,7 @@ import { contributionNote, isBillingPeriodSelected, isComparableToAllowance, + showsPlanCeiling, planHasBillingPeriod, periodsFor, resolvePeriod, @@ -106,6 +107,21 @@ describe('UsageDashboard utils', () => { }) }) + describe('showsPlanCeiling', () => { + it('draws the ceiling for the organisation over a comparable period', () => { + expect(showsPlanCeiling(undefined, undefined)).toBe(true) + expect(showsPlanCeiling('current_billing_period', undefined)).toBe(true) + }) + + it('drops it for one project, which will never reach the ceiling', () => { + expect(showsPlanCeiling(undefined, 12)).toBe(false) + }) + + it('drops it for the 90 day window', () => { + expect(showsPlanCeiling('90_day_period', undefined)).toBe(false) + }) + }) + describe('periodsFor', () => { it('offers the billing periods only when there is a term', () => { expect(periodsFor(true).map((period) => period.value)).toContain( diff --git a/frontend/web/components/pages/usage/utils.ts b/frontend/web/components/pages/usage/utils.ts index 2cd2c1b399c6..eedd5c20dc7e 100644 --- a/frontend/web/components/pages/usage/utils.ts +++ b/frontend/web/components/pages/usage/utils.ts @@ -43,6 +43,11 @@ export const contributionNote = ( return `${projectName} accounts for ${percent}% of that usage.` } +export const showsPlanCeiling = ( + period: BillingPeriod, + projectId: number | undefined, +): boolean => isComparableToAllowance(period) && !projectId + export const periodLabel = ( periods: PeriodOption[], period: BillingPeriod, From 35641f9284cc10058ba80bf401ca40b464500b4b Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 26 Aug 2026 09:40:53 -0300 Subject: [PATCH 4/6] feat(usage): offer a retry when usage fails to load Failing to load left you reloading the page. The retry refetches the organisation first, since a failed organisation query leaves the usage query skipped and retrying it alone cannot recover the page. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/pages/usage/UsageDashboard.tsx | 9 ++++++++ .../pages/usage/UsageDashboardPage.tsx | 21 +++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/frontend/web/components/pages/usage/UsageDashboard.tsx b/frontend/web/components/pages/usage/UsageDashboard.tsx index e1c75a2458b7..e94ad587998b 100644 --- a/frontend/web/components/pages/usage/UsageDashboard.tsx +++ b/frontend/web/components/pages/usage/UsageDashboard.tsx @@ -15,6 +15,7 @@ export type UsageDashboardProps = { hasBillingPeriod: boolean isError?: boolean isLoading?: boolean + onRetry?: () => void filters?: ReactNode breakdown?: ReactNode } @@ -29,6 +30,7 @@ const UsageDashboard: FC = ({ isLoading, limit, meterNote, + onRetry, showPlanCeiling, total, }) => { @@ -46,6 +48,13 @@ const UsageDashboard: FC = ({ title='Usage could not be loaded' description='Something went wrong fetching usage for this period. Try again in a moment.' icon='bar-chart' + action={ + onRetry && ( + + ) + } /> ) } else { diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.tsx b/frontend/web/components/pages/usage/UsageDashboardPage.tsx index f899861c82cd..78fbe4186867 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -37,6 +37,7 @@ const UsageDashboardPage: FC = ({ data: organisation, isError: organisationFailed, isLoading: loadingOrganisation, + refetch: refetchOrganisation, } = useGetOrganisationQuery( organisationId ? { id: organisationId } : skipToken, ) @@ -52,6 +53,8 @@ const UsageDashboardPage: FC = ({ data, isError: usageFailed, isFetching: loadingUsage, + isUninitialized: usageNotStarted, + refetch: refetchUsage, } = useGetOrganisationUsageQuery( organisationId && organisation ? { @@ -67,10 +70,13 @@ const UsageDashboardPage: FC = ({ : skipToken, ) - const { data: subscriptionMeta, isLoading: loadingLimit } = - useGetSubscriptionMetadataQuery( - organisationId ? { id: organisationId } : skipToken, - ) + const { + data: subscriptionMeta, + isLoading: loadingLimit, + refetch: refetchLimit, + } = useGetSubscriptionMetadataQuery( + organisationId ? { id: organisationId } : skipToken, + ) const periods = periodsFor(planIsBilled) @@ -113,6 +119,13 @@ const UsageDashboardPage: FC = ({ } isError={organisationFailed || usageFailed} isLoading={loadingOrganisation || loadingUsage || loadingLimit} + onRetry={() => { + refetchOrganisation() + refetchLimit() + if (!usageNotStarted) { + refetchUsage() + } + }} filters={
From a9acb21bc5e0c6417e0cc39a664672e317bc4b59 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 26 Aug 2026 09:41:24 -0300 Subject: [PATCH 5/6] fix(usage): stop refetching usage every time the tab regains focus The base API sets refetchOnFocus for everything, which Wadii spotted on the breakdown: switching tabs and back reloads the page behind a loader. Worse than the flicker, usage-data is throttled at five requests a minute per user, so tab switching quietly spends the budget the page needs. Off for both usage queries, since the listener is what the features page relies on. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/web/components/pages/usage/UsageDashboardPage.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.tsx b/frontend/web/components/pages/usage/UsageDashboardPage.tsx index 78fbe4186867..51ac97e7f4f0 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -63,11 +63,17 @@ const UsageDashboardPage: FC = ({ projectId: selectedProjectId, } : skipToken, + // usage-data is throttled at five requests a minute per user, so + // refetching every time the tab regains focus spends that budget. + { refetchOnFocus: false }, ) const { data: organisationData } = useGetOrganisationUsageQuery( organisationId && organisation ? { billing_period: billingPeriod, organisationId } : skipToken, + // usage-data is throttled at five requests a minute per user, so + // refetching every time the tab regains focus spends that budget. + { refetchOnFocus: false }, ) const { From e80a32b8fed4130168a15344902d96bd1b7ea760 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 27 Aug 2026 16:32:33 -0300 Subject: [PATCH 6/6] fix(usage): give the meter its own window The period selector drove the meter, so the numerator moved to whatever was on screen while the allowance stayed put. Ninety days of usage read three times too high against a thirty day allowance. The meter now measures the window the allowance actually covers, the billing period where there is one and the trailing thirty days otherwise, and says which. The selectors drive the chart and the breakdown, which is what they were for. That removes the comparability rule rather than working around it: with the meter no longer following the selector there is nothing left to be incomparable. Fixes #8353 Co-Authored-By: Claude Opus 5 (1M context) --- .../components/UsageDashboard.stories.tsx | 4 ++ .../components/UsageMeter.stories.tsx | 7 +++- .../components/pages/usage/UsageDashboard.tsx | 6 +-- .../pages/usage/UsageDashboardPage.tsx | 11 +++-- .../pages/usage/__tests__/utils.test.ts | 16 ++++---- .../components/UsageMeter/UsageMeter.tsx | 14 ++++--- .../UsageMeter/__tests__/utils.test.ts | 21 ++-------- .../usage/components/UsageMeter/utils.ts | 41 +++++++------------ frontend/web/components/pages/usage/utils.ts | 11 +++-- 9 files changed, 63 insertions(+), 68 deletions(-) diff --git a/frontend/documentation/components/UsageDashboard.stories.tsx b/frontend/documentation/components/UsageDashboard.stories.tsx index 45139cb3cbaa..c637ee0336f1 100644 --- a/frontend/documentation/components/UsageDashboard.stories.tsx +++ b/frontend/documentation/components/UsageDashboard.stories.tsx @@ -3,6 +3,7 @@ import { UsageDashboard } from 'components/pages/usage' import { Res } from 'common/types/responses' const meta: Meta = { + args: { meterWindow: 'this billing period' }, component: UsageDashboard, parameters: { layout: 'fullscreen' }, title: 'Pages/Usage Dashboard/Page', @@ -85,6 +86,7 @@ export const FreeOnARollingWindow: Story = { data: free, hasBillingPeriod: false, limit: 50000, + meterWindow: 'last 30 days', total: free.totals.total, }, } @@ -96,6 +98,7 @@ export const EnterpriseWithoutABillingPeriod: Story = { data: paid, hasBillingPeriod: false, limit: 50000000, + meterWindow: 'last 30 days', total: paid.totals.total, }, } @@ -107,6 +110,7 @@ export const WithoutAPlanLimit: Story = { data: paid, hasBillingPeriod: false, limit: null, + meterWindow: 'last 30 days', total: paid.totals.total, }, } diff --git a/frontend/documentation/components/UsageMeter.stories.tsx b/frontend/documentation/components/UsageMeter.stories.tsx index 8eb53252d7b4..2c1ae466c3a7 100644 --- a/frontend/documentation/components/UsageMeter.stories.tsx +++ b/frontend/documentation/components/UsageMeter.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from 'storybook' import UsageMeter from 'components/pages/usage/components/UsageMeter' const meta: Meta = { + args: { windowLabel: 'this billing period' }, component: UsageMeter, parameters: { layout: 'padded' }, title: 'Pages/Usage Dashboard/Components/UsageMeter', @@ -28,7 +29,11 @@ export const OverTheLimit: Story = { // Nothing to be a percentage of, so the total stands on its own. export const WithoutAPlanLimit: Story = { - args: { limit: null, total: 340000 }, + args: { + limit: null, + total: 340000, + windowLabel: 'last 30 days', + }, } export const NoUsageYet: Story = { diff --git a/frontend/web/components/pages/usage/UsageDashboard.tsx b/frontend/web/components/pages/usage/UsageDashboard.tsx index e94ad587998b..c059fee626b1 100644 --- a/frontend/web/components/pages/usage/UsageDashboard.tsx +++ b/frontend/web/components/pages/usage/UsageDashboard.tsx @@ -9,7 +9,7 @@ export type UsageDashboardProps = { data: Res['organisationUsage'] | undefined total: number limit: PlanLimit - comparable?: boolean + meterWindow: string meterNote?: ReactNode showPlanCeiling?: boolean hasBillingPeriod: boolean @@ -22,7 +22,6 @@ export type UsageDashboardProps = { const UsageDashboard: FC = ({ breakdown, - comparable, data, filters, hasBillingPeriod, @@ -30,6 +29,7 @@ const UsageDashboard: FC = ({ isLoading, limit, meterNote, + meterWindow, onRetry, showPlanCeiling, total, @@ -63,7 +63,7 @@ const UsageDashboard: FC = ({ diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.tsx b/frontend/web/components/pages/usage/UsageDashboardPage.tsx index 51ac97e7f4f0..aa22c8dc6862 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -12,7 +12,8 @@ import UsageDashboard from './UsageDashboard' import { isBillingPeriodSelected, contributionNote, - isComparableToAllowance, + allowanceWindow, + allowanceWindowLabel, showsPlanCeiling, periodLabel, periodsFor, @@ -69,7 +70,11 @@ const UsageDashboardPage: FC = ({ ) const { data: organisationData } = useGetOrganisationUsageQuery( organisationId && organisation - ? { billing_period: billingPeriod, organisationId } + ? { + // The meter answers for the allowance window, not the one on screen. + billing_period: allowanceWindow(planIsBilled), + organisationId, + } : skipToken, // usage-data is throttled at five requests a minute per user, so // refetching every time the tab regains focus spends that budget. @@ -105,7 +110,7 @@ const UsageDashboardPage: FC = ({ total={organisationData?.totals?.total ?? 0} limit={subscriptionMeta?.max_api_calls} hasBillingPeriod={isBillingPeriodSelected(billingPeriod)} - comparable={isComparableToAllowance(billingPeriod)} + meterWindow={allowanceWindowLabel(planIsBilled)} showPlanCeiling={showsPlanCeiling(billingPeriod, selectedProjectId)} meterNote={ selectedProjectId && projectName diff --git a/frontend/web/components/pages/usage/__tests__/utils.test.ts b/frontend/web/components/pages/usage/__tests__/utils.test.ts index 81fe89fb57c0..cd7e5b7c551a 100644 --- a/frontend/web/components/pages/usage/__tests__/utils.test.ts +++ b/frontend/web/components/pages/usage/__tests__/utils.test.ts @@ -2,7 +2,8 @@ import { Subscription } from 'common/types/responses' import { contributionNote, isBillingPeriodSelected, - isComparableToAllowance, + allowanceWindow, + allowanceWindowLabel, showsPlanCeiling, planHasBillingPeriod, periodsFor, @@ -96,14 +97,15 @@ describe('UsageDashboard utils', () => { }) }) - describe('isComparableToAllowance', () => { - it('compares the 30 day window and the billing periods', () => { - expect(isComparableToAllowance(undefined)).toBe(true) - expect(isComparableToAllowance('current_billing_period')).toBe(true) + describe('allowanceWindow', () => { + it('measures a billed organisation over its billing period', () => { + expect(allowanceWindow(true)).toBe('current_billing_period') + expect(allowanceWindowLabel(true)).toBe('this billing period') }) - it('refuses the 90 day window against a 30 day allowance', () => { - expect(isComparableToAllowance('90_day_period')).toBe(false) + it('measures everyone else over the trailing 30 days', () => { + expect(allowanceWindow(false)).toBeUndefined() + expect(allowanceWindowLabel(false)).toBe('last 30 days') }) }) diff --git a/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx index c9af7c6c3059..60727cd499f8 100644 --- a/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx +++ b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx @@ -10,24 +10,26 @@ const NOTIFICATION_THRESHOLDS = [WARN_AT, 100] export type UsageMeterProps = { total: number limit: PlanLimit - comparable?: boolean + windowLabel: string note?: ReactNode } const UsageMeter: FC = ({ - comparable = true, limit, note, total, + windowLabel, }) => { - const copy = meterCopy(total, limit, comparable) - const tone = meterTone(total, limit, comparable, WARN_AT) + const copy = meterCopy(total, limit) + const tone = meterTone(total, limit, WARN_AT) return (
-

Plan usage

+

+ Plan usage ยท {windowLabel} +

= ({ )}
- {!!limit && comparable && ( + {!!limit && ( { 'falls back to the raw count when the limit is %p', (limit) => { expect(meterCopy(1500000, limit)).toEqual({ - fraction: undefined, headline: '1.5M', headlineCaption: 'API calls', }) }, ) - it('names the allowance window when the period rules a percentage out', () => { - expect(meterCopy(1500000, 2000000, false)).toEqual({ - fraction: { caption: 'allowance per 30 days', value: '2M' }, - headline: '1.5M', - headlineCaption: 'API calls', - }) - }) - it('shows zero rather than NaN for an organisation with no calls', () => { expect(meterCopy(0, null).headline).toBe('0') expect(meterCopy(0, 2000000).headline).toBe('0%') @@ -49,17 +40,13 @@ describe('UsageMeter utils', () => { describe('meterTone', () => { it('tracks the thresholds when the comparison holds', () => { - expect(meterTone(500000, 2000000, true, 75)).toBe('success') - expect(meterTone(1600000, 2000000, true, 75)).toBe('warning') - expect(meterTone(2000000, 2000000, true, 75)).toBe('danger') - }) - - it('has no tone to give when the comparison does not hold', () => { - expect(meterTone(9000000, 2000000, false, 75)).toBeUndefined() + expect(meterTone(500000, 2000000, 75)).toBe('success') + expect(meterTone(1600000, 2000000, 75)).toBe('warning') + expect(meterTone(2000000, 2000000, 75)).toBe('danger') }) it('has no tone to give without a limit', () => { - expect(meterTone(9000000, null, true, 75)).toBeUndefined() + expect(meterTone(9000000, null, 75)).toBeUndefined() }) }) }) diff --git a/frontend/web/components/pages/usage/components/UsageMeter/utils.ts b/frontend/web/components/pages/usage/components/UsageMeter/utils.ts index dee226aa4de5..98747fe86445 100644 --- a/frontend/web/components/pages/usage/components/UsageMeter/utils.ts +++ b/frontend/web/components/pages/usage/components/UsageMeter/utils.ts @@ -16,38 +16,25 @@ type MeterCopy = { } } -const withLimit = (total: number, limit: number): MeterCopy => ({ - fraction: { - caption: 'API calls used / plan limit', - suffix: ` / ${Format.shortenNumber(limit)}`, - value: Format.shortenNumber(total), - }, - headline: `${usagePercent(total, limit)}%`, - headlineCaption: 'of plan consumed', -}) - -const withoutLimit = (total: number, limit: PlanLimit): MeterCopy => ({ - fraction: limit +export const meterCopy = (total: number, limit: PlanLimit): MeterCopy => + limit ? { - caption: 'allowance per 30 days', - value: Format.shortenNumber(limit), + fraction: { + caption: 'API calls used / plan limit', + suffix: ` / ${Format.shortenNumber(limit)}`, + value: Format.shortenNumber(total), + }, + headline: `${usagePercent(total, limit)}%`, + headlineCaption: 'of plan consumed', + } + : { + headline: Format.shortenNumber(total), + headlineCaption: 'API calls', } - : undefined, - headline: Format.shortenNumber(total), - headlineCaption: 'API calls', -}) - -export const meterCopy = ( - total: number, - limit: PlanLimit, - comparable = true, -): MeterCopy => - limit && comparable ? withLimit(total, limit) : withoutLimit(total, limit) export const meterTone = ( total: number, limit: PlanLimit, - comparable: boolean, warnAt: number, ): UsageTone | undefined => - limit && comparable ? toneFor(usagePercent(total, limit), warnAt) : undefined + limit ? toneFor(usagePercent(total, limit), warnAt) : undefined diff --git a/frontend/web/components/pages/usage/utils.ts b/frontend/web/components/pages/usage/utils.ts index eedd5c20dc7e..1eb9e84636aa 100644 --- a/frontend/web/components/pages/usage/utils.ts +++ b/frontend/web/components/pages/usage/utils.ts @@ -26,9 +26,6 @@ export const resolvePeriod = ( export const isBillingPeriodSelected = (period: BillingPeriod): boolean => period === 'current_billing_period' || period === 'previous_billing_period' -export const isComparableToAllowance = (period: BillingPeriod): boolean => - period !== '90_day_period' - export const contributionNote = ( projectName: string, scopedTotal: number, @@ -46,7 +43,13 @@ export const contributionNote = ( export const showsPlanCeiling = ( period: BillingPeriod, projectId: number | undefined, -): boolean => isComparableToAllowance(period) && !projectId +): boolean => period !== '90_day_period' && !projectId + +export const allowanceWindow = (planIsBilled: boolean): BillingPeriod => + planIsBilled ? 'current_billing_period' : undefined + +export const allowanceWindowLabel = (planIsBilled: boolean): string => + planIsBilled ? 'this billing period' : 'last 30 days' export const periodLabel = ( periods: PeriodOption[],