From f28a727df4b701dd39c9dbd81c21be3c8b00c986 Mon Sep 17 00:00:00 2001 From: Jnyananjan Date: Fri, 30 Oct 2026 13:13:32 +0530 Subject: [PATCH 1/4] fix(time): correct parseLocalToUtc offset near DST transitions (fixes #2) --- src/shared/time.ts | 45 +++++++++++++++++++++++------------------ tests/unit/time.test.ts | 9 +++++++++ 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/shared/time.ts b/src/shared/time.ts index 4094226..1aead47 100644 --- a/src/shared/time.ts +++ b/src/shared/time.ts @@ -93,28 +93,33 @@ export function parseLocalToUtc(localIsoString: string, timeZone: string = 'Asia hour12: false, }); - const formattedParts = formatter.formatToParts(utcGuess); - const partMap: Record = {}; - for (const part of formattedParts) { - if (part.type !== 'literal') { - partMap[part.type] = parseInt(part.value, 10); + const getOffsetMs = (date: Date): number => { + const formattedParts = formatter.formatToParts(date); + const partMap: Record = {}; + for (const part of formattedParts) { + if (part.type !== 'literal') { + partMap[part.type] = parseInt(part.value, 10); + } } - } - // Formatted parts in target timezone for the guess - const tzHour = partMap.hour === 24 ? 0 : partMap.hour; - const targetTimeInTz = Date.UTC( - partMap.year, - partMap.month - 1, - partMap.day, - tzHour, - partMap.minute, - partMap.second - ); + const tzHour = partMap.hour === 24 ? 0 : partMap.hour; + const targetTimeInTz = Date.UTC( + partMap.year, + partMap.month - 1, + partMap.day, + tzHour, + partMap.minute, + partMap.second + ); + return targetTimeInTz - date.getTime(); + }; - // Offset in milliseconds between UTC guess and target timezone representation - const offsetMs = targetTimeInTz - utcGuess.getTime(); + // Two-pass offset evaluation handles DST transitions + const offset1 = getOffsetMs(utcGuess); + const firstResult = new Date(utcGuess.getTime() - offset1); + const offset2 = getOffsetMs(firstResult); - // The actual UTC date is utcGuess - offsetMs - return new Date(utcGuess.getTime() - offsetMs); + return offset1 === offset2 + ? firstResult + : new Date(utcGuess.getTime() - offset2); } diff --git a/tests/unit/time.test.ts b/tests/unit/time.test.ts index 2b57eb8..d492796 100644 --- a/tests/unit/time.test.ts +++ b/tests/unit/time.test.ts @@ -26,6 +26,15 @@ describe('Time utilities', () => { // Winter (EST, UTC-5): 2026-12-15 12:00 -> 17:00 UTC const winterUtc = parseLocalToUtc('2026-12-15T12:00:00', 'America/New_York'); expect(winterUtc.toISOString()).toBe('2026-12-15T17:00:00.000Z'); + + // DST boundary tests (Issue #2) + // Spring forward (2026-03-08): 05:00 EDT should be 09:00Z + const springDst = parseLocalToUtc('2026-03-08T05:00:00', 'America/New_York'); + expect(springDst.toISOString()).toBe('2026-03-08T09:00:00.000Z'); + + // Fall back (2026-11-01): 03:00 EST should be 08:00Z + const fallDst = parseLocalToUtc('2026-11-01T03:00:00', 'America/New_York'); + expect(fallDst.toISOString()).toBe('2026-11-01T08:00:00.000Z'); }); it('correctly parses already-offset ISO strings directly', () => { From 56b646f631dab38dd49ac82301a3c411ccef9af8 Mon Sep 17 00:00:00 2001 From: Jnyananjan Date: Fri, 30 Oct 2026 13:16:02 +0530 Subject: [PATCH 2/4] fix(db): respect user timezone in listTasks today filter (fixes #3) --- src/db/in-memory-repository.ts | 11 ++++++++--- src/db/supabase-repository.ts | 11 ++++++++--- tests/integration/reminder-lifecycle.test.ts | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/db/in-memory-repository.ts b/src/db/in-memory-repository.ts index c4e0434..35bf5f3 100644 --- a/src/db/in-memory-repository.ts +++ b/src/db/in-memory-repository.ts @@ -19,6 +19,7 @@ import { MemoryCategory, MemoryRelationship, } from '../memory/types.js'; +import { parseLocalToUtc } from '../shared/time.js'; export class InMemoryDatabaseRepository implements DatabaseRepository { public users: Map = new Map(); @@ -150,7 +151,8 @@ export class InMemoryDatabaseRepository implements DatabaseRepository { async listTasks( userId: string, - filter: 'all' | 'today' | 'upcoming' | 'completed' = 'all' + filter: 'all' | 'today' | 'upcoming' | 'completed' = 'all', + userTimezone: string = 'Asia/Kolkata' ): Promise { const userTasks = Array.from(this.tasks.values()).filter((t) => t.user_id === userId); @@ -162,8 +164,11 @@ export class InMemoryDatabaseRepository implements DatabaseRepository { if (filter === 'today') { const now = this.clock.now(); - const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const endOfDay = startOfDay + 24 * 60 * 60 * 1000; + const localDate = new Intl.DateTimeFormat('en-CA', { timeZone: userTimezone }).format(now); + const startOfDay = parseLocalToUtc(`${localDate}T00:00:00`, userTimezone).getTime(); + const nextDayDate = new Date(startOfDay + 26 * 60 * 60 * 1000); + const nextLocalDate = new Intl.DateTimeFormat('en-CA', { timeZone: userTimezone }).format(nextDayDate); + const endOfDay = parseLocalToUtc(`${nextLocalDate}T00:00:00`, userTimezone).getTime(); return openTasks.filter((t) => { if (!t.due_at) return false; diff --git a/src/db/supabase-repository.ts b/src/db/supabase-repository.ts index 30d9e11..82dc5ed 100644 --- a/src/db/supabase-repository.ts +++ b/src/db/supabase-repository.ts @@ -18,6 +18,7 @@ import { MemoryCategory, MemoryRelationship, } from '../memory/types.js'; +import { parseLocalToUtc } from '../shared/time.js'; export interface SupabaseConfig { supabaseUrl: string; @@ -176,7 +177,8 @@ export class SupabaseDatabaseRepository implements DatabaseRepository { async listTasks( userId: string, - filter: 'all' | 'today' | 'upcoming' | 'completed' = 'all' + filter: 'all' | 'today' | 'upcoming' | 'completed' = 'all', + userTimezone: string = 'Asia/Kolkata' ): Promise { let query = this.client .from('tasks') @@ -199,8 +201,11 @@ export class SupabaseDatabaseRepository implements DatabaseRepository { if (filter === 'today') { const now = new Date(); - const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const endOfDay = startOfDay + 24 * 60 * 60 * 1000; + const localDate = new Intl.DateTimeFormat('en-CA', { timeZone: userTimezone }).format(now); + const startOfDay = parseLocalToUtc(`${localDate}T00:00:00`, userTimezone).getTime(); + const nextDayDate = new Date(startOfDay + 26 * 60 * 60 * 1000); + const nextLocalDate = new Intl.DateTimeFormat('en-CA', { timeZone: userTimezone }).format(nextDayDate); + const endOfDay = parseLocalToUtc(`${nextLocalDate}T00:00:00`, userTimezone).getTime(); return tasks.filter((t) => { if (!t.due_at) return false; diff --git a/tests/integration/reminder-lifecycle.test.ts b/tests/integration/reminder-lifecycle.test.ts index 94ae30a..feb926d 100644 --- a/tests/integration/reminder-lifecycle.test.ts +++ b/tests/integration/reminder-lifecycle.test.ts @@ -421,5 +421,22 @@ describe('Reminder Lifecycle & Escalation Integration', () => { expect(recorded).toBe(false); }); + + it('correctly filters "today" tasks in user timezone (Asia/Kolkata)', async () => { + // Current time: 2026-10-30 02:00:00 IST (2026-10-29 20:30:00 UTC) + const tzClock = new ControllableClock('2026-10-29T20:30:00Z'); + const tzDb = new InMemoryDatabaseRepository(tzClock); + const user = await tzDb.getOrCreateUser(111, 111, 'Bob'); + + // Task 1: Due Oct 30 at 03:00 IST (Oct 29 21:30:00 UTC) - is TODAY in Asia/Kolkata + await tzDb.createTask(user.id, 'Early morning task', '2026-10-29T21:30:00Z'); + + // Task 2: Due Oct 31 at 02:00 IST (Oct 30 20:30:00 UTC) - is TOMORROW in Asia/Kolkata + await tzDb.createTask(user.id, 'Tomorrow task', '2026-10-30T20:30:00Z'); + + const todayTasks = await tzDb.listTasks(user.id, 'today', 'Asia/Kolkata'); + expect(todayTasks).toHaveLength(1); + expect(todayTasks[0].title).toBe('Early morning task'); + }); }); From 0ab51e75c0094fe1872c352323ea662eace1fd96 Mon Sep 17 00:00:00 2001 From: Jnyananjan Date: Fri, 30 Oct 2026 13:20:40 +0530 Subject: [PATCH 3/4] fix(worker): terminate reminder loop on permanent errors and back off transient retries (fixes #1) --- src/db/in-memory-repository.ts | 17 ++- src/db/repository.ts | 3 +- src/db/supabase-repository.ts | 28 +++-- src/reminders/worker.ts | 36 ++++++- supabase/functions/process-reminders/index.ts | 101 ++++++++++++++++-- supabase/functions/telegram-webhook/index.ts | 75 +++++++------ tests/integration/reminder-lifecycle.test.ts | 24 +++++ 7 files changed, 225 insertions(+), 59 deletions(-) diff --git a/src/db/in-memory-repository.ts b/src/db/in-memory-repository.ts index 35bf5f3..aca886f 100644 --- a/src/db/in-memory-repository.ts +++ b/src/db/in-memory-repository.ts @@ -541,7 +541,8 @@ export class InMemoryDatabaseRepository implements DatabaseRepository { reminderId: string, workerToken: string, error: string, - retryAt: string + retryAt: string | null, + newFollowUpCount?: number ): Promise { const reminder = this.reminders.get(reminderId); if (!reminder || reminder.lease_token !== workerToken) { @@ -549,8 +550,16 @@ export class InMemoryDatabaseRepository implements DatabaseRepository { } const now = this.clock.now().toISOString(); - reminder.status = 'scheduled'; // Schedule for retry - reminder.scheduled_at = retryAt; + if (newFollowUpCount !== undefined) { + reminder.follow_up_count = newFollowUpCount; + } + + if (retryAt) { + reminder.status = 'scheduled'; // Schedule for retry + reminder.scheduled_at = retryAt; + } else { + reminder.status = 'failed'; + } reminder.last_error = error; reminder.lease_token = null; reminder.lease_until = null; @@ -559,7 +568,7 @@ export class InMemoryDatabaseRepository implements DatabaseRepository { await this.recordEvent( reminder.user_id, 'reminder_send_failed', - { error, retryAt }, + { error, retryAt, status: reminder.status }, reminderId, reminder.task_id ); diff --git a/src/db/repository.ts b/src/db/repository.ts index 24364e4..d3a4103 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -125,7 +125,8 @@ export interface DatabaseRepository { reminderId: string, workerToken: string, error: string, - retryAt: string + retryAt: string | null, + newFollowUpCount?: number ): Promise; // Audit Events diff --git a/src/db/supabase-repository.ts b/src/db/supabase-repository.ts index 82dc5ed..2b277b3 100644 --- a/src/db/supabase-repository.ts +++ b/src/db/supabase-repository.ts @@ -600,19 +600,27 @@ export class SupabaseDatabaseRepository implements DatabaseRepository { reminderId: string, workerToken: string, error: string, - retryAt: string + retryAt: string | null, + newFollowUpCount?: number ): Promise { const now = new Date().toISOString(); + const updatePayload: Record = { + status: retryAt ? 'scheduled' : 'failed', + last_error: error, + lease_token: null, + lease_until: null, + updated_at: now, + }; + if (retryAt) { + updatePayload.scheduled_at = retryAt; + } + if (newFollowUpCount !== undefined) { + updatePayload.follow_up_count = newFollowUpCount; + } + const { data } = await this.client .from('reminders') - .update({ - status: 'scheduled', - scheduled_at: retryAt, - last_error: error, - lease_token: null, - lease_until: null, - updated_at: now, - }) + .update(updatePayload) .eq('id', reminderId) .eq('lease_token', workerToken) .select('id, user_id, task_id') @@ -622,7 +630,7 @@ export class SupabaseDatabaseRepository implements DatabaseRepository { await this.recordEvent( data.user_id, 'reminder_send_failed', - { error, retryAt }, + { error, retryAt, status: updatePayload.status }, reminderId, data.task_id ); diff --git a/src/reminders/worker.ts b/src/reminders/worker.ts index a9d5e06..fbf2c35 100644 --- a/src/reminders/worker.ts +++ b/src/reminders/worker.ts @@ -33,6 +33,18 @@ export interface WorkerRunResult { }>; } +export function isPermanentTelegramError(error?: string): boolean { + if (!error) return false; + const lower = error.toLowerCase(); + return ( + lower.includes('403') || + lower.includes('blocked') || + lower.includes('chat not found') || + lower.includes('deactivated') || + lower.includes("bot can't initiate") + ); +} + export class ReminderWorker { private readonly db: DatabaseRepository; private readonly notification: NotificationProvider; @@ -148,13 +160,31 @@ export class ReminderWorker { }); } } else { - // Definite send failure: retry in 2 minutes - const retryAt = new Date(now.getTime() + 2 * 60 * 1000).toISOString(); + const isPermanent = isPermanentTelegramError(sendResult.error); + const newFollowUpCount = reminder.follow_up_count + 1; + const isMaxReached = this.policy.isMaxReached( + newFollowUpCount, + reminder.max_follow_ups + ); + + let retryAt: string | null = null; + if (!isPermanent && !isMaxReached) { + // Exponential backoff for transient errors: 2, 4, 8... min (capped at 60 min) + const backoffMinutes = Math.min( + 60, + 2 * Math.pow(2, reminder.follow_up_count) + ); + retryAt = new Date( + now.getTime() + backoffMinutes * 60 * 1000 + ).toISOString(); + } + await this.db.recordReminderFailure( reminder.reminder_id, workerToken, sendResult.error || 'Unknown Telegram send error', - retryAt + retryAt, + newFollowUpCount ); result.failedCount++; diff --git a/supabase/functions/process-reminders/index.ts b/supabase/functions/process-reminders/index.ts index 31f59d5..ef2996a 100644 --- a/supabase/functions/process-reminders/index.ts +++ b/supabase/functions/process-reminders/index.ts @@ -32,6 +32,55 @@ function formatLocalDateTime(dateOrIso, timeZone = "Asia/Kolkata") { }).format(date); } } +function parseLocalToUtc(localIsoString, timeZone = "Asia/Kolkata") { + if (localIsoString.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(localIsoString)) { + return new Date(localIsoString); + } + const parts = localIsoString.split(/[-T:.]/); + if (parts.length < 5) { + throw new Error(`Invalid local ISO string format: ${localIsoString}`); + } + const year = parseInt(parts[0], 10); + const month = parseInt(parts[1], 10); + const day = parseInt(parts[2], 10); + const hour = parseInt(parts[3], 10); + const minute = parseInt(parts[4], 10); + const second = parts[5] ? parseInt(parts[5], 10) : 0; + const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: false + }); + const getOffsetMs = (date) => { + const formattedParts = formatter.formatToParts(date); + const partMap = {}; + for (const part of formattedParts) { + if (part.type !== "literal") { + partMap[part.type] = parseInt(part.value, 10); + } + } + const tzHour = partMap.hour === 24 ? 0 : partMap.hour; + const targetTimeInTz = Date.UTC( + partMap.year, + partMap.month - 1, + partMap.day, + tzHour, + partMap.minute, + partMap.second + ); + return targetTimeInTz - date.getTime(); + }; + const offset1 = getOffsetMs(utcGuess); + const firstResult = new Date(utcGuess.getTime() - offset1); + const offset2 = getOffsetMs(firstResult); + return offset1 === offset2 ? firstResult : new Date(utcGuess.getTime() - offset2); +} // src/ai/intent-schema.ts import { z } from "npm:zod@3"; @@ -356,7 +405,7 @@ var SupabaseDatabaseRepository = class { } return data; } - async listTasks(userId, filter = "all") { + async listTasks(userId, filter = "all", userTimezone = "Asia/Kolkata") { let query = this.client.from("tasks").select("*").eq("user_id", userId).order("due_at", { ascending: true, nullsFirst: false }); if (filter === "completed") { query = query.eq("status", "completed"); @@ -370,8 +419,11 @@ var SupabaseDatabaseRepository = class { const tasks = data || []; if (filter === "today") { const now = /* @__PURE__ */ new Date(); - const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const endOfDay = startOfDay + 24 * 60 * 60 * 1e3; + const localDate = new Intl.DateTimeFormat("en-CA", { timeZone: userTimezone }).format(now); + const startOfDay = parseLocalToUtc(`${localDate}T00:00:00`, userTimezone).getTime(); + const nextDayDate = new Date(startOfDay + 26 * 60 * 60 * 1e3); + const nextLocalDate = new Intl.DateTimeFormat("en-CA", { timeZone: userTimezone }).format(nextDayDate); + const endOfDay = parseLocalToUtc(`${nextLocalDate}T00:00:00`, userTimezone).getTime(); return tasks.filter((t) => { if (!t.due_at) return false; const due = new Date(t.due_at).getTime(); @@ -593,21 +645,27 @@ var SupabaseDatabaseRepository = class { } return false; } - async recordReminderFailure(reminderId, workerToken, error, retryAt) { + async recordReminderFailure(reminderId, workerToken, error, retryAt, newFollowUpCount) { const now = (/* @__PURE__ */ new Date()).toISOString(); - const { data } = await this.client.from("reminders").update({ - status: "scheduled", - scheduled_at: retryAt, + const updatePayload = { + status: retryAt ? "scheduled" : "failed", last_error: error, lease_token: null, lease_until: null, updated_at: now - }).eq("id", reminderId).eq("lease_token", workerToken).select("id, user_id, task_id").maybeSingle(); + }; + if (retryAt) { + updatePayload.scheduled_at = retryAt; + } + if (newFollowUpCount !== void 0) { + updatePayload.follow_up_count = newFollowUpCount; + } + const { data } = await this.client.from("reminders").update(updatePayload).eq("id", reminderId).eq("lease_token", workerToken).select("id, user_id, task_id").maybeSingle(); if (data) { await this.recordEvent( data.user_id, "reminder_send_failed", - { error, retryAt }, + { error, retryAt, status: updatePayload.status }, reminderId, data.task_id ); @@ -918,6 +976,11 @@ var DefaultReminderPolicy = class { }; // src/reminders/worker.ts +function isPermanentTelegramError(error) { + if (!error) return false; + const lower = error.toLowerCase(); + return lower.includes("403") || lower.includes("blocked") || lower.includes("chat not found") || lower.includes("deactivated") || lower.includes("bot can't initiate"); +} var ReminderWorker = class { db; notification; @@ -1008,12 +1071,28 @@ var ReminderWorker = class { }); } } else { - const retryAt = new Date(now.getTime() + 2 * 60 * 1e3).toISOString(); + const isPermanent = isPermanentTelegramError(sendResult.error); + const newFollowUpCount = reminder.follow_up_count + 1; + const isMaxReached = this.policy.isMaxReached( + newFollowUpCount, + reminder.max_follow_ups + ); + let retryAt = null; + if (!isPermanent && !isMaxReached) { + const backoffMinutes = Math.min( + 60, + 2 * Math.pow(2, reminder.follow_up_count) + ); + retryAt = new Date( + now.getTime() + backoffMinutes * 60 * 1e3 + ).toISOString(); + } await this.db.recordReminderFailure( reminder.reminder_id, workerToken, sendResult.error || "Unknown Telegram send error", - retryAt + retryAt, + newFollowUpCount ); result.failedCount++; result.details.push({ diff --git a/supabase/functions/telegram-webhook/index.ts b/supabase/functions/telegram-webhook/index.ts index 3156992..5cfac69 100644 --- a/supabase/functions/telegram-webhook/index.ts +++ b/supabase/functions/telegram-webhook/index.ts @@ -57,24 +57,29 @@ function parseLocalToUtc(localIsoString, timeZone = "Asia/Kolkata") { second: "numeric", hour12: false }); - const formattedParts = formatter.formatToParts(utcGuess); - const partMap = {}; - for (const part of formattedParts) { - if (part.type !== "literal") { - partMap[part.type] = parseInt(part.value, 10); - } - } - const tzHour = partMap.hour === 24 ? 0 : partMap.hour; - const targetTimeInTz = Date.UTC( - partMap.year, - partMap.month - 1, - partMap.day, - tzHour, - partMap.minute, - partMap.second - ); - const offsetMs = targetTimeInTz - utcGuess.getTime(); - return new Date(utcGuess.getTime() - offsetMs); + const getOffsetMs = (date) => { + const formattedParts = formatter.formatToParts(date); + const partMap = {}; + for (const part of formattedParts) { + if (part.type !== "literal") { + partMap[part.type] = parseInt(part.value, 10); + } + } + const tzHour = partMap.hour === 24 ? 0 : partMap.hour; + const targetTimeInTz = Date.UTC( + partMap.year, + partMap.month - 1, + partMap.day, + tzHour, + partMap.minute, + partMap.second + ); + return targetTimeInTz - date.getTime(); + }; + const offset1 = getOffsetMs(utcGuess); + const firstResult = new Date(utcGuess.getTime() - offset1); + const offset2 = getOffsetMs(firstResult); + return offset1 === offset2 ? firstResult : new Date(utcGuess.getTime() - offset2); } // src/ai/intent-schema.ts @@ -201,7 +206,8 @@ Your job is to strictly output a single JSON object adhering to ONE of these int 1. create_task_with_reminder: {"intent":"create_task_with_reminder","title":"...","due_at_local":"YYYY-MM-DDTHH:mm:ss","reminder_offset_minutes":0,"timezone":"${input.userTimezone}","needs_clarification":false} - reminder_offset_minutes is how many minutes BEFORE the due time to remind (e.g. 5 minutes before = 5, at deadline = 0). - - If time is relative (e.g. "tomorrow at 6 PM"), resolve YYYY-MM-DDTHH:mm:ss based on current reference time. + - If time is relative (e.g. "tomorrow at 6 PM", "at 4:01"), resolve YYYY-MM-DDTHH:mm:ss based on current reference time. + - If the user says "remind me at