From 636390a4c166dcde86e3f2edd63dd01c6e9d7a55 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 27 Jul 2026 14:34:53 -0300 Subject: [PATCH 01/20] spike(usage): prototype billing-aligned usage view to scope BE work Reframes OrganisationUsagePage with a "usage vs plan limit" summary: % of plan consumed, a meter, and a cumulative-vs-limit chart, all wired to the existing usage-data + max_api_calls (no backend change). Everything the API cannot feed yet is marked TODO(BE) and listed in the component so the diff doubles as the backend ask: reset date / billing period boundaries, the current-billing-period date-range bug, projection, grace-period status, and cost in currency. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UsageBillingPrototype.scss | 58 +++++++++ .../UsageBillingPrototype.tsx | 119 ++++++++++++++++++ .../usage/UsageBillingPrototype/index.ts | 1 + .../pages/OrganisationUsagePage.tsx | 6 + 4 files changed, 184 insertions(+) create mode 100644 frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss create mode 100644 frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx create mode 100644 frontend/web/components/organisation-settings/usage/UsageBillingPrototype/index.ts diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss new file mode 100644 index 000000000000..2ba31a64fd20 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss @@ -0,0 +1,58 @@ +.usage-proto { + border: 1px solid var(--hr-border-color, rgba(101, 109, 123, 0.16)); + border-radius: 8px; + padding: 20px; + background: var(--panel-bg, #ffffff); + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + } + + &__stub { + font-size: 12px; + color: var(--text-icon-light-grey, #9da4ae); + font-style: italic; + } + + &__headline { + display: flex; + align-items: baseline; + gap: 8px; + } + + &__pct { + font-size: 40px; + font-weight: 700; + line-height: 1; + } + + &__sub { + font-size: 13px; + color: var(--text-icon-grey, #656d7b); + } + + &__track { + height: 14px; + border-radius: 100px; + background: var(--bg-light300, #eff1f4); + overflow: hidden; + margin: 12px 0 20px; + } + + &__fill { + height: 100%; + border-radius: 100px; + } + + &__notes { + margin-top: 16px; + padding: 12px 16px; + border-radius: 6px; + background: var(--bg-light200, #fafafb); + font-size: 13px; + color: var(--text-icon-grey, #656d7b); + } +} diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx new file mode 100644 index 000000000000..1c7c8e2d94ed --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx @@ -0,0 +1,119 @@ +import { FC, useMemo } from 'react' +import { Res } from 'common/types/responses' +import LineChart from 'components/charts/LineChart' +import { ChartDataPoint } from 'components/charts/types' +import './UsageBillingPrototype.scss' + +type UsageBillingPrototypeProps = { + data: Res['organisationUsage'] | undefined + maxApiCalls?: number | null +} + +const ACCENT = '#6837fc' +const DANGER = '#ef4d56' + +// Compact number formatting to match the design (1.24M / 68.4k). +const compact = (n: number): string => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k` + return `${n}` +} + +/** + * SPIKE — Billing & Usage Transparency prototype. + * Reframes the usage view as "usage vs plan limit" using ONLY data the + * frontend already receives (usage-data totals/events + max_api_calls). + * Everything the API cannot feed yet is marked TODO(BE) and listed in the + * "needs BE" panel below, so the diff doubles as the backend ask. + */ +const UsageBillingPrototype: FC = ({ + data, + maxApiCalls, +}) => { + const total = data?.totals?.total ?? 0 + const limit = maxApiCalls ?? 0 + const pct = limit > 0 ? Math.round((total / limit) * 100) : 0 + const over = pct >= 100 + + // Cumulative usage over the period = running sum of the daily event totals. + const chartData = useMemo(() => { + const events = [...(data?.events_list ?? [])].sort((a, b) => + a.day < b.day ? -1 : 1, + ) + let running = 0 + return events.map((event) => { + running += + (event.flags ?? 0) + + (event.identities ?? 0) + + (event.traits ?? 0) + + (event.environment_document ?? 0) + // `limit` is repeated on every point so LineChart draws a flat ceiling. + return { cumulative: running, day: event.day, limit } + }) + }, [data?.events_list, limit]) + + return ( +
+
+
Usage vs plan limit
+ {/* TODO(BE): billing-period reset date is not serialised. The dates + exist on OrganisationSubscriptionInformationCache + (current_billing_term_starts_at/ends_at) but are never exposed on + usage-data or get-subscription-metadata. */} + Resets: needs BE +
+ +
+ + {pct}% + + + of plan consumed · {compact(total)} / {limit ? compact(limit) : '—'}{' '} + API calls + +
+ +
+
+
+ + + +
+ Prototype notes — wired to existing data: % of plan, + cumulative chart, and the plan-limit ceiling all come from + usage-data + max_api_calls, no BE change. +
+ Needs BE (see PR description): +
    +
  • Reset date / billing-period boundaries (not serialised)
  • +
  • + Fix the current-billing-period date-range bug (annual plans) +
  • +
  • Projected end-of-period (needs the period length above)
  • +
  • Grace-period status and cost in currency (not exposed)
  • +
+
+
+
+ ) +} + +export default UsageBillingPrototype diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/index.ts b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/index.ts new file mode 100644 index 000000000000..86063736ebfd --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/index.ts @@ -0,0 +1 @@ +export { default } from './UsageBillingPrototype' diff --git a/frontend/web/components/pages/OrganisationUsagePage.tsx b/frontend/web/components/pages/OrganisationUsagePage.tsx index 9e3df27809f4..ed373996ab25 100644 --- a/frontend/web/components/pages/OrganisationUsagePage.tsx +++ b/frontend/web/components/pages/OrganisationUsagePage.tsx @@ -15,6 +15,7 @@ import { useGetOrganisationUsageQuery } from 'common/services/useOrganisationUsa import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscriptionMetadata' import UsageChartFilters from 'components/organisation-settings/usage/components/UsageChartFilters' import UsageChartTotals from 'components/organisation-settings/usage/components/UsageChartTotals' +import UsageBillingPrototype from 'components/organisation-settings/usage/UsageBillingPrototype' const OrganisationUsagePage: FC = () => { const isSdkViewEnabled = Utils.getFlagsmithHasFeature('sdk_usage_charts') @@ -161,6 +162,11 @@ const OrganisationUsagePage: FC = () => { setBillingPeriod={setBillingPeriod} isOnFreePlanPeriods={isOnFreePlanPeriods} /> + {/* SPIKE: billing-aligned usage prototype (validates M1b against live data) */} + Date: Mon, 27 Jul 2026 14:53:08 -0300 Subject: [PATCH 02/20] spike(usage): build full S1 (healthy) design as prototype Expands the prototype to the full v0.2 layout for the global view: billing-period strip, hero meter with Notify markers, stat tiles, cumulative usage-vs-limit chart (Area + plan-limit ReferenceLine + today dot), and the request-type breakdown. Global view now renders this in place of the old totals + bar chart; By SDK view is unchanged. Still wired only to existing data; reset date, projection and cost remain TODO(BE). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UsageBillingPrototype.scss | 180 +++++++++- .../UsageBillingPrototype.tsx | 337 +++++++++++++++--- .../pages/OrganisationUsagePage.tsx | 83 ++--- 3 files changed, 477 insertions(+), 123 deletions(-) diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss index 2ba31a64fd20..10e2e26e5c41 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss @@ -1,25 +1,55 @@ .usage-proto { - border: 1px solid var(--hr-border-color, rgba(101, 109, 123, 0.16)); - border-radius: 8px; - padding: 20px; - background: var(--panel-bg, #ffffff); + $border: var(--hr-border-color, rgba(101, 109, 123, 0.16)); + $muted: var(--text-icon-grey, #656d7b); - &__head { + &__strip { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 12px; + padding: 12px 16px; + border: 1px solid $border; + border-radius: 8px; + margin-bottom: 16px; + font-size: 13px; } &__stub { - font-size: 12px; color: var(--text-icon-light-grey, #9da4ae); font-style: italic; + font-size: 12px; + } + + &__panel { + border: 1px solid $border; + border-radius: 8px; + padding: 20px; + margin-bottom: 16px; + background: var(--panel-bg, #ffffff); + } + + &__panel-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; } &__headline { display: flex; - align-items: baseline; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 28px; + } + + &__label { + font-size: 13px; + color: $muted; + margin-bottom: 4px; + } + + &__big { + display: flex; + align-items: flex-end; gap: 8px; } @@ -30,8 +60,18 @@ } &__sub { - font-size: 13px; - color: var(--text-icon-grey, #656d7b); + font-size: 12px; + color: $muted; + } + + &__frac { + text-align: right; + font-size: 18px; + } + + &__meter { + position: relative; + padding-top: 22px; } &__track { @@ -39,7 +79,6 @@ border-radius: 100px; background: var(--bg-light300, #eff1f4); overflow: hidden; - margin: 12px 0 20px; } &__fill { @@ -47,12 +86,127 @@ border-radius: 100px; } + &__marker { + position: absolute; + top: 4px; + bottom: 0; + width: 2px; + background: rgba(101, 109, 123, 0.25); + transform: translateX(-50%); + } + + &__marker-label { + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + font-size: 11px; + font-weight: 600; + } + + &__tiles { + display: flex; + gap: 16px; + margin-bottom: 16px; + } + + &__tile { + flex: 1; + border: 1px solid $border; + border-radius: 8px; + padding: 16px; + background: var(--panel-bg, #ffffff); + } + + &__tile-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; + } + + &__tile-label { + font-size: 13px; + color: $muted; + } + + &__tile-value { + font-size: 28px; + font-weight: 700; + } + + &__badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 100px; + white-space: nowrap; + + &--success { + color: #27ab95; + background: rgba(39, 171, 149, 0.12); + } + + &--estimate { + color: $muted; + background: var(--bg-light300, #eff1f4); + } + } + + &__breakdown { + display: flex; + flex-direction: column; + } + + &__row { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 0; + border-top: 1px solid $border; + + &:first-child { + border-top: 0; + } + } + + &__row-label { + width: 280px; + flex: 0 0 auto; + } + + &__bar-track { + flex: 1; + height: 8px; + border-radius: 100px; + background: var(--bg-light300, #eff1f4); + overflow: hidden; + } + + &__bar-fill { + height: 100%; + border-radius: 100px; + } + + &__row-value { + width: 70px; + text-align: right; + font-weight: 600; + } + + &__row-pct { + width: 44px; + text-align: right; + } + &__notes { - margin-top: 16px; + margin-top: 8px; padding: 12px 16px; border-radius: 6px; background: var(--bg-light200, #fafafb); font-size: 13px; - color: var(--text-icon-grey, #656d7b); + color: $muted; } } diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx index 1c7c8e2d94ed..df7b958285d0 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx @@ -1,7 +1,17 @@ import { FC, useMemo } from 'react' +import { + Area, + CartesianGrid, + ComposedChart, + ReferenceDot, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' import { Res } from 'common/types/responses' -import LineChart from 'components/charts/LineChart' -import { ChartDataPoint } from 'components/charts/types' +import { colorTextSecondary } from 'common/theme/tokens' import './UsageBillingPrototype.scss' type UsageBillingPrototypeProps = { @@ -11,20 +21,36 @@ type UsageBillingPrototypeProps = { const ACCENT = '#6837fc' const DANGER = '#ef4d56' +const SUCCESS = '#27ab95' +const WARNING = '#f79009' // Compact number formatting to match the design (1.24M / 68.4k). const compact = (n: number): string => { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M` if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k` - return `${n}` + return `${Math.round(n)}` +} + +// Green under 75%, amber approaching, red at/over the limit. +const meterColorFor = (percent: number): string => { + if (percent >= 100) return DANGER + if (percent >= 75) return WARNING + return SUCCESS +} + +type Tile = { + label: string + value: string + sub: string + badge?: { text: string; tone: 'success' | 'estimate' } } /** - * SPIKE — Billing & Usage Transparency prototype. - * Reframes the usage view as "usage vs plan limit" using ONLY data the - * frontend already receives (usage-data totals/events + max_api_calls). - * Everything the API cannot feed yet is marked TODO(BE) and listed in the - * "needs BE" panel below, so the diff doubles as the backend ask. + * SPIKE — Billing & Usage Transparency prototype (S1 / healthy). + * Builds the v0.2 Pencil design against ONLY data the frontend already + * receives (usage-data totals/events + max_api_calls). Anything the API + * cannot feed yet is marked TODO(BE) and surfaced in the notes panel, so the + * diff doubles as the backend ask. */ const UsageBillingPrototype: FC = ({ data, @@ -34,72 +60,285 @@ const UsageBillingPrototype: FC = ({ const limit = maxApiCalls ?? 0 const pct = limit > 0 ? Math.round((total / limit) * 100) : 0 const over = pct >= 100 + const meterColor = meterColorFor(pct) // Cumulative usage over the period = running sum of the daily event totals. - const chartData = useMemo(() => { + const { chartData, todayPoint } = useMemo(() => { const events = [...(data?.events_list ?? [])].sort((a, b) => a.day < b.day ? -1 : 1, ) let running = 0 - return events.map((event) => { + const points = events.map((event) => { running += (event.flags ?? 0) + (event.identities ?? 0) + (event.traits ?? 0) + (event.environment_document ?? 0) - // `limit` is repeated on every point so LineChart draws a flat ceiling. - return { cumulative: running, day: event.day, limit } + return { cumulative: running, day: event.day } }) - }, [data?.events_list, limit]) + return { + chartData: points, + todayPoint: points[points.length - 1], + } + }, [data?.events_list]) + + // Request-type breakdown (the four billable types), ranked by volume. + const breakdown = useMemo(() => { + const t = data?.totals + const rows = [ + { label: 'Flag evaluations', op: 'get-flags', value: t?.flags ?? 0 }, + { + label: 'Identity flag evaluations', + op: 'get-identity-flags', + value: t?.identities ?? 0, + }, + { + label: 'Trait updates', + op: 'set-identity-traits', + value: t?.traits ?? 0, + }, + { + label: 'Environment bootstrap', + op: 'get-environment-document', + value: t?.environmentDocument ?? 0, + }, + ] + const max = Math.max(1, ...rows.map((r) => r.value)) + return rows + .sort((a, b) => b.value - a.value) + .map((r) => ({ ...r, width: Math.round((r.value / max) * 100) })) + }, [data?.totals]) + + const tiles: Tile[] = [ + { + badge: over + ? undefined + : { text: pct >= 75 ? 'Watch' : 'On track', tone: 'success' }, + label: 'Total API calls', + sub: `of ${limit ? compact(limit) : '—'} plan limit`, + value: compact(total), + }, + { + label: '% of plan consumed', + sub: 'this period', // TODO(BE): days-left needs the reset date + value: `${pct}%`, + }, + { + // TODO(BE): projection needs the billing-period length (reset date) + badge: { text: 'Estimate', tone: 'estimate' }, + label: 'Projected end-of-period', + sub: 'needs period length (BE)', + value: '—', + }, + { + // TODO(BE): no per-org pricing is exposed; Chargebee only + badge: { text: 'Estimate', tone: 'estimate' }, + label: 'Est. cost this period', + sub: 'needs pricing (BE)', + value: '—', + }, + ] return (
-
-
Usage vs plan limit
- {/* TODO(BE): billing-period reset date is not serialised. The dates - exist on OrganisationSubscriptionInformationCache - (current_billing_term_starts_at/ends_at) but are never exposed on - usage-data or get-subscription-metadata. */} + {/* Billing-period strip */} +
+ + Billing period{' '} + needs BE (boundaries) + + {/* TODO(BE): reset date not serialised (OrganisationSubscription + InformationCache.current_billing_term_ends_at) */} Resets: needs BE
-
- - {pct}% - - - of plan consumed · {compact(total)} / {limit ? compact(limit) : '—'}{' '} - API calls - + {/* Hero meter */} +
+
+
+
Plan usage this period
+
+ + {pct}% + + of plan consumed +
+
+
+
+ {compact(total)} / {limit ? compact(limit) : '—'} +
+
API calls used / plan limit
+
+
+ +
+
+
+
+ + + Notify 75% + + + + + Notify 100% + + +
-
-
+ {/* Stat tiles */} +
+ {tiles.map((tile) => ( +
+
+ {tile.label} + {tile.badge && ( + + {tile.badge.text} + + )} +
+
{tile.value}
+
{tile.sub}
+
+ ))}
- + {/* Cumulative usage vs plan limit */} +
+
+ Usage vs plan limit + Cumulative · this period +
+ + + + + + + + + + + + value >= 1000 ? `${(value / 1000).toFixed(0)}k` : `${value}` + } + /> + [compact(value), 'Cumulative']} + /> + + {limit > 0 && ( + + )} + {todayPoint && ( + + )} + + +
+ + {/* Usage by request type */} +
+
+ Usage by request type + + Where your API calls came from + +
+
+ {breakdown.map((row, i) => ( +
+
+
{row.label}
+
{row.op}
+
+
+
+
+
{compact(row.value)}
+
+ {total ? Math.round((row.value / total) * 100) : 0}% +
+
+ ))} +
+
+ {/* Prototype notes / BE gaps */}
Prototype notes — wired to existing data: % of plan, - cumulative chart, and the plan-limit ceiling all come from - usage-data + max_api_calls, no BE change. + the cumulative chart + ceiling, and the request-type breakdown all come + from usage-data + max_api_calls, no BE change.
Needs BE (see PR description):
    diff --git a/frontend/web/components/pages/OrganisationUsagePage.tsx b/frontend/web/components/pages/OrganisationUsagePage.tsx index ed373996ab25..99da5fbcc228 100644 --- a/frontend/web/components/pages/OrganisationUsagePage.tsx +++ b/frontend/web/components/pages/OrganisationUsagePage.tsx @@ -1,12 +1,10 @@ -import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import { FC, useCallback, useEffect, useState } from 'react' import cn from 'classnames' -import OrganisationUsage from 'components/organisation-settings/usage/OrganisationUsage.container' import ConfigProvider from 'common/providers/ConfigProvider' import { useLocation } from 'react-router-dom' import OrganisationUsageMetrics from 'components/organisation-settings/usage/OrganisationUsageMetrics.container' import OrganisationUsageSideBar from 'components/organisation-settings/usage/components/OrganisationUsageSideBar' import { useRouteContext } from 'components/providers/RouteContext' -import { AggregateUsageDataItem } from 'common/types/responses' import Utils from 'common/utils/utils' import AccountStore from 'common/stores/account-store' import { planNames } from 'common/utils/utils' @@ -54,7 +52,7 @@ const OrganisationUsagePage: FC = () => { Req['getOrganisationUsage']['billing_period'] >(isOnFreePlanPeriods ? '90_day_period' : 'current_billing_period') - const { data, isError } = useGetOrganisationUsageQuery( + const { data } = useGetOrganisationUsageQuery( { billing_period: billingPeriod, environmentId: environment, @@ -69,44 +67,6 @@ const OrganisationUsagePage: FC = () => { { skip: !organisationId }, ) - // Aggregate usage events by date, summing metrics across all client types - const chartData = useMemo(() => { - const consolidated = Object.values( - data?.events_list?.reduce((acc, event) => { - const date = event.day - if (!acc[date]) { - acc[date] = { - day: date, - environment_document: 0, - flags: 0, - identities: 0, - traits: 0, - } - } - - acc[date].flags = (acc[date].flags ?? 0) + (event.flags ?? 0) - acc[date].identities = - (acc[date].identities ?? 0) + (event.identities ?? 0) - acc[date].traits = (acc[date].traits ?? 0) + (event.traits ?? 0) - acc[date].environment_document = - (acc[date].environment_document ?? 0) + - (event.environment_document ?? 0) - - return acc - }, {} as Record) || {}, - ) - - return consolidated.map((v) => ({ - ...v, - environment_document: selection.includes('Environment Document') - ? v.environment_document - : null, - flags: selection.includes('Flags') ? v.flags : null, - identities: selection.includes('Identities') ? v.identities : null, - traits: selection.includes('Traits') ? v.traits : null, - })) - }, [data?.events_list, selection]) - useEffect(() => { if (!isSdkViewEnabled) { return setChartsView('global') @@ -162,27 +122,28 @@ const OrganisationUsagePage: FC = () => { setBillingPeriod={setBillingPeriod} isOnFreePlanPeriods={isOnFreePlanPeriods} /> - {/* SPIKE: billing-aligned usage prototype (validates M1b against live data) */} - - + {/* SPIKE: billing-aligned usage redesign prototype. + Global view renders the reframed design; By SDK keeps the + existing totals + per-SDK charts. */} {chartsView === 'user-agents' ? ( - + <> + + + ) : ( - )}
From b9c0c4b13ec90a5dc7185071c3961e9520469617 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 27 Jul 2026 14:56:36 -0300 Subject: [PATCH 03/20] spike(usage): use semantic colour tokens for dark-mode support Swaps the hardcoded hex (accent/danger/success/warning + panel/border/ text/track) for the --color-* semantic tokens via common/theme/tokens, so the prototype flips correctly under .dark. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UsageBillingPrototype.scss | 30 +++++++++++-------- .../UsageBillingPrototype.tsx | 25 +++++++++------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss index 10e2e26e5c41..c5a1b44863b0 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss @@ -1,6 +1,10 @@ .usage-proto { - $border: var(--hr-border-color, rgba(101, 109, 123, 0.16)); - $muted: var(--text-icon-grey, #656d7b); + $border: var(--color-border-default); + $muted: var(--color-text-secondary); + $surface: var(--color-surface-default); + $track: var(--color-surface-muted); + + color: var(--color-text-default); &__strip { display: flex; @@ -14,7 +18,7 @@ } &__stub { - color: var(--text-icon-light-grey, #9da4ae); + color: var(--color-text-tertiary); font-style: italic; font-size: 12px; } @@ -24,7 +28,7 @@ border-radius: 8px; padding: 20px; margin-bottom: 16px; - background: var(--panel-bg, #ffffff); + background: $surface; } &__panel-head { @@ -77,7 +81,7 @@ &__track { height: 14px; border-radius: 100px; - background: var(--bg-light300, #eff1f4); + background: $track; overflow: hidden; } @@ -91,7 +95,7 @@ top: 4px; bottom: 0; width: 2px; - background: rgba(101, 109, 123, 0.25); + background: var(--color-border-strong); transform: translateX(-50%); } @@ -116,7 +120,7 @@ border: 1px solid $border; border-radius: 8px; padding: 16px; - background: var(--panel-bg, #ffffff); + background: $surface; } &__tile-head { @@ -145,13 +149,13 @@ white-space: nowrap; &--success { - color: #27ab95; - background: rgba(39, 171, 149, 0.12); + color: var(--color-text-success); + background: var(--color-surface-success); } &--estimate { - color: $muted; - background: var(--bg-light300, #eff1f4); + color: var(--color-text-secondary); + background: var(--color-surface-muted); } } @@ -181,7 +185,7 @@ flex: 1; height: 8px; border-radius: 100px; - background: var(--bg-light300, #eff1f4); + background: $track; overflow: hidden; } @@ -205,7 +209,7 @@ margin-top: 8px; padding: 12px 16px; border-radius: 6px; - background: var(--bg-light200, #fafafb); + background: var(--color-surface-subtle); font-size: 13px; color: $muted; } diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx index df7b958285d0..d3238c012654 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx @@ -11,7 +11,13 @@ import { YAxis, } from 'recharts' import { Res } from 'common/types/responses' -import { colorTextSecondary } from 'common/theme/tokens' +import { + colorSurfaceAction, + colorTextDanger, + colorTextSecondary, + colorTextSuccess, + colorTextWarning, +} from 'common/theme/tokens' import './UsageBillingPrototype.scss' type UsageBillingPrototypeProps = { @@ -19,10 +25,11 @@ type UsageBillingPrototypeProps = { maxApiCalls?: number | null } -const ACCENT = '#6837fc' -const DANGER = '#ef4d56' -const SUCCESS = '#27ab95' -const WARNING = '#f79009' +// Semantic tokens (dark-mode aware via the --color-* vars). +const ACCENT = colorSurfaceAction +const DANGER = colorTextDanger +const SUCCESS = colorTextSuccess +const WARNING = colorTextWarning // Compact number formatting to match the design (1.24M / 68.4k). const compact = (n: number): string => { @@ -309,7 +316,7 @@ const UsageBillingPrototype: FC = ({
- {breakdown.map((row, i) => ( + {breakdown.map((row) => (
{row.label}
@@ -318,11 +325,7 @@ const UsageBillingPrototype: FC = ({
{compact(row.value)}
From 2e6c914622f54f10f6dd56e7a3157ee8e7a9f889 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 27 Jul 2026 14:59:06 -0300 Subject: [PATCH 04/20] fix(usage): correct current-billing-period range for annual plans `_get_start_date_and_stop_date_for_subscribed_organisation` derived the current period start from relativedelta(...).months only, dropping the years component, so terms that started >12 months ago (annual plans) resolved to the wrong year. Add years*12, mirroring the existing PREVIOUS_BILLING_PERIOD branch, and cover the annual case with a test. Note: api env (uv) not available locally; needs CI to run. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app_analytics/analytics_db_service.py | 1 + .../test_analytics_db_service.py | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/api/app_analytics/analytics_db_service.py b/api/app_analytics/analytics_db_service.py index dd5f336daf1a..7a8a4d2a4836 100644 --- a/api/app_analytics/analytics_db_service.py +++ b/api/app_analytics/analytics_db_service.py @@ -342,6 +342,7 @@ def _get_start_date_and_stop_date_for_subscribed_organisation( raise NotFound("No billing periods found for this organisation.") month_delta = relativedelta(now, starts_at).months + month_delta += relativedelta(now, starts_at).years * 12 date_start = relativedelta(months=month_delta) + starts_at return date_start, now diff --git a/api/tests/unit/app_analytics/test_analytics_db_service.py b/api/tests/unit/app_analytics/test_analytics_db_service.py index 50321bdcc47a..5ce4235ddee3 100644 --- a/api/tests/unit/app_analytics/test_analytics_db_service.py +++ b/api/tests/unit/app_analytics/test_analytics_db_service.py @@ -707,6 +707,39 @@ def test_get_usage_data__current_billing_period__passes_correct_date_range( ) +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_get_usage_data__current_billing_period_annual_plan__passes_correct_date_range( + mocker: MockerFixture, + settings: SettingsWrapper, + organisation: Organisation, + cache: OrganisationSubscriptionInformationCache, +) -> None: + # Given + # A billing term that started more than 12 months ago (annual plan): the + # months-only delta used to drop the years component and land a year early. + settings.USE_POSTGRES_FOR_ANALYTICS = True + cache.current_billing_term_starts_at = datetime( + 2021, 12, 30, 9, 9, 47, 325132, tzinfo=UTC + ) + cache.save() + mocked_get_usage_data_from_local_db = mocker.patch( + "app_analytics.analytics_db_service.get_usage_data_from_local_db", autospec=True + ) + + # When + get_usage_data(organisation, period=CURRENT_BILLING_PERIOD) + + # Then the current period start is this month, not a year ago. + mocked_get_usage_data_from_local_db.assert_called_once_with( + organisation=organisation, + environment_id=None, + project_id=None, + date_start=datetime(2022, 12, 30, 9, 9, 47, 325132, tzinfo=UTC), + date_stop=datetime(2023, 1, 19, 9, 9, 47, 325132, tzinfo=UTC), + labels_filter=None, + ) + + @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") def test_get_usage_data__previous_billing_period__passes_correct_date_range( mocker: MockerFixture, From ff0b745166d51c4df8c6de8c1695873735e136dc Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 27 Jul 2026 15:27:18 -0300 Subject: [PATCH 05/20] spike(usage): drop legacy By Endpoint/SDK nav, keep project/env filters The reframed design drops the left "By Endpoint / By SDK" sidebar (SDK becomes a breakdown-dimension option instead), so remove the sidebar and the global/user-agents toggle from the page. Keep the Period/Project/Environment filters. Per the agreed behaviour, the plan limit is org-level, so the meter + cumulative chart use an org-wide query (period only) while Project/Environment filter a second query that feeds only the request-type breakdown. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UsageBillingPrototype.tsx | 18 ++- .../pages/OrganisationUsagePage.tsx | 137 ++++-------------- 2 files changed, 41 insertions(+), 114 deletions(-) diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx index d3238c012654..1321e5d4bc19 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx @@ -21,7 +21,10 @@ import { import './UsageBillingPrototype.scss' type UsageBillingPrototypeProps = { + // Org-level usage: drives the meter + cumulative chart (limit is org-wide). data: Res['organisationUsage'] | undefined + // Project/environment-filtered usage: drives the request-type breakdown. + breakdownData?: Res['organisationUsage'] | undefined maxApiCalls?: number | null } @@ -60,6 +63,7 @@ type Tile = { * diff doubles as the backend ask. */ const UsageBillingPrototype: FC = ({ + breakdownData, data, maxApiCalls, }) => { @@ -90,8 +94,9 @@ const UsageBillingPrototype: FC = ({ }, [data?.events_list]) // Request-type breakdown (the four billable types), ranked by volume. + // Uses the project/environment-filtered totals when a filter is applied. const breakdown = useMemo(() => { - const t = data?.totals + const t = (breakdownData ?? data)?.totals const rows = [ { label: 'Flag evaluations', op: 'get-flags', value: t?.flags ?? 0 }, { @@ -111,10 +116,15 @@ const UsageBillingPrototype: FC = ({ }, ] const max = Math.max(1, ...rows.map((r) => r.value)) + const sum = rows.reduce((acc, r) => acc + r.value, 0) return rows .sort((a, b) => b.value - a.value) - .map((r) => ({ ...r, width: Math.round((r.value / max) * 100) })) - }, [data?.totals]) + .map((r) => ({ + ...r, + pct: sum ? Math.round((r.value / sum) * 100) : 0, + width: Math.round((r.value / max) * 100), + })) + }, [breakdownData, data]) const tiles: Tile[] = [ { @@ -330,7 +340,7 @@ const UsageBillingPrototype: FC = ({
{compact(row.value)}
- {total ? Math.round((row.value / total) * 100) : 0}% + {row.pct}%
))} diff --git a/frontend/web/components/pages/OrganisationUsagePage.tsx b/frontend/web/components/pages/OrganisationUsagePage.tsx index 99da5fbcc228..0ef24f893cbb 100644 --- a/frontend/web/components/pages/OrganisationUsagePage.tsx +++ b/frontend/web/components/pages/OrganisationUsagePage.tsx @@ -1,47 +1,20 @@ -import { FC, useCallback, useEffect, useState } from 'react' -import cn from 'classnames' +import { FC, useState } from 'react' import ConfigProvider from 'common/providers/ConfigProvider' -import { useLocation } from 'react-router-dom' -import OrganisationUsageMetrics from 'components/organisation-settings/usage/OrganisationUsageMetrics.container' -import OrganisationUsageSideBar from 'components/organisation-settings/usage/components/OrganisationUsageSideBar' import { useRouteContext } from 'components/providers/RouteContext' -import Utils from 'common/utils/utils' +import Utils, { planNames } from 'common/utils/utils' import AccountStore from 'common/stores/account-store' -import { planNames } from 'common/utils/utils' import { Req } from 'common/types/requests' import { useGetOrganisationUsageQuery } from 'common/services/useOrganisationUsage' import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscriptionMetadata' import UsageChartFilters from 'components/organisation-settings/usage/components/UsageChartFilters' -import UsageChartTotals from 'components/organisation-settings/usage/components/UsageChartTotals' import UsageBillingPrototype from 'components/organisation-settings/usage/UsageBillingPrototype' const OrganisationUsagePage: FC = () => { - const isSdkViewEnabled = Utils.getFlagsmithHasFeature('sdk_usage_charts') - const { organisationId } = useRouteContext() - const location = useLocation() - - const getInitialView = useCallback((): 'global' | 'user-agents' => { - if (!isSdkViewEnabled) { - return 'global' - } - const params = new URLSearchParams(location.search) - return params.get('p') === 'user-agents' ? 'user-agents' : 'global' - }, [isSdkViewEnabled, location.search]) - const [chartsView, setChartsView] = useState<'global' | 'user-agents'>( - getInitialView(), - ) const [project, setProject] = useState() const [environment, setEnvironment] = useState() - const [selection, setSelection] = useState([ - 'Flags', - 'Identities', - 'Environment Document', - 'Traits', - ]) - const colours = ['#0AADDF', '#27AB95', '#FF9F43', '#EF4D56'] const currentPlan = Utils.getPlanName(AccountStore.getActiveOrgPlan()) const orgSubscription = AccountStore.getOrganisation()?.subscription const isOnFreePlanPeriods = @@ -52,7 +25,14 @@ const OrganisationUsagePage: FC = () => { Req['getOrganisationUsage']['billing_period'] >(isOnFreePlanPeriods ? '90_day_period' : 'current_billing_period') - const { data } = useGetOrganisationUsageQuery( + // Option A: the plan limit is org-level, so the meter + cumulative chart + // always use org-wide usage (period only). Project/Environment refine the + // breakdown, so it gets its own filtered query. + const { data: orgData } = useGetOrganisationUsageQuery( + { billing_period: billingPeriod, organisationId: organisationId || 0 }, + { skip: !organisationId }, + ) + const { data: filteredData } = useGetOrganisationUsageQuery( { billing_period: billingPeriod, environmentId: environment, @@ -67,87 +47,24 @@ const OrganisationUsagePage: FC = () => { { skip: !organisationId }, ) - useEffect(() => { - if (!isSdkViewEnabled) { - return setChartsView('global') - } - - const currentView = getInitialView() - if (currentView !== chartsView) { - setChartsView(currentView) - } - }, [location.search, chartsView, getInitialView, isSdkViewEnabled]) - - const updateSelection = (key: string) => { - if (selection.includes(key)) { - setSelection(selection.filter((v) => v !== key)) - } else { - setSelection(selection.concat([key])) - } - } - return ( -
- - {isSdkViewEnabled && ( -
- {organisationId && ( - - )} -
- )} -
- - {/* SPIKE: billing-aligned usage redesign prototype. - Global view renders the reframed design; By SDK keeps the - existing totals + per-SDK charts. */} - {chartsView === 'user-agents' ? ( - <> - - - - ) : ( - - )} -
-
+
+ + {/* SPIKE: billing-aligned usage redesign prototype. */} +
) } From e4c9894c7697eb65309645a610c1e90999eda6b2 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 27 Jul 2026 15:31:51 -0300 Subject: [PATCH 06/20] spike(usage): drop environment filter, keep project only Environment is a drill-down within a project and too granular for org-level usage; project is the meaningful pivot. Remove the environment select from UsageChartFilters and the page's filtered query. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../usage/components/UsageChartFilters.tsx | 16 ---------------- .../components/pages/OrganisationUsagePage.tsx | 4 ---- 2 files changed, 20 deletions(-) diff --git a/frontend/web/components/organisation-settings/usage/components/UsageChartFilters.tsx b/frontend/web/components/organisation-settings/usage/components/UsageChartFilters.tsx index 832017523be0..706c9f497861 100644 --- a/frontend/web/components/organisation-settings/usage/components/UsageChartFilters.tsx +++ b/frontend/web/components/organisation-settings/usage/components/UsageChartFilters.tsx @@ -1,14 +1,11 @@ import React, { FC } from 'react' import ProjectFilter from 'components/ProjectFilter' -import EnvironmentFilter from 'components/EnvironmentFilter' import { billingPeriods, freePeriods, Req } from 'common/types/requests' interface UsageChartFiltersProps { organisationId: number project: string | undefined setProject: (project: string | undefined) => void - environment: string | undefined - setEnvironment: (environment: string | undefined) => void billingPeriod: Req['getOrganisationUsage']['billing_period'] setBillingPeriod: ( period: Req['getOrganisationUsage']['billing_period'], @@ -18,12 +15,10 @@ interface UsageChartFiltersProps { const UsageChartFilters: FC = ({ billingPeriod, - environment, isOnFreePlanPeriods, organisationId, project, setBillingPeriod, - setEnvironment, setProject, }) => { return ( @@ -45,17 +40,6 @@ const UsageChartFilters: FC = ({ value={project} />
- {project && ( -
- - -
- )}
) } diff --git a/frontend/web/components/pages/OrganisationUsagePage.tsx b/frontend/web/components/pages/OrganisationUsagePage.tsx index 0ef24f893cbb..65c88544d271 100644 --- a/frontend/web/components/pages/OrganisationUsagePage.tsx +++ b/frontend/web/components/pages/OrganisationUsagePage.tsx @@ -13,7 +13,6 @@ const OrganisationUsagePage: FC = () => { const { organisationId } = useRouteContext() const [project, setProject] = useState() - const [environment, setEnvironment] = useState() const currentPlan = Utils.getPlanName(AccountStore.getActiveOrgPlan()) const orgSubscription = AccountStore.getOrganisation()?.subscription @@ -35,7 +34,6 @@ const OrganisationUsagePage: FC = () => { const { data: filteredData } = useGetOrganisationUsageQuery( { billing_period: billingPeriod, - environmentId: environment, organisationId: organisationId || 0, projectId: project, }, @@ -53,8 +51,6 @@ const OrganisationUsagePage: FC = () => { organisationId={organisationId || 0} project={project} setProject={setProject} - environment={environment} - setEnvironment={setEnvironment} billingPeriod={billingPeriod} setBillingPeriod={setBillingPeriod} isOnFreePlanPeriods={isOnFreePlanPeriods} From 8be59dacd5f23cc0cf048d0f663ab7dd58f62147 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 27 Jul 2026 15:36:30 -0300 Subject: [PATCH 07/20] spike(usage): move period/project filters into header, center the page Match the design: Usage title on the left, Period + Project selects on the right of the page header (dropping the separate labelled filter row). Give the surface a max-width and centre it instead of the left-aligned, narrow app-container layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UsageBillingPrototype.scss | 25 +++++++++++++ .../UsageBillingPrototype.tsx | 37 +++++++++++++++++++ .../pages/OrganisationUsagePage.tsx | 15 +++----- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss index c5a1b44863b0..531d65573e48 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss @@ -5,6 +5,31 @@ $track: var(--color-surface-muted); color: var(--color-text-default); + max-width: 1280px; + margin: 0 auto; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + &__title { + margin: 0; + font-size: 22px; + font-weight: 700; + } + + &__header-filters { + display: flex; + align-items: center; + gap: 8px; + } + + &__select { + min-width: 210px; + } &__strip { display: flex; diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx index 1321e5d4bc19..8772e8520eb8 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx @@ -18,6 +18,8 @@ import { colorTextSuccess, colorTextWarning, } from 'common/theme/tokens' +import ProjectFilter from 'components/ProjectFilter' +import { billingPeriods, freePeriods, Req } from 'common/types/requests' import './UsageBillingPrototype.scss' type UsageBillingPrototypeProps = { @@ -26,6 +28,14 @@ type UsageBillingPrototypeProps = { // Project/environment-filtered usage: drives the request-type breakdown. breakdownData?: Res['organisationUsage'] | undefined maxApiCalls?: number | null + organisationId: number + project: string | undefined + setProject: (project: string | undefined) => void + billingPeriod: Req['getOrganisationUsage']['billing_period'] + setBillingPeriod: ( + period: Req['getOrganisationUsage']['billing_period'], + ) => void + isOnFreePlanPeriods: boolean } // Semantic tokens (dark-mode aware via the --color-* vars). @@ -63,9 +73,15 @@ type Tile = { * diff doubles as the backend ask. */ const UsageBillingPrototype: FC = ({ + billingPeriod, breakdownData, data, + isOnFreePlanPeriods, maxApiCalls, + organisationId, + project, + setBillingPeriod, + setProject, }) => { const total = data?.totals?.total ?? 0 const limit = maxApiCalls ?? 0 @@ -158,6 +174,27 @@ const UsageBillingPrototype: FC = ({ return (
+ {/* Page header: title + period/project filters (matches design) */} +
+

Usage

+
+
+ setDimension(v.value)} + value={BREAKDOWN_DIMENSIONS.find((d) => d.value === dimension)} + options={BREAKDOWN_DIMENSIONS} + /> +
- {[...view.breakdown] + {!rows.length && ( +
+ The API does not break usage down this way yet. +
+ )} + {[...rows] .sort((a, b) => b.value - a.value) .map((row) => ( -
+
{row.label}
-
{row.op}
+ {row.op &&
{row.op}
}
= ({ organisationId={organisationId} project={project} setProject={setProject} - billingPeriod={billingPeriod} + // Fixtures do not re-query, so the selector follows the fixture + // rather than contradicting the period shown underneath it. + billingPeriod={ + scenario === 'live' ? billingPeriod : view.period.selectValue + } setBillingPeriod={setBillingPeriod} - isOnFreePlanPeriods={isOnFreePlanPeriods} + isOnFreePlanPeriods={ + scenario === 'live' ? isOnFreePlanPeriods : view.plan === 'free' + } /> ) : ( = ({ }} /> )} + {!!projected && rows.length > 0 && ( + + )} {today && ( { + const limit = view.limit + if (!limit) { + return null + } + const over = view.total - limit + const overPercent = Math.max(percent - 100, 0) + + if (view.restricted) { + return { + text: `Flag serving is paused. Reduce usage below ${compact( + limit, + )} calls or upgrade to restore service.`, + tone: 'danger', + } + } + + if (view.grace === 'countdown') { + return { + text: `Over your limit by ~${compact( + over, + )} calls (${overPercent}%). Flag serving pauses in ${ + view.graceDaysLeft ?? 0 + } days unless usage drops back below ${compact(limit)}.`, + tone: 'warning', + } + } + + if (view.grace === 'covering') { + return { + text: `Overage this period: ~${compact( + over, + )} calls (${overPercent}%). Covered by your grace period this month, so there is no charge.`, + tone: 'warning', + } + } + + if (view.grace === 'used') { + return { + text: `Overage this period: ~${compact(over)} calls (${overPercent}%)${ + view.overageCost ? `, estimated ${currency(view.overageCost)}` : '' + }. Reduce usage or upgrade to avoid further charges.`, + tone: 'danger', + } + } + + if (!view.projected) { + return null + } + + const projectedPercent = Math.round((view.projected / limit) * 100) + const by = view.period.resetsAt + ? ` by ${view.period.resetsAt}` + : ' by the end of this window' + + return projectedPercent >= 100 + ? { + text: `At this rate you will pass your limit${by}, reaching ~${compact( + view.projected, + )} calls (${projectedPercent}% of your limit).`, + tone: 'warning', + } + : { + text: `On track to use ~${compact( + view.projected, + )} calls (${projectedPercent}% of your limit)${by}.`, + tone: 'success', + } +} + +const UsageNote: FC = ({ percent, view }) => { + const note = noteFor(view, percent) + if (!note) { + return null + } + + return ( +
+ + {note.text} +
+ ) +} + +export default UsageNote diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx index 51e6fce4a593..2ea235d11098 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx @@ -117,9 +117,14 @@ const UsageNotifications: FC = ({
What counts as an API call? - - These four request types are billable - + + See docs +
{BILLABLE_CALLS.map((call) => (
diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/fixtures.ts b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/fixtures.ts index ea212a090f05..fdad051a65ab 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/fixtures.ts +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/fixtures.ts @@ -1,5 +1,10 @@ import moment from 'moment' -import { BreakdownRow, UsagePoint, UsageView } from './types' +import { + BreakdownDimension, + BreakdownRow, + UsagePoint, + UsageView, +} from './types' /** * PROTOTYPE (#8184). Fake data so every designed state can be demonstrated. @@ -50,9 +55,31 @@ const buildSeries = ( }) } -const buildBreakdown = (total: number): BreakdownRow[] => { - // Split roughly as a real account does: flag evaluations dominate. - const shares = [ +const split = ( + total: number, + parts: { label: string; op?: string; share: number }[], +): BreakdownRow[] => + parts.map(({ label, op, share }) => ({ + label, + op, + value: Math.round(total * share), + })) + +// Obviously invented names, so nobody mistakes the demo for their own data. +const buildBreakdowns = ( + total: number, +): Record => ({ + environment: split(total, [ + { label: 'Production', share: 0.81 }, + { label: 'Staging', share: 0.13 }, + { label: 'Development', share: 0.06 }, + ]), + project: split(total, [ + { label: 'Web app', share: 0.54 }, + { label: 'Mobile', share: 0.31 }, + { label: 'Internal tools', share: 0.15 }, + ]), + 'request-type': split(total, [ { label: 'Flag evaluations', op: 'get-flags', share: 0.63 }, { label: 'Identity flag evaluations', @@ -65,13 +92,14 @@ const buildBreakdown = (total: number): BreakdownRow[] => { op: 'get-environment-document', share: 0.04, }, - ] - return shares.map(({ label, op, share }) => ({ - label, - op, - value: Math.round(total * share), - })) -} + ]), + sdk: split(total, [ + { label: 'JavaScript', op: 'flagsmith-js', share: 0.42 }, + { label: 'Python', op: 'flagsmith-python', share: 0.27 }, + { label: 'Java', op: 'flagsmith-java', share: 0.19 }, + { label: 'Go', op: 'flagsmith-go', share: 0.12 }, + ]), +}) type ScenarioInput = { plan: UsageView['plan'] @@ -105,7 +133,7 @@ const buildView = ({ const resetsAt = periodStart.clone().add(periodDays, 'days') return { - breakdown: buildBreakdown(total), + breakdowns: buildBreakdowns(total), channels: { email: true, inApp: true }, grace, graceDaysLeft, @@ -121,7 +149,10 @@ const buildView = ({ label: periodLabel ?? `${periodStart.format('D MMM')} to ${resetsAt.format('D MMM YYYY')}`, - resetsAt: resetsAt.format('D MMM YYYY'), + // Rolling windows never reset, so they get no reset date. + resetsAt: isBillingPeriod ? resetsAt.format('D MMM YYYY') : '', + + selectValue: isBillingPeriod ? 'current_billing_period' : undefined, }, plan, // Run rate to the end of the period. Deliberately null early on, which is diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/types.ts b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/types.ts index 7015cdb05074..21e61b225853 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/types.ts +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/types.ts @@ -1,3 +1,5 @@ +import { Req } from 'common/types/requests' + /** * PROTOTYPE (#8184). The view model the usage page renders. * @@ -21,11 +23,13 @@ export type GraceState = export type UsagePeriod = { label: string - /** Human date, e.g. "9 Aug 2026". Comes from the billing term today. */ + /** Human date, e.g. "9 Aug 2026". Empty on rolling windows, which never reset. */ resetsAt: string daysRemaining: number /** False for the rolling windows (last 30 / 90 days). */ isBillingPeriod: boolean + /** Keeps the period selector honest about what is on screen. */ + selectValue: Req['getOrganisationUsage']['billing_period'] } export type UsagePoint = { @@ -33,10 +37,26 @@ export type UsagePoint = { cumulative: number } +export type BreakdownDimension = + | 'request-type' + | 'project' + | 'environment' + | 'sdk' + +export const BREAKDOWN_DIMENSIONS: { + value: BreakdownDimension + label: string +}[] = [ + { label: 'By request type', value: 'request-type' }, + { label: 'By project', value: 'project' }, + { label: 'By environment', value: 'environment' }, + { label: 'By SDK', value: 'sdk' }, +] + export type BreakdownRow = { label: string /** Canonical operation name, shared with the "what counts" docs. */ - op: string + op?: string value: number } @@ -51,7 +71,7 @@ export type UsageView = { total: number limit: number | null series: UsagePoint[] - breakdown: BreakdownRow[] + breakdowns: Record grace: GraceState /** Only set while `grace` is 'countdown'. */ graceDaysLeft?: number diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/usePrototypeUsage.ts b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/usePrototypeUsage.ts index 37aae0334984..ed5edc0dd4a7 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/usePrototypeUsage.ts +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/usePrototypeUsage.ts @@ -45,24 +45,35 @@ const buildLiveView = ( const totals = (breakdownUsage ?? usage)?.totals return { - breakdown: [ - { label: 'Flag evaluations', op: 'get-flags', value: totals?.flags ?? 0 }, - { - label: 'Identity flag evaluations', - op: 'get-identity-flags', - value: totals?.identities ?? 0, - }, - { - label: 'Trait updates', - op: 'set-identity-traits', - value: totals?.traits ?? 0, - }, - { - label: 'Environment bootstrap', - op: 'get-environment-document', - value: totals?.environmentDocument ?? 0, - }, - ], + breakdowns: { + // The API only breaks usage down by request type today. The other + // dimensions need work that is not raised yet, so they stay empty. + environment: [], + project: [], + 'request-type': [ + { + label: 'Flag evaluations', + op: 'get-flags', + value: totals?.flags ?? 0, + }, + { + label: 'Identity flag evaluations', + op: 'get-identity-flags', + value: totals?.identities ?? 0, + }, + { + label: 'Trait updates', + op: 'set-identity-traits', + value: totals?.traits ?? 0, + }, + { + label: 'Environment bootstrap', + op: 'get-environment-document', + value: totals?.environmentDocument ?? 0, + }, + ], + sdk: [], + }, channels: { email: true, inApp: true }, // Grace state is not serialised by the API yet (see the epic), so live // data can only ever show the neutral case. @@ -85,6 +96,7 @@ const buildLiveView = ( ).format('D MMM YYYY')}` : 'No usage recorded', resetsAt: '', + selectValue: isOnFreePlanPeriods ? undefined : 'current_billing_period', }, plan: isOnFreePlanPeriods ? 'free' : 'paid', projected: null, From dae918b86fb8cfad6baaf705f3a396a7f134c8aa Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 3 Aug 2026 11:00:35 -0300 Subject: [PATCH 18/20] spike(usage): stop the projected label being clipped The y-axis top was the projected value itself, so its label had nowhere to render and got cut off by the edge of the chart. The axis now carries 8% headroom above the highest value, and the label sits above the endpoint rather than inside the corner. Co-Authored-By: Claude Opus 5 (1M context) --- .../usage/UsageBillingPrototype/UsageChart.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageChart.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageChart.tsx index a4033c059c19..6dc10e6e60e2 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageChart.tsx +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageChart.tsx @@ -80,6 +80,9 @@ const UsageChart: FC = ({ projected ?? 0, series[series.length - 1]?.cumulative ?? 0, ) + // Headroom above the highest value, so the topmost label has room to render + // instead of being clipped by the top of the chart. + const ceiling = peak ? Math.round(peak * 1.08) : 0 const today = series[series.length - 1] return ( @@ -110,7 +113,7 @@ const UsageChart: FC = ({ compact(value)} @@ -125,7 +128,7 @@ const UsageChart: FC = ({ {!!limit && peak > limit && ( = ({ label={{ fill: ACCENT, fontSize: 11, - position: 'insideBottomRight', + offset: 8, + position: 'top', value: `Projected · ${compact(projected)}`, }} /> From 9c01b8804c9d6f4818da8c19f20291ee68bd4815 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 3 Aug 2026 11:01:51 -0300 Subject: [PATCH 19/20] spike(usage): let notifications be removed, fix the row copy You could add a notification but never get rid of one. Each row now has a remove control next to its toggle, labelled for screen readers. Rows above 100% claimed "You have reached your plan limit", which is wrong once you are past it. They now read "You are over your plan limit", and only the 100% row claims to be at it. Co-Authored-By: Claude Opus 5 (1M context) --- .../UsageBillingPrototype.scss | 16 +++++++++ .../UsageNotifications.tsx | 33 ++++++++++++++----- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss index b6407e33b130..d3fc9a494eae 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss @@ -331,6 +331,22 @@ } } + &__notify-actions { + display: flex; + align-items: center; + gap: 12px; + } + + &__remove { + display: flex; + align-items: center; + opacity: 0.6; + + &:hover { + opacity: 1; + } + } + &__call-detail { max-width: 420px; text-align: right; diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx index 2ea235d11098..646ac2a2166b 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx @@ -3,6 +3,7 @@ import Switch from 'components/Switch' import BareButton from 'components/base/forms/BareButton' import { Button } from 'components/base/forms/Button' import Icon from 'components/icons/Icon' +import { colorIconSecondary } from 'common/theme/tokens' import { UsageNotification } from './types' type UsageNotificationsProps = { @@ -10,6 +11,12 @@ type UsageNotificationsProps = { channels: { email: boolean; inApp: boolean } } +const describe = (percent: number): string => { + if (percent > 100) return 'You are over your plan limit' + if (percent === 100) return 'You have reached your plan limit' + return 'Early warning, while there is time to act' +} + const BILLABLE_CALLS = [ { detail: 'Flags fetched for an anonymous visitor.', @@ -52,6 +59,9 @@ const UsageNotifications: FC = ({ ), ) + const removeRow = (percent: number) => + setRows(rows.filter((row) => row.percent !== percent)) + const addRow = () => { const next = [...rows].map((row) => row.percent).sort((a, b) => b - a)[0] setRows(rows.concat({ enabled: true, percent: Math.min(next + 25, 500) })) @@ -75,16 +85,21 @@ const UsageNotifications: FC = ({
{row.percent}% of plan consumed
-
- {row.percent >= 100 - ? 'You have reached your plan limit' - : 'Early warning, while there is time to act'} -
+
{describe(row.percent)}
+
+
+ toggleRow(row.percent)} + /> + removeRow(row.percent)} + > + +
- toggleRow(row.percent)} - />
))} From 282cd65dcf9b47656b7be37553378eeb4f195af9 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 3 Aug 2026 11:06:46 -0300 Subject: [PATCH 20/20] spike(usage): make notification edits reach the meter The settings screen kept its own copy of the notifications, so removing 75% left the "Notify 75%" marker sitting on the meter, and edits vanished on tab switch. State lives on the page now and both screens read the same list. Changing scenario drops the edits, so each fixture still shows its own notifications. Co-Authored-By: Claude Opus 5 (1M context) --- .../UsageBillingPrototypePage.tsx | 26 ++++++++++++++++--- .../UsageNotifications.tsx | 21 ++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototypePage.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototypePage.tsx index 04d42fae55d2..290e687943ed 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototypePage.tsx +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototypePage.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from 'react' +import { FC, useMemo, useState } from 'react' import Utils, { planNames } from 'common/utils/utils' import AccountStore from 'common/stores/account-store' import BareButton from 'components/base/forms/BareButton' @@ -7,6 +7,7 @@ import UsageBillingPrototype from './UsageBillingPrototype' import UsageNotifications from './UsageNotifications' import usePrototypeUsage from './usePrototypeUsage' import { ScenarioId, SCENARIOS } from './fixtures' +import { UsageNotification } from './types' import './UsageBillingPrototype.scss' type UsageBillingPrototypePageProps = { @@ -42,7 +43,14 @@ const UsageBillingPrototypePage: FC = ({ Req['getOrganisationUsage']['billing_period'] >(isOnFreePlanPeriods ? '90_day_period' : 'current_billing_period') - const view = usePrototypeUsage({ + // Notifications live here rather than in the settings screen, so removing + // one takes its marker off the meter. Null until edited, so switching + // scenario shows that fixture's own notifications again. + const [editedNotifications, setEditedNotifications] = useState< + UsageNotification[] | null + >(null) + + const baseView = usePrototypeUsage({ billingPeriod, isOnFreePlanPeriods, organisationId, @@ -51,6 +59,14 @@ const UsageBillingPrototypePage: FC = ({ scenario, }) + const view = useMemo( + () => + editedNotifications + ? { ...baseView, notifications: editedNotifications } + : baseView, + [baseView, editedNotifications], + ) + return (
@@ -63,7 +79,10 @@ const UsageBillingPrototypePage: FC = ({ {SCENARIOS.map((option) => ( setScenario(option.id)} + onClick={() => { + setScenario(option.id) + setEditedNotifications(null) + }} aria-pressed={option.id === scenario} className={ option.id === scenario @@ -116,6 +135,7 @@ const UsageBillingPrototypePage: FC = ({ ) : ( )} diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx index 646ac2a2166b..03b10c2c3721 100644 --- a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx @@ -8,6 +8,8 @@ import { UsageNotification } from './types' type UsageNotificationsProps = { notifications: UsageNotification[] + /** Lifted, so edits reach the meter markers on the usage screen. */ + onChange: (notifications: UsageNotification[]) => void channels: { email: boolean; inApp: boolean } } @@ -46,25 +48,30 @@ const BILLABLE_CALLS = [ */ const UsageNotifications: FC = ({ channels, - notifications, + notifications: rows, + onChange, }) => { - const [rows, setRows] = useState(notifications) const [inApp, setInApp] = useState(channels.inApp) const [email, setEmail] = useState(channels.email) const toggleRow = (percent: number) => - setRows( + onChange( rows.map((row) => row.percent === percent ? { ...row, enabled: !row.enabled } : row, ), ) const removeRow = (percent: number) => - setRows(rows.filter((row) => row.percent !== percent)) + onChange(rows.filter((row) => row.percent !== percent)) const addRow = () => { - const next = [...rows].map((row) => row.percent).sort((a, b) => b - a)[0] - setRows(rows.concat({ enabled: true, percent: Math.min(next + 25, 500) })) + const highest = [...rows].map((row) => row.percent).sort((a, b) => b - a)[0] + onChange( + rows.concat({ + enabled: true, + percent: Math.min((highest ?? 50) + 25, 500), + }), + ) } return ( @@ -79,7 +86,7 @@ const UsageNotifications: FC = ({
Notify me at
- {rows + {[...rows] .sort((a, b) => a.percent - b.percent) .map((row) => (