Skip to content

Commit 4378656

Browse files
committed
show local freebuff model availability
1 parent 3276d9e commit 4378656

3 files changed

Lines changed: 238 additions & 28 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: 158 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ export interface FreebuffModelOption {
2020
export const FREEBUFF_DEPLOYMENT_HOURS_LABEL = '9am ET-5pm PT'
2121
export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1'
2222
export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7'
23+
const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York'
24+
const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles'
25+
26+
interface ZonedDateParts {
27+
year: number
28+
month: number
29+
day: number
30+
weekday: string
31+
hour: number
32+
minute: number
33+
minutes: number
34+
}
35+
36+
interface LocalTimeFormatOptions {
37+
locale?: string
38+
timeZone?: string
39+
}
2340

2441
export const FREEBUFF_MODELS = [
2542
{
@@ -71,29 +88,163 @@ export function getFreebuffModel(id: string): FreebuffModelOption {
7188
)
7289
}
7390

74-
function getZonedParts(
75-
date: Date,
76-
timeZone: string,
77-
): { weekday: string; minutes: number } {
91+
function getZonedParts(date: Date, timeZone: string): ZonedDateParts {
7892
const parts = new Intl.DateTimeFormat('en-US', {
7993
timeZone,
94+
year: 'numeric',
95+
month: '2-digit',
96+
day: '2-digit',
8097
weekday: 'short',
8198
hour: '2-digit',
8299
minute: '2-digit',
83100
hourCycle: 'h23',
84101
}).formatToParts(date)
85-
const value = (type: string) => parts.find((part) => part.type === type)?.value
102+
const value = (type: string) =>
103+
parts.find((part) => part.type === type)?.value
104+
const year = Number(value('year') ?? 0)
105+
const month = Number(value('month') ?? 1)
106+
const day = Number(value('day') ?? 1)
86107
const hour = Number(value('hour') ?? 0)
87108
const minute = Number(value('minute') ?? 0)
88109
return {
110+
year,
111+
month,
112+
day,
89113
weekday: value('weekday') ?? '',
114+
hour,
115+
minute,
90116
minutes: hour * 60 + minute,
91117
}
92118
}
93119

120+
function addDaysToYmd(
121+
year: number,
122+
month: number,
123+
day: number,
124+
days: number,
125+
): Pick<ZonedDateParts, 'year' | 'month' | 'day'> {
126+
const next = new Date(Date.UTC(year, month - 1, day))
127+
next.setUTCDate(next.getUTCDate() + days)
128+
return {
129+
year: next.getUTCFullYear(),
130+
month: next.getUTCMonth() + 1,
131+
day: next.getUTCDate(),
132+
}
133+
}
134+
135+
function getUtcForZonedTime(
136+
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
137+
timeZone: string,
138+
hour: number,
139+
minute: number,
140+
): Date {
141+
let guess = new Date(
142+
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute),
143+
)
144+
145+
for (let i = 0; i < 3; i++) {
146+
const actual = getZonedParts(guess, timeZone)
147+
const desiredUtc = Date.UTC(
148+
parts.year,
149+
parts.month - 1,
150+
parts.day,
151+
hour,
152+
minute,
153+
)
154+
const actualUtc = Date.UTC(
155+
actual.year,
156+
actual.month - 1,
157+
actual.day,
158+
actual.hour,
159+
actual.minute,
160+
)
161+
guess = new Date(guess.getTime() + (desiredUtc - actualUtc))
162+
}
163+
164+
return guess
165+
}
166+
167+
function isWeekend(
168+
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
169+
): boolean {
170+
const weekday = new Date(
171+
Date.UTC(parts.year, parts.month - 1, parts.day),
172+
).getUTCDay()
173+
return weekday === 0 || weekday === 6
174+
}
175+
176+
function getNextFreebuffDeploymentStart(now: Date): Date {
177+
const easternNow = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE)
178+
179+
for (let offset = 0; offset < 8; offset++) {
180+
const day = addDaysToYmd(
181+
easternNow.year,
182+
easternNow.month,
183+
easternNow.day,
184+
offset,
185+
)
186+
if (isWeekend(day)) continue
187+
const candidate = getUtcForZonedTime(day, FREEBUFF_EASTERN_TIMEZONE, 9, 0)
188+
if (candidate.getTime() > now.getTime()) return candidate
189+
}
190+
191+
return getUtcForZonedTime(
192+
addDaysToYmd(easternNow.year, easternNow.month, easternNow.day, 8),
193+
FREEBUFF_EASTERN_TIMEZONE,
194+
9,
195+
0,
196+
)
197+
}
198+
199+
function getCurrentFreebuffDeploymentEnd(now: Date): Date {
200+
const pacificNow = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE)
201+
return getUtcForZonedTime(pacificNow, FREEBUFF_PACIFIC_TIMEZONE, 17, 0)
202+
}
203+
204+
function isSameLocalDay(left: Date, right: Date, timeZone?: string): boolean {
205+
const formatter = new Intl.DateTimeFormat('en-CA', {
206+
timeZone,
207+
year: 'numeric',
208+
month: '2-digit',
209+
day: '2-digit',
210+
})
211+
return formatter.format(left) === formatter.format(right)
212+
}
213+
214+
function formatLocalTime(
215+
date: Date,
216+
referenceNow: Date,
217+
options: LocalTimeFormatOptions = {},
218+
): string {
219+
const shouldShowWeekday = !isSameLocalDay(
220+
date,
221+
referenceNow,
222+
options.timeZone,
223+
)
224+
return new Intl.DateTimeFormat(options.locale, {
225+
timeZone: options.timeZone,
226+
weekday: shouldShowWeekday ? 'short' : undefined,
227+
hour: 'numeric',
228+
minute: '2-digit',
229+
}).format(date)
230+
}
231+
232+
export function getFreebuffDeploymentAvailabilityLabel(
233+
now: Date = new Date(),
234+
options: LocalTimeFormatOptions = {},
235+
): string {
236+
if (isFreebuffDeploymentHours(now)) {
237+
const closesAt = getCurrentFreebuffDeploymentEnd(now)
238+
return `until ${formatLocalTime(closesAt, now, options)} local`
239+
}
240+
241+
const opensAt = getNextFreebuffDeploymentStart(now)
242+
return `opens ${formatLocalTime(opensAt, now, options)} local`
243+
}
244+
94245
export function isFreebuffDeploymentHours(now: Date = new Date()): boolean {
95-
const eastern = getZonedParts(now, 'America/New_York')
96-
const pacific = getZonedParts(now, 'America/Los_Angeles')
246+
const eastern = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE)
247+
const pacific = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE)
97248
if (eastern.weekday === 'Sat' || eastern.weekday === 'Sun') return false
98249
return eastern.minutes >= 9 * 60 && pacific.minutes < 17 * 60
99250
}

0 commit comments

Comments
 (0)