From 437865607d4706f421b8cba2fcb4bef3c0098172 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 24 Apr 2026 17:31:53 -0700 Subject: [PATCH 1/2] show local freebuff model availability --- .../components/freebuff-model-selector.tsx | 51 +++--- common/src/__tests__/freebuff-models.test.ts | 50 ++++++ common/src/constants/freebuff-models.ts | 165 +++++++++++++++++- 3 files changed, 238 insertions(+), 28 deletions(-) create mode 100644 common/src/__tests__/freebuff-models.test.ts diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx index 0850a0bd73..b6e46faef0 100644 --- a/cli/src/components/freebuff-model-selector.tsx +++ b/cli/src/components/freebuff-model-selector.tsx @@ -5,9 +5,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Button } from './button' import { FALLBACK_FREEBUFF_MODEL_ID, - FREEBUFF_DEPLOYMENT_HOURS_LABEL, FREEBUFF_GLM_MODEL_ID, FREEBUFF_MODELS, + getFreebuffDeploymentAvailabilityLabel, isFreebuffModelAvailable, } from '@codebuff/common/constants/freebuff-models' @@ -48,6 +48,10 @@ export const FreebuffModelSelector: React.FC = () => { const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel) const session = useFreebuffSessionStore((s) => s.session) const now = useNow(60_000) + const deploymentAvailabilityLabel = useMemo( + () => getFreebuffDeploymentAvailabilityLabel(new Date(now)), + [now], + ) const [pending, setPending] = useState(null) const [hoveredId, setHoveredId] = useState(null) // Keyboard cursor — separate from the actually-selected model so that @@ -96,7 +100,7 @@ export const FreebuffModelSelector: React.FC = () => { out[id] = id === session.model ? Math.max(0, session.position - 1) - : depths[id] ?? 0 + : (depths[id] ?? 0) } return out } @@ -127,7 +131,7 @@ export const FreebuffModelSelector: React.FC = () => { 3 /* " · " */ + model.tagline.length + (model.availability === 'deployment_hours' - ? 3 + FREEBUFF_DEPLOYMENT_HOURS_LABEL.length + ? 3 + deploymentAvailabilityLabel.length : 0) + 2 /* " " */ + hintWidth @@ -135,13 +139,12 @@ export const FreebuffModelSelector: React.FC = () => { }, 0) // Leave a small margin for the surrounding padding on the waiting-room screen. return total > terminalWidth - 4 - }, [hintWidth, terminalWidth]) + }, [deploymentAvailabilityLabel, hintWidth, terminalWidth]) // "Already committed to this model" — only when the server has us queued // on it. On the landing screen (status 'none'), nothing is committed yet, // so picking the focused model is always a real action (first join). - const committedModelId = - session?.status === 'queued' ? session.model : null + const committedModelId = session?.status === 'queued' ? session.model : null const pick = useCallback( (modelId: string) => { @@ -166,7 +169,8 @@ export const FreebuffModelSelector: React.FC = () => { name === 'right' || name === 'down' || (name === 'tab' && !key.shift) const isBackward = name === 'left' || name === 'up' || (name === 'tab' && key.shift) - const isCommit = name === 'return' || name === 'enter' || name === 'space' + const isCommit = + name === 'return' || name === 'enter' || name === 'space' if (!isForward && !isBackward && !isCommit) return if (isCommit) { if ( @@ -222,19 +226,20 @@ export const FreebuffModelSelector: React.FC = () => { const isAvailable = isFreebuffModelAvailable(model.id, new Date(now)) const indicator = isSelected ? '●' : '○' const indicatorColor = isSelected ? theme.primary : theme.muted - const labelColor = isSelected && isAvailable ? theme.foreground : theme.muted + const labelColor = + isSelected && isAvailable ? theme.foreground : theme.muted // Clickable whenever picking would actually do something — i.e. // anything except re-picking the queue we're already in. - const interactable = !pending && isAvailable && model.id !== committedModelId + const interactable = + !pending && isAvailable && model.id !== committedModelId const ahead = aheadByModel?.[model.id] - const hint = - !isAvailable - ? 'Closed' - : ahead === undefined - ? '' - : ahead === 0 - ? 'No wait' - : `${ahead} ahead` + const hint = !isAvailable + ? 'Closed' + : ahead === undefined + ? '' + : ahead === 0 + ? 'No wait' + : `${ahead} ahead` const borderColor = isSelected ? theme.primary @@ -250,7 +255,9 @@ export const FreebuffModelSelector: React.FC = () => { if (isAvailable) pick(model.id) }} onMouseOver={() => interactable && setHoveredId(model.id)} - onMouseOut={() => setHoveredId((curr) => (curr === model.id ? null : curr))} + onMouseOut={() => + setHoveredId((curr) => (curr === model.id ? null : curr)) + } style={{ borderStyle: 'single', borderColor, @@ -263,15 +270,17 @@ export const FreebuffModelSelector: React.FC = () => { {indicator} {model.displayName} · {model.tagline} {model.availability === 'deployment_hours' && ( - · {FREEBUFF_DEPLOYMENT_HOURS_LABEL} + · {deploymentAvailabilityLabel} )} - {hint.padEnd(hintWidth)} + {hint.padEnd(hintWidth)} ) diff --git a/common/src/__tests__/freebuff-models.test.ts b/common/src/__tests__/freebuff-models.test.ts new file mode 100644 index 0000000000..c4ff0bb3e9 --- /dev/null +++ b/common/src/__tests__/freebuff-models.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' + +import { + getFreebuffDeploymentAvailabilityLabel, + isFreebuffDeploymentHours, +} from '../constants/freebuff-models' + +describe('freebuff model availability', () => { + test('formats the close time in the user local timezone while deployment is open', () => { + expect( + getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-05T18:00:00Z'), { + locale: 'en-US', + timeZone: 'America/Los_Angeles', + }), + ).toBe('until 5:00 PM local') + }) + + test('formats the next open time in the user local timezone while deployment is closed', () => { + expect( + getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-05T12:00:00Z'), { + locale: 'en-US', + timeZone: 'America/Los_Angeles', + }), + ).toBe('opens 6:00 AM local') + }) + + test('includes the weekday when the next opening is on a later local day', () => { + expect( + getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-10T20:00:00Z'), { + locale: 'en-US', + timeZone: 'America/Los_Angeles', + }), + ).toBe('opens Mon 6:00 AM local') + }) + + test('tracks deployment hours correctly across the open and close boundaries', () => { + expect(isFreebuffDeploymentHours(new Date('2026-01-05T13:59:00Z'))).toBe( + false, + ) + expect(isFreebuffDeploymentHours(new Date('2026-01-05T14:00:00Z'))).toBe( + true, + ) + expect(isFreebuffDeploymentHours(new Date('2026-01-06T00:59:00Z'))).toBe( + true, + ) + expect(isFreebuffDeploymentHours(new Date('2026-01-06T01:00:00Z'))).toBe( + false, + ) + }) +}) diff --git a/common/src/constants/freebuff-models.ts b/common/src/constants/freebuff-models.ts index 2e1ef8d8ea..f3590689f1 100644 --- a/common/src/constants/freebuff-models.ts +++ b/common/src/constants/freebuff-models.ts @@ -20,6 +20,23 @@ export interface FreebuffModelOption { export const FREEBUFF_DEPLOYMENT_HOURS_LABEL = '9am ET-5pm PT' export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1' export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7' +const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York' +const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles' + +interface ZonedDateParts { + year: number + month: number + day: number + weekday: string + hour: number + minute: number + minutes: number +} + +interface LocalTimeFormatOptions { + locale?: string + timeZone?: string +} export const FREEBUFF_MODELS = [ { @@ -71,29 +88,163 @@ export function getFreebuffModel(id: string): FreebuffModelOption { ) } -function getZonedParts( - date: Date, - timeZone: string, -): { weekday: string; minutes: number } { +function getZonedParts(date: Date, timeZone: string): ZonedDateParts { const parts = new Intl.DateTimeFormat('en-US', { timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).formatToParts(date) - const value = (type: string) => parts.find((part) => part.type === type)?.value + const value = (type: string) => + parts.find((part) => part.type === type)?.value + const year = Number(value('year') ?? 0) + const month = Number(value('month') ?? 1) + const day = Number(value('day') ?? 1) const hour = Number(value('hour') ?? 0) const minute = Number(value('minute') ?? 0) return { + year, + month, + day, weekday: value('weekday') ?? '', + hour, + minute, minutes: hour * 60 + minute, } } +function addDaysToYmd( + year: number, + month: number, + day: number, + days: number, +): Pick { + const next = new Date(Date.UTC(year, month - 1, day)) + next.setUTCDate(next.getUTCDate() + days) + return { + year: next.getUTCFullYear(), + month: next.getUTCMonth() + 1, + day: next.getUTCDate(), + } +} + +function getUtcForZonedTime( + parts: Pick, + timeZone: string, + hour: number, + minute: number, +): Date { + let guess = new Date( + Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute), + ) + + for (let i = 0; i < 3; i++) { + const actual = getZonedParts(guess, timeZone) + const desiredUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + hour, + minute, + ) + const actualUtc = Date.UTC( + actual.year, + actual.month - 1, + actual.day, + actual.hour, + actual.minute, + ) + guess = new Date(guess.getTime() + (desiredUtc - actualUtc)) + } + + return guess +} + +function isWeekend( + parts: Pick, +): boolean { + const weekday = new Date( + Date.UTC(parts.year, parts.month - 1, parts.day), + ).getUTCDay() + return weekday === 0 || weekday === 6 +} + +function getNextFreebuffDeploymentStart(now: Date): Date { + const easternNow = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE) + + for (let offset = 0; offset < 8; offset++) { + const day = addDaysToYmd( + easternNow.year, + easternNow.month, + easternNow.day, + offset, + ) + if (isWeekend(day)) continue + const candidate = getUtcForZonedTime(day, FREEBUFF_EASTERN_TIMEZONE, 9, 0) + if (candidate.getTime() > now.getTime()) return candidate + } + + return getUtcForZonedTime( + addDaysToYmd(easternNow.year, easternNow.month, easternNow.day, 8), + FREEBUFF_EASTERN_TIMEZONE, + 9, + 0, + ) +} + +function getCurrentFreebuffDeploymentEnd(now: Date): Date { + const pacificNow = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE) + return getUtcForZonedTime(pacificNow, FREEBUFF_PACIFIC_TIMEZONE, 17, 0) +} + +function isSameLocalDay(left: Date, right: Date, timeZone?: string): boolean { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + return formatter.format(left) === formatter.format(right) +} + +function formatLocalTime( + date: Date, + referenceNow: Date, + options: LocalTimeFormatOptions = {}, +): string { + const shouldShowWeekday = !isSameLocalDay( + date, + referenceNow, + options.timeZone, + ) + return new Intl.DateTimeFormat(options.locale, { + timeZone: options.timeZone, + weekday: shouldShowWeekday ? 'short' : undefined, + hour: 'numeric', + minute: '2-digit', + }).format(date) +} + +export function getFreebuffDeploymentAvailabilityLabel( + now: Date = new Date(), + options: LocalTimeFormatOptions = {}, +): string { + if (isFreebuffDeploymentHours(now)) { + const closesAt = getCurrentFreebuffDeploymentEnd(now) + return `until ${formatLocalTime(closesAt, now, options)} local` + } + + const opensAt = getNextFreebuffDeploymentStart(now) + return `opens ${formatLocalTime(opensAt, now, options)} local` +} + export function isFreebuffDeploymentHours(now: Date = new Date()): boolean { - const eastern = getZonedParts(now, 'America/New_York') - const pacific = getZonedParts(now, 'America/Los_Angeles') + const eastern = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE) + const pacific = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE) if (eastern.weekday === 'Sat' || eastern.weekday === 'Sun') return false return eastern.minutes >= 9 * 60 && pacific.minutes < 17 * 60 } From c703c2ac87de16b6692db333f625668f8f4f1f3f Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 24 Apr 2026 22:17:16 -0700 Subject: [PATCH 2/2] simplify freebuff deployment time helpers --- common/src/constants/freebuff-models.ts | 45 +++++++++++++++---------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/common/src/constants/freebuff-models.ts b/common/src/constants/freebuff-models.ts index f3590689f1..a4ddd6f412 100644 --- a/common/src/constants/freebuff-models.ts +++ b/common/src/constants/freebuff-models.ts @@ -17,6 +17,9 @@ export interface FreebuffModelOption { availability: 'always' | 'deployment_hours' } +/** Server-facing fallback copy for APIs and provider errors that can't know + * the caller's local timezone. The CLI should render + * `getFreebuffDeploymentAvailabilityLabel()` instead. */ export const FREEBUFF_DEPLOYMENT_HOURS_LABEL = '9am ET-5pm PT' export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1' export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7' @@ -30,7 +33,6 @@ interface ZonedDateParts { weekday: string hour: number minute: number - minutes: number } interface LocalTimeFormatOptions { @@ -113,7 +115,6 @@ function getZonedParts(date: Date, timeZone: string): ZonedDateParts { weekday: value('weekday') ?? '', hour, minute, - minutes: hour * 60 + minute, } } @@ -167,29 +168,34 @@ function getUtcForZonedTime( function isWeekend( parts: Pick, ): boolean { - const weekday = new Date( - Date.UTC(parts.year, parts.month - 1, parts.day), - ).getUTCDay() + const weekday = getWeekdayIndex(parts) return weekday === 0 || weekday === 6 } +function getWeekdayIndex( + parts: Pick, +): number { + return new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay() +} + function getNextFreebuffDeploymentStart(now: Date): Date { const easternNow = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE) + const weekday = getWeekdayIndex(easternNow) + const isBeforeTodayOpen = easternNow.hour < 9 - for (let offset = 0; offset < 8; offset++) { - const day = addDaysToYmd( - easternNow.year, - easternNow.month, - easternNow.day, - offset, - ) - if (isWeekend(day)) continue - const candidate = getUtcForZonedTime(day, FREEBUFF_EASTERN_TIMEZONE, 9, 0) - if (candidate.getTime() > now.getTime()) return candidate - } + const offset = + weekday === 6 + ? 2 + : weekday === 0 + ? 1 + : isBeforeTodayOpen + ? 0 + : weekday === 5 + ? 3 + : 1 return getUtcForZonedTime( - addDaysToYmd(easternNow.year, easternNow.month, easternNow.day, 8), + addDaysToYmd(easternNow.year, easternNow.month, easternNow.day, offset), FREEBUFF_EASTERN_TIMEZONE, 9, 0, @@ -246,7 +252,10 @@ export function isFreebuffDeploymentHours(now: Date = new Date()): boolean { const eastern = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE) const pacific = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE) if (eastern.weekday === 'Sat' || eastern.weekday === 'Sun') return false - return eastern.minutes >= 9 * 60 && pacific.minutes < 17 * 60 + return ( + eastern.hour * 60 + eastern.minute >= 9 * 60 && + pacific.hour * 60 + pacific.minute < 17 * 60 + ) } export function isFreebuffModelAvailable(