@@ -16,30 +16,39 @@ import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
1616const logger = createLogger ( 'UsageIndicator' )
1717
1818/**
19- * Minimum number of pills to display (at minimum sidebar width)
19+ * Minimum number of pills to display (at minimum sidebar width).
2020 */
2121const MIN_PILL_COUNT = 6
2222
2323/**
24- * Maximum number of pills to display
24+ * Maximum number of pills to display.
2525 */
2626const MAX_PILL_COUNT = 8
2727
2828/**
29- * Width increase (in pixels) required to add one additional pill
29+ * Width increase (in pixels) required to add one additional pill.
3030 */
3131const WIDTH_PER_PILL = 50
3232
3333/**
34- * Animation configuration for usage pills
35- * Controls how smoothly and quickly the highlight progresses across pills
34+ * Animation tick interval in milliseconds.
35+ * Controls the update frequency of the wave animation.
3636 */
3737const PILL_ANIMATION_TICK_MS = 30
38+
39+ /**
40+ * Speed of the wave animation in pills per second.
41+ */
3842const PILLS_PER_SECOND = 1.8
43+
44+ /**
45+ * Distance (in pill units) the wave advances per animation tick.
46+ * Derived from {@link PILLS_PER_SECOND} and {@link PILL_ANIMATION_TICK_MS}.
47+ */
3948const PILL_STEP_PER_TICK = ( PILLS_PER_SECOND * PILL_ANIMATION_TICK_MS ) / 1000
4049
4150/**
42- * Plan name mapping
51+ * Human-readable plan name labels.
4352 */
4453const PLAN_NAMES = {
4554 enterprise : 'Enterprise' ,
@@ -48,17 +57,37 @@ const PLAN_NAMES = {
4857 free : 'Free' ,
4958} as const
5059
60+ /**
61+ * Props for the {@link UsageIndicator} component.
62+ */
5163interface UsageIndicatorProps {
64+ /**
65+ * Optional click handler. If provided, overrides the default behavior
66+ * of opening the settings modal to the subscription tab.
67+ */
5268 onClick ?: ( ) => void
5369}
5470
71+ /**
72+ * Displays a visual usage indicator showing current subscription usage
73+ * with an animated pill bar that responds to hover interactions.
74+ *
75+ * The component shows:
76+ * - Current plan type (Free, Pro, Team, Enterprise)
77+ * - Current usage vs. limit (e.g., $7.00 / $10.00)
78+ * - Visual pill bar representing usage percentage
79+ * - Upgrade button for free plans or when blocked
80+ *
81+ * @param props - Component props
82+ * @returns A usage indicator component with responsive pill visualization
83+ */
5584export function UsageIndicator ( { onClick } : UsageIndicatorProps ) {
5685 const { data : subscriptionData , isLoading } = useSubscriptionData ( )
5786 const sidebarWidth = useSidebarStore ( ( state ) => state . sidebarWidth )
5887
5988 /**
60- * Calculate pill count based on sidebar width (6-8 pills dynamically)
61- * This provides responsive feedback as the sidebar width changes
89+ * Calculate pill count based on sidebar width (6-8 pills dynamically).
90+ * This provides responsive feedback as the sidebar width changes.
6291 */
6392 const pillCount = useMemo ( ( ) => {
6493 const widthDelta = sidebarWidth - MIN_SIDEBAR_WIDTH
@@ -82,54 +111,56 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
82111
83112 const billingStatus = getBillingStatus ( subscriptionData ?. data )
84113 const isBlocked = billingStatus === 'blocked'
85- const showUpgradeButton = planType === 'free' || isBlocked
114+ const showUpgradeButton =
115+ ( planType === 'free' || isBlocked || progressPercentage >= 80 ) && planType !== 'enterprise'
86116
87117 /**
88- * Calculate which pills should be filled based on usage percentage
89- * Uses shared Math.ceil heuristic but with dynamic pill count (6-8)
90- * This ensures consistent calculation logic while maintaining responsive pill count
118+ * Calculate which pills should be filled based on usage percentage.
119+ * Uses Math.ceil heuristic with dynamic pill count (6-8).
120+ * This ensures consistent calculation logic while maintaining responsive pill count.
91121 */
92122 const filledPillsCount = Math . ceil ( ( progressPercentage / 100 ) * pillCount )
93123 const isAlmostOut = filledPillsCount === pillCount
94124
95125 const [ isHovered , setIsHovered ] = useState ( false )
96126 const [ wavePosition , setWavePosition ] = useState < number | null > ( null )
97- const [ hasWrapped , setHasWrapped ] = useState ( false )
98127
99128 const startAnimationIndex = pillCount === 0 ? 0 : Math . min ( filledPillsCount , pillCount - 1 )
100129
101130 useEffect ( ( ) => {
102- if ( ! isHovered || pillCount <= 0 ) {
131+ const isFreePlan = subscription . isFree
132+
133+ if ( ! isHovered || pillCount <= 0 || ! isFreePlan ) {
103134 setWavePosition ( null )
104- setHasWrapped ( false )
105135 return
106136 }
107137
108- const totalSpan = pillCount
109- let wrapped = false
110- setHasWrapped ( false )
138+ /**
139+ * Maximum distance (in pill units) the wave should travel from
140+ * {@link startAnimationIndex} to the end of the row. The wave stops
141+ * once it reaches the final pill and does not wrap.
142+ */
143+ const maxDistance = pillCount <= 0 ? 0 : Math . max ( 0 , pillCount - startAnimationIndex )
144+
111145 setWavePosition ( 0 )
112146
113147 const interval = window . setInterval ( ( ) => {
114148 setWavePosition ( ( prev ) => {
115149 const current = prev ?? 0
116- const next = current + PILL_STEP_PER_TICK
117150
118- // Mark as wrapped after first complete cycle
119- if ( next >= totalSpan && ! wrapped ) {
120- wrapped = true
121- setHasWrapped ( true )
151+ if ( current >= maxDistance ) {
152+ return current
122153 }
123154
124- // Return continuous value, never reset (seamless loop)
125- return next
155+ const next = current + PILL_STEP_PER_TICK
156+ return next >= maxDistance ? maxDistance : next
126157 } )
127158 } , PILL_ANIMATION_TICK_MS )
128159
129160 return ( ) => {
130161 window . clearInterval ( interval )
131162 }
132- } , [ isHovered , pillCount , startAnimationIndex ] )
163+ } , [ isHovered , pillCount , startAnimationIndex , subscription . isFree ] )
133164
134165 if ( isLoading ) {
135166 return (
@@ -225,58 +256,33 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
225256 let backgroundColor = baseColor
226257 let backgroundImage : string | undefined
227258
228- if ( isHovered && wavePosition !== null && pillCount > 0 ) {
229- const totalSpan = pillCount
259+ if ( isHovered && wavePosition !== null && pillCount > 0 && subscription . isFree ) {
230260 const grayColor = '#414141'
231261 const activeColor = isAlmostOut ? '#ef4444' : '#34B5FF'
232262
233- if ( ! hasWrapped ) {
234- // First pass: respect original fill state, start from startAnimationIndex
235- const headIndex = Math . floor ( wavePosition )
236- const progress = wavePosition - headIndex
237-
238- const pillOffsetFromStart =
239- i >= startAnimationIndex
240- ? i - startAnimationIndex
241- : totalSpan - startAnimationIndex + i
242-
243- if ( pillOffsetFromStart < headIndex ) {
244- backgroundColor = baseColor
245- backgroundImage = `linear-gradient(to right, ${ activeColor } 0%, ${ activeColor } 100%)`
246- } else if ( pillOffsetFromStart === headIndex ) {
247- const fillPercent = Math . max ( 0 , Math . min ( 1 , progress ) ) * 100
248- backgroundColor = baseColor
249- backgroundImage = `linear-gradient(to right, ${ activeColor } 0%, ${ activeColor } ${ fillPercent } %, ${ baseColor } ${ fillPercent } %, ${ baseColor } 100%)`
250- }
263+ /**
264+ * Single-pass wave: travel from {@link startAnimationIndex} to the end
265+ * of the row without wrapping. Previously highlighted pills remain
266+ * filled; the wave only affects pills at or after the start index.
267+ */
268+ const headIndex = Math . floor ( wavePosition )
269+ const progress = wavePosition - headIndex
270+
271+ const pillOffsetFromStart = i - startAnimationIndex
272+
273+ if ( pillOffsetFromStart < 0 ) {
274+ // Before the wave start; keep original baseColor.
275+ } else if ( pillOffsetFromStart < headIndex ) {
276+ backgroundColor = isFilled ? baseColor : grayColor
277+ backgroundImage = `linear-gradient(to right, ${ activeColor } 0%, ${ activeColor } 100%)`
278+ } else if ( pillOffsetFromStart === headIndex ) {
279+ const fillPercent = Math . max ( 0 , Math . min ( 1 , progress ) ) * 100
280+ backgroundColor = isFilled ? baseColor : grayColor
281+ backgroundImage = `linear-gradient(to right, ${ activeColor } 0%, ${ activeColor } ${ fillPercent } %, ${
282+ isFilled ? baseColor : grayColor
283+ } ${ fillPercent } %, ${ isFilled ? baseColor : grayColor } 100%)`
251284 } else {
252- // Subsequent passes: render wave at BOTH current and next-cycle positions for seamless wrap
253- const wrappedPosition = wavePosition % totalSpan
254- const currentHead = Math . floor ( wrappedPosition )
255- const progress = wrappedPosition - currentHead
256-
257- // Primary wave position
258- const primaryFilled = i < currentHead
259- const primaryActive = i === currentHead
260-
261- // Secondary wave position (one full cycle ahead, wraps to beginning)
262- const secondaryHead = Math . floor ( wavePosition + totalSpan ) % totalSpan
263- const secondaryProgress =
264- wavePosition + totalSpan - Math . floor ( wavePosition + totalSpan )
265- const secondaryFilled = i < secondaryHead
266- const secondaryActive = i === secondaryHead
267-
268- // Render: pill is filled if either wave position has filled it
269- if ( primaryFilled || secondaryFilled ) {
270- backgroundColor = grayColor
271- backgroundImage = `linear-gradient(to right, ${ activeColor } 0%, ${ activeColor } 100%)`
272- } else if ( primaryActive || secondaryActive ) {
273- const activeProgress = primaryActive ? progress : secondaryProgress
274- const fillPercent = Math . max ( 0 , Math . min ( 1 , activeProgress ) ) * 100
275- backgroundColor = grayColor
276- backgroundImage = `linear-gradient(to right, ${ activeColor } 0%, ${ activeColor } ${ fillPercent } %, ${ grayColor } ${ fillPercent } %, ${ grayColor } 100%)`
277- } else {
278- backgroundColor = grayColor
279- }
285+ backgroundColor = isFilled ? baseColor : grayColor
280286 }
281287 }
282288
0 commit comments