Files
supabase/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx
Mert YEREKAPAN 74d634b9d9 fix(studio): show full counts in plan usage card (50,000 not 50k) (#46811)
## Summary

Follow-up to the upgrade CTA placement experiment (#45858). The
`PlanUsageCard` count metrics (Monthly active users) were abbreviated to
`50k`, which reads ambiguously. This shows them in full with thousands
separators (`50,000`) to match the pricing page.

- `Monthly active users` row now renders e.g. `8,200 / 50,000` instead
of `8.2k / 50k`.
- Collapses the two count formatters (`formatCount` /
`formatCountLimit`) into one `toLocaleString()` call so the current
value and limit always use the same format.
- GB metrics (Egress, Database size, File storage) are unchanged.

## Test plan
- [ ] On the org projects page with the `org_projects_list` variant, the
Monthly active users row shows full numbers (`0 / 50,000`).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Updated plan usage display formatting to show full numbers with
thousands separators instead of abbreviated notation, improving clarity
when viewing usage limits.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-11 10:39:28 +02:00

245 lines
8.2 KiB
TypeScript

import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { cn } from 'ui'
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
import { PricingMetric } from '@/data/analytics/org-daily-stats-query'
import { OrgMetricsUsage, useOrgUsageQuery } from '@/data/usage/org-usage-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useTrack } from '@/lib/telemetry/track'
type MetricUnit = 'gigabytes' | 'count'
type MetricConfig = {
key: PricingMetric
label: string
unit: MetricUnit
/** Anchor id of the matching section on the org usage page. */
anchor: string
}
const METRICS: MetricConfig[] = [
{ key: PricingMetric.EGRESS, label: 'Egress', unit: 'gigabytes', anchor: 'egress' },
{
key: PricingMetric.DATABASE_SIZE,
label: 'Database size',
unit: 'gigabytes',
anchor: 'databaseSize',
},
{
key: PricingMetric.MONTHLY_ACTIVE_USERS,
label: 'Monthly active users',
unit: 'count',
anchor: 'mau',
},
{
key: PricingMetric.STORAGE_SIZE,
label: 'File storage',
unit: 'gigabytes',
anchor: 'storageSize',
},
]
const formatGigabytes = (value: number) => {
if (value === 0) return '0 GB'
if (value < 1) return `${(value * 1000).toFixed(0)} MB`
return `${value.toFixed(value < 10 ? 2 : 1)} GB`
}
const formatGigabyteLimit = (limit: number) => {
if (limit < 1) return `${(limit * 1000).toFixed(0)} MB`
return `${limit} GB`
}
// Show counts in full with thousands separators (e.g. `50,000`) rather than abbreviated
// (`50k`), to match the pricing page and avoid ambiguity around plan limits.
const formatCount = (value: number) => value.toLocaleString()
const formatValue = (value: number, unit: MetricUnit) =>
unit === 'gigabytes' ? formatGigabytes(value) : formatCount(value)
const formatLimit = (limit: number, unit: MetricUnit) =>
unit === 'gigabytes' ? formatGigabyteLimit(limit) : formatCount(limit)
const RING_RADIUS = 7
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const ProgressRing = ({
ratio,
isOver,
isApproaching,
}: {
ratio: number
isOver: boolean
isApproaching: boolean
}) => {
const clamped = Math.max(0, Math.min(1, ratio))
const offset = RING_CIRCUMFERENCE * (1 - clamped)
return (
<svg
width="16"
height="16"
viewBox="0 0 18 18"
className="shrink-0"
aria-hidden="true"
role="presentation"
>
<circle
cx="9"
cy="9"
r={RING_RADIUS}
strokeWidth="2"
fill="none"
stroke="currentColor"
className="text-foreground-muted/40"
/>
<circle
cx="9"
cy="9"
r={RING_RADIUS}
strokeWidth="2"
fill="none"
stroke="currentColor"
strokeDasharray={RING_CIRCUMFERENCE}
strokeDashoffset={offset}
strokeLinecap="round"
transform="rotate(-90 9 9)"
className={cn(
'transition-[stroke-dashoffset] duration-500',
isOver ? 'text-warning-600' : isApproaching ? 'text-warning' : 'text-brand'
)}
/>
</svg>
)
}
// The upgrade CTA placement experiment variant this card represents. Used as the telemetry
// `source` + `placement` value. Kept as a constant so the tracking stays explicit.
const PLACEMENT = 'org_projects_list'
const CompactMetricRow = ({
usageItem,
config,
orgSlug,
}: {
usageItem: OrgMetricsUsage
config: MetricConfig
orgSlug: string
}) => {
const current = usageItem.usage ?? 0
const limit = usageItem.pricing_free_units ?? 0
const ratio = limit > 0 ? current / limit : 0
const isOver = limit > 0 && current >= limit
const isApproaching = limit > 0 && ratio >= 0.8 && !isOver
return (
<Link
href={`/org/${orgSlug}/usage#${config.anchor}`}
className="group/row block px-4 hover:bg-surface-200 transition-colors"
>
<div className="flex items-center justify-between gap-2 py-2 border-t border-dashed">
<div className="flex items-center gap-2 min-w-0">
<ProgressRing ratio={ratio} isOver={isOver} isApproaching={isApproaching} />
<span className="text-[11px] text-foreground-light uppercase font-mono truncate">
{config.label}
</span>
</div>
<div className="flex items-center shrink-0">
<span className="text-[11px] font-mono tabular-nums whitespace-nowrap">
<span className={cn(isOver ? 'text-warning' : 'text-foreground')}>
{formatValue(current, config.unit)}
</span>
<span className="text-muted"> / </span>
<span className="text-foreground-lighter"> {formatLimit(limit, config.unit)}</span>
</span>
<ChevronRight
size={12}
strokeWidth={1.5}
className="text-foreground w-0 ml-0 opacity-0 overflow-hidden group-hover/row:w-3 group-hover/row:ml-1 group-hover/row:opacity-100 transition-all"
/>
</div>
</div>
</Link>
)
}
const SkeletonMetricRow = ({ label }: { label: string }) => (
<div className="px-4">
<div className="flex items-center justify-between gap-2 py-2 border-t border-dashed">
<div className="flex items-center gap-2 min-w-0">
<div
className="w-4 h-4 rounded-full border-2 border-foreground-muted/40 shrink-0"
aria-hidden
/>
<span className="text-xs text-foreground-light truncate">{label}</span>
</div>
<div className="h-3 w-16 rounded bg-surface-200 animate-pulse" aria-hidden />
</div>
</div>
)
/**
* Renders the upgrade CTA's plan-usage card as the first tile in the org project list
* (the `org_projects_list` experiment variant). The parent is responsible for gating on
* the experiment variant + free plan — this component only renders the visual sections
* once usage data is available. Shaped like a `ProjectCard` so it reads as another tile.
*/
export const PlanUsageCard = () => {
const track = useTrack()
const { data: organization } = useSelectedOrganizationQuery()
const { data: usage, isSuccess, isError } = useOrgUsageQuery({ orgSlug: organization?.slug })
const visibleRows = isSuccess
? METRICS.map((config) => {
const usageItem = usage.usages.find((u) => u.metric === config.key)
if (!usageItem) return null
if (!usageItem.available_in_plan) return null
if (!usageItem.pricing_free_units || usageItem.pricing_free_units <= 0) return null
return { config, usageItem }
}).filter((row): row is { config: MetricConfig; usageItem: OrgMetricsUsage } => row !== null)
: []
// Hide entirely on hard error or when the org has zero applicable metrics — both
// are extreme edge cases. Otherwise always render the card shell so the layout is
// reserved from first paint and the usage rows fade in once the query resolves.
if (isError) return null
if (isSuccess && visibleRows.length === 0) return null
return (
<li className="list-none h-min">
<div
className={cn(
'group relative bg-surface-100 border border-surface rounded-md',
'flex flex-col gap-y-2'
)}
>
<div className="flex items-center justify-between gap-4 p-4 pb-2">
<div className="flex flex-col min-w-0">
<h5 className="text-sm text-foreground truncate">Free plan usage</h5>
<p className="text-xs text-foreground-lighter truncate">Current billing cycle</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<UpgradePlanButton
source={PLACEMENT}
plan="Pro"
onClick={() => track('upgrade_cta_clicked', { placement: PLACEMENT })}
/>
</div>
</div>
<div className="flex flex-col justify-end pb-2 [&>:first-child>*]:border-t-0">
{isSuccess
? visibleRows.map(({ config, usageItem }) => (
<CompactMetricRow
key={config.key}
usageItem={usageItem}
config={config}
orgSlug={organization?.slug ?? '_'}
/>
))
: METRICS.map((config) => <SkeletonMetricRow key={config.key} label={config.label} />)}
</div>
</div>
</li>
)
}