@@ -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. */
2023export const FREEBUFF_DEPLOYMENT_HOURS_LABEL = '9am ET-5pm PT'
2124export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1'
2225export 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
2443export 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
94251export 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
101261export function isFreebuffModelAvailable (
0 commit comments