Skip to content

Commit 9768c42

Browse files
committed
Add Freebuff premium reset countdown
1 parent c6064c3 commit 9768c42

4 files changed

Lines changed: 179 additions & 16 deletions

File tree

cli/src/components/waiting-room-screen.tsx

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { TextAttributes } from '@opentui/core'
22
import { useKeyboard, useRenderer } from '@opentui/react'
3-
import React, { useCallback, useMemo, useState } from 'react'
3+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
44

55
import { Button } from './button'
66
import { ChoiceAdBanner, CHOICE_AD_BANNER_HEIGHT } from './choice-ad-banner'
77
import { FreebuffModelSelector } from './freebuff-model-selector'
88
import { ShimmerText } from './shimmer-text'
9-
import { takeOverFreebuffSession } from '../hooks/use-freebuff-session'
9+
import {
10+
refreshFreebuffLandingMetadata,
11+
takeOverFreebuffSession,
12+
} from '../hooks/use-freebuff-session'
1013
import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit'
1114
import { useGravityAd } from '../hooks/use-gravity-ad'
1215
import { useLogo } from '../hooks/use-logo'
@@ -15,6 +18,10 @@ import { useSheenAnimation } from '../hooks/use-sheen-animation'
1518
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
1619
import { useTheme } from '../hooks/use-theme'
1720
import { exitFreebuffCleanly } from '../utils/freebuff-exit'
21+
import {
22+
formatFreebuffPremiumResetCountdown,
23+
getFreebuffPremiumResetAt,
24+
} from '../utils/freebuff-premium-reset'
1825
import { formatSessionUnits } from '../utils/format-session-units'
1926
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
2027
import { FREEBUFF_PREMIUM_SESSION_LIMIT } from '@codebuff/common/constants/freebuff-models'
@@ -247,30 +254,31 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
247254

248255
const [exitHover, setExitHover] = useState(false)
249256

250-
// Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if
251-
// the user wanders away and comes back.
252-
const queuedAtMs = useMemo(() => {
253-
if (session?.status === 'queued') return Date.parse(session.queuedAt)
254-
return null
255-
}, [session])
256-
const now = useNow(1000, queuedAtMs !== null)
257-
const elapsedMs = queuedAtMs ? now - queuedAtMs : 0
258-
259257
const isQueued = session?.status === 'queued'
260258
// 'none' = user hasn't joined any queue yet. We're in the pre-chat landing
261259
// state: show the picker with live N-in-line hints and a prompt. Picking a
262260
// model triggers joinFreebuffQueue, which POSTs and transitions us to
263261
// 'queued' (waiting room) or straight to 'active' (chat) if no wait.
264262
const isLanding = session?.status === 'none'
263+
// Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if
264+
// the user wanders away and comes back. On the landing picker we tick once a
265+
// minute so the premium reset countdown stays fresh.
266+
const queuedAtMs = useMemo(() => {
267+
if (session?.status === 'queued') return Date.parse(session.queuedAt)
268+
return null
269+
}, [session])
270+
const now = useNow(isQueued ? 1000 : 60_000, isQueued || isLanding)
271+
const elapsedMs = queuedAtMs ? now - queuedAtMs : 0
265272

266273
// Premium quota counter for the title line. All premium models share one
267274
// pool; the server replicates the same snapshot under each premium model
268275
// id, so any entry has the right count. Renders amber when exhausted so
269276
// the limit reads as "you've hit it" rather than just another count.
270277
const rateLimitsByModel = getRateLimitsByModel(session)
271-
const sharedPremiumUsed = rateLimitsByModel
272-
? (Object.values(rateLimitsByModel)[0]?.recentCount ?? 0)
273-
: 0
278+
const premiumRateLimit = rateLimitsByModel
279+
? Object.values(rateLimitsByModel)[0]
280+
: undefined
281+
const sharedPremiumUsed = premiumRateLimit?.recentCount ?? 0
274282
const isPremiumExhausted =
275283
sharedPremiumUsed >= FREEBUFF_PREMIUM_SESSION_LIMIT
276284
const premiumUsedColor = isPremiumExhausted ? theme.secondary : theme.muted
@@ -280,6 +288,26 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
280288
const formattedSharedPremiumUsed = formatSessionUnits(
281289
sharedPremiumUsed,
282290
).padStart(sessionUnitWidth)
291+
const premiumResetAt = getFreebuffPremiumResetAt({
292+
rateLimitsByModel,
293+
nowMs: now,
294+
})
295+
const premiumResetAtMs = premiumResetAt.getTime()
296+
const premiumResetCountdown = formatFreebuffPremiumResetCountdown(
297+
premiumResetAt,
298+
now,
299+
)
300+
301+
useEffect(() => {
302+
if (!isLanding || !premiumRateLimit) return
303+
304+
const delayMs = Math.max(0, premiumResetAtMs - Date.now() + 1_000)
305+
const timer = setTimeout(() => {
306+
refreshFreebuffLandingMetadata().catch(() => {})
307+
}, delayMs)
308+
309+
return () => clearTimeout(timer)
310+
}, [isLanding, premiumRateLimit, premiumResetAtMs])
283311

