From 68e54c8f8c7c508c220bb4b2359b0fd06ec0c508 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:03:17 -0700 Subject: [PATCH 1/2] feat(digest): the daily digest arrives at 08:00 in the person's own day Stickiness Phase 2b (TASK-257). The digest went out at 06:00 UTC for everyone: 23:00 the night before in California, 11:30 in India. - The zone: the v2 shell sends the browser's IANA zone once per signed-in account (useTimeZoneCapture) to POST /api/users/me/timezone, which keeps the first valid zone and never overwrites it (User.timezone). It is a JSON body on an existing-shape request, not a new header: the API's CORS list allows only Content-Type, Authorization and x-auth-token, and a new header would fail every request from a new frontend during a rolling deploy. A frontend ahead of the backend gets a harmless 404. A zone the runtime does not know is ignored (utils/timeZone.ts); the reply is 204. - The send: the digest job runs hourly at minute 1, and each run takes the people for whom it is 08:00 in their zone, or 06:00 UTC when it is unknown or unusable. Minute 1 still lands inside 08:00 in half-hour zones. Nobody gets a second digest within 20 hours, so a zone captured mid-day, or a repeated run, never sends twice. The away rule and the digest.sending stop switch are unchanged. - The runbook says when the digest goes out. Tests: the zone cleaner keeps known zones and drops unknown ones, markup and objects; the local hour, half-hour zones included; who is due when (a known zone at its 08:00 and not otherwise, no zone at 06:00 UTC, an unusable zone falls back); a scheduled run takes only those due; nobody gets a second digest within 20 hours; the route keeps the first zone, ignores bad ones and bots; the shell sends once per account per browser, not when signed out, and retries a failed send. Mutations: the zone ignored, the repeat guard removed, the route overwriting, the zone not validated, the hook resending, and the hook sending signed out each red an arm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../unit/routes/users.timezone.test.js | 64 +++++++++++++++++++ .../dailyDigestService.membership.test.js | 61 +++++++++++++++++- .../schedulerService.digestEmail.test.js | 2 +- backend/__tests__/unit/utils/timeZone.test.js | 29 +++++++++ backend/models/User.ts | 4 ++ backend/routes/users.ts | 23 +++++++ backend/services/dailyDigestService.ts | 34 ++++++++-- backend/services/schedulerService.ts | 6 +- backend/utils/timeZone.ts | 26 ++++++++ docs/runbooks/daily-digest.md | 14 ++-- frontend/src/v2/V2App.tsx | 3 + .../src/v2/hooks/useTimeZoneCapture.test.tsx | 53 +++++++++++++++ frontend/src/v2/hooks/useTimeZoneCapture.ts | 40 ++++++++++++ 13 files changed, 343 insertions(+), 16 deletions(-) create mode 100644 backend/__tests__/unit/routes/users.timezone.test.js create mode 100644 backend/__tests__/unit/utils/timeZone.test.js create mode 100644 backend/utils/timeZone.ts create mode 100644 frontend/src/v2/hooks/useTimeZoneCapture.test.tsx create mode 100644 frontend/src/v2/hooks/useTimeZoneCapture.ts 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..f408e406ae 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,54 @@ 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)); + }); +}); + 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 +372,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 +384,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 +397,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]); +}; From a5764259883ee64928a96b320058d6e003a69b56 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:04:37 -0700 Subject: [PATCH 2/2] test(digest): two real hourly runs within 20 hours never digest the same person twice Cofounder on #2286: the repeat guard was tested against a seeded earlier digest, not two actual runs. This arm runs the scheduled job at 06:00 UTC (no zone on file: due), captures the person's zone between runs so 08:00 UTC is their hour too, runs again, and finds one digest. Mutation: the repeat guard removed reds it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../dailyDigestService.membership.test.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index f408e406ae..34e51ec63f 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -365,6 +365,29 @@ describe('the digest hour', () => { }); }); +// 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));