Skip to content
Merged
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
28 changes: 21 additions & 7 deletions src/db/in-memory-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AppUser> = new Map();
Expand Down Expand Up @@ -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<Task[]> {
const userTasks = Array.from(this.tasks.values()).filter((t) => t.user_id === userId);

Expand All @@ -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;
Expand Down Expand Up @@ -536,16 +541,25 @@ export class InMemoryDatabaseRepository implements DatabaseRepository {
reminderId: string,
workerToken: string,
error: string,
retryAt: string
retryAt: string | null,
newFollowUpCount?: number
): Promise<boolean> {
const reminder = this.reminders.get(reminderId);
if (!reminder || reminder.lease_token !== workerToken) {
return false;
}

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;
Expand All @@ -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
);
Expand Down
3 changes: 2 additions & 1 deletion src/db/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ export interface DatabaseRepository {
reminderId: string,
workerToken: string,
error: string,
retryAt: string
retryAt: string | null,
newFollowUpCount?: number
): Promise<boolean>;

// Audit Events
Expand Down
39 changes: 26 additions & 13 deletions src/db/supabase-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
MemoryCategory,
MemoryRelationship,
} from '../memory/types.js';
import { parseLocalToUtc } from '../shared/time.js';

export interface SupabaseConfig {
supabaseUrl: string;
Expand Down Expand Up @@ -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<Task[]> {
let query = this.client
.from('tasks')
Expand All @@ -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;
Expand Down Expand Up @@ -595,19 +600,27 @@ export class SupabaseDatabaseRepository implements DatabaseRepository {
reminderId: string,
workerToken: string,
error: string,
retryAt: string
retryAt: string | null,
newFollowUpCount?: number
): Promise<boolean> {
const now = new Date().toISOString();
const updatePayload: Record<string, unknown> = {
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')
Expand All @@ -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
);
Expand Down
36 changes: 33 additions & 3 deletions src/reminders/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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++;
Expand Down
45 changes: 25 additions & 20 deletions src/shared/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,28 +93,33 @@ export function parseLocalToUtc(localIsoString: string, timeZone: string = 'Asia
hour12: false,
});

const formattedParts = formatter.formatToParts(utcGuess);
const partMap: Record<string, number> = {};
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<string, number> = {};
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);
}
Loading
Loading