diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx index 5d1c90cf1c..a7275686e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx @@ -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 (
@@ -93,9 +91,9 @@ export function UsageHeader({
- {/* Pills row - matching UsageIndicator */} + {/* Pills row - fixed 8 pills with shared heuristic */}
- {Array.from({ length: PILL_COUNT }).map((_, i) => { + {Array.from({ length: USAGE_PILL_COUNT }).map((_, i) => { const isFilled = i < filledPillsCount return (
(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)} />
@@ -544,11 +541,11 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { )} {/* Next Billing Date */} - {subscription.isPaid && subscriptionData?.periodEnd && ( + {subscription.isPaid && subscriptionData?.data?.periodEnd && (
Next Billing Date - {new Date(subscriptionData.periodEnd).toLocaleDateString()} + {new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
)} @@ -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, }} />
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx index 099a0ee23e..08a13ed244 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx @@ -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]' )} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx index 7f223aa3da..f27bc83662 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx @@ -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 diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index 524e4b8f8e..f8e2065f4b 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -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 */ diff --git a/apps/sim/lib/subscription/usage-visualization.ts b/apps/sim/lib/subscription/usage-visualization.ts new file mode 100644 index 0000000000..e165a1d0f1 --- /dev/null +++ b/apps/sim/lib/subscription/usage-visualization.ts @@ -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) => ( + * + * )) + */ +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, + } + }) +}