@@ -46,6 +46,7 @@ import { useAuth } from "@/lib/auth";
4646import { supabase } from "@/lib/supabase" ;
4747import { useToast } from "@/hooks/use-toast" ;
4848import { useRBAC } from "@/hooks/useRBAC" ;
49+ import { getTrueUTCTime , calculateNextRunUTC } from "@/lib/timeUtils" ;
4950import ScheduleRebalanceModal from "./schedule-rebalance/ScheduleRebalanceModal" ;
5051
5152interface ScheduleListModalProps {
@@ -274,183 +275,66 @@ export default function ScheduleListModal({ isOpen, onClose }: ScheduleListModal
274275 return days . map ( d => dayNames [ d ] ) . join ( ', ' ) ;
275276 } ;
276277
277- const calculateNextRun = ( schedule : Schedule ) : Date | null => {
278- const now = new Date ( ) ;
279- const [ hours , minutes ] = schedule . time_of_day . split ( ':' ) . map ( Number ) ;
280-
281- // Helper function to convert schedule time to UTC-aware date
282- const createScheduledDate = ( localDate : Date ) : Date => {
283- // Format date for the target timezone
284- const year = localDate . getFullYear ( ) ;
285- const month = String ( localDate . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
286- const day = String ( localDate . getDate ( ) ) . padStart ( 2 , '0' ) ;
287- const hourStr = String ( hours ) . padStart ( 2 , '0' ) ;
288- const minuteStr = String ( minutes ) . padStart ( 2 , '0' ) ;
289-
290- // Create an ISO string for the target timezone time
291- const dateTimeStr = `${ year } -${ month } -${ day } T${ hourStr } :${ minuteStr } :00` ;
292-
293- // Use Intl.DateTimeFormat to handle timezone conversion properly
294- // This simulates the SQL AT TIME ZONE behavior
295- const formatter = new Intl . DateTimeFormat ( 'en-US' , {
296- timeZone : schedule . timezone ,
297- year : 'numeric' ,
298- month : '2-digit' ,
299- day : '2-digit' ,
300- hour : '2-digit' ,
301- minute : '2-digit' ,
302- second : '2-digit' ,
303- hour12 : false
304- } ) ;
278+ // State for storing calculated next run times and true UTC time
279+ const [ nextRunTimes , setNextRunTimes ] = useState < Map < string , Date | null > > ( new Map ( ) ) ;
280+ const [ trueCurrentTime , setTrueCurrentTime ] = useState < Date | null > ( null ) ;
281+ const [ calculatingTimes , setCalculatingTimes ] = useState ( false ) ;
282+
283+ // Calculate next run times when schedules change
284+ useEffect ( ( ) => {
285+ const calculateAllNextRuns = async ( ) => {
286+ if ( schedules . length === 0 ) return ;
305287
306- // Parse the target date/time in the schedule's timezone
307- const testDate = new Date ( dateTimeStr ) ;
308- const parts = formatter . formatToParts ( testDate ) ;
309- const dateParts : any = { } ;
310- parts . forEach ( part => dateParts [ part . type ] = part . value ) ;
288+ setCalculatingTimes ( true ) ;
289+ const times = new Map < string , Date | null > ( ) ;
311290
312- // Get timezone offset for this specific date/time
313- const tzFormatter = new Intl . DateTimeFormat ( 'en-US' , {
314- timeZone : schedule . timezone ,
315- timeZoneName : 'longOffset'
316- } ) ;
317- const tzParts = tzFormatter . formatToParts ( new Date ( dateTimeStr ) ) ;
318- const offsetStr = tzParts . find ( p => p . type === 'timeZoneName' ) ?. value || 'GMT+00:00' ;
319- const match = offsetStr . match ( / G M T ( [ + - ] \d { 2 } ) : ( \d { 2 } ) / ) ;
320- let offsetMinutes = 0 ;
321- if ( match ) {
322- const offsetHours = parseInt ( match [ 1 ] ) ;
323- const offsetMins = parseInt ( match [ 2 ] ) ;
324- offsetMinutes = offsetHours * 60 + ( offsetHours < 0 ? - offsetMins : offsetMins ) ;
325- }
291+ // Get true UTC time once for all calculations
292+ const currentTime = await getTrueUTCTime ( ) ;
293+ setTrueCurrentTime ( currentTime ) ;
326294
327- // Create the date in the local timezone and adjust for the schedule's timezone offset
328- const localTime = new Date ( dateTimeStr ) ;
329- const utcTime = localTime . getTime ( ) - ( offsetMinutes * 60 * 1000 ) ;
330- return new Date ( utcTime ) ;
331- } ;
332-
333- // Helper function to find next occurrence for weekly schedules with specific days
334- const findNextWeeklyOccurrence = ( startDate : Date , daysOfWeek : number [ ] ) : Date => {
335- let checkDate = new Date ( startDate ) ;
336-
337- // Check up to 7 days ahead
338- for ( let i = 0 ; i < 7 ; i ++ ) {
339- // Get day of week in the schedule's timezone
340- const formatter = new Intl . DateTimeFormat ( 'en-US' , {
341- timeZone : schedule . timezone ,
342- weekday : 'long'
343- } ) ;
344- const dayName = formatter . format ( checkDate ) ;
345- const dayMap : { [ key : string ] : number } = {
346- 'Sunday' : 0 ,
347- 'Monday' : 1 ,
348- 'Tuesday' : 2 ,
349- 'Wednesday' : 3 ,
350- 'Thursday' : 4 ,
351- 'Friday' : 5 ,
352- 'Saturday' : 6
353- } ;
354- const currentDay = dayMap [ dayName ] ;
355-
356- if ( daysOfWeek . includes ( currentDay ) ) {
357- const scheduledTime = createScheduledDate ( checkDate ) ;
358- if ( scheduledTime > now ) {
359- return scheduledTime ;
360- }
295+ for ( const schedule of schedules ) {
296+ try {
297+ const nextRun = await calculateNextRunUTC ( schedule ) ;
298+ times . set ( schedule . id , nextRun ) ;
299+ } catch ( error ) {
300+ console . error ( `Failed to calculate next run for schedule ${ schedule . id } :` , error ) ;
301+ times . set ( schedule . id , null ) ;
361302 }
362-
363- checkDate . setDate ( checkDate . getDate ( ) + 1 ) ;
364303 }
365304
366- // Fallback
367- return createScheduledDate ( checkDate ) ;
305+ setNextRunTimes ( times ) ;
306+ setCalculatingTimes ( false ) ;
368307 } ;
308+
309+ calculateAllNextRuns ( ) ;
310+ } , [ schedules ] ) ;
369311
370- // If never executed, calculate from current date
371- if ( ! schedule . last_executed_at ) {
372- // For weekly schedules with specific days
373- if ( schedule . interval_unit === 'weeks' && schedule . day_of_week && schedule . day_of_week . length > 0 ) {
374- return findNextWeeklyOccurrence ( now , schedule . day_of_week ) ;
375- }
376-
377- // Create a date at the scheduled time in the target timezone
378- let nextRun = createScheduledDate ( now ) ;
379-
380- // If that time has already passed today, add the interval
381- if ( nextRun <= now ) {
382- const nextDate = new Date ( now ) ;
383- switch ( schedule . interval_unit ) {
384- case 'days' :
385- nextDate . setDate ( nextDate . getDate ( ) + schedule . interval_value ) ;
386- break ;
387- case 'weeks' :
388- nextDate . setDate ( nextDate . getDate ( ) + ( schedule . interval_value * 7 ) ) ;
389- break ;
390- case 'months' :
391- nextDate . setMonth ( nextDate . getMonth ( ) + schedule . interval_value ) ;
392- break ;
393- }
394- nextRun = createScheduledDate ( nextDate ) ;
395- }
396-
397- return nextRun ;
398- }
399-
400- // Calculate from last execution
401- const lastRun = new Date ( schedule . last_executed_at ) ;
402- let nextDate = new Date ( lastRun ) ;
403-
404- // For weekly schedules with specific days
405- if ( schedule . interval_unit === 'weeks' && schedule . day_of_week && schedule . day_of_week . length > 0 ) {
406- // Start from the day after last execution
407- nextDate . setDate ( nextDate . getDate ( ) + 1 ) ;
408- return findNextWeeklyOccurrence ( nextDate , schedule . day_of_week ) ;
409- }
410-
411- // Add the interval for regular schedules
412- switch ( schedule . interval_unit ) {
413- case 'days' :
414- nextDate . setDate ( nextDate . getDate ( ) + schedule . interval_value ) ;
415- break ;
416- case 'weeks' :
417- nextDate . setDate ( nextDate . getDate ( ) + ( schedule . interval_value * 7 ) ) ;
418- break ;
419- case 'months' :
420- nextDate . setMonth ( nextDate . getMonth ( ) + schedule . interval_value ) ;
421- break ;
422- }
423-
424- let nextRun = createScheduledDate ( nextDate ) ;
425-
426- // IMPORTANT: If the calculated next run is in the past (e.g., schedule was paused),
427- // advance it to the next valid future time
428- while ( nextRun <= now ) {
429- switch ( schedule . interval_unit ) {
430- case 'days' :
431- nextDate . setDate ( nextDate . getDate ( ) + schedule . interval_value ) ;
432- break ;
433- case 'weeks' :
434- nextDate . setDate ( nextDate . getDate ( ) + ( schedule . interval_value * 7 ) ) ;
435- break ;
436- case 'months' :
437- nextDate . setMonth ( nextDate . getMonth ( ) + schedule . interval_value ) ;
438- break ;
439- }
440- nextRun = createScheduledDate ( nextDate ) ;
441- }
442-
443- return nextRun ;
444- } ;
312+ // Refresh true time periodically for accurate countdown
313+ useEffect ( ( ) => {
314+ if ( ! isOpen ) return ;
315+
316+ const updateTrueTime = async ( ) => {
317+ const currentTime = await getTrueUTCTime ( ) ;
318+ setTrueCurrentTime ( currentTime ) ;
319+ } ;
320+
321+ // Update immediately and then every 30 seconds
322+ updateTrueTime ( ) ;
323+ const interval = setInterval ( updateTrueTime , 30000 ) ;
324+
325+ return ( ) => clearInterval ( interval ) ;
326+ } , [ isOpen ] ) ;
445327
446328 const formatNextRun = ( schedule : Schedule ) => {
447329 if ( ! schedule . enabled ) return 'Paused' ;
448-
449- const nextRun = calculateNextRun ( schedule ) ;
330+
331+ const nextRun = nextRunTimes . get ( schedule . id ) ;
332+ if ( calculatingTimes ) return 'Calculating...' ;
450333 if ( ! nextRun ) return 'Not scheduled' ;
334+ if ( ! trueCurrentTime ) return 'Loading...' ;
451335
452- const now = new Date ( ) ;
453- const diffMs = nextRun . getTime ( ) - now . getTime ( ) ;
336+ // Use true UTC time instead of client time
337+ const diffMs = nextRun . getTime ( ) - trueCurrentTime . getTime ( ) ;
454338 const diffHours = Math . floor ( diffMs / ( 1000 * 60 * 60 ) ) ;
455339 const diffDays = Math . floor ( diffHours / 24 ) ;
456340
0 commit comments