Skip to content

Commit ff79078

Browse files
committed
feat(daily): adjust cache to be until next timezone relative time
1 parent a64b62c commit ff79078

5 files changed

Lines changed: 110 additions & 5 deletions

File tree

__tests__/common/date.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,62 @@
1-
import { isWeekend, DayOfWeek } from '../../src/common/date';
1+
import { formatInTimeZone } from 'date-fns-tz';
2+
import {
3+
isWeekend,
4+
DayOfWeek,
5+
secondsUntilNextHourInTimezone,
6+
} from '../../src/common/date';
7+
import { ONE_DAY_IN_SECONDS } from '../../src/common/constants';
28

39
describe('date', () => {
10+
describe('secondsUntilNextHourInTimezone', () => {
11+
const expiryAt = (now: Date, ttl: number): Date =>
12+
new Date(now.getTime() + ttl * 1000);
13+
14+
it('should expire at the next 9am in the given timezone', () => {
15+
const now = new Date('2026-06-19T06:00:00Z');
16+
17+
['Etc/UTC', 'America/New_York', 'Asia/Tokyo'].forEach((timezone) => {
18+
const ttl = secondsUntilNextHourInTimezone({ hour: 9, timezone, now });
19+
20+
expect(ttl).toBeGreaterThan(0);
21+
expect(ttl).toBeLessThanOrEqual(ONE_DAY_IN_SECONDS);
22+
expect(formatInTimeZone(expiryAt(now, ttl), timezone, 'HH:mm')).toBe(
23+
'09:00',
24+
);
25+
expect(expiryAt(now, ttl).getTime()).toBeGreaterThan(now.getTime());
26+
});
27+
});
28+
29+
it('should roll to the next day once 9am has passed in the timezone', () => {
30+
// 23:00 UTC is already past 9am UTC today, so it targets tomorrow.
31+
const now = new Date('2026-06-19T23:00:00Z');
32+
const ttl = secondsUntilNextHourInTimezone({
33+
hour: 9,
34+
timezone: 'Etc/UTC',
35+
now,
36+
});
37+
38+
expect(
39+
formatInTimeZone(expiryAt(now, ttl), 'Etc/UTC', 'yyyy-MM-dd HH:mm'),
40+
).toBe('2026-06-20 09:00');
41+
});
42+
43+
it('should shift with the timezone for the same instant', () => {
44+
const now = new Date('2026-06-19T06:00:00Z');
45+
const utc = secondsUntilNextHourInTimezone({
46+
hour: 9,
47+
timezone: 'Etc/UTC',
48+
now,
49+
});
50+
const tokyo = secondsUntilNextHourInTimezone({
51+
hour: 9,
52+
timezone: 'Asia/Tokyo',
53+
now,
54+
});
55+
56+
expect(utc).not.toEqual(tokyo);
57+
});
58+
});
59+
460
describe('isWeekend', () => {
561
it('should return true for Saturday and Sunday when the week starts on Monday', () => {
662
expect(isWeekend(new Date('2024-08-10'), DayOfWeek.Monday)).toBe(true); // Saturday

src/common/date.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { zonedTimeToUtc } from 'date-fns-tz';
2+
13
export enum DayOfWeek {
24
Sunday = 0,
35
Monday = 1,
@@ -27,6 +29,25 @@ export const isWeekend = (
2729
}
2830
};
2931

32+
export const secondsUntilNextHourInTimezone = ({
33+
hour,
34+
timezone,
35+
now = new Date(),
36+
}: {
37+
hour: number;
38+
timezone: string;
39+
now?: Date;
40+
}): number => {
41+
const target = new Date(now);
42+
target.setHours(hour, 0, 0, 0);
43+
let targetUtc = zonedTimeToUtc(target, timezone);
44+
if (targetUtc.getTime() <= now.getTime()) {
45+
target.setDate(target.getDate() + 1);
46+
targetUtc = zonedTimeToUtc(target, timezone);
47+
}
48+
return Math.max(60, Math.ceil((targetUtc.getTime() - now.getTime()) / 1000));
49+
};
50+
3051
export const getSecondsTimestamp = (ms: number | Date): number => {
3152
const msValue = ms instanceof Date ? ms.getTime() : ms;
3253

src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export enum StorageTopic {
4343
RedisCounter = 'redis_counter',
4444
Cron = 'cron',
4545
LiveRoom = 'live_room',
46+
Feed = 'feeds',
4647
}
4748

4849
export enum StorageKey {
@@ -57,6 +58,7 @@ export enum StorageKey {
5758
UserLastOnline = 'ulo',
5859
ParticipantCount = 'participant_count',
5960
HasLiveRooms = 'has_live_rooms',
61+
DailyFeed = 'daily',
6062
}
6163

6264
export const generateStorageKey = (

src/remoteConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export type RemoteConfigValue = {
5454
onboarding_funnel_id: string;
5555
}>;
5656
dailyBriefLimit: number;
57+
dailyFeedCacheKey: string;
5758
superAgentTrial: SuperAgentTrialConfig;
5859
digestPostEnabled: boolean;
5960
newViewLogs: boolean;

src/schema/feeds.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,12 @@ import {
7272
getForYouByTagFeedGenerator,
7373
} from '../integrations/feed/generators';
7474
import { getRedisObject, setRedisObjectWithExpiry } from '../redis';
75-
import { ONE_DAY_IN_SECONDS } from '../common/constants';
75+
import { remoteConfig } from '../remoteConfig';
76+
import { generateStorageKey, StorageKey, StorageTopic } from '../config';
77+
import {
78+
DEFAULT_TIMEZONE,
79+
secondsUntilNextHourInTimezone,
80+
} from '../common/date';
7681
import type { FeedResponse } from '../integrations/feed';
7782
import {
7883
AuthenticationError,
@@ -1695,7 +1700,19 @@ const dailyFeedResolver = feedResolver<
16951700
(ctx, args, page, builder) => builder,
16961701
{
16971702
fetchQueryParams: async (ctx, args: FeedArgs, page) => {
1698-
const cacheKey = `feeds:daily:${ctx.userId ?? ctx.trackingId}:${page.limit}:${page.cursor ?? ''}:${(args.supportedTypes ?? []).join(',')}`;
1703+
const cacheKey = generateStorageKey(
1704+
StorageTopic.Feed,
1705+
StorageKey.DailyFeed,
1706+
[
1707+
remoteConfig.vars.dailyFeedCacheKey,
1708+
ctx.userId ?? ctx.trackingId,
1709+
page.limit,
1710+
page.cursor,
1711+
args.supportedTypes?.join(','),
1712+
]
1713+
.filter(Boolean)
1714+
.join(':'),
1715+
);
16991716
const cached = await getRedisObject(cacheKey);
17001717
if (cached) {
17011718
return JSON.parse(cached) as FeedResponse;
@@ -1717,12 +1734,20 @@ const dailyFeedResolver = feedResolver<
17171734
config,
17181735
extraMetadata,
17191736
);
1720-
// Don't cache empty responses so a not-yet-generated feed isn't pinned for a day.
17211737
if (response.data.length) {
1738+
const user = ctx.userId
1739+
? await ctx.dataLoader.user.load({
1740+
userId: ctx.userId,
1741+
select: ['timezone'],
1742+
})
1743+
: null;
17221744
await setRedisObjectWithExpiry(
17231745
cacheKey,
17241746
JSON.stringify(response),
1725-
ONE_DAY_IN_SECONDS,
1747+
secondsUntilNextHourInTimezone({
1748+
hour: 9,
1749+
timezone: user?.timezone || DEFAULT_TIMEZONE,
1750+
}),
17261751
);
17271752
}
17281753
return response;

0 commit comments

Comments
 (0)