diff --git a/backend/__tests__/unit/routes/users.timezone.test.js b/backend/__tests__/unit/routes/users.timezone.test.js
new file mode 100644
index 0000000000..ea0278bdeb
--- /dev/null
+++ b/backend/__tests__/unit/routes/users.timezone.test.js
@@ -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', 'UTC'],
+ ['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();
+});
diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js
index 9933bc73cf..34e51ec63f 100644
--- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js
+++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js
@@ -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,
@@ -310,6 +317,77 @@ 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));
@@ -317,7 +395,7 @@ test('scheduler includes a member with recent pod work even after seven days awa
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);
@@ -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();
});
@@ -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();
});
diff --git a/backend/__tests__/unit/services/schedulerService.digestEmail.test.js b/backend/__tests__/unit/services/schedulerService.digestEmail.test.js
index 862970f25b..e8b9dd357a 100644
--- a/backend/__tests__/unit/services/schedulerService.digestEmail.test.js
+++ b/backend/__tests__/unit/services/schedulerService.digestEmail.test.js
@@ -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);
diff --git a/backend/__tests__/unit/utils/timeZone.test.js b/backend/__tests__/unit/utils/timeZone.test.js
new file mode 100644
index 0000000000..e995c5753d
--- /dev/null
+++ b/backend/__tests__/unit/utils/timeZone.test.js
@@ -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', 'UTC'],
+ ['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
+ });
+});
diff --git a/backend/models/User.ts b/backend/models/User.ts
index ca03d6159d..1b6f44a07c 100644
--- a/backend/models/User.ts
+++ b/backend/models/User.ts
@@ -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).
@@ -417,6 +420,7 @@ const userSchema = new Schema({
signupRef: { type: String },
},
lastDigestSent: { type: Date },
+ timezone: { type: String },
createdAt: { type: Date, default: Date.now },
});
diff --git a/backend/routes/users.ts b/backend/routes/users.ts
index 97a9e7c606..58b7aab246 100644
--- a/backend/routes/users.ts
+++ b/backend/routes/users.ts
@@ -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');
@@ -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 {};
diff --git a/backend/services/dailyDigestService.ts b/backend/services/dailyDigestService.ts
index 086aea504c..191595c065 100644
--- a/backend/services/dailyDigestService.ts
+++ b/backend/services/dailyDigestService.ts
@@ -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
@@ -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;
@@ -259,11 +269,17 @@ This might be a great time to start a new conversation or share something intere
};
}
- async generateAllDailyDigests(): Promise {
+ /** 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 {
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 },
@@ -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) => {
diff --git a/backend/services/schedulerService.ts b/backend/services/schedulerService.ts
index 75ca19f2e1..720964d9cf 100644
--- a/backend/services/schedulerService.ts
+++ b/backend/services/schedulerService.ts
@@ -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 {
diff --git a/backend/utils/timeZone.ts b/backend/utils/timeZone.ts
new file mode 100644
index 0000000000..e62c3a5601
--- /dev/null
+++ b/backend/utils/timeZone.ts
@@ -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 {};
diff --git a/docs/runbooks/daily-digest.md b/docs/runbooks/daily-digest.md
index 5c38f6b23c..bfed1ac5ff 100644
--- a/docs/runbooks/daily-digest.md
+++ b/docs/runbooks/daily-digest.md
@@ -1,11 +1,15 @@
# Daily digest: stopping a bad run
The daily digest ("What your agents did while you were away") is generated and
-emailed by the backend scheduler at **06:00 UTC** (`schedulerService`,
-`dailyDigestService` → `digestEmailService`). Content comes from the activity
-recap and is limited to the recipient's own private pods. Only digests with at
-least one line are emailed, and only to verified users who haven't turned the
-digest off.
+emailed by the backend scheduler **every hour, at minute 1** (`schedulerService`,
+`dailyDigestService` → `digestEmailService`). Each run takes the people for
+whom it is **08:00 in their own time zone**. The zone is set once from the
+browser (`POST /api/users/me/timezone`, `User.timezone`); with no zone on file,
+it's **06:00 UTC**. Nobody gets a second digest within 20 hours.
+
+Content comes from the activity recap and is limited to the recipient's own
+private pods. Only digests with at least one line are emailed, and only to
+verified users who haven't turned the digest off.
## Stop all digest email, in seconds
diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx
index 11107bd7a9..b1e3d15a1b 100644
--- a/frontend/src/v2/V2App.tsx
+++ b/frontend/src/v2/V2App.tsx
@@ -3,6 +3,7 @@ import { Routes, Route, Navigate, useLocation, useParams } from 'react-router-do
import V2Layout from './components/V2Layout';
import V2FeaturePage from './components/V2FeaturePage';
import { useAuth } from '../context/AuthContext';
+import { useTimeZoneCapture } from './hooks/useTimeZoneCapture';
import RegistrationInviteRequired from '../components/RegistrationInviteRequired';
import VerifyEmail from '../components/VerifyEmail';
import DiscordCallback from '../components/DiscordCallback';
@@ -174,6 +175,8 @@ const V2PodIdRoute: React.FC = () => {
};
const V2App: React.FC = () => {
+ // The daily digest's 08:00 is the person's own (TASK-257 Phase 2b).
+ useTimeZoneCapture();
React.useEffect(() => {
try {
sessionStorage.setItem('commonly.v2.active', '1');
diff --git a/frontend/src/v2/hooks/useTimeZoneCapture.test.tsx b/frontend/src/v2/hooks/useTimeZoneCapture.test.tsx
new file mode 100644
index 0000000000..9cb6e49e1e
--- /dev/null
+++ b/frontend/src/v2/hooks/useTimeZoneCapture.test.tsx
@@ -0,0 +1,53 @@
+// @ts-nocheck
+import React from 'react';
+import { render, waitFor } from '@testing-library/react';
+import { AuthContext } from '../../context/AuthContext';
+import { useTimeZoneCapture } from './useTimeZoneCapture';
+
+jest.mock('../../utils/axiosConfig', () => ({ __esModule: true, default: { post: jest.fn() } }));
+const axios = jest.requireMock('../../utils/axiosConfig').default;
+
+const Probe = () => { useTimeZoneCapture(); return null; };
+const renderAs = (auth) => render(
+
+
+ ,
+);
+const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+beforeEach(() => {
+ axios.post.mockReset().mockResolvedValue({ status: 204 });
+ localStorage.clear();
+});
+
+test('a signed-in account sends its zone once, then not again from this browser', async () => {
+ const me = { isAuthenticated: true, currentUser: { _id: 'u1' } };
+ const first = renderAs(me);
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/users/me/timezone', { timeZone: zone }));
+ first.unmount();
+ await waitFor(() => expect(localStorage.getItem('commonly.timeZoneSent:u1')).toBe(zone));
+
+ renderAs(me);
+ await new Promise((r) => setTimeout(r, 20));
+ expect(axios.post).toHaveBeenCalledTimes(1);
+});
+
+test('another account on the same browser still sends its own', async () => {
+ localStorage.setItem('commonly.timeZoneSent:u1', zone);
+ renderAs({ isAuthenticated: true, currentUser: { _id: 'u2' } });
+ await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1));
+});
+
+test('signed out, nothing is sent, even with a user object still around', async () => {
+ renderAs({ isAuthenticated: false, currentUser: { _id: 'u4' } });
+ await new Promise((r) => setTimeout(r, 20));
+ expect(axios.post).not.toHaveBeenCalled();
+});
+
+test('a failed send is retried next time, not marked as sent', async () => {
+ axios.post.mockRejectedValueOnce(new Error('404'));
+ renderAs({ isAuthenticated: true, currentUser: { _id: 'u3' } });
+ await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1));
+ await new Promise((r) => setTimeout(r, 20));
+ expect(localStorage.getItem('commonly.timeZoneSent:u3')).toBeNull();
+});
diff --git a/frontend/src/v2/hooks/useTimeZoneCapture.ts b/frontend/src/v2/hooks/useTimeZoneCapture.ts
new file mode 100644
index 0000000000..434c336081
--- /dev/null
+++ b/frontend/src/v2/hooks/useTimeZoneCapture.ts
@@ -0,0 +1,40 @@
+import { useEffect } from 'react';
+import axios from '../../utils/axiosConfig';
+import { useAuth } from '../../context/AuthContext';
+
+/**
+ * Sends this browser's IANA time zone once per signed-in account, so the daily
+ * digest arrives at 08:00 in the person's own day (TASK-257 Phase 2b). The
+ * server keeps the first zone it is sent and never overwrites it (POST
+ * /api/users/me/timezone), so this only saves repeat requests. A browser that
+ * cannot report a zone, or storage that throws, just means "try next time".
+ */
+const sentKey = (userId: string) => `commonly.timeZoneSent:${userId}`;
+
+export const browserTimeZone = (): string | null => {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
+ } catch {
+ return null;
+ }
+};
+
+export const useTimeZoneCapture = (): void => {
+ const { isAuthenticated, currentUser } = useAuth();
+ const userId = currentUser?._id;
+ useEffect(() => {
+ if (!isAuthenticated || !userId) return;
+ const zone = browserTimeZone();
+ if (!zone) return;
+ try {
+ if (window.localStorage.getItem(sentKey(userId)) === zone) return;
+ } catch {
+ // Unreadable storage: send anyway; the server is write-once.
+ }
+ axios.post('/api/users/me/timezone', { timeZone: zone })
+ .then(() => {
+ try { window.localStorage.setItem(sentKey(userId), zone); } catch { /* next time */ }
+ })
+ .catch(() => {});
+ }, [isAuthenticated, userId]);
+};