99 * "0 9 * * *" → daily at 9:00
1010 * "*/ 5 * * * * " → every 5 minutes
1111 * "0 9 * * 1" → every Monday at 9 :00
12+ *
13+ * Timezone support uses the IANA timezone database via Intl . DateTimeFormat .
1214 * /
1315
1416import { InputNode } from "@aituber-flow/sdk" ;
@@ -96,25 +98,71 @@ function parseCron(expression: string): CronSchedule {
9698 } ;
9799}
98100
99- function matchesCron ( schedule : CronSchedule , date : Date ) : boolean {
101+ /**
102+ * Get the local time parts (minute/hour/date/month/weekday) in the given IANA timezone.
103+ * Falls back to system local time if the timezone is empty or invalid.
104+ */
105+ function getTimeParts (
106+ date : Date ,
107+ timezone : string ,
108+ ) : { minute : number ; hour : number ; day : number ; month : number ; weekday : number } {
109+ try {
110+ const tz = timezone || undefined ;
111+ const fmt = new Intl . DateTimeFormat ( "en-US" , {
112+ timeZone : tz ,
113+ minute : "numeric" ,
114+ hour : "numeric" ,
115+ day : "numeric" ,
116+ month : "numeric" ,
117+ weekday : "narrow" ,
118+ hour12 : false ,
119+ } ) ;
120+ const parts = Object . fromEntries (
121+ fmt . formatToParts ( date ) . map ( ( p ) => [ p . type , p . value ] ) ,
122+ ) ;
123+ // Intl weekday narrow: Sun=Su, Mon=Mo, Tue=Tu, Wed=We, Thu=Th, Fri=Fr, Sat=Sa
124+ const weekdayMap : Record < string , number > = {
125+ Su : 0 , Mo : 1 , Tu : 2 , We : 3 , Th : 4 , Fr : 5 , Sa : 6 ,
126+ } ;
127+ return {
128+ minute : parseInt ( parts . minute , 10 ) ,
129+ hour : parseInt ( parts . hour , 10 ) % 24 , // 24:xx → 0:xx
130+ day : parseInt ( parts . day , 10 ) ,
131+ month : parseInt ( parts . month , 10 ) ,
132+ weekday : weekdayMap [ parts . weekday ] ?? date . getDay ( ) ,
133+ } ;
134+ } catch {
135+ // Invalid timezone — fall back to system local
136+ return {
137+ minute : date . getMinutes ( ) ,
138+ hour : date . getHours ( ) ,
139+ day : date . getDate ( ) ,
140+ month : date . getMonth ( ) + 1 ,
141+ weekday : date . getDay ( ) ,
142+ } ;
143+ }
144+ }
145+
146+ function matchesCron ( schedule : CronSchedule , date : Date , timezone : string ) : boolean {
147+ const { minute, hour, day, month, weekday } = getTimeParts ( date , timezone ) ;
100148 return (
101- schedule . minutes . has ( date . getMinutes ( ) ) &&
102- schedule . hours . has ( date . getHours ( ) ) &&
103- schedule . daysOfMonth . has ( date . getDate ( ) ) &&
104- schedule . months . has ( date . getMonth ( ) + 1 ) &&
105- schedule . daysOfWeek . has ( date . getDay ( ) )
149+ schedule . minutes . has ( minute ) &&
150+ schedule . hours . has ( hour ) &&
151+ schedule . daysOfMonth . has ( day ) &&
152+ schedule . months . has ( month ) &&
153+ schedule . daysOfWeek . has ( weekday )
106154 ) ;
107155}
108156
109- function getNextCronTime ( schedule : CronSchedule , from : Date ) : Date {
157+ function getNextCronTime ( schedule : CronSchedule , from : Date , timezone : string ) : Date {
110158 const next = new Date ( from ) ;
111159 next . setSeconds ( 0 , 0 ) ;
112160 next . setMinutes ( next . getMinutes ( ) + 1 ) ;
113161
114162 // Search up to 366 days ahead
115163 const maxIterations = 366 * 24 * 60 ;
116164 for ( let i = 0 ; i < maxIterations ; i ++ ) {
117- if ( matchesCron ( schedule , next ) ) {
165+ if ( matchesCron ( schedule , next , timezone ) ) {
118166 return next ;
119167 }
120168 next . setMinutes ( next . getMinutes ( ) + 1 ) ;
@@ -126,6 +174,7 @@ function getNextCronTime(schedule: CronSchedule, from: Date): Date {
126174
127175export default class CronTriggerNode extends InputNode {
128176 private cronExpression : string = "0 * * * *" ;
177+ private timezone : string = "" ;
129178 private enabled : boolean = true ;
130179 private executionCount : number = 0 ;
131180 private running : boolean = false ;
@@ -136,6 +185,7 @@ export default class CronTriggerNode extends InputNode {
136185 context : NodeContext ,
137186 ) : Promise < void > {
138187 this . cronExpression = config . cron ?? "0 * * * *" ;
188+ this . timezone = config . timezone ?? "" ;
139189 this . enabled = config . enabled ?? true ;
140190 this . executionCount = 0 ;
141191 this . running = true ;
@@ -152,9 +202,10 @@ export default class CronTriggerNode extends InputNode {
152202 return ;
153203 }
154204
155- const nextRun = getNextCronTime ( this . schedule , new Date ( ) ) ;
205+ const tzLabel = this . timezone || "system local" ;
206+ const nextRun = getNextCronTime ( this . schedule , new Date ( ) , this . timezone ) ;
156207 await context . log (
157- `Cron configured: "${ this . cronExpression } " — next run: ${ nextRun . toLocaleString ( ) } ` ,
208+ `Cron configured: "${ this . cronExpression } " (timezone: ${ tzLabel } ) — next run: ${ nextRun . toLocaleString ( ) } ` ,
158209 ) ;
159210
160211 context . createTask ( ( signal ) => this . cronLoop ( signal , context ) ) ;
@@ -168,7 +219,7 @@ export default class CronTriggerNode extends InputNode {
168219
169220 while ( ! signal . aborted && this . running ) {
170221 const now = new Date ( ) ;
171- const nextRun = getNextCronTime ( this . schedule , now ) ;
222+ const nextRun = getNextCronTime ( this . schedule , now , this . timezone ) ;
172223 const delayMs = nextRun . getTime ( ) - now . getTime ( ) ;
173224
174225 // Wait until the next cron time
@@ -188,7 +239,7 @@ export default class CronTriggerNode extends InputNode {
188239
189240 // Verify we're at the right time (within 30s tolerance)
190241 const currentTime = new Date ( ) ;
191- if ( matchesCron ( this . schedule , currentTime ) ) {
242+ if ( matchesCron ( this . schedule , currentTime , this . timezone ) ) {
192243 await this . emitTick ( context ) ;
193244
194245 // Wait until the current minute passes to avoid double-firing
@@ -217,9 +268,10 @@ export default class CronTriggerNode extends InputNode {
217268 ) ;
218269 await context . emitEvent (
219270 createEvent ( "timer.tick" , {
220- tick : this . executionCount ,
271+ executionCount : this . executionCount ,
221272 timestamp,
222273 cronExpression : this . cronExpression ,
274+ timezone : this . timezone || null ,
223275 } ) ,
224276 ) ;
225277 }
0 commit comments