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
64 changes: 64 additions & 0 deletions backend/__tests__/unit/routes/users.timezone.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// POST /api/users/me/timezone: the app shell sends the browser's IANA zone
// once, and the daily digest goes out at 08:00 there (TASK-257 Phase 2b). Set
// once and never overwritten; a zone this runtime does not know is ignored.
const request = require('supertest');
const express = require('express');
const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils');

jest.mock('../../../middleware/auth', () => (req, res, next) => {
const userId = req.get('authorization');
req.user = { id: userId };
req.userId = userId;
next();
});

const User = require('../../../models/User');
const routes = require('../../../routes/users');

const app = express();
app.use(express.json());
app.use('/api/users', routes);

let serial = 0;
const person = async () => {
serial += 1;
return User.create({ username: `tz-${serial}`, email: `tz-${serial}@example.com`, password: 'Password123!' });
};
const send = (user, timeZone) => request(app).post('/api/users/me/timezone')
.set('authorization', String(user._id)).send({ timeZone });
const zoneOf = async (user) => (await User.findById(user._id).lean()).timezone;

beforeAll(setupMongoDb);
afterAll(closeMongoDb);
afterEach(clearMongoDb);

it('keeps the first zone it is sent, and never overwrites it', async () => {
const user = await person();

await send(user, 'America/Los_Angeles').expect(204);
await send(user, 'Europe/Berlin').expect(204);

expect(await zoneOf(user)).toBe('America/Los_Angeles');
});

it.each([
['an unknown zone', 'Mars/Phobos'],
['markup', '<b>UTC</b>'],
['an object', { $gt: '' }],
['nothing', undefined],
])('ignores %s and still answers 204', async (_label, timeZone) => {
const user = await person();

await send(user, timeZone).expect(204);

expect(await zoneOf(user)).toBeUndefined();
});

it('never sets a zone on a bot', async () => {
const bot = await person();
await User.updateOne({ _id: bot._id }, { $set: { isBot: true } });

await send(bot, 'UTC').expect(204);

expect(await zoneOf(bot)).toBeUndefined();
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ const agentUser = async (username, botMetadata) => {
});
};

// The scheduled run takes only the people for whom it is the digest hour; with
// no zone on file that is 06:00 UTC. Today's 06:00 UTC, so the windows built
// from Date.now() in these fixtures still hold.
const digestHourUtc = () => { const at = new Date(); at.setUTCHours(6, 0, 0, 0); return at; };
// 15:01 UTC is 08:01 in Los Angeles under daylight time (Mar-Nov) only.
const localHourIsEight = (at) => require('../../../utils/timeZone').localHour('America/Los_Angeles', at) === 8;

const podWithSummary = async (owner, name, options = {}) => {
const pod = await Pod.create({
name,
Expand Down Expand Up @@ -310,14 +317,85 @@ test("a digest never carries another pod's summary: summaries are not an input",
spy.mockRestore();
});

// Phase 2b (TASK-257): the digest goes out at 08:00 in the person's own zone,
// 06:00 UTC when it is unknown, and at most once in 20 hours.
describe('the digest hour', () => {
it.each([
['08:00 in a known zone', 'America/Los_Angeles', '2026-10-17T15:01:00Z', true],
['another hour there', 'America/Los_Angeles', '2026-10-17T06:01:00Z', false],
['a half-hour zone at 08:31', 'Asia/Kolkata', '2026-10-17T03:01:00Z', true],
['06:00 UTC with no zone', undefined, '2026-10-17T06:01:00Z', true],
['another hour with no zone', undefined, '2026-10-17T07:01:00Z', false],
['an unusable zone falls back to 06:00 UTC', 'Mars/Phobos', '2026-10-17T06:01:00Z', true],
])('%s', (_label, timezone, at, due) => {
expect(DailyDigestService.dueAt({ timezone }, new Date(at))).toBe(due);
});

it('a scheduled run takes only the people for whom it is the hour', async () => {
const la = await person(new Date(Date.now() - 2 * 86400000));
await User.updateOne({ _id: la._id }, { $set: { timezone: 'America/Los_Angeles' } });
const nozone = await person(new Date(Date.now() - 2 * 86400000));
const service = new DailyDigestService();
const generate = jest.spyOn(service, 'generateUserDailyDigest').mockResolvedValue({ _id: 'digest-1' });
const laEight = new Date(); laEight.setUTCHours(15, 1, 0, 0);

await service.generateAllDailyDigests({ at: laEight });

const called = generate.mock.calls.map(([id]) => String(id));
expect(laEight.getUTCHours() === 15 && localHourIsEight(laEight)).toBe(true);
expect(called).toContain(String(la._id));
expect(called).not.toContain(String(nozone._id));
});

it('nobody gets a second digest within 20 hours, even if their hour comes round again', async () => {
const member = await person(new Date(Date.now() - 2 * 86400000));
await Summary.create({
type: 'daily-digest',
title: 'earlier',
content: '- earlier',
timeRange: { start: new Date(Date.now() - 86400000), end: new Date() },
metadata: { totalItems: 1, userId: String(member._id) },
});
const service = new DailyDigestService();
const generate = jest.spyOn(service, 'generateUserDailyDigest').mockResolvedValue({ _id: 'digest-1' });

await service.generateAllDailyDigests({ at: digestHourUtc() });

expect(generate.mock.calls.map(([id]) => String(id))).not.toContain(String(member._id));
});
});

// Cofounder on #2286: two real hourly runs within 20 hours never digest the
// same person twice, here when their zone is captured between the runs and
// makes the second run their hour too.
test('two scheduled runs within 20 hours never digest the same person twice', async () => {
const member = await person(new Date(Date.now() - 2 * 86400000));
const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({
board: [{
taskId: 'TASK-9', title: 'Ship it', status: 'done', podId: 'p1', podName: 'Payments API',
}],
}));
const service = new DailyDigestService();
const sixUtc = digestHourUtc();
const eightUtc = new Date(sixUtc.getTime() + 2 * 60 * 60 * 1000);

await service.generateAllDailyDigests({ at: sixUtc });
await User.updateOne({ _id: member._id }, { $set: { timezone: 'UTC' } });
expect(DailyDigestService.dueAt({ timezone: 'UTC' }, eightUtc)).toBe(true);
await service.generateAllDailyDigests({ at: eightUtc });

expect(await Summary.countDocuments({ type: 'daily-digest', 'metadata.userId': String(member._id) })).toBe(1);
spy.mockRestore();
});

test('scheduler includes a member with recent pod work even after seven days away', async () => {
const member = await person(new Date(Date.now() - 10 * 86400000));
const creator = await person(new Date(Date.now() - 10 * 86400000));
await podWithSummary(creator, 'Active Agent Pod', { members: [member] });
const service = new DailyDigestService();
const generate = jest.spyOn(service, 'generateUserDailyDigest').mockResolvedValue({ _id: 'digest-1' });

const results = await service.generateAllDailyDigests();
const results = await service.generateAllDailyDigests({ at: digestHourUtc() });

expect(generate).toHaveBeenCalledWith(member._id, { requireAway: true });
expect(results).toHaveLength(2);
Expand All @@ -329,7 +407,7 @@ test('scheduler does not wake a stale member for public-read pod activity', asyn
const service = new DailyDigestService();
const generate = jest.spyOn(service, 'generateUserDailyDigest').mockResolvedValue({ _id: 'digest-1' });

await service.generateAllDailyDigests();
await service.generateAllDailyDigests({ at: digestHourUtc() });

expect(generate).not.toHaveBeenCalled();
});
Expand All @@ -342,7 +420,7 @@ test('scheduler does not generate a digest for a bot', async () => {
const service = new DailyDigestService();
const generate = jest.spyOn(service, 'generateUserDailyDigest').mockResolvedValue({ _id: 'digest-1' });

await service.generateAllDailyDigests();
await service.generateAllDailyDigests({ at: digestHourUtc() });

expect(generate).not.toHaveBeenCalled();
});
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('scheduler daily digest delivery', () => {
digestEmailService.sendDigestEmails.mockResolvedValue({ sent: 1 });
schedulerService.start();

const dailyDigestCallback = mockScheduledCallbacks.get('0 6 * * *');
const dailyDigestCallback = mockScheduledCallbacks.get('1 * * * *');
await dailyDigestCallback();

expect(dailyDigestService.generateAllDailyDigests).toHaveBeenCalledTimes(1);
Expand Down
29 changes: 29 additions & 0 deletions backend/__tests__/unit/utils/timeZone.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const { cleanTimeZone, localHour } = require('../../../utils/timeZone');

describe('cleanTimeZone', () => {
it('keeps a zone this runtime knows, in its canonical spelling', () => {
expect(cleanTimeZone('America/Los_Angeles')).toBe('America/Los_Angeles');
// ICU may answer with a link's older name (Node 22: Asia/Kolkata -> Asia/Calcutta); either is the same zone.
expect(['Asia/Kolkata', 'Asia/Calcutta']).toContain(cleanTimeZone(' Asia/Kolkata '));
expect(cleanTimeZone('UTC')).toBe('UTC');
});

it.each([
['an unknown zone', 'Mars/Phobos'],
['markup', '<b>UTC</b>'],
['too long', `America/${'x'.repeat(70)}`],
['an object', { $gt: '' }],
['empty', ''],
])('drops %s', (_label, raw) => {
expect(cleanTimeZone(raw)).toBeUndefined();
});
});

describe('localHour', () => {
it('reads the hour in that zone, half-hour zones included', () => {
const at = new Date('2026-10-17T15:00:00Z');
expect(localHour('America/Los_Angeles', at)).toBe(8); // PDT, UTC-7
expect(localHour('UTC', at)).toBe(15);
expect(localHour('Asia/Kolkata', new Date('2026-10-17T03:01:00Z'))).toBe(8); // 08:31, UTC+5:30
});
});
4 changes: 4 additions & 0 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ export interface IUser extends Document {
digestUnsubscribeToken?: string;
lastActive: Date;
lastDigestSent?: Date;
// IANA zone the browser reported, set once (routes/users.ts POST
// /me/timezone); the daily digest goes out at 08:00 there.
timezone?: string;
/**
* First-session milestones: write-once timestamps, set at most once per
* person and never overwritten (services/onboardingMarks.ts).
Expand Down Expand Up @@ -417,6 +420,7 @@ const userSchema = new Schema<IUser>({
signupRef: { type: String },
},
lastDigestSent: { type: Date },
timezone: { type: String },
createdAt: { type: Date, default: Date.now },
});

Expand Down
23 changes: 23 additions & 0 deletions backend/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const auth = require('../middleware/auth');
// eslint-disable-next-line global-require
const User = require('../models/User');
// eslint-disable-next-line global-require
const { cleanTimeZone } = require('../utils/timeZone');
// eslint-disable-next-line global-require
const { AgentInstallation } = require('../models/AgentRegistry');
// eslint-disable-next-line global-require
const AgentIdentityService = require('../services/agentIdentityService');
Expand Down Expand Up @@ -178,6 +180,27 @@ router.get('/:id', auth, getUserById);
router.post('/:id/follow', auth, followUser);
router.delete('/:id/follow', auth, unfollowUser);

// POST /me/timezone { timeZone }: the IANA zone this person's browser reports,
// sent once by the app shell. Set once and never overwritten, so the digest's
// 08:00 is stable (services/dailyDigestService.ts). A zone this runtime does
// not know is ignored, and the reply is 204 either way.
router.post('/me/timezone', profileWriteIngressLimit, auth, profileWriteUserLimit, async (req: any, res: any) => {
const userId = req.userId || req.user?.id || req.user?._id;
if (!userId) return res.status(401).json({ msg: 'Unauthorized' });
const timeZone = cleanTimeZone(req.body?.timeZone);
if (timeZone) {
try {
await User.updateOne(
{ _id: userId, isBot: { $ne: true }, timezone: { $exists: false } },
{ $set: { timezone: timeZone } },
);
} catch (error) {
console.warn('[users] timezone capture failed:', (error as Error).message);
}
}
return res.status(204).end();
});

module.exports = router;

export {};
34 changes: 28 additions & 6 deletions backend/services/dailyDigestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const Pod = require('../models/Pod');
const ActivityService = require('./activityService');
// eslint-disable-next-line global-require
const { resolveAgentDisplayLabel } = require('./agentIdentityService');
// eslint-disable-next-line global-require
const { cleanTimeZone, localHour } = require('../utils/timeZone');

/**
* The daily digest is "what your agents did while you were away" (stickiness
Expand Down Expand Up @@ -51,6 +53,14 @@ interface RecapLike {

/** Someone active this recently has seen it in the app; the email would repeat it. */
export const DIGEST_AWAY_MS = 20 * 60 * 60 * 1000;
// Phase 2b (TASK-257): the scheduler runs every hour, and each run takes the
// people for whom it is 08:00 now. A known zone (User.timezone) means 08:00
// there; an unknown or unusable one means 06:00 UTC, the one send time before.
export const DIGEST_LOCAL_HOUR = 8;
export const DIGEST_DEFAULT_UTC_HOUR = 6;
// Anyone with a digest from the last 20 hours is skipped, so a zone captured
// mid-day, or a run repeated after a restart, never sends twice in a day.
const DIGEST_REPEAT_GUARD_MS = 20 * 60 * 60 * 1000;
const MAX_NEEDS_YOU = 3;
const MAX_AGENTS = 5;
const MAX_BOARD = 5;
Expand Down Expand Up @@ -259,11 +269,17 @@ This might be a great time to start a new conversation or share something intere
};
}

async generateAllDailyDigests(): Promise<DigestResult[]> {
/** Whether it is the digest hour for this person at `at`. */
static dueAt(user: { timezone?: unknown }, at: Date): boolean {
const zone = cleanTimeZone(user.timezone);
return zone ? localHour(zone, at) === DIGEST_LOCAL_HOUR : at.getUTCHours() === DIGEST_DEFAULT_UTC_HOUR;
}

async generateAllDailyDigests({ at = new Date() }: { at?: Date } = {}): Promise<DigestResult[]> {
try {
console.log('Starting daily digest generation for all users...');

const startTime = new Date(Date.now() - 24 * 60 * 60 * 1000);
const startTime = new Date(at.getTime() - 24 * 60 * 60 * 1000);
const recentPodIds = await Summary.distinct('podId', {
type: { $in: ['posts', 'chats'] },
podId: { $exists: true },
Expand All @@ -276,15 +292,21 @@ This might be a great time to start a new conversation or share something intere
publicRead: { $ne: true },
});

const activeUsers = await User.find({
const candidates = await User.find({
isBot: { $ne: true },
$or: [
{ lastActive: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } },
{ lastActive: { $gte: new Date(at.getTime() - 7 * 24 * 60 * 60 * 1000) } },
{ _id: { $in: recentMemberIds } },
],
}).lean() as Array<{ _id: unknown; username?: string }>;
}).lean() as Array<{ _id: unknown; username?: string; timezone?: string }>;
const alreadyHave = new Set((await Summary.distinct('metadata.userId', {
type: 'daily-digest',
createdAt: { $gte: new Date(at.getTime() - DIGEST_REPEAT_GUARD_MS) },
})).map(String));
const activeUsers = candidates.filter((user) => DailyDigestService.dueAt(user, at)
&& !alreadyHave.has(String(user._id)));

console.log(`Found ${activeUsers.length} active users for daily digest generation`);
console.log(`Found ${activeUsers.length} of ${candidates.length} active users due for a digest this hour`);

const results = await Promise.allSettled(
activeUsers.map(async (user) => {
Expand Down
6 changes: 5 additions & 1 deletion backend/services/schedulerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,12 @@ class SchedulerService {
{ scheduled: false, timezone: 'UTC' },
);

// Hourly, at minute 1: each run sends to the people for whom it is 08:00
// now (06:00 UTC when their zone is unknown), at most once a day
// (dailyDigestService). A minute past the hour still lands inside 08:00 in
// the half-hour zones (India's 08:30 at 03:01 UTC).
const dailyDigestJob: CronJob = cron.schedule(
'0 6 * * *',
'1 * * * *',
async () => {
console.log('Running daily digest generation...');
try {
Expand Down
26 changes: 26 additions & 0 deletions backend/utils/timeZone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// An IANA time zone name as a browser reports it
// (Intl.DateTimeFormat().resolvedOptions().timeZone), kept only if this
// runtime knows it, in its canonical spelling. Used to send the daily digest
// at 08:00 in the person's own day (services/dailyDigestService.ts).
const TIME_ZONE_SHAPE = /^[A-Za-z0-9_+\-/]{1,64}$/;

const cleanTimeZone = (raw: unknown): string | undefined => {
const zone = typeof raw === 'string' ? raw.trim() : '';
if (!TIME_ZONE_SHAPE.test(zone)) return undefined;
try {
return new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone;
} catch {
return undefined;
}
};

/** The hour (0-23) it is now in that zone. */
const localHour = (zone: string, at: Date): number => {
const hour = new Intl.DateTimeFormat('en-US', { timeZone: zone, hour: 'numeric', hourCycle: 'h23' })
.formatToParts(at).find((part) => part.type === 'hour');
return Number(hour?.value);
};

module.exports = { cleanTimeZone, localHour };

export {};
Loading
Loading