284312
return (
285313
<box
@@ -366,10 +394,17 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
366394
<span fg={theme.foreground} attributes={TextAttributes.BOLD}>
367395
Pick a model to start
368396
</span>
397+
</text>
398+
<text
399+
style={{ fg: theme.muted, marginBottom: 1, wrapMode: 'word' }}
400+
>
369401
<span fg={premiumUsedColor}>
370-
{' · '}
371402
{formattedSharedPremiumUsed} of{' '}
372-
{FREEBUFF_PREMIUM_SESSION_LIMIT} premium sessions used today
403+
{FREEBUFF_PREMIUM_SESSION_LIMIT} premium sessions used
404+
</span>
405+
<span fg={theme.muted}>
406+
{' · '}
407+
resets in {premiumResetCountdown}
373408
</span>
374409
</text>
375410
<FreebuffModelSelector />

cli/src/hooks/use-freebuff-session.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,13 @@ export function returnToFreebuffLanding(
284284
})
285285
}
286286

287+
/** Refresh picker-only metadata (quota and queue depths) while staying on the
288+
* model selection screen. Used when a midnight-Pacific premium quota reset
289+
* passes while the landing screen is open. */
290+
export function refreshFreebuffLandingMetadata(): Promise<void> {
291+
return restartFreebuffSession('landing')
292+
}
293+
287294
/**
288295
* Join (or re-queue for) `model`. Dual-purpose:
289296
* - First join: called from the pre-chat landing picker. The session starts
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import {
4+
formatFreebuffPremiumResetCountdown,
5+
getFreebuffPremiumResetAt,
6+
} from '../freebuff-premium-reset'
7+
8+
describe('freebuff premium reset helpers', () => {
9+
test('uses server resetAt when it is in the future', () => {
10+
const nowMs = Date.parse('2026-05-11T20:00:00.000Z')
11+
const resetAt = getFreebuffPremiumResetAt({
12+
nowMs,
13+
rateLimitsByModel: {
14+
'test/model': {
15+
model: 'test/model',
16+
limit: 5,
17+
period: 'pacific_day',
18+
resetTimeZone: 'America/Los_Angeles',
19+
resetAt: '2026-05-12T07:00:00.000Z',
20+
windowHours: 24,
21+
recentCount: 2,
22+
},
23+
},
24+
})
25+
26+
expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z')
27+
})
28+
29+
test('falls back to next midnight Pacific when resetAt is absent', () => {
30+
const resetAt = getFreebuffPremiumResetAt({
31+
nowMs: Date.parse('2026-05-11T20:00:00.000Z'),
32+
})
33+
34+
expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z')
35+
})
36+
37+
test('keeps expired server resetAt instead of rolling stale quota forward', () => {
38+
const nowMs = Date.parse('2026-05-12T07:05:00.000Z')
39+
const resetAt = getFreebuffPremiumResetAt({
40+
nowMs,
41+
rateLimitsByModel: {
42+
'test/model': {
43+
model: 'test/model',
44+
limit: 5,
45+
period: 'pacific_day',
46+
resetTimeZone: 'America/Los_Angeles',
47+
resetAt: '2026-05-12T07:00:00.000Z',
48+
windowHours: 24,
49+
recentCount: 5,
50+
},
51+
},
52+
})
53+
54+
expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z')
55+
expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('now')
56+
})
57+
58+
test('handles Pacific daylight saving time boundaries', () => {
59+
const resetAt = getFreebuffPremiumResetAt({
60+
nowMs: Date.parse('2026-01-15T20:00:00.000Z'),
61+
})
62+
63+
expect(resetAt.toISOString()).toBe('2026-01-16T08:00:00.000Z')
64+
})
65+
66+
test('formats hours and minutes left', () => {
67+
const nowMs = Date.parse('2026-05-11T20:00:00.000Z')
68+
const resetAt = new Date('2026-05-12T07:30:00.000Z')
69+
70+
expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('11h 30m')
71+
})
72+
73+
test('formats sub-hour reset countdowns', () => {
74+
const nowMs = Date.parse('2026-05-12T06:30:00.000Z')
75+
const resetAt = new Date('2026-05-12T07:00:00.000Z')
76+
77+
expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('30m')
78+
})
79+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE } from '@codebuff/common/constants/freebuff-models'
2+
import { getZonedDayBounds } from '@codebuff/common/util/zoned-time'
3+
4+
import type { FreebuffSessionRateLimitByModel } from '@codebuff/common/types/freebuff-session'
5+
6+
export function getFreebuffPremiumResetAt(params: {
7+
rateLimitsByModel?: FreebuffSessionRateLimitByModel
8+
nowMs: number
9+
}): Date {
10+
const { rateLimitsByModel, nowMs } = params
11+
const serverResetAt = rateLimitsByModel
12+
? Object.values(rateLimitsByModel)[0]?.resetAt
13+
: undefined
14+
const parsedServerResetAt = serverResetAt ? new Date(serverResetAt) : null
15+
16+
if (
17+
parsedServerResetAt &&
18+
Number.isFinite(parsedServerResetAt.getTime())
19+
) {
20+
return parsedServerResetAt
21+
}
22+
23+
return getZonedDayBounds(
24+
new Date(nowMs),
25+
FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE,
26+
).resetsAt
27+
}
28+
29+
export function formatFreebuffPremiumResetCountdown(
30+
resetAt: Date,
31+
nowMs: number,
32+
): string {
33+
const diffMs = resetAt.getTime() - nowMs
34+
if (!Number.isFinite(diffMs) || diffMs <= 0) return 'now'
35+
36+
const totalMinutes = Math.max(1, Math.floor(diffMs / 60_000))
37+
const hours = Math.floor(totalMinutes / 60)
38+
const minutes = totalMinutes % 60
39+
40+
if (hours === 0) return `${minutes}m`
41+
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
42+
}

0 commit comments

Comments
 (0)