feat(automation): add timezone-safe daily briefing dedupe

This commit is contained in:
William Valentin
2026-02-16 14:01:14 -08:00
parent 71af3b5a42
commit 56854f04bd
10 changed files with 118 additions and 4 deletions
+35
View File
@@ -14,6 +14,7 @@ function makeCronJob(overrides?: Partial<CronJobConfig>): CronJobConfig {
message: 'Hello from cron',
output: { channel: 'telegram', peer: '123' },
enabled: true,
once_per_local_day: false,
...overrides,
};
}
@@ -176,4 +177,38 @@ describe('CronScheduler', () => {
const names = scheduler.getJobNames();
expect(names).toEqual(['job-a', 'job-b']);
});
it('dedupes once_per_local_day job within same local day', () => {
const jobs = [makeCronJob({ once_per_local_day: true, timezone: 'America/New_York' })];
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
const messages: InboundMessage[] = [];
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValueOnce(new Date('2026-02-16T13:00:00.000Z').getTime()); // 08:00 local
scheduler.triggerJob('test-job');
nowSpy.mockReturnValueOnce(new Date('2026-02-16T18:00:00.000Z').getTime()); // still same local day
scheduler.triggerJob('test-job');
nowSpy.mockRestore();
expect(messages).toHaveLength(1);
});
it('allows once_per_local_day job on next local day', () => {
const jobs = [makeCronJob({ once_per_local_day: true, timezone: 'America/New_York' })];
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
const messages: InboundMessage[] = [];
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValueOnce(new Date('2026-02-16T13:00:00.000Z').getTime()); // 08:00 local
scheduler.triggerJob('test-job');
nowSpy.mockReturnValueOnce(new Date('2026-02-17T13:00:00.000Z').getTime()); // next local day
scheduler.triggerJob('test-job');
nowSpy.mockRestore();
expect(messages).toHaveLength(2);
});
});
+37
View File
@@ -17,6 +17,7 @@ export class CronScheduler implements ChannelAdapter {
private messageHandler?: (msg: InboundMessage) => void;
private cronInstances: Map<string, Cron> = new Map();
private jobs: Map<string, CronJobConfig> = new Map();
private lastTriggeredLocalDateByJob: Map<string, string> = new Map();
constructor(
private readonly jobConfigs: CronJobConfig[],
@@ -89,6 +90,14 @@ export class CronScheduler implements ChannelAdapter {
triggerJob(jobName: string): void {
const job = this.jobs.get(jobName);
if (!job) {return;}
if (job.once_per_local_day) {
const dayKey = this.localDayKey(Date.now(), job.timezone);
const lastDayKey = this.lastTriggeredLocalDateByJob.get(jobName);
if (lastDayKey === dayKey) {
return;
}
this.lastTriggeredLocalDateByJob.set(jobName, dayKey);
}
const runId = `run-${randomUUID()}`;
const senderId = this.deliveryMode === 'isolated_job' ? `${jobName}:${runId}` : jobName;
@@ -174,9 +183,37 @@ export class CronScheduler implements ChannelAdapter {
}
this.jobs.delete(name);
this.lastTriggeredLocalDateByJob.delete(name);
auditLogger?.cronRemove(name);
return true;
}
private localDayKey(timestamp: number, timezone?: string): string {
const date = new Date(timestamp);
const format = (timeZoneValue?: string): string => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timeZoneValue,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const year = parts.find((part) => part.type === 'year')?.value ?? '0000';
const month = parts.find((part) => part.type === 'month')?.value ?? '00';
const day = parts.find((part) => part.type === 'day')?.value ?? '00';
return `${year}-${month}-${day}`;
};
if (!timezone) {
return format();
}
try {
return format(timezone);
} catch {
console.warn(`CronScheduler: invalid timezone '${timezone}' for job local-day dedupe; falling back to system timezone`);
return format();
}
}
}
+20
View File
@@ -12,6 +12,7 @@ describe('buildPresetCronJobs', () => {
enabled: true,
schedule: '0 7 * * *',
timezone: 'America/New_York',
dedupe_per_local_day: true,
output: { channel: 'telegram', peer: '1' },
model_tier: 'fast',
prompt: 'Daily briefing prompt',
@@ -28,9 +29,28 @@ describe('buildPresetCronJobs', () => {
output: { channel: 'telegram', peer: '1' },
model_tier: 'fast',
message: 'Daily briefing prompt',
once_per_local_day: true,
});
});
it('can disable daily briefing per-local-day dedupe', () => {
const config = configSchema.parse({
telegram: { bot_token: 'token', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-sonnet' } },
automation: {
daily_briefing: {
enabled: true,
dedupe_per_local_day: false,
output: { channel: 'telegram', peer: '1' },
},
},
});
const jobs = buildPresetCronJobs(config);
expect(jobs).toHaveLength(1);
expect(jobs[0].once_per_local_day).toBe(false);
});
it('skips daily briefing job when output is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const config = configSchema.parse({
+1
View File
@@ -23,6 +23,7 @@ export function buildPresetCronJobs(config: Config): CronJobConfig[] {
enabled: true,
timezone: briefing.timezone,
model_tier: briefing.model_tier,
once_per_local_day: briefing.dedupe_per_local_day,
});
}
}