Skip to content

Commit 862d1a9

Browse files
[codex] show local freebuff model availability (#542)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent fc9a76d commit 862d1a9

3 files changed

Lines changed: 249 additions & 30 deletions

File tree

cli/src/components/freebuff-model-selector.tsx

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
55
import { Button } from './button'
66
import {
77
FALLBACK_FREEBUFF_MODEL_ID,
8-
FREEBUFF_DEPLOYMENT_HOURS_LABEL,
98
FREEBUFF_GLM_MODEL_ID,
109
FREEBUFF_MODELS,
10+
getFreebuffDeploymentAvailabilityLabel,
1111
isFreebuffModelAvailable,
1212
} from '@codebuff/common/constants/freebuff-models'
1313

@@ -48,6 +48,10 @@ export const FreebuffModelSelector: React.FC = () => {
4848
const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel)
4949
const session = useFreebuffSessionStore((s) => s.session)
5050
const now = useNow(60_000)
51+
const deploymentAvailabilityLabel = useMemo(
52+
() => getFreebuffDeploymentAvailabilityLabel(new Date(now)),
53+
[now],
54+
)
5155
const [pending, setPending] = useState<string | null>(null)
5256
const [hoveredId, setHoveredId] = useState<string | null>(null)
5357
// Keyboard cursor — separate from the actually-selected model so that
@@ -96,7 +100,7 @@ export const FreebuffModelSelector: React.FC = () => {
96100
out[id] =
97101
id === session.model
98102
? Math.max(0, session.position - 1)
99-
: depths[id] ?? 0
103+
: (depths[id] ?? 0)
100104
}
101105
return out
102106
}
@@ -127,21 +131,20 @@ export const FreebuffModelSelector: React.FC = () => {
127131
3 /* " · " */ +
128132
model.tagline.length +
129133
(model.availability === 'deployment_hours'
130-
? 3 + FREEBUFF_DEPLOYMENT_HOURS_LABEL.length
134+
? 3 + deploymentAvailabilityLabel.length
131135
: 0) +
132136
2 /* " " */ +
133137
hintWidth
134138
return sum + inner + BUTTON_CHROME + (idx > 0 ? GAP : 0)
135139
}, 0)
136140
// Leave a small margin for the surrounding padding on the waiting-room screen.
137141
return total > terminalWidth - 4
138-
}, [hintWidth, terminalWidth])
142+
}, [deploymentAvailabilityLabel, hintWidth, terminalWidth])
139143

140144
// "Already committed to this model" — only when the server has us queued
141145
// on it. On the landing screen (status 'none'), nothing is committed yet,
142146
// so picking the focused model is always a real action (first join).
143-
const committedModelId =
144-
session?.status === 'queued' ? session.model : null
147+
const committedModelId = session?.status === 'queued' ? session.model : null
145148

146149
const pick = useCallback(
147150
(modelId: string) => {
@@ -166,7 +169,8 @@ export const FreebuffModelSelector: React.FC = () => {
166169
name === 'right' || name === 'down' || (name === 'tab' && !key.shift)
167170
const isBackward =
168171
name === 'left' || name === 'up' || (name === 'tab' && key.shift)
169-
const isCommit = name === 'return' || name === 'enter' || name === 'space'
172+
const isCommit =
173+
name === 'return' || name === 'enter' || name === 'space'
170174
if (!isForward && !isBackward && !isCommit) return
171175
if (isCommit) {
172176
if (
@@ -222,19 +226,20 @@ export const FreebuffModelSelector: React.FC = () => {
222226
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
223227
const indicator = isSelected ? '●' : '○'
224228
const indicatorColor = isSelected ? theme.primary : theme.muted
225-
const labelColor = isSelected && isAvailable ? theme.foreground : theme.muted
229+
const labelColor =
230+
isSelected && isAvailable ? theme.foreground : theme.muted
226231
// Clickable whenever picking would actually do something — i.e.
227232
// anything except re-picking the queue we're already in.
228-
const interactable = !pending && isAvailable && model.id !== committedModelId
233+
const interactable =
234+
!pending && isAvailable && model.id !== committedModelId
229235
const ahead = aheadByModel?.[model.id]
230-
const hint =
231-
!isAvailable
232-
? 'Closed'
233-
: ahead === undefined
234-
? ''
235-
: ahead === 0
236-
? 'No wait'
237-
: `${ahead} ahead`
236+
const hint = !isAvailable
237+
? 'Closed'
238+
: ahead === undefined
239+
? ''
240+
: ahead === 0
241+
? 'No wait'
242+
: `${ahead} ahead`
238243

239244
const borderColor = isSelected
240245
? theme.primary
@@ -250,7 +255,9 @@ export const FreebuffModelSelector: React.FC = () => {
250255
if (isAvailable) pick(model.id)
251256
}}
252257
onMouseOver={() => interactable && setHoveredId(model.id)}
253-
onMouseOut={() => setHoveredId((curr) => (curr === model.id ? null : curr))}
258+
onMouseOut={() =>
259+
setHoveredId((curr) => (curr === model.id ? null : curr))
260+
}
254261
style={{
255262
borderStyle: 'single',
256263
borderColor,
@@ -263,15 +270,17 @@ export const FreebuffModelSelector: React.FC = () => {
263270
<span fg={indicatorColor}>{indicator} </span>
264271
<span
265272
fg={labelColor}
266-
attributes={isSelected ? TextAttributes.BOLD : TextAttributes.NONE}
273+
attributes={
274+
isSelected ? TextAttributes.BOLD : TextAttributes.NONE
275+
}
267276
>
268277
{model.displayName}
269278
</span>
270279
<span fg={theme.muted}> · {model.tagline}</span>
271280
{model.availability === 'deployment_hours' && (
272-
<span fg={theme.muted}> · {FREEBUFF_DEPLOYMENT_HOURS_LABEL}</span>
281+
<span fg={theme.muted}> · {deploymentAvailabilityLabel}</span>
273282
)}
274-
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
283+
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
275284
</text>
276285
</Button>
277286
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import {
4+
getFreebuffDeploymentAvailabilityLabel,
5+
isFreebuffDeploymentHours,
6+
} from '../constants/freebuff-models'
7+
8+
describe('freebuff model availability', () => {
9+
test('formats the close time in the user local timezone while deployment is open', () => {
10+
expect(
11+
getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-05T18:00:00Z'), {
12+
locale: 'en-US',
13+
timeZone: 'America/Los_Angeles',
14+
}),
15+
).toBe('until 5:00 PM local')
16+
})
17+
18+
test('formats the next open time in the user local timezone while deployment is closed', () => {
19+
expect(
20+
getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-05T12:00:00Z'), {
21+
locale: 'en-US',
22+
timeZone: 'America/Los_Angeles',
23+
}),
24+
).toBe('opens 6:00 AM local')
25+
})
26+
27+
test('includes the weekday when the next opening is on a later local day', () => {
28+
expect(
29+
getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-10T20:00:00Z'), {
30+
locale: 'en-US',
31+
timeZone: 'America/Los_Angeles',
32+
}),
33+
).toBe('opens Mon 6:00 AM local')
34+
})
35+
36+
test('tracks deployment hours correctly across the open and close boundaries', () => {
37+
expect(isFreebuffDeploymentHours(new Date('2026-01-05T13:59:00Z'))).toBe(
38+
false,
39+
)
40+
expect(isFreebuffDeploymentHours(new Date('2026-01-05T14:00:00Z'))).toBe(
41+
true,
42+
)
43+
expect(isFreebuffDeploymentHours(new Date('2026-01-06T00:59:00Z'))).toBe(
44+
true,
45+
)
46+
expect(isFreebuffDeploymentHours(new Date('2026-01-06T01:00:00Z'))).toBe(
47+
false,
48+
)
49+
})
50+
})

common/src/constants/freebuff-models.ts

Lines changed: 169 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,28 @@ export interface FreebuffModelOption {
1717
availability: 'always' | 'deployment_hours'
1818
}
1919

20+
/** Server-facing fallback copy for APIs and provider errors that can't know
21+
* the caller's local timezone. The CLI should render
22+
* `getFreebuffDeploymentAvailabilityLabel()` instead. */
2023
export const FREEBUFF_DEPLOYMENT_HOURS_LABEL = '9am ET-5pm PT'
2124
export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1'
2225
export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7'
26+
const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York'
27+
const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles'
28+
29+
interface ZonedDateParts {
30+
year: number
31+
month: number
32+
day: number
33+
weekday: string
34+
hour: number
35+
minute: number
36+
}
37+
38+
interface LocalTimeFormatOptions {
39+
locale?: string
40+
timeZone?: string
41+
}
2342

2443
export const FREEBUFF_MODELS = [
2544
{
@@ -71,31 +90,172 @@ export function getFreebuffModel(id: string): FreebuffModelOption {
7190
)
7291
}
7392

74-
function getZonedParts(
75-
date: Date,
76-
timeZone: string,
77-
): { weekday: string; minutes: number } {
93+
function getZonedParts(date: Date, timeZone: string): ZonedDateParts {
7894
const parts = new Intl.DateTimeFormat('en-US', {
7995
timeZone,
96+
year: 'numeric',
97+
month: '2-digit',
98+
day: '2-digit',
8099
weekday: 'short',
81100
hour: '2-digit',
82101
minute: '2-digit',
83102
hourCycle: 'h23',
84103
}).formatToParts(date)
85-
const value = (type: string) => parts.find((part) => part.type === type)?.value
104+
const value = (type: string) =>
105+
parts.find((part) => part.type === type)?.value
106+
const year = Number(value('year') ?? 0)
107+
const month = Number(value('month') ?? 1)
108+
const day = Number(value('day') ?? 1)
86109
const hour = Number(value('hour') ?? 0)
87110
const minute = Number(value('minute') ?? 0)
88111
return {
112+
year,
113+
month,
114+
day,
89115
weekday: value('weekday') ?? '',
90-
minutes: hour * 60 + minute,
116+
hour,
117+
minute,
118+
}
119+
}
120+
121+
function addDaysToYmd(
122+
year: number,
123+
month: number,
124+
day: number,
125+
days: number,
126+
): Pick<ZonedDateParts, 'year' | 'month' | 'day'> {
127+
const next = new Date(Date.UTC(year, month - 1, day))
128+
next.setUTCDate(next.getUTCDate() + days)
129+
return {
130+
year: next.getUTCFullYear(),
131+
month: next.getUTCMonth() + 1,
132+
day: next.getUTCDate(),
133+
}
134+
}
135+
136+
function getUtcForZonedTime(
137+
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
138+
timeZone: string,
139+
hour: number,
140+
minute: number,
141+
): Date {
142+
let guess = new Date(
143+
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute),
144+
)
145+
146+
for (let i = 0; i < 3; i++) {
147+
const actual = getZonedParts(guess, timeZone)
148+
const desiredUtc = Date.UTC(
149+
parts.year,
150+
parts.month - 1,
151+
parts.day,
152+
hour,
153+
minute,
154+
)
155+
const actualUtc = Date.UTC(
156+
actual.year,
157+
actual.month - 1,
158+
actual.day,
159+
actual.hour,
160+
actual.minute,
161+
)
162+
guess = new Date(guess.getTime() + (desiredUtc - actualUtc))
163+
}
164+
165+
return guess
166+
}
167+
168+
function isWeekend(
169+
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
170+
): boolean {
171+
const weekday = getWeekdayIndex(parts)
172+
return weekday === 0 || weekday === 6
173+
}
174+
175+
function getWeekdayIndex(
176+
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
177+
): number {
178+
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay()
179+
}
180+
181+
function getNextFreebuffDeploymentStart(now: Date): Date {
182+
const easternNow = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE)
183+
const weekday = getWeekdayIndex(easternNow)
184+
const isBeforeTodayOpen = easternNow.hour < 9
185+
186+
const offset =
187+
weekday === 6
188+
? 2
189+
: weekday === 0
190+
? 1
191+
: isBeforeTodayOpen
192+
? 0
193+
: weekday === 5
194+
? 3
195+
: 1
196+
197+
return getUtcForZonedTime(
198+
addDaysToYmd(easternNow.year, easternNow.month, easternNow.day, offset),
199+
FREEBUFF_EASTERN_TIMEZONE,
200+
9,
201+
0,
202+
)
203+
}
204+
205+
function getCurrentFreebuffDeploymentEnd(now: Date): Date {
206+
const pacificNow = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE)
207+
return getUtcForZonedTime(pacificNow, FREEBUFF_PACIFIC_TIMEZONE, 17, 0)
208+
}
209+
210+
function isSameLocalDay(left: Date, right: Date, timeZone?: string): boolean {
211+
const formatter = new Intl.DateTimeFormat('en-CA', {
212+
timeZone,
213+
year: 'numeric',
214+
month: '2-digit',
215+
day: '2-digit',
216+
})
217+
return formatter.format(left) === formatter.format(right)
218+
}
219+
220+
function formatLocalTime(
221+
date: Date,
222+
referenceNow: Date,
223+
options: LocalTimeFormatOptions = {},
224+
): string {
225+
const shouldShowWeekday = !isSameLocalDay(
226+
date,
227+
referenceNow,
228+
options.timeZone,
229+
)
230+
return new Intl.DateTimeFormat(options.locale, {
231+
timeZone: options.timeZone,
232+
weekday: shouldShowWeekday ? 'short' : undefined,
233+
hour: 'numeric',
234+
minute: '2-digit',
235+
}).format(date)
236+
}
237+
238+
export function getFreebuffDeploymentAvailabilityLabel(
239+
now: Date = new Date(),
240+
options: LocalTimeFormatOptions = {},
241+
): string {
242+
if (isFreebuffDeploymentHours(now)) {
243+
const closesAt = getCurrentFreebuffDeploymentEnd(now)
244+
return `until ${formatLocalTime(closesAt, now, options)} local`
91245
}
246+
247+
const opensAt = getNextFreebuffDeploymentStart(now)
248+
return `opens ${formatLocalTime(opensAt, now, options)} local`
92249
}
93250

94251
export function isFreebuffDeploymentHours(now: Date = new Date()): boolean {
95-
const eastern = getZonedParts(now, 'America/New_York')
96-
const pacific = getZonedParts(now, 'America/Los_Angeles')
252+
const eastern = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE)
253+
const pacific = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE)
97254
if (eastern.weekday === 'Sat' || eastern.weekday === 'Sun') return false
98-
return eastern.minutes >= 9 * 60 && pacific.minutes < 17 * 60
255+
return (
256+
eastern.hour * 60 + eastern.minute >= 9 * 60 &&
257+
pacific.hour * 60 + pacific.minute < 17 * 60
258+
)
99259
}
100260

101261
export function isFreebuffModelAvailable(

0 commit comments

Comments
 (0)