Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/cron/__tests__/schedule-google-calendar.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Tests for timezone-aware cron expression generation
* Issue: Calendar reminder is not taking in consideration the time zone
* https://github.com/TeamNovaSoft/novabot/issues/109
*/

/**
* Converts a date to a CRON expression in the specified timezone.
* This ensures the cron expression matches the intended time in the event's timezone,
* preventing timezone offset issues when the server is in a different timezone.
*
* @param {Date} date - The date to convert.
* @param {string} timeZone - The IANA timezone identifier.
* @returns {string} A CRON expression derived from the provided date in the specified timezone.
*/
const dateToCronExpressionInTimezone = (date, timeZone) => {
const options = {
timeZone,
minute: 'numeric',
hour: 'numeric',
day: 'numeric',
month: 'numeric',
year: 'numeric',
hour12: false,
};

// Get date components in the target timezone
const formatter = new Intl.DateTimeFormat('en-US', options);
const parts = formatter.formatToParts(date);

const getPart = (type) =>
parseInt(parts.find((p) => p.type === type)?.value, 10);

const minutes = getPart('minute');
const hours = getPart('hour');
const day = getPart('day');
const month = getPart('month');

return `${minutes} ${hours} ${day} ${month} *`;
};

describe('dateToCronExpressionInTimezone', () => {
test('should generate correct cron for Argentina timezone event', () => {
// Event at 1:00 PM Argentina time
const eventDate = new Date('2025-03-15T13:00:00-03:00'); // 1pm ART
const timeZone = 'America/Argentina/Buenos_Aires';

const cron = dateToCronExpressionInTimezone(eventDate, timeZone);

// Should be 13:00 (1pm) in cron format
expect(cron).toBe('0 13 15 3 *');
});

test('should generate same cron expression regardless of server timezone', () => {
// Same event time, but let's verify it's consistent
// 1pm Argentina = 11am Colombia (2 hour difference)
const argentinaEvent = new Date('2025-03-15T13:00:00-03:00');
const argentinaTz = 'America/Argentina/Buenos_Aires';

// Generate cron for Argentina timezone
const argentinaCron = dateToCronExpressionInTimezone(
argentinaEvent,
argentinaTz
);

// The cron should reflect 13:00 (1pm) in Argentina time
expect(argentinaCron).toBe('0 13 15 3 *');
});

test('should handle reminder offset correctly', () => {
// Event at 1:00 PM Argentina, with 10-minute reminder
const eventDate = new Date('2025-03-15T13:00:00-03:00');
const reminderDate = new Date(eventDate.getTime() - 10 * 60 * 1000); // 12:50 PM
const timeZone = 'America/Argentina/Buenos_Aires';

const cron = dateToCronExpressionInTimezone(reminderDate, timeZone);

// Should be 12:50 (10 min before 1pm) in cron format
expect(cron).toBe('50 12 15 3 *');
});

test('should handle different timezones correctly', () => {
// Same UTC moment, different timezone representations
const utcDate = new Date('2025-03-15T16:00:00Z'); // 4pm UTC

// In Colombia (UTC-5), this is 11:00 AM
const colombiaCron = dateToCronExpressionInTimezone(
utcDate,
'America/Bogota'
);
expect(colombiaCron).toBe('0 11 15 3 *');

// In Argentina (UTC-3), this is 1:00 PM
const argentinaCron = dateToCronExpressionInTimezone(
utcDate,
'America/Argentina/Buenos_Aires'
);
expect(argentinaCron).toBe('0 13 15 3 *');
});
});
47 changes: 45 additions & 2 deletions src/cron/schedule-google-calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ const { CronJob } = require('cron');
const { listEvents } = require('../../calendar');
const { EmbedBuilder } = require('discord.js');
const { translateLanguage } = require('../languages');
const dateToCronExpression = require('../utils/date-to-cron-expression');
const convertCronToText = require('../utils/cron-to-text-parser');
const {
FIREBASE_CONFIG,
Expand All @@ -14,6 +13,45 @@ let activeCronJobs = [];

const minutesBeforeEvent = 10;

/**
* Converts a date to a CRON expression in the specified timezone.
* This ensures the cron expression matches the intended time in the event's timezone,
* preventing timezone offset issues when the server is in a different timezone.
*
* @param {Date} date - The date to convert.
* @param {string} timeZone - The IANA timezone identifier (e.g., 'America/Argentina/Buenos_Aires').
* @returns {string} A CRON expression derived from the provided date in the specified timezone.
*
* @example
* dateToCronExpressionInTimezone(new Date('2025-01-22T15:30:00'), 'America/Bogota');
* // Returns '30 15 22 1 *'
*/
const dateToCronExpressionInTimezone = (date, timeZone) => {
const options = {
timeZone,
minute: 'numeric',
hour: 'numeric',
day: 'numeric',
month: 'numeric',
year: 'numeric',
hour12: false,
};

// Get date components in the target timezone
const formatter = new Intl.DateTimeFormat('en-US', options);
const parts = formatter.formatToParts(date);

const getPart = (type) =>
parseInt(parts.find((p) => p.type === type)?.value, 10);

const minutes = getPart('minute');
const hours = getPart('hour');
const day = getPart('day');
const month = getPart('month');

return `${minutes} ${hours} ${day} ${month} *`;
};

const clearAllCronJobs = () => {
activeCronJobs.forEach((job) => job.stop());
activeCronJobs = [];
Expand Down Expand Up @@ -74,7 +112,12 @@ const scheduleEventNotification = async ({ client, event }) => {
const startDate = new Date(event.start.dateTime);
startDate.setMinutes(startDate.getMinutes() - minutesBeforeEvent);

const cronExpression = dateToCronExpression(startDate);
// Use timezone-aware cron expression to ensure correct scheduling
// regardless of the server's local timezone
const cronExpression = dateToCronExpressionInTimezone(
startDate,
event.start.timeZone
);
const job = new CronJob(
cronExpression,
async () => {
Expand Down