diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 8bc07fe652bd..8f4ae03ca9e3 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -74,7 +74,10 @@ export enum PermissionRoleType { GRANTED_FOR_TAGS = 'GRANTED_FOR_TAGS', NONE = 'NONE', } -export const billingPeriods = [ +export type BillingPeriod = Req['getOrganisationUsage']['billing_period'] +export type PeriodOption = { label: string; value: BillingPeriod } + +export const periodOptions: PeriodOption[] = [ { label: 'Current billing period', value: 'current_billing_period', @@ -86,7 +89,7 @@ export const billingPeriods = [ { label: 'Last 90 days', value: '90_day_period' }, { label: 'Last 30 days', value: undefined }, ] -export const freePeriods = [ +export const rollingPeriodOptions: PeriodOption[] = [ { label: 'Last 90 days', value: '90_day_period' }, { label: 'Last 30 days', value: undefined }, ] diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index ce81d9128f93..5eec1303ede8 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -514,6 +514,7 @@ export type Subscription = { customer_id: string payment_method: string notes: string | null + has_active_billing_periods: boolean } export type OnboardingVariant = 'control' | 'single_page' diff --git a/frontend/common/utils/__tests__/format.test.ts b/frontend/common/utils/__tests__/format.test.ts index 5c0eb7f8fab9..211fbc708a9d 100644 --- a/frontend/common/utils/__tests__/format.test.ts +++ b/frontend/common/utils/__tests__/format.test.ts @@ -12,6 +12,12 @@ describe('Format', () => { ${1234} | ${'1.2K'} ${12345} | ${'12.3K'} ${123456} | ${'123.5K'} + ${0} | ${'0'} + ${undefined} | ${'0'} + ${null} | ${'0'} + ${NaN} | ${'0'} + ${Infinity} | ${'0'} + ${-Infinity} | ${'0'} `('shortenNumber($input) returns $expected', ({ expected, input }) => { expect(Format.shortenNumber(input)).toBe(expected) }) diff --git a/frontend/common/utils/format.ts b/frontend/common/utils/format.ts index c2b235d7165a..637fbdfc5fb7 100644 --- a/frontend/common/utils/format.ts +++ b/frontend/common/utils/format.ts @@ -4,6 +4,8 @@ type Person = { email?: string } +type NullableNumber = number | null | undefined + const Format = { camelCase(val: string): string { // hello world > Hello world @@ -39,9 +41,14 @@ const Format = { newLineDelimiter: '↵', - shortenNumber(number: number): string { + shortenNumber(number: NullableNumber): string { // Converts a float number into a short literal with suffix for the magnitude: // 1523125 > 1.5M + // Guarded because the maths below takes log10 of the value, so zero and + // anything missing come back as NaN. + if (!number || !Number.isFinite(number)) { + return '0' + } const suffixes = ['', 'K', 'M', 'B', 'T'] const numDigits = Math.floor(Math.log10(number)) + 1 const suffixIndex = Math.floor((numDigits - 1) / 3) diff --git a/frontend/documentation/components/UsageBar.stories.tsx b/frontend/documentation/components/UsageBar.stories.tsx new file mode 100644 index 000000000000..fc237e4f608f --- /dev/null +++ b/frontend/documentation/components/UsageBar.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from 'storybook' +import UsageBar from 'components/shared/UsageBar' + +const meta: Meta = { + component: UsageBar, + parameters: { layout: 'padded' }, + title: 'Components/Data Display/UsageBar', +} +export default meta + +type Story = StoryObj + +export const WithLabel: Story = { + args: { label: 'Segment overrides', limit: 100, usage: 42 }, +} + +export const Warning: Story = { + args: { label: 'Segment overrides', limit: 100, usage: 91 }, +} + +export const OverTheLimit: Story = { + args: { label: 'Segment overrides', limit: 100, usage: 118 }, +} + +// Thresholds are marked on the bar, for usage that is notified at set points. +export const WithThresholds: Story = { + args: { + ariaLabel: 'Plan usage this period', + limit: 2000000, + thresholds: [75, 100], + usage: 1240000, + warnAt: 75, + }, +} + +export const WithThresholdsOverTheLimit: Story = { + args: { + ariaLabel: 'Plan usage this period', + limit: 50000, + thresholds: [75, 100], + usage: 68400, + warnAt: 75, + }, +} + +export const NoUsageYet: Story = { + args: { label: 'Segment overrides', limit: 100, usage: 0 }, +} diff --git a/frontend/documentation/components/UsageDashboard.stories.tsx b/frontend/documentation/components/UsageDashboard.stories.tsx new file mode 100644 index 000000000000..45139cb3cbaa --- /dev/null +++ b/frontend/documentation/components/UsageDashboard.stories.tsx @@ -0,0 +1,140 @@ +import type { Meta, StoryObj } from 'storybook' +import { UsageDashboard } from 'components/pages/usage' +import { Res } from 'common/types/responses' + +const meta: Meta = { + component: UsageDashboard, + parameters: { layout: 'fullscreen' }, + title: 'Pages/Usage Dashboard/Page', +} +export default meta + +type Story = StoryObj + +const DAY_WEIGHTS = [1.08, 1.12, 1.05, 1.1, 0.98, 0.62, 0.58] + +const usage = (days: number, perDay: number): Res['organisationUsage'] => { + const events = Array.from({ length: days }).map((_, index) => { + const weight = DAY_WEIGHTS[index % DAY_WEIGHTS.length] + return { + day: `2026-08-${`${index + 1}`.padStart(2, '0')}`, + environment_document: Math.round(perDay * weight * 0.04), + flags: Math.round(perDay * weight * 0.63), + identities: Math.round(perDay * weight * 0.24), + labels: { user_agent: null }, + traits: Math.round(perDay * weight * 0.09), + } + }) + const sum = ( + key: 'flags' | 'identities' | 'traits' | 'environment_document', + ) => events.reduce((acc, event) => acc + event[key], 0) + + return { + events_list: events, + totals: { + environmentDocument: sum('environment_document'), + flags: sum('flags'), + identities: sum('identities'), + total: + sum('flags') + + sum('identities') + + sum('traits') + + sum('environment_document'), + traits: sum('traits'), + }, + } +} + +const paid = usage(18, 70000) +const free = usage(30, 1800) +const paidApproaching = usage(26, 75000) +const paidOver = usage(28, 92000) + +// A plan with a billing term: usage climbs towards a reset, so it is drawn +// cumulatively against the ceiling. +export const PaidWithABillingPeriod: Story = { + args: { + data: paid, + hasBillingPeriod: true, + limit: 2000000, + total: paid.totals.total, + }, +} + +export const PaidApproachingTheLimit: Story = { + args: { + data: paidApproaching, + hasBillingPeriod: true, + limit: 2000000, + total: paidApproaching.totals.total, + }, +} + +export const PaidOverTheLimit: Story = { + args: { + data: paidOver, + hasBillingPeriod: true, + limit: 2000000, + total: paidOver.totals.total, + }, +} + +// No billing term, so no reset to accumulate towards: daily volume instead. +export const FreeOnARollingWindow: Story = { + args: { + data: free, + hasBillingPeriod: false, + limit: 50000, + total: free.totals.total, + }, +} + +// Enterprise agreements are not billed through Chargebee, so they have a real +// limit and no period. The meter still works; the chart falls back to volume. +export const EnterpriseWithoutABillingPeriod: Story = { + args: { + data: paid, + hasBillingPeriod: false, + limit: 50000000, + total: paid.totals.total, + }, +} + +// Self-hosted has no subscription data at all, so there is nothing to be a +// percentage of. +export const WithoutAPlanLimit: Story = { + args: { + data: paid, + hasBillingPeriod: false, + limit: null, + total: paid.totals.total, + }, +} + +export const NoUsageYet: Story = { + args: { + data: usage(0, 0), + hasBillingPeriod: true, + limit: 2000000, + total: 0, + }, +} + +export const Loading: Story = { + args: { + hasBillingPeriod: true, + isLoading: true, + limit: 2000000, + total: 0, + }, +} + +/** Distinct from NoUsageYet, which would otherwise look identical. */ +export const FailedToLoad: Story = { + args: { + hasBillingPeriod: true, + isError: true, + limit: 2000000, + total: 0, + }, +} diff --git a/frontend/documentation/components/UsageMeter.stories.tsx b/frontend/documentation/components/UsageMeter.stories.tsx new file mode 100644 index 000000000000..8eb53252d7b4 --- /dev/null +++ b/frontend/documentation/components/UsageMeter.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from 'storybook' +import UsageMeter from 'components/pages/usage/components/UsageMeter' + +const meta: Meta = { + component: UsageMeter, + parameters: { layout: 'padded' }, + title: 'Pages/Usage Dashboard/Components/UsageMeter', +} +export default meta + +type Story = StoryObj + +export const UnderTheLimit: Story = { + args: { limit: 2000000, total: 1240000 }, +} + +export const ApproachingTheLimit: Story = { + args: { limit: 2000000, total: 1760000 }, +} + +export const AtTheLimit: Story = { + args: { limit: 2000000, total: 2000000 }, +} + +export const OverTheLimit: Story = { + args: { limit: 50000, total: 68400 }, +} + +// Nothing to be a percentage of, so the total stands on its own. +export const WithoutAPlanLimit: Story = { + args: { limit: null, total: 340000 }, +} + +export const NoUsageYet: Story = { + args: { limit: 50000, total: 0 }, +} + +export const WithANote: Story = { + args: { + limit: 2000000, + note: ( +

+ On track to use ~1.9M calls by the end of the period. +

+ ), + total: 1240000, + }, +} diff --git a/frontend/documentation/components/UsageOverTime.stories.tsx b/frontend/documentation/components/UsageOverTime.stories.tsx new file mode 100644 index 000000000000..41a3a8009a53 --- /dev/null +++ b/frontend/documentation/components/UsageOverTime.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from 'storybook' +import UsageOverTime from 'components/pages/usage/components/UsageOverTime' +import { Res } from 'common/types/responses' + +const meta: Meta = { + component: UsageOverTime, + parameters: { layout: 'padded' }, + title: 'Pages/Usage Dashboard/Components/UsageOverTime', +} +export default meta + +type Story = StoryObj + +// Weekends lighter, so the shape reads like real traffic rather than a ramp. +const DAY_WEIGHTS = [1.08, 1.12, 1.05, 1.1, 0.98, 0.62, 0.58] + +const usage = (days: number, perDay: number): Res['organisationUsage'] => { + const events = Array.from({ length: days }).map((_, index) => { + const weight = DAY_WEIGHTS[index % DAY_WEIGHTS.length] + return { + day: `2026-08-${`${index + 1}`.padStart(2, '0')}`, + environment_document: Math.round(perDay * weight * 0.04), + flags: Math.round(perDay * weight * 0.63), + identities: Math.round(perDay * weight * 0.24), + labels: { user_agent: null }, + traits: Math.round(perDay * weight * 0.09), + } + }) + const sum = (key: keyof (typeof events)[number]) => + events.reduce((acc, event) => acc + Number(event[key] ?? 0), 0) + + return { + events_list: events, + totals: { + environmentDocument: sum('environment_document'), + flags: sum('flags'), + identities: sum('identities'), + total: + sum('flags') + + sum('identities') + + sum('traits') + + sum('environment_document'), + traits: sum('traits'), + }, + } +} + +export const CumulativeUnderTheCeiling: Story = { + args: { + data: usage(18, 70000), + isBillingPeriod: true, + limit: 2000000, + }, +} + +export const CumulativeCrossingTheCeiling: Story = { + args: { + data: usage(24, 110000), + isBillingPeriod: true, + limit: 2000000, + }, +} + +// A rolling window's total falls as old days drop out, so it gets daily volume +// rather than a line that only ever climbs. +export const DailyVolumeOnARollingWindow: Story = { + args: { + data: usage(30, 2000), + isBillingPeriod: false, + limit: 50000, + }, +} + +export const NoLimitToDrawAgainst: Story = { + args: { + data: usage(18, 70000), + isBillingPeriod: true, + limit: null, + }, +} + +export const NoUsageRecorded: Story = { + args: { + data: usage(0, 0), + isBillingPeriod: true, + limit: 2000000, + }, +} diff --git a/frontend/web/components/ProjectFilter.tsx b/frontend/web/components/ProjectFilter.tsx index 7b46b62ce979..cc54f7542b0a 100644 --- a/frontend/web/components/ProjectFilter.tsx +++ b/frontend/web/components/ProjectFilter.tsx @@ -6,9 +6,11 @@ type ProjectFilterType = { value?: string onChange: (id: string, name: string) => void showAll?: boolean + inputId?: string } const ProjectFilter: FC = ({ + inputId, onChange, organisationId, showAll, @@ -44,6 +46,7 @@ const ProjectFilter: FC = ({ onChange={(value: { value: string; label: string }) => onChange(value.value || '', value.label || '') } + inputId={inputId} data-test='project-select' /> ) diff --git a/frontend/web/components/charts/LineChart.tsx b/frontend/web/components/charts/LineChart.tsx index ea53f6ca6b5e..4dbe654fa488 100644 --- a/frontend/web/components/charts/LineChart.tsx +++ b/frontend/web/components/charts/LineChart.tsx @@ -1,9 +1,11 @@ import React, { FC } from 'react' +import { AxisDomain } from 'recharts/types/util/types' import { CartesianGrid, Legend, Line, LineChart as RawLineChart, + ReferenceLine, ResponsiveContainer, Tooltip, XAxis, @@ -22,12 +24,34 @@ type LineChartProps = { showLegend?: boolean seriesLabels?: Record verticalGrid?: boolean + referenceLine?: Threshold } +type Threshold = { value: number; label?: string; colour: string } + +const axisDomainFor = (referenceLine?: Threshold): AxisDomain | undefined => + referenceLine + ? [ + 0, + (max: number) => Math.round(Math.max(max, referenceLine.value) * 1.08), + ] + : undefined + +const thresholdLabelFor = (referenceLine?: Threshold) => + referenceLine?.label + ? { + fill: referenceLine.colour, + fontSize: 11, + position: 'insideTopRight' as const, + value: referenceLine.label, + } + : undefined + const LineChart: FC = ({ colorMap, data, height = 400, + referenceLine, series, seriesLabels, showLegend = false, @@ -56,6 +80,7 @@ const LineChart: FC = ({ value >= 1000 ? `${(value / 1000).toFixed(0)}k` : value } @@ -72,6 +97,14 @@ const LineChart: FC = ({ } /> )} + {referenceLine && ( + + )} {series.map((label, index) => ( = ({ setChosenPeriod(option.value)} + value={periods.find((period) => period.value === billingPeriod)} + options={periods} + /> + +
+ Project + +
+ + } + /> + ) +} + +export default UsageDashboardPage diff --git a/frontend/web/components/pages/usage/__tests__/fixtures.ts b/frontend/web/components/pages/usage/__tests__/fixtures.ts new file mode 100644 index 000000000000..510e43c58a71 --- /dev/null +++ b/frontend/web/components/pages/usage/__tests__/fixtures.ts @@ -0,0 +1,38 @@ +import { Res, UsageEventsList } from 'common/types/responses' + +const COUNTED = [ + 'flags', + 'identities', + 'traits', + 'environment_document', +] as const + +const sum = ( + events: UsageEventsList[], + key: (typeof COUNTED)[number], +): number => events.reduce((running, event) => running + (event[key] ?? 0), 0) + +export const usageEvent = ( + values: Partial = {}, +): UsageEventsList => ({ + day: '2026-08-01', + environment_document: 0, + flags: 0, + identities: 0, + labels: { user_agent: null }, + traits: 0, + ...values, +}) + +export const usageResponse = ( + events: UsageEventsList[], +): Res['organisationUsage'] => ({ + events_list: events, + totals: { + environmentDocument: sum(events, 'environment_document'), + flags: sum(events, 'flags'), + identities: sum(events, 'identities'), + total: COUNTED.reduce((running, key) => running + sum(events, key), 0), + traits: sum(events, 'traits'), + }, +}) diff --git a/frontend/web/components/pages/usage/__tests__/utils.test.ts b/frontend/web/components/pages/usage/__tests__/utils.test.ts new file mode 100644 index 000000000000..f8b795a205ca --- /dev/null +++ b/frontend/web/components/pages/usage/__tests__/utils.test.ts @@ -0,0 +1,102 @@ +import { Subscription } from 'common/types/responses' +import { + isBillingPeriodSelected, + planHasBillingPeriod, + periodsFor, + resolvePeriod, +} from 'components/pages/usage/utils' + +const subscription = (values: Partial): Subscription => + ({ has_active_billing_periods: false, plan: null, ...values } as Subscription) + +describe('UsageDashboard utils', () => { + describe('planHasBillingPeriod', () => { + it('is true for a paid plan with active billing periods', () => { + expect( + planHasBillingPeriod( + subscription({ has_active_billing_periods: true }), + false, + ), + ).toBe(true) + }) + + // Enterprise agreements are not billed through Chargebee, so they carry a + // limit but no term. + it('is false for a paid plan without active billing periods', () => { + expect( + planHasBillingPeriod( + subscription({ has_active_billing_periods: false }), + false, + ), + ).toBe(false) + }) + + it('is false on the free plan even if the flag is somehow set', () => { + expect( + planHasBillingPeriod( + subscription({ has_active_billing_periods: true }), + true, + ), + ).toBe(false) + }) + + it('is false before the subscription has loaded', () => { + expect(planHasBillingPeriod(undefined, false)).toBe(false) + }) + }) + + // A billed organisation can still pick a rolling window, and 90 days of + // usage must not be drawn against a monthly allowance. + describe('isBillingPeriodSelected', () => { + it.each` + period | expected + ${'current_billing_period'} | ${true} + ${'previous_billing_period'} | ${true} + ${'90_day_period'} | ${false} + ${undefined} | ${false} + `('$period is a billing period: $expected', ({ expected, period }) => { + expect(isBillingPeriodSelected(period)).toBe(expected) + }) + }) + + describe('resolvePeriod', () => { + it('defaults to the current billing period when there is one', () => { + expect(resolvePeriod('default', true)).toBe('current_billing_period') + }) + + it('defaults to the rolling window when there is not', () => { + expect(resolvePeriod('default', false)).toBeUndefined() + }) + + it('keeps an explicit choice', () => { + expect(resolvePeriod('90_day_period', true)).toBe('90_day_period') + expect(resolvePeriod('previous_billing_period', true)).toBe( + 'previous_billing_period', + ) + }) + + // 'Last 30 days' is undefined, so it has to survive rather than fall back. + it('keeps an explicit rolling-window choice on a billed plan', () => { + expect(resolvePeriod(undefined, true)).toBeUndefined() + }) + }) + + describe('periodsFor', () => { + it('offers the billing periods only when there is a term', () => { + expect(periodsFor(true).map((period) => period.value)).toContain( + 'current_billing_period', + ) + expect(periodsFor(false).map((period) => period.value)).not.toContain( + 'current_billing_period', + ) + }) + + it('always offers the rolling windows', () => { + for (const periods of [periodsFor(true), periodsFor(false)]) { + expect(periods.map((period) => period.value)).toEqual( + expect.arrayContaining(['90_day_period', undefined]), + ) + } + }) + }) +}) diff --git a/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.scss b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.scss new file mode 100644 index 000000000000..ec627b142e43 --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.scss @@ -0,0 +1,9 @@ +.usage-meter { + &__percent { + font-size: 40px; + } + + &__fraction { + font-size: 18px; + } +} diff --git a/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx new file mode 100644 index 000000000000..1643de2d4fef --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx @@ -0,0 +1,65 @@ +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 './UsageMeter.scss' + +const WARN_AT = 75 +const NOTIFICATION_THRESHOLDS = [WARN_AT, 100] + +export type UsageMeterProps = { + total: number + limit: PlanLimit + note?: ReactNode +} + +const UsageMeter: FC = ({ limit, note, total }) => { + const copy = meterCopy(total, limit) + const tone = toneFor(usagePercent(total, limit), WARN_AT) + + return ( +
+
+
+

Plan usage

+
+ + {copy.headline} + + + {copy.headlineCaption} + +
+
+
+
+ {Format.shortenNumber(total)} + {copy.fractionSuffix} +
+
+ {copy.fractionCaption} +
+
+
+ + {!!limit && ( + + )} + + {note} +
+ ) +} + +export default UsageMeter diff --git a/frontend/web/components/pages/usage/components/UsageMeter/__tests__/utils.test.ts b/frontend/web/components/pages/usage/components/UsageMeter/__tests__/utils.test.ts new file mode 100644 index 000000000000..d6d472f0467b --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageMeter/__tests__/utils.test.ts @@ -0,0 +1,36 @@ +import { meterCopy } from 'components/pages/usage/components/UsageMeter/utils' + +describe('UsageMeter utils', () => { + 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', + headline: '75%', + headlineCaption: 'of plan consumed', + }) + }) + + it('reports past 100% rather than capping', () => { + expect(meterCopy(2400000, 2000000).headline).toBe('120%') + }) + + // Self-hosted has no subscription data, so there is nothing to divide by. + it.each([[null], [undefined], [0]])( + 'falls back to the raw count when the limit is %p', + (limit) => { + expect(meterCopy(1500000, limit)).toEqual({ + fractionCaption: 'API calls used', + fractionSuffix: '', + 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%') + }) + }) +}) diff --git a/frontend/web/components/pages/usage/components/UsageMeter/index.ts b/frontend/web/components/pages/usage/components/UsageMeter/index.ts new file mode 100644 index 000000000000..a824367e4c75 --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageMeter/index.ts @@ -0,0 +1,2 @@ +export { default } from './UsageMeter' +export type { UsageMeterProps } from './UsageMeter' diff --git a/frontend/web/components/pages/usage/components/UsageMeter/utils.ts b/frontend/web/components/pages/usage/components/UsageMeter/utils.ts new file mode 100644 index 000000000000..80ddc2a5e9a2 --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageMeter/utils.ts @@ -0,0 +1,26 @@ +import Format from 'common/utils/format' +import { PlanLimit, usagePercent } from 'components/shared/UsageBar/utils' + +export type MeterCopy = { + headline: string + headlineCaption: string + fractionSuffix: string + fractionCaption: string +} + +const withLimit = (total: number, limit: number): MeterCopy => ({ + fractionCaption: 'API calls used / plan limit', + fractionSuffix: ` / ${Format.shortenNumber(limit)}`, + headline: `${usagePercent(total, limit)}%`, + headlineCaption: 'of plan consumed', +}) + +const withoutLimit = (total: number): MeterCopy => ({ + fractionCaption: 'API calls used', + fractionSuffix: '', + headline: Format.shortenNumber(total), + headlineCaption: 'API calls', +}) + +export const meterCopy = (total: number, limit: PlanLimit): MeterCopy => + limit ? withLimit(total, limit) : withoutLimit(total) diff --git a/frontend/web/components/pages/usage/components/UsageOverTime/UsageOverTime.tsx b/frontend/web/components/pages/usage/components/UsageOverTime/UsageOverTime.tsx new file mode 100644 index 000000000000..3fd10d3906fe --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageOverTime/UsageOverTime.tsx @@ -0,0 +1,83 @@ +import { FC, useMemo } from 'react' +import { Res } from 'common/types/responses' +import { colorSurfaceAction } from 'common/theme/tokens' +import EmptyState from 'components/EmptyState' +import { PlanLimit } from 'components/shared/UsageBar/utils' +import BarChart from 'components/charts/BarChart' +import LineChart from 'components/charts/LineChart' +import { + cumulativeTotals, + dailyTotals, + planLimitThreshold, + xAxisIntervalFor, +} from './utils' + +type UsageOverTimeProps = { + data: Res['organisationUsage'] | undefined + limit: PlanLimit + isBillingPeriod: boolean +} + +const headingFor = (isBillingPeriod: boolean, limit: PlanLimit) => { + if (!isBillingPeriod) return 'Daily usage' + return limit ? 'Usage vs plan limit' : 'Cumulative usage' +} + +const UsageOverTime: FC = ({ + data, + isBillingPeriod, + limit, +}) => { + const daily = useMemo(() => dailyTotals(data), [data]) + + const cumulative = useMemo(() => cumulativeTotals(daily), [daily]) + + const xAxisInterval = xAxisIntervalFor(daily.length) + + const chart = isBillingPeriod ? ( + + ) : ( + + ) + + return ( +
+
+ {headingFor(isBillingPeriod, limit)} + + {isBillingPeriod + ? 'Cumulative · this billing period' + : 'Per day · rolling window'} + +
+ {daily.length ? ( + chart + ) : ( + + )} +
+ ) +} + +export default UsageOverTime diff --git a/frontend/web/components/pages/usage/components/UsageOverTime/__tests__/utils.test.ts b/frontend/web/components/pages/usage/components/UsageOverTime/__tests__/utils.test.ts new file mode 100644 index 000000000000..29140d7916c6 --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageOverTime/__tests__/utils.test.ts @@ -0,0 +1,124 @@ +import { UsageEventsList } from 'common/types/responses' +import { + usageEvent, + usageResponse, +} from 'components/pages/usage/__tests__/fixtures' +import { + cumulativeTotals, + dailyTotals, + planLimitThreshold, + xAxisIntervalFor, +} from 'components/pages/usage/components/UsageOverTime/utils' + +describe('UsageOverTime utils', () => { + describe('dailyTotals', () => { + it('sums every metric on a day', () => { + const result = dailyTotals( + usageResponse([ + usageEvent({ + day: '2026-08-01', + environment_document: 1, + flags: 10, + identities: 5, + traits: 2, + }), + ]), + ) + + expect(result).toEqual([{ day: '1 Aug', total: 18 }]) + }) + + // The API returns a row per day and client type, so a day can appear twice. + it('collapses several rows for the same day into one point', () => { + const result = dailyTotals( + usageResponse([ + usageEvent({ day: '2026-08-01', flags: 10 }), + usageEvent({ day: '2026-08-01', flags: 5 }), + usageEvent({ day: '2026-08-02', flags: 3 }), + ]), + ) + + expect(result).toEqual([ + { day: '1 Aug', total: 15 }, + { day: '2 Aug', total: 3 }, + ]) + }) + + it('orders by date rather than by the formatted label', () => { + const result = dailyTotals( + usageResponse([ + usageEvent({ day: '2026-08-10', flags: 1 }), + usageEvent({ day: '2026-08-02', flags: 2 }), + usageEvent({ day: '2026-09-01', flags: 3 }), + ]), + ) + + expect(result.map((point) => point.day)).toEqual([ + '2 Aug', + '10 Aug', + '1 Sep', + ]) + }) + + it('treats missing metrics as zero', () => { + const result = dailyTotals( + usageResponse([{ day: '2026-08-01' } as UsageEventsList]), + ) + + expect(result).toEqual([{ day: '1 Aug', total: 0 }]) + }) + + it('returns nothing when there is no data', () => { + expect(dailyTotals(undefined)).toEqual([]) + expect(dailyTotals(usageResponse([]))).toEqual([]) + }) + }) + + describe('cumulativeTotals', () => { + it('accumulates across the period', () => { + const result = cumulativeTotals([ + { day: '1 Aug', total: 10 }, + { day: '2 Aug', total: 5 }, + { day: '3 Aug', total: 0 }, + ]) + + expect(result).toEqual([ + { cumulative: 10, day: '1 Aug' }, + { cumulative: 15, day: '2 Aug' }, + { cumulative: 15, day: '3 Aug' }, + ]) + }) + + it('returns nothing for an empty period', () => { + expect(cumulativeTotals([])).toEqual([]) + }) + }) + + describe('planLimitThreshold', () => { + it('labels the ceiling with the shortened limit', () => { + expect(planLimitThreshold(2000000)).toEqual( + expect.objectContaining({ label: 'Plan limit · 2M', value: 2000000 }), + ) + }) + + it.each([[null], [undefined], [0]])( + 'has nothing to draw for %p', + (limit) => { + expect(planLimitThreshold(limit)).toBeUndefined() + }, + ) + }) + + describe('xAxisIntervalFor', () => { + it.each` + points | expected + ${0} | ${0} + ${12} | ${0} + ${13} | ${1} + ${30} | ${2} + ${90} | ${7} + `('thins $points points to every $expected', ({ expected, points }) => { + expect(xAxisIntervalFor(points)).toBe(expected) + }) + }) +}) diff --git a/frontend/web/components/pages/usage/components/UsageOverTime/index.ts b/frontend/web/components/pages/usage/components/UsageOverTime/index.ts new file mode 100644 index 000000000000..b3a2143d5319 --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageOverTime/index.ts @@ -0,0 +1 @@ +export { default } from './UsageOverTime' diff --git a/frontend/web/components/pages/usage/components/UsageOverTime/utils.ts b/frontend/web/components/pages/usage/components/UsageOverTime/utils.ts new file mode 100644 index 000000000000..b01f985d7e64 --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageOverTime/utils.ts @@ -0,0 +1,47 @@ +import moment from 'moment' +import { Res } from 'common/types/responses' +import { colorBorderDanger } from 'common/theme/tokens' +import Format from 'common/utils/format' +import { PlanLimit } from 'components/shared/UsageBar/utils' + +export type DailyPoint = { day: string; total: number } +export type CumulativePoint = { day: string; cumulative: number } + +export const dailyTotals = ( + data: Res['organisationUsage'] | undefined, +): DailyPoint[] => { + const byDay = new Map() + + for (const event of data?.events_list ?? []) { + const total = + (event.flags ?? 0) + + (event.identities ?? 0) + + (event.traits ?? 0) + + (event.environment_document ?? 0) + byDay.set(event.day, (byDay.get(event.day) ?? 0) + total) + } + + return [...byDay.entries()] + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([day, total]) => ({ day: moment(day).format('D MMM'), total })) +} + +export const cumulativeTotals = (daily: DailyPoint[]): CumulativePoint[] => { + let running = 0 + return daily.map((point) => { + running += point.total + return { cumulative: running, day: point.day } + }) +} + +export const planLimitThreshold = (limit: PlanLimit) => + limit + ? { + colour: colorBorderDanger, + label: `Plan limit · ${Format.shortenNumber(limit)}`, + value: limit, + } + : undefined + +export const xAxisIntervalFor = (pointCount: number) => + Math.max(0, Math.ceil(pointCount / 12) - 1) diff --git a/frontend/web/components/pages/usage/index.ts b/frontend/web/components/pages/usage/index.ts new file mode 100644 index 000000000000..a42d3b483e04 --- /dev/null +++ b/frontend/web/components/pages/usage/index.ts @@ -0,0 +1,3 @@ +export { default } from './UsageDashboardPage' +export { default as UsageDashboard } from './UsageDashboard' +export type { UsageDashboardProps } from './UsageDashboard' diff --git a/frontend/web/components/pages/usage/utils.ts b/frontend/web/components/pages/usage/utils.ts new file mode 100644 index 000000000000..75229f2349af --- /dev/null +++ b/frontend/web/components/pages/usage/utils.ts @@ -0,0 +1,30 @@ +import { + BillingPeriod, + PeriodOption, + periodOptions, + rollingPeriodOptions, +} from 'common/types/requests' +import { Subscription } from 'common/types/responses' + +export type PeriodSelection = BillingPeriod | 'default' + +export const planHasBillingPeriod = ( + subscription: Subscription | undefined, + isFreePlan: boolean, +): boolean => !isFreePlan && !!subscription?.has_active_billing_periods + +export const resolvePeriod = ( + chosen: PeriodSelection, + billingPeriodAvailable: boolean, +): BillingPeriod => { + if (chosen !== 'default') { + return chosen + } + return billingPeriodAvailable ? 'current_billing_period' : undefined +} + +export const isBillingPeriodSelected = (period: BillingPeriod): boolean => + period === 'current_billing_period' || period === 'previous_billing_period' + +export const periodsFor = (billingPeriodAvailable: boolean): PeriodOption[] => + billingPeriodAvailable ? periodOptions : rollingPeriodOptions diff --git a/frontend/web/components/shared/UsageBar.tsx b/frontend/web/components/shared/UsageBar.tsx deleted file mode 100644 index 1ef6599a60f5..000000000000 --- a/frontend/web/components/shared/UsageBar.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { FC } from 'react' - -type UsageBarProps = { - label: string - limit: number - usage: number -} - -const UsageBar: FC = ({ label, limit, usage }) => { - const percentage = limit > 0 ? (usage / limit) * 100 : 0 - const barWidth = Math.min(percentage, 100) - - let colourClass = 'bg-primary' - if (percentage >= 100) { - colourClass = 'bg-danger' - } else if (percentage >= 85) { - colourClass = 'bg-warning' - } - - return ( -
-
- {label} - - {usage}/{limit} - -
-
-
-
-
- ) -} - -export default UsageBar diff --git a/frontend/web/components/shared/UsageBar/UsageBar.scss b/frontend/web/components/shared/UsageBar/UsageBar.scss new file mode 100644 index 000000000000..3abf371f331f --- /dev/null +++ b/frontend/web/components/shared/UsageBar/UsageBar.scss @@ -0,0 +1,17 @@ +.usage-bar { + &__wrap { + padding-top: 32px; + } + + &__track { + height: 8px; + } + + &__fill { + transition: width var(--duration-slow) var(--easing-standard); + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } +} diff --git a/frontend/web/components/shared/UsageBar/UsageBar.tsx b/frontend/web/components/shared/UsageBar/UsageBar.tsx new file mode 100644 index 000000000000..c1a837d2fa71 --- /dev/null +++ b/frontend/web/components/shared/UsageBar/UsageBar.tsx @@ -0,0 +1,81 @@ +import { FC } from 'react' +import { + colorBorderDanger, + colorBorderWarning, + colorSurfaceAction, +} from 'common/theme/tokens' +import Format from 'common/utils/format' +import UsageBarThresholds from './UsageBarThresholds' +import { boundPercent, toneFor, usagePercent } from './utils' +import './UsageBar.scss' + +const FILL_COLOURS = { + danger: colorBorderDanger, + success: colorSurfaceAction, + warning: colorBorderWarning, +} + +export type { UsageTone } from './utils' + +export type UsageBarProps = { + usage: number + limit: number + label?: string + thresholds?: number[] + warnAt?: number + ariaLabel?: string +} + +const UsageBar: FC = ({ + ariaLabel, + label, + limit, + thresholds, + usage, + warnAt = 85, +}) => { + const percent = usagePercent(usage, limit) + const boundedPercent = boundPercent(percent) + const tone = toneFor(percent, warnAt) + + return ( +
+ {label && ( +
+ {label} + + {usage}/{limit} + +
+ )} + +
+
+
+
+ + {!!thresholds?.length && } +
+
+ ) +} + +export default UsageBar diff --git a/frontend/web/components/shared/UsageBar/UsageBarThresholds.scss b/frontend/web/components/shared/UsageBar/UsageBarThresholds.scss new file mode 100644 index 000000000000..3016a899a26d --- /dev/null +++ b/frontend/web/components/shared/UsageBar/UsageBarThresholds.scss @@ -0,0 +1,24 @@ +.usage-bar { + &__marker { + position: absolute; + top: 4px; + bottom: 0; + width: 2px; + background: var(--color-border-strong); + transform: translateX(-50%); + } + + &__marker-label { + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + } + + &__marker--end &__marker-label { + left: auto; + right: 4px; + transform: none; + } +} diff --git a/frontend/web/components/shared/UsageBar/UsageBarThresholds.tsx b/frontend/web/components/shared/UsageBar/UsageBarThresholds.tsx new file mode 100644 index 000000000000..e73cd0d1c892 --- /dev/null +++ b/frontend/web/components/shared/UsageBar/UsageBarThresholds.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react' +import './UsageBarThresholds.scss' + +export type UsageBarThresholdsProps = { + thresholds: number[] +} + +const UsageBarThresholds: FC = ({ thresholds }) => ( + <> + {thresholds.map((threshold) => { + const atLimit = threshold >= 100 + + return ( + + + Notify {threshold}% + + + ) + })} + +) + +export default UsageBarThresholds diff --git a/frontend/web/components/shared/UsageBar/__tests__/utils.test.ts b/frontend/web/components/shared/UsageBar/__tests__/utils.test.ts new file mode 100644 index 000000000000..501b331244fa --- /dev/null +++ b/frontend/web/components/shared/UsageBar/__tests__/utils.test.ts @@ -0,0 +1,56 @@ +import { + boundPercent, + toneFor, + usagePercent, +} from 'components/shared/UsageBar/utils' + +describe('UsageBar utils', () => { + describe('usagePercent', () => { + it.each` + usage | limit | expected + ${0} | ${100} | ${0} + ${50} | ${100} | ${50} + ${118} | ${100} | ${118} + ${1} | ${3} | ${33} + ${2} | ${3} | ${67} + ${10} | ${0} | ${0} + ${10} | ${null} | ${0} + ${10} | ${undefined} | ${0} + ${10} | ${-5} | ${0} + `('$usage of $limit is $expected%', ({ expected, limit, usage }) => { + expect(usagePercent(usage, limit)).toBe(expected) + }) + }) + + describe('boundPercent', () => { + it.each` + percent | expected + ${-10} | ${0} + ${0} | ${0} + ${55} | ${55} + ${100} | ${100} + ${118} | ${100} + `('clamps $percent to $expected', ({ expected, percent }) => { + expect(boundPercent(percent)).toBe(expected) + }) + }) + + describe('toneFor', () => { + it.each` + percent | warnAt | expected + ${0} | ${85} | ${'success'} + ${84} | ${85} | ${'success'} + ${85} | ${85} | ${'warning'} + ${99} | ${85} | ${'warning'} + ${100} | ${85} | ${'danger'} + ${250} | ${85} | ${'danger'} + ${74} | ${75} | ${'success'} + ${75} | ${75} | ${'warning'} + `( + '$percent% against a $warnAt% threshold is $expected', + ({ expected, percent, warnAt }) => { + expect(toneFor(percent, warnAt)).toBe(expected) + }, + ) + }) +}) diff --git a/frontend/web/components/shared/UsageBar/index.ts b/frontend/web/components/shared/UsageBar/index.ts new file mode 100644 index 000000000000..889d81a39543 --- /dev/null +++ b/frontend/web/components/shared/UsageBar/index.ts @@ -0,0 +1,3 @@ +export { default } from './UsageBar' +export type { UsageBarProps } from './UsageBar' +export type { PlanLimit, UsageTone } from './utils' diff --git a/frontend/web/components/shared/UsageBar/utils.ts b/frontend/web/components/shared/UsageBar/utils.ts new file mode 100644 index 000000000000..9e615d8c158b --- /dev/null +++ b/frontend/web/components/shared/UsageBar/utils.ts @@ -0,0 +1,15 @@ +export type UsageTone = 'success' | 'warning' | 'danger' + +export type PlanLimit = number | null | undefined + +export const usagePercent = (usage: number, limit: PlanLimit): number => + limit && limit > 0 ? Math.round((usage / limit) * 100) : 0 + +export const boundPercent = (percent: number): number => + Math.min(Math.max(percent, 0), 100) + +export const toneFor = (percent: number, warnAt: number): UsageTone => { + if (percent >= 100) return 'danger' + if (percent >= warnAt) return 'warning' + return 'success' +}