diff --git a/src/db/in-memory-repository.ts b/src/db/in-memory-repository.ts index c4e0434..aca886f 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; @@ -536,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) { @@ -544,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; @@ -554,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 30d9e11..2b277b3 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; @@ -595,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') @@ -617,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/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/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