Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'>
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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>

Expand Down Expand Up @@ -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>
)}
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]'
)}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 10 additions & 20 deletions apps/sim/hooks/queries/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
104 changes: 104 additions & 0 deletions apps/sim/lib/subscription/usage-visualization.ts
Original file line number Diff line number Diff line change
@@ -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,
}
})
}