From 9768c425681d76b92036f6208489546bb33a434f Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 11 May 2026 21:48:45 -0700 Subject: [PATCH] Add Freebuff premium reset countdown --- cli/src/components/waiting-room-screen.tsx | 67 ++++++++++++---- cli/src/hooks/use-freebuff-session.ts | 7 ++ .../__tests__/freebuff-premium-reset.test.ts | 79 +++++++++++++++++++ cli/src/utils/freebuff-premium-reset.ts | 42 ++++++++++ 4 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 cli/src/utils/__tests__/freebuff-premium-reset.test.ts create mode 100644 cli/src/utils/freebuff-premium-reset.ts diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index 87874a4cc..455da1b2a 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -1,12 +1,15 @@ import { TextAttributes } from '@opentui/core' import { useKeyboard, useRenderer } from '@opentui/react' -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Button } from './button' import { ChoiceAdBanner, CHOICE_AD_BANNER_HEIGHT } from './choice-ad-banner' import { FreebuffModelSelector } from './freebuff-model-selector' import { ShimmerText } from './shimmer-text' -import { takeOverFreebuffSession } from '../hooks/use-freebuff-session' +import { + refreshFreebuffLandingMetadata, + takeOverFreebuffSession, +} from '../hooks/use-freebuff-session' import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit' import { useGravityAd } from '../hooks/use-gravity-ad' import { useLogo } from '../hooks/use-logo' @@ -15,6 +18,10 @@ import { useSheenAnimation } from '../hooks/use-sheen-animation' import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' import { useTheme } from '../hooks/use-theme' import { exitFreebuffCleanly } from '../utils/freebuff-exit' +import { + formatFreebuffPremiumResetCountdown, + getFreebuffPremiumResetAt, +} from '../utils/freebuff-premium-reset' import { formatSessionUnits } from '../utils/format-session-units' import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system' import { FREEBUFF_PREMIUM_SESSION_LIMIT } from '@codebuff/common/constants/freebuff-models' @@ -247,30 +254,31 @@ export const WaitingRoomScreen: React.FC = ({ const [exitHover, setExitHover] = useState(false) - // Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if - // the user wanders away and comes back. - const queuedAtMs = useMemo(() => { - if (session?.status === 'queued') return Date.parse(session.queuedAt) - return null - }, [session]) - const now = useNow(1000, queuedAtMs !== null) - const elapsedMs = queuedAtMs ? now - queuedAtMs : 0 - const isQueued = session?.status === 'queued' // 'none' = user hasn't joined any queue yet. We're in the pre-chat landing // state: show the picker with live N-in-line hints and a prompt. Picking a // model triggers joinFreebuffQueue, which POSTs and transitions us to // 'queued' (waiting room) or straight to 'active' (chat) if no wait. const isLanding = session?.status === 'none' + // Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if + // the user wanders away and comes back. On the landing picker we tick once a + // minute so the premium reset countdown stays fresh. + const queuedAtMs = useMemo(() => { + if (session?.status === 'queued') return Date.parse(session.queuedAt) + return null + }, [session]) + const now = useNow(isQueued ? 1000 : 60_000, isQueued || isLanding) + const elapsedMs = queuedAtMs ? now - queuedAtMs : 0 // Premium quota counter for the title line. All premium models share one // pool; the server replicates the same snapshot under each premium model // id, so any entry has the right count. Renders amber when exhausted so // the limit reads as "you've hit it" rather than just another count. const rateLimitsByModel = getRateLimitsByModel(session) - const sharedPremiumUsed = rateLimitsByModel - ? (Object.values(rateLimitsByModel)[0]?.recentCount ?? 0) - : 0 + const premiumRateLimit = rateLimitsByModel + ? Object.values(rateLimitsByModel)[0] + : undefined + const sharedPremiumUsed = premiumRateLimit?.recentCount ?? 0 const isPremiumExhausted = sharedPremiumUsed >= FREEBUFF_PREMIUM_SESSION_LIMIT const premiumUsedColor = isPremiumExhausted ? theme.secondary : theme.muted @@ -280,6 +288,26 @@ export const WaitingRoomScreen: React.FC = ({ const formattedSharedPremiumUsed = formatSessionUnits( sharedPremiumUsed, ).padStart(sessionUnitWidth) + const premiumResetAt = getFreebuffPremiumResetAt({ + rateLimitsByModel, + nowMs: now, + }) + const premiumResetAtMs = premiumResetAt.getTime() + const premiumResetCountdown = formatFreebuffPremiumResetCountdown( + premiumResetAt, + now, + ) + + useEffect(() => { + if (!isLanding || !premiumRateLimit) return + + const delayMs = Math.max(0, premiumResetAtMs - Date.now() + 1_000) + const timer = setTimeout(() => { + refreshFreebuffLandingMetadata().catch(() => {}) + }, delayMs) + + return () => clearTimeout(timer) + }, [isLanding, premiumRateLimit, premiumResetAtMs]) return ( = ({ Pick a model to start + + - {' · '} {formattedSharedPremiumUsed} of{' '} - {FREEBUFF_PREMIUM_SESSION_LIMIT} premium sessions used today + {FREEBUFF_PREMIUM_SESSION_LIMIT} premium sessions used + + + {' · '} + resets in {premiumResetCountdown} diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts index 3211acb7a..3de3e9256 100644 --- a/cli/src/hooks/use-freebuff-session.ts +++ b/cli/src/hooks/use-freebuff-session.ts @@ -284,6 +284,13 @@ export function returnToFreebuffLanding( }) } +/** Refresh picker-only metadata (quota and queue depths) while staying on the + * model selection screen. Used when a midnight-Pacific premium quota reset + * passes while the landing screen is open. */ +export function refreshFreebuffLandingMetadata(): Promise { + return restartFreebuffSession('landing') +} + /** * Join (or re-queue for) `model`. Dual-purpose: * - First join: called from the pre-chat landing picker. The session starts diff --git a/cli/src/utils/__tests__/freebuff-premium-reset.test.ts b/cli/src/utils/__tests__/freebuff-premium-reset.test.ts new file mode 100644 index 000000000..d69021bfc --- /dev/null +++ b/cli/src/utils/__tests__/freebuff-premium-reset.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' + +import { + formatFreebuffPremiumResetCountdown, + getFreebuffPremiumResetAt, +} from '../freebuff-premium-reset' + +describe('freebuff premium reset helpers', () => { + test('uses server resetAt when it is in the future', () => { + const nowMs = Date.parse('2026-05-11T20:00:00.000Z') + const resetAt = getFreebuffPremiumResetAt({ + nowMs, + rateLimitsByModel: { + 'test/model': { + model: 'test/model', + limit: 5, + period: 'pacific_day', + resetTimeZone: 'America/Los_Angeles', + resetAt: '2026-05-12T07:00:00.000Z', + windowHours: 24, + recentCount: 2, + }, + }, + }) + + expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z') + }) + + test('falls back to next midnight Pacific when resetAt is absent', () => { + const resetAt = getFreebuffPremiumResetAt({ + nowMs: Date.parse('2026-05-11T20:00:00.000Z'), + }) + + expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z') + }) + + test('keeps expired server resetAt instead of rolling stale quota forward', () => { + const nowMs = Date.parse('2026-05-12T07:05:00.000Z') + const resetAt = getFreebuffPremiumResetAt({ + nowMs, + rateLimitsByModel: { + 'test/model': { + model: 'test/model', + limit: 5, + period: 'pacific_day', + resetTimeZone: 'America/Los_Angeles', + resetAt: '2026-05-12T07:00:00.000Z', + windowHours: 24, + recentCount: 5, + }, + }, + }) + + expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z') + expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('now') + }) + + test('handles Pacific daylight saving time boundaries', () => { + const resetAt = getFreebuffPremiumResetAt({ + nowMs: Date.parse('2026-01-15T20:00:00.000Z'), + }) + + expect(resetAt.toISOString()).toBe('2026-01-16T08:00:00.000Z') + }) + + test('formats hours and minutes left', () => { + const nowMs = Date.parse('2026-05-11T20:00:00.000Z') + const resetAt = new Date('2026-05-12T07:30:00.000Z') + + expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('11h 30m') + }) + + test('formats sub-hour reset countdowns', () => { + const nowMs = Date.parse('2026-05-12T06:30:00.000Z') + const resetAt = new Date('2026-05-12T07:00:00.000Z') + + expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('30m') + }) +}) diff --git a/cli/src/utils/freebuff-premium-reset.ts b/cli/src/utils/freebuff-premium-reset.ts new file mode 100644 index 000000000..efbcb2ec1 --- /dev/null +++ b/cli/src/utils/freebuff-premium-reset.ts @@ -0,0 +1,42 @@ +import { FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE } from '@codebuff/common/constants/freebuff-models' +import { getZonedDayBounds } from '@codebuff/common/util/zoned-time' + +import type { FreebuffSessionRateLimitByModel } from '@codebuff/common/types/freebuff-session' + +export function getFreebuffPremiumResetAt(params: { + rateLimitsByModel?: FreebuffSessionRateLimitByModel + nowMs: number +}): Date { + const { rateLimitsByModel, nowMs } = params + const serverResetAt = rateLimitsByModel + ? Object.values(rateLimitsByModel)[0]?.resetAt + : undefined + const parsedServerResetAt = serverResetAt ? new Date(serverResetAt) : null + + if ( + parsedServerResetAt && + Number.isFinite(parsedServerResetAt.getTime()) + ) { + return parsedServerResetAt + } + + return getZonedDayBounds( + new Date(nowMs), + FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE, + ).resetsAt +} + +export function formatFreebuffPremiumResetCountdown( + resetAt: Date, + nowMs: number, +): string { + const diffMs = resetAt.getTime() - nowMs + if (!Number.isFinite(diffMs) || diffMs <= 0) return 'now' + + const totalMinutes = Math.max(1, Math.floor(diffMs / 60_000)) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + + if (hours === 0) return `${minutes}m` + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m` +}