fix(settings): update usage data in settings > subs to use reactquery hooks (#1983)

* fix(settings): update usage data in settings > subs to use reactquery hooks

* standardize usage pills calculation
This commit is contained in:
Waleed
2025-11-13 19:57:48 -08:00
committed by GitHub
parent 1e915d5427
commit 3ba33791f7
6 changed files with 157 additions and 66 deletions

View File

@@ -2,14 +2,12 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/emcn'
import { calculateFilledPills, USAGE_PILL_COUNT } from '@/lib/subscription/usage-visualization'
import { cn } from '@/lib/utils'
const GRADIENT_BADGE_STYLES =
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer'
// Constants matching UsageIndicator
const PILL_COUNT = 8
interface UsageHeaderProps {
title: string
gradientTitle?: boolean
@@ -45,9 +43,9 @@ export function UsageHeader({
}: UsageHeaderProps) {
const progress = progressValue ?? (limit > 0 ? Math.min((current / limit) * 100, 100) : 0)
// Calculate filled pills based on usage percentage
const filledPillsCount = Math.ceil((progress / 100) * PILL_COUNT)
const isAlmostOut = filledPillsCount === PILL_COUNT
// Calculate filled pills based on usage percentage using shared utility (fixed 8 pills)
const filledPillsCount = calculateFilledPills(progress)
const isAlmostOut = filledPillsCount === USAGE_PILL_COUNT
return (
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
@@ -93,9 +91,9 @@ export function UsageHeader({
</div>
</div>
{/* Pills row - matching UsageIndicator */}
{/* Pills row - fixed 8 pills with shared heuristic */}
<div className='flex items-center gap-[4px]'>
{Array.from({ length: PILL_COUNT }).map((_, i) => {
{Array.from({ length: USAGE_PILL_COUNT }).map((_, i) => {
const isFilled = i < filledPillsCount
return (
<div

View File

@@ -35,7 +35,7 @@ import {
getVisiblePlans,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription-permissions'
import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData, useUsageData, useUsageLimitData } from '@/hooks/queries/subscription'
import { useSubscriptionData, useUsageLimitData } from '@/hooks/queries/subscription'
import { useUpdateWorkspaceSettings, useWorkspaceSettings } from '@/hooks/queries/workspace'
import { useGeneralStore } from '@/stores/settings/general/store'
@@ -170,7 +170,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
// React Query hooks for data fetching
const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionData()
const { data: usageResponse, isLoading: isUsageLoading } = useUsageData()
const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
const updateWorkspaceMutation = useUpdateWorkspaceSettings()
@@ -188,38 +187,38 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const usageLimitRef = useRef<UsageLimitRef | null>(null)
// Combine all loading states
const isLoading =
isSubscriptionLoading || isUsageLoading || isUsageLimitLoading || isWorkspaceLoading
const isLoading = isSubscriptionLoading || isUsageLimitLoading || isWorkspaceLoading
// Extract subscription status from data
// Extract subscription status from subscriptionData.data
const subscription = {
isFree: subscriptionData?.plan === 'free' || !subscriptionData?.plan,
isPro: subscriptionData?.plan === 'pro',
isTeam: subscriptionData?.plan === 'team',
isEnterprise: subscriptionData?.plan === 'enterprise',
isFree: subscriptionData?.data?.plan === 'free' || !subscriptionData?.data?.plan,
isPro: subscriptionData?.data?.plan === 'pro',
isTeam: subscriptionData?.data?.plan === 'team',
isEnterprise: subscriptionData?.data?.plan === 'enterprise',
isPaid:
subscriptionData?.plan &&
['pro', 'team', 'enterprise'].includes(subscriptionData.plan) &&
subscriptionData?.status === 'active',
plan: subscriptionData?.plan || 'free',
status: subscriptionData?.status || 'inactive',
seats: subscriptionData?.seats || 1,
subscriptionData?.data?.plan &&
['pro', 'team', 'enterprise'].includes(subscriptionData.data.plan) &&
subscriptionData?.data?.status === 'active',
plan: subscriptionData?.data?.plan || 'free',
status: subscriptionData?.data?.status || 'inactive',
seats: subscriptionData?.data?.seats || 1,
}
// Extract usage data
// Extract usage data from subscriptionData.data.usage (same source as panel usage indicator)
const usage = {
current: usageResponse?.usage?.current || 0,
limit: usageResponse?.usage?.limit || 0,
percentUsed: usageResponse?.usage?.percentUsed || 0,
current: subscriptionData?.data?.usage?.current || 0,
limit: subscriptionData?.data?.usage?.limit || 0,
percentUsed: subscriptionData?.data?.usage?.percentUsed || 0,
}
// Extract usage limit metadata from usageLimitResponse.data
const usageLimitData = {
currentLimit: usageLimitResponse?.usage?.limit || 0,
minimumLimit: usageLimitResponse?.usage?.minimumLimit || (subscription.isPro ? 20 : 40),
currentLimit: usageLimitResponse?.data?.currentLimit || 0,
minimumLimit: usageLimitResponse?.data?.minimumLimit || (subscription.isPro ? 20 : 40),
}
// Extract billing status
const billingStatus = subscriptionData?.billingBlocked ? 'blocked' : 'ok'
const billingStatus = subscriptionData?.data?.billingBlocked ? 'blocked' : 'ok'
// Extract workspace settings
const billedAccountUserId = workspaceData?.settings?.workspace?.billedAccountUserId ?? null
@@ -406,20 +405,18 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
}
isBlocked={Boolean(subscriptionData?.billingBlocked)}
isBlocked={Boolean(subscriptionData?.data?.billingBlocked)}
status={billingStatus}
percentUsed={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.totalUsageLimit &&
organizationBillingData.totalUsageLimit > 0 &&
organizationBillingData.totalCurrentUsage !== undefined
? Math.round(
(organizationBillingData.totalCurrentUsage /
organizationBillingData.totalUsageLimit) *
100
)
: Math.round(usage.percentUsed)
: Math.round(usage.percentUsed)
? (organizationBillingData.totalCurrentUsage /
organizationBillingData.totalUsageLimit) *
100
: usage.percentUsed
: usage.percentUsed
}
onResolvePayment={async () => {
try {
@@ -467,7 +464,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
/>
) : undefined
}
progressValue={Math.min(Math.round(usage.percentUsed), 100)}
progressValue={Math.min(usage.percentUsed, 100)}
/>
</div>
@@ -544,11 +541,11 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
)}
{/* Next Billing Date */}
{subscription.isPaid && subscriptionData?.periodEnd && (
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
<div className='mt-4 flex items-center justify-between'>
<span className='font-medium text-sm'>Next Billing Date</span>
<span className='text-muted-foreground text-sm'>
{new Date(subscriptionData.periodEnd).toLocaleDateString()}
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
</span>
</div>
)}
@@ -574,8 +571,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
isPaid: subscription.isPaid,
}}
subscriptionData={{
periodEnd: subscriptionData?.periodEnd || null,
cancelAtPeriodEnd: subscriptionData?.cancelAtPeriodEnd,
periodEnd: subscriptionData?.data?.periodEnd || null,
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
}}
/>
</div>

View File

@@ -128,7 +128,7 @@ export function TeamSeatsOverview({
key={i}
className={cn(
'h-[6px] flex-1 rounded-full transition-colors',
isFilled ? 'bg-[#4285F4]' : 'bg-[#2C2C2C]'
isFilled ? 'bg-[#34B5FF]' : 'bg-[#2C2C2C]'
)}
/>
)

View File

@@ -49,8 +49,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
/**
* Calculate pill count based on sidebar width
* Starts at MIN_PILL_COUNT at minimum width, adds 1 pill per WIDTH_PER_PILL increase
* Calculate pill count based on sidebar width (6-8 pills dynamically)
* This provides responsive feedback as the sidebar width changes
*/
const pillCount = useMemo(() => {
const widthDelta = sidebarWidth - MIN_SIDEBAR_WIDTH
@@ -100,6 +100,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
/**
* Calculate which pills should be filled based on usage percentage
* Uses shared Math.ceil heuristic but with dynamic pill count (6-8)
* This ensures consistent calculation logic while maintaining responsive pill count
*/
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
const isAlmostOut = filledPillsCount === pillCount

View File

@@ -34,42 +34,32 @@ export function useSubscriptionData() {
}
/**
* Fetch user usage data
* Fetch user usage limit metadata
* Note: This endpoint returns limit information (currentLimit, minimumLimit, canEdit, etc.)
* For actual usage data (current, limit, percentUsed), use useSubscriptionData() instead
*/
async function fetchUsageData() {
async function fetchUsageLimitData() {
const response = await fetch('/api/usage?context=user')
if (!response.ok) {
throw new Error('Failed to fetch usage data')
throw new Error('Failed to fetch usage limit data')
}
return response.json()
}
/**
* Base hook to fetch user usage data (single query)
* Hook to fetch usage limit metadata
* Returns: currentLimit, minimumLimit, canEdit, plan, updatedAt
* Use this for editing usage limits, not for displaying current usage
*/
function useUsageDataBase() {
export function useUsageLimitData() {
return useQuery({
queryKey: subscriptionKeys.usage(),
queryFn: fetchUsageData,
queryFn: fetchUsageLimitData,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
/**
* Hook to fetch user usage data
*/
export function useUsageData() {
return useUsageDataBase()
}
/**
* Hook to fetch usage limit data
*/
export function useUsageLimitData() {
return useUsageDataBase()
}
/**
* Update usage limit mutation
*/

View File

@@ -0,0 +1,104 @@
/**
* Shared utilities for consistent usage visualization across the application.
*
* This module provides a single source of truth for how usage metrics are
* displayed visually through "pills" or progress indicators.
*/
/**
* Number of pills to display in usage indicators.
*
* Using 8 pills provides:
* - 12.5% granularity per pill
* - Good balance between precision and visual clarity
* - Consistent representation across panel and settings
*/
export const USAGE_PILL_COUNT = 8
/**
* Color values for usage pill states
*/
export const USAGE_PILL_COLORS = {
/** Unfilled pill color (gray) */
UNFILLED: '#414141',
/** Normal filled pill color (blue) */
FILLED: '#34B5FF',
/** Warning/limit reached pill color (red) */
AT_LIMIT: '#ef4444',
} as const
/**
* Calculate the number of filled pills based on usage percentage.
*
* Uses Math.ceil() to ensure even minimal usage (0.01%) shows visual feedback.
* This provides better UX by making it clear that there is some usage, even if small.
*
* @param percentUsed - The usage percentage (0-100). Can be a decimal (e.g., 0.315 for 0.315%)
* @returns Number of pills that should be filled (0 to USAGE_PILL_COUNT)
*
* @example
* calculateFilledPills(0.315) // Returns 1 (shows feedback for 0.315% usage)
* calculateFilledPills(50) // Returns 4 (50% of 8 pills)
* calculateFilledPills(100) // Returns 8 (completely filled)
* calculateFilledPills(150) // Returns 8 (clamped to maximum)
*/
export function calculateFilledPills(percentUsed: number): number {
// Clamp percentage to valid range [0, 100]
const safePercent = Math.min(Math.max(percentUsed, 0), 100)
// Calculate filled pills using ceil to show feedback for any usage
return Math.ceil((safePercent / 100) * USAGE_PILL_COUNT)
}
/**
* Determine if usage has reached the limit (all pills filled).
*
* @param percentUsed - The usage percentage (0-100)
* @returns true if all pills should be filled (at or over limit)
*/
export function isUsageAtLimit(percentUsed: number): boolean {
return calculateFilledPills(percentUsed) >= USAGE_PILL_COUNT
}
/**
* Get the appropriate color for a pill based on its state.
*
* @param isFilled - Whether this pill should be filled
* @param isAtLimit - Whether usage has reached the limit
* @returns Hex color string
*/
export function getPillColor(isFilled: boolean, isAtLimit: boolean): string {
if (!isFilled) return USAGE_PILL_COLORS.UNFILLED
if (isAtLimit) return USAGE_PILL_COLORS.AT_LIMIT
return USAGE_PILL_COLORS.FILLED
}
/**
* Generate an array of pill states for rendering.
*
* @param percentUsed - The usage percentage (0-100)
* @returns Array of pill states with colors
*
* @example
* const pills = generatePillStates(50)
* pills.forEach((pill, index) => (
* <Pill key={index} color={pill.color} filled={pill.filled} />
* ))
*/
export function generatePillStates(percentUsed: number): Array<{
filled: boolean
color: string
index: number
}> {
const filledCount = calculateFilledPills(percentUsed)
const atLimit = isUsageAtLimit(percentUsed)
return Array.from({ length: USAGE_PILL_COUNT }, (_, index) => {
const filled = index < filledCount
return {
filled,
color: getPillColor(filled, atLimit),
index,
}
})
}