Skip to content

Commit 130616f

Browse files
committed
update: replace system time with WorldTimeApi for UTC timestamp source of truth
1 parent be501b9 commit 130616f

File tree

3 files changed

+302
-190
lines changed

3 files changed

+302
-190
lines changed

src/components/ScheduleListModal.tsx

Lines changed: 48 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { useAuth } from "@/lib/auth";
4646
import { supabase } from "@/lib/supabase";
4747
import { useToast } from "@/hooks/use-toast";
4848
import { useRBAC } from "@/hooks/useRBAC";
49+
import { getTrueUTCTime, calculateNextRunUTC } from "@/lib/timeUtils";
4950
import ScheduleRebalanceModal from "./schedule-rebalance/ScheduleRebalanceModal";
5051

5152
interface 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(/GMT([+-]\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

src/components/schedule-rebalance/utils.ts

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -36,36 +36,62 @@ export const getNextRunTime = (config: ScheduleConfig): string => {
3636
hours = 0;
3737
}
3838

39-
// Helper function to create a date at the scheduled time in the target timezone
40-
const createScheduledDate = (localDate: Date): Date => {
41-
const year = localDate.getFullYear();
42-
const month = String(localDate.getMonth() + 1).padStart(2, '0');
43-
const day = String(localDate.getDate()).padStart(2, '0');
44-
const hourStr = String(hours).padStart(2, '0');
45-
const minuteStr = String(minutes).padStart(2, '0');
46-
47-
// Create an ISO string for the target timezone time
48-
const dateTimeStr = `${year}-${month}-${day}T${hourStr}:${minuteStr}:00`;
49-
50-
// Get timezone offset for this specific date/time
51-
const tzFormatter = new Intl.DateTimeFormat('en-US', {
39+
// Helper function to create a UTC date for the scheduled time in the target timezone
40+
const createScheduledDate = (baseDate: Date): Date => {
41+
// Get the date in the schedule's timezone
42+
const formatter = new Intl.DateTimeFormat('en-US', {
5243
timeZone: config.timezone,
53-
timeZoneName: 'longOffset'
44+
year: 'numeric',
45+
month: '2-digit',
46+
day: '2-digit'
5447
});
55-
const tzParts = tzFormatter.formatToParts(new Date(dateTimeStr));
56-
const offsetStr = tzParts.find(p => p.type === 'timeZoneName')?.value || 'GMT+00:00';
57-
const match = offsetStr.match(/GMT([+-]\d{2}):(\d{2})/);
58-
let offsetMinutes = 0;
59-
if (match) {
60-
const offsetHours = parseInt(match[1]);
61-
const offsetMins = parseInt(match[2]);
62-
offsetMinutes = offsetHours * 60 + (offsetHours < 0 ? -offsetMins : offsetMins);
48+
49+
const dateStr = formatter.format(baseDate);
50+
const [month, day, year] = dateStr.split('/');
51+
52+
// Find the UTC time that corresponds to the scheduled time in the target timezone
53+
// We'll check different UTC hours to find which one gives us the desired time in the target timezone
54+
for (let utcHour = 0; utcHour < 48; utcHour++) {
55+
// Try UTC hours from today and tomorrow to handle timezone differences
56+
const testDate = new Date(Date.UTC(
57+
parseInt(year),
58+
parseInt(month) - 1,
59+
parseInt(day) + Math.floor(utcHour / 24) - 1, // Adjust day for hours > 24
60+
utcHour % 24,
61+
minutes,
62+
0
63+
));
64+
65+
// Check what time this UTC date shows in the target timezone
66+
const tzFormatter = new Intl.DateTimeFormat('en-US', {
67+
timeZone: config.timezone,
68+
hour: 'numeric',
69+
minute: 'numeric',
70+
day: 'numeric',
71+
hour12: false
72+
});
73+
74+
const parts = tzFormatter.formatToParts(testDate);
75+
const tzHour = parseInt(parts.find(p => p.type === 'hour')?.value || '0');
76+
const tzMinute = parseInt(parts.find(p => p.type === 'minute')?.value || '0');
77+
const tzDay = parseInt(parts.find(p => p.type === 'day')?.value || '0');
78+
79+
// If this UTC time gives us the correct scheduled time in the target timezone
80+
if (tzHour === hours && tzMinute === minutes && tzDay === parseInt(day)) {
81+
return testDate;
82+
}
6383
}
6484

65-
// Create the date in the local timezone and adjust for the schedule's timezone offset
66-
const localTime = new Date(dateTimeStr);
67-
const utcTime = localTime.getTime() - (offsetMinutes * 60 * 1000);
68-
return new Date(utcTime);
85+
// Fallback: just return a reasonable approximation
86+
// Most US timezones are UTC-5 to UTC-8, so use UTC-7 as a middle ground
87+
return new Date(Date.UTC(
88+
parseInt(year),
89+
parseInt(month) - 1,
90+
parseInt(day),
91+
hours + 7, // Approximate offset
92+
minutes,
93+
0
94+
));
6995
};
7096

7197
let nextDate = new Date(now);

0 commit comments

Comments
 (0)