diff --git a/apps/web/package.json b/apps/web/package.json index 01ca5ac..efb70a3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,11 +7,7 @@ "bin": { "synk-cal": "./bin/server.js" }, - "files": [ - "bin", - "public", - "build" - ], + "files": ["bin", "public", "build"], "scripts": { "build": "remix vite:build", "dev": "remix vite:dev", diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 54ecc53..e4e9f80 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -25,6 +25,7 @@ describe('Config', () => { expect(parsedConfig).toEqual({ GOOGLE_AUTH_SUBJECT: 'test@example.com', CALENDAR_IDS: ['calendar1', 'calendar2'], + TIMEZONE: 'UTC', REMINDER_SETTINGS: [ { minutesBefore: 10, notificationType: 'email' }, { hour: 9, minute: 0, notificationType: 'sms', target: '+1234567890' }, @@ -49,6 +50,7 @@ describe('Config', () => { expect(parsedConfig).toEqual({ GOOGLE_AUTH_SUBJECT: undefined, CALENDAR_IDS: ['calendar1'], + TIMEZONE: 'UTC', REMINDER_SETTINGS: [], REMINDER_TEMPLATE: undefined, WEBHOOK_URL: undefined, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 69568f6..0cc5329 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -21,6 +21,7 @@ export const ConfigSchema = v.object({ v.optional(v.string()), v.transform((value) => value?.split(',') ?? []), ), + TIMEZONE: v.optional(v.string(), 'UTC'), REMINDER_SETTINGS: v.pipe( v.optional(v.string()), v.transform((value) => { diff --git a/packages/usecase/package.json b/packages/usecase/package.json index 000acd0..f95a6a9 100644 --- a/packages/usecase/package.json +++ b/packages/usecase/package.json @@ -16,6 +16,7 @@ "@synk-cal/core": "workspace:*", "@synk-cal/repository": "workspace:*", "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "eta": "^3.5.0" }, "devDependencies": { diff --git a/packages/usecase/src/process_reminders.test.ts b/packages/usecase/src/process_reminders.test.ts index 6761d5e..815b22f 100644 --- a/packages/usecase/src/process_reminders.test.ts +++ b/packages/usecase/src/process_reminders.test.ts @@ -6,6 +6,7 @@ import { processReminders } from './process_reminders' vi.mock('@synk-cal/core', () => ({ config: { REMINDER_TEMPLATE: 'Custom reminder: <%= it.title %> <%= it.minutesBefore ? `in ${it.minutesBefore} minutes` : `tomorrow at ${String(it.hour).padStart(2, "0")}:${String(it.minute).padStart(2, "0")}` %>.', + TIMEZONE: 'UTC', }, })) @@ -116,7 +117,7 @@ describe('processReminders', () => { }) it('should send notifications for day before at specific hour', async () => { - const baseTime = parseISO('2023-06-01T10:00:00Z') // 19:00 JST + const baseTime = parseISO('2023-06-01T19:00:00Z') // UTC 19:00 const eventStart = parseISO('2023-06-02T10:00:00Z') vi.mocked(mockCalendarRepository.getEvents).mockResolvedValue([ @@ -153,14 +154,14 @@ describe('processReminders', () => { }) it('should handle multiple attendees for the same event', async () => { - const baseTime = parseISO('2023-06-01T10:00:00Z') - const eventStart = parseISO('2023-06-01T10:10:00Z') + const baseTime = parseISO('2023-06-01T19:00:00Z') + const eventStart = parseISO('2023-06-02T10:10:00Z') vi.mocked(mockCalendarRepository.getEvents).mockResolvedValue([ { id: '1', start: eventStart.toISOString(), - end: parseISO('2023-06-01T11:10:00Z').toISOString(), + end: parseISO('2023-06-02T11:10:00Z').toISOString(), title: 'Event 1', people: [ { email: 'user1@example.com', organizer: false }, @@ -200,17 +201,25 @@ describe('processReminders', () => { }) it('should handle multiple reminder settings for the same user', async () => { - const baseTime = parseISO('2023-06-01T10:00:00Z') - const eventStart = parseISO('2023-06-01T10:10:00Z') + const baseTime = parseISO('2023-06-01T19:00:00Z') + const event1Start = parseISO('2023-06-01T19:10:00Z') // For minutesBefore + const event2Start = parseISO('2023-06-02T10:10:00Z') // For hour/minute vi.mocked(mockCalendarRepository.getEvents).mockResolvedValue([ { id: '1', - start: eventStart.toISOString(), - end: parseISO('2023-06-01T11:10:00Z').toISOString(), + start: event1Start.toISOString(), + end: parseISO('2023-06-01T20:10:00Z').toISOString(), title: 'Event 1', people: [{ email: 'user1@example.com', organizer: false }], }, + { + id: '2', + start: event2Start.toISOString(), + end: parseISO('2023-06-02T11:10:00Z').toISOString(), + title: 'Event 2', + people: [{ email: 'user1@example.com', organizer: false }], + }, ]) const reminderSettingsMap: Record = { @@ -241,7 +250,7 @@ describe('processReminders', () => { expect(mockWebhookNotificationRepository.notify).toHaveBeenCalledWith( 'user1@example.com', - 'Custom reminder: Event 1 tomorrow at 19:00.', + 'Custom reminder: Event 2 tomorrow at 19:00.', ) }) diff --git a/packages/usecase/src/process_reminders.ts b/packages/usecase/src/process_reminders.ts index fd09d31..e5c857b 100644 --- a/packages/usecase/src/process_reminders.ts +++ b/packages/usecase/src/process_reminders.ts @@ -2,10 +2,11 @@ import type { CalendarRepository, NotificationRepository, ReminderSettingsRepository, - ReminderTiming, + ReminderSetting, } from '@synk-cal/core' import { config } from '@synk-cal/core' -import { addDays, isSameHour, isSameMinute, parseISO, setHours, setMinutes, subMinutes } from 'date-fns' +import { addDays, addMinutes, isSameMinute, parseISO, subMinutes } from 'date-fns' +import { formatInTimeZone, toZonedTime, fromZonedTime } from 'date-fns-tz' import { Eta } from 'eta' const DEFAULT_TEMPLATE = `Reminder: "<%= it.title %>" starts <%= @@ -14,11 +15,22 @@ const DEFAULT_TEMPLATE = `Reminder: "<%= it.title %>" starts <%= : \`tomorrow at \${String(it.hour).padStart(2, '0')}:\${String(it.minute).padStart(2, '0')}\` %>.` -const getReminderTime = (eventStart: Date, setting: ReminderTiming) => { +const getReminderTime = (eventStart: Date, setting: ReminderSetting) => { + const timezone = config.TIMEZONE + if ('minutesBefore' in setting) { return subMinutes(eventStart, setting.minutesBefore) } else { - return setMinutes(setHours(addDays(eventStart, -1), setting.hour), setting.minute) + // イベント開始時刻の前日の指定時刻を設定 + const reminderDateStr = formatInTimeZone( + addDays(eventStart, -1), + timezone, + 'yyyy-MM-dd' + ) + // 指定された時刻を設定 + const reminderTimeStr = `${reminderDateStr}T${String(setting.hour).padStart(2, '0')}:${String(setting.minute).padStart(2, '0')}:00` + // タイムゾーンを考慮してUTCに変換 + return fromZonedTime(reminderTimeStr, timezone) } } @@ -31,15 +43,18 @@ export async function processReminders( const eta = new Eta() // Get all events first + const maxMinutesAfter = 24 * 60 // 1日後まで const events = ( await Promise.all( - calendarRepositories.map((calendarRepository) => calendarRepository.getEvents(baseTime, addDays(baseTime, 1))), + calendarRepositories.map((calendarRepository) => + calendarRepository.getEvents(baseTime, addDays(baseTime, 1)), + ), ) ).flat() console.debug('Fetched events', events.map((e) => `${e.title} (${e.start})`).join('\n')) const notifications: Array<{ repository: NotificationRepository; target: string; message: string }> = [] - const reminderSettingsCache = new Map>() + const reminderSettingsCache = new Map() // Process each event for (const event of events) { @@ -75,11 +90,7 @@ export async function processReminders( } const reminderTime = getReminderTime(parseISO(event.start), setting) - const isTimeMatch = - 'minutesBefore' in setting - ? isSameMinute(reminderTime, baseTime) - : isSameHour(baseTime, setHours(baseTime, setting.hour)) && - isSameMinute(baseTime, setMinutes(setHours(baseTime, setting.hour), setting.minute)) + const isTimeMatch = isSameMinute(reminderTime, baseTime) if (!isTimeMatch) { console.debug( `Event "${event.title}" reminder time ${reminderTime} does not match current time ${baseTime} for ${attendee.email}`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 637d813..6c9ce8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + date-fns-tz: + specifier: ^3.2.0 + version: 3.2.0(date-fns@4.1.0) eta: specifier: ^3.5.0 version: 3.5.0 @@ -3895,6 +3898,11 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -12130,6 +12138,10 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-tz@3.2.0(date-fns@4.1.0): + dependencies: + date-fns: 4.1.0 + date-fns@4.1.0: {} debug@2.6.9: