Skip to content

Commit 1a7df8f

Browse files
authored
Merge pull request #148 from ExcelDsigN-tech/fix/optimize-getUpcomingSubscriptions
perf: optimize getUpcomingSubscriptions Date creation
2 parents a6d4e05 + 322c511 commit 1a7df8f

File tree

2 files changed

+183
-7
lines changed

2 files changed

+183
-7
lines changed
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { getUpcomingSubscriptions, _clearUpcomingCache } from '../dummyData';
2+
import { Subscription, SubscriptionCategory, BillingCycle } from '../../types/subscription';
3+
4+
/** Helper to build a minimal Subscription for testing. */
5+
const makeSub = (
6+
overrides: Partial<Subscription> & { id: string; nextBillingDate: Date }
7+
): Subscription => ({
8+
name: `Sub ${overrides.id}`,
9+
description: '',
10+
category: SubscriptionCategory.OTHER,
11+
price: 9.99,
12+
currency: 'USD',
13+
billingCycle: BillingCycle.MONTHLY,
14+
isActive: true,
15+
isCryptoEnabled: false,
16+
createdAt: new Date('2024-01-01'),
17+
updatedAt: new Date('2024-01-01'),
18+
...overrides,
19+
});
20+
21+
const DAY_MS = 24 * 60 * 60 * 1000;
22+
23+
describe('getUpcomingSubscriptions', () => {
24+
beforeAll(() => {
25+
jest.useFakeTimers();
26+
jest.setSystemTime(new Date('2024-06-15T12:00:00Z'));
27+
});
28+
29+
afterAll(() => {
30+
jest.useRealTimers();
31+
});
32+
33+
beforeEach(() => {
34+
_clearUpcomingCache();
35+
});
36+
37+
const NOW = new Date('2024-06-15T12:00:00Z').getTime();
38+
39+
it('returns active subscriptions within the next 7 days', () => {
40+
const subs: Subscription[] = [
41+
makeSub({ id: '1', nextBillingDate: new Date(NOW + 1 * DAY_MS) }),
42+
makeSub({ id: '2', nextBillingDate: new Date(NOW + 6 * DAY_MS) }),
43+
];
44+
45+
const result = getUpcomingSubscriptions(subs);
46+
expect(result).toHaveLength(2);
47+
expect(result[0].id).toBe('1');
48+
expect(result[1].id).toBe('2');
49+
});
50+
51+
it('excludes inactive subscriptions', () => {
52+
const subs: Subscription[] = [
53+
makeSub({ id: '1', nextBillingDate: new Date(NOW + 1 * DAY_MS), isActive: false }),
54+
makeSub({ id: '2', nextBillingDate: new Date(NOW + 2 * DAY_MS) }),
55+
];
56+
57+
const result = getUpcomingSubscriptions(subs);
58+
expect(result).toHaveLength(1);
59+
expect(result[0].id).toBe('2');
60+
});
61+
62+
it('excludes subscriptions beyond 7 days', () => {
63+
const subs: Subscription[] = [
64+
makeSub({ id: '1', nextBillingDate: new Date(NOW + 8 * DAY_MS) }),
65+
makeSub({ id: '2', nextBillingDate: new Date(NOW + 15 * DAY_MS) }),
66+
];
67+
68+
const result = getUpcomingSubscriptions(subs);
69+
expect(result).toHaveLength(0);
70+
});
71+
72+
it('excludes subscriptions in the past', () => {
73+
const subs: Subscription[] = [
74+
makeSub({ id: '1', nextBillingDate: new Date(NOW - 1 * DAY_MS) }),
75+
];
76+
77+
const result = getUpcomingSubscriptions(subs);
78+
expect(result).toHaveLength(0);
79+
});
80+
81+
it('returns results sorted by billing date ascending', () => {
82+
const subs: Subscription[] = [
83+
makeSub({ id: 'c', nextBillingDate: new Date(NOW + 5 * DAY_MS) }),
84+
makeSub({ id: 'a', nextBillingDate: new Date(NOW + 1 * DAY_MS) }),
85+
makeSub({ id: 'b', nextBillingDate: new Date(NOW + 3 * DAY_MS) }),
86+
];
87+
88+
const result = getUpcomingSubscriptions(subs);
89+
expect(result.map((s) => s.id)).toEqual(['a', 'b', 'c']);
90+
});
91+
92+
it('includes subscriptions due exactly today (now)', () => {
93+
const subs: Subscription[] = [makeSub({ id: '1', nextBillingDate: new Date(NOW) })];
94+
95+
const result = getUpcomingSubscriptions(subs);
96+
expect(result).toHaveLength(1);
97+
});
98+
99+
it('includes subscriptions due exactly at the 7-day boundary', () => {
100+
const subs: Subscription[] = [
101+
makeSub({ id: '1', nextBillingDate: new Date(NOW + 7 * DAY_MS) }),
102+
];
103+
104+
const result = getUpcomingSubscriptions(subs);
105+
expect(result).toHaveLength(1);
106+
});
107+
108+
it('handles empty array', () => {
109+
expect(getUpcomingSubscriptions([])).toEqual([]);
110+
});
111+
112+
it('handles null / undefined gracefully', () => {
113+
expect(getUpcomingSubscriptions(null as unknown as Subscription[])).toEqual([]);
114+
expect(getUpcomingSubscriptions(undefined as unknown as Subscription[])).toEqual([]);
115+
});
116+
117+
it('returns cached result for the same input reference', () => {
118+
const subs: Subscription[] = [
119+
makeSub({ id: '1', nextBillingDate: new Date(NOW + 1 * DAY_MS) }),
120+
];
121+
122+
const first = getUpcomingSubscriptions(subs);
123+
const second = getUpcomingSubscriptions(subs);
124+
expect(first).toBe(second); // Same reference (===) means cache hit
125+
});
126+
127+
it('invalidates cache when input reference changes', () => {
128+
const subs1: Subscription[] = [
129+
makeSub({ id: '1', nextBillingDate: new Date(NOW + 1 * DAY_MS) }),
130+
];
131+
const subs2: Subscription[] = [
132+
makeSub({ id: '2', nextBillingDate: new Date(NOW + 2 * DAY_MS) }),
133+
];
134+
135+
const first = getUpcomingSubscriptions(subs1);
136+
const second = getUpcomingSubscriptions(subs2);
137+
expect(first).not.toBe(second);
138+
expect(second[0].id).toBe('2');
139+
});
140+
});

src/utils/dummyData.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -151,21 +151,57 @@ export const dummySubscriptions: Subscription[] = [
151151
},
152152
];
153153

154+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
155+
const CACHE_TTL_MS = 60_000;
156+
157+
let _cache: {
158+
ref: Subscription[];
159+
len: number;
160+
ts: number;
161+
result: Subscription[];
162+
} | null = null;
163+
164+
/** Convert a Date, string, or numeric timestamp to a millisecond timestamp. */
165+
const toTimestamp = (d: Date | string | number): number =>
166+
typeof d === 'number' ? d : d instanceof Date ? d.getTime() : new Date(d).getTime();
167+
168+
/**
169+
* Clear the internal memoization cache.
170+
* Exposed for testing purposes only.
171+
*/
172+
export const _clearUpcomingCache = (): void => {
173+
_cache = null;
174+
};
175+
154176
export const getUpcomingSubscriptions = (subscriptions: Subscription[]): Subscription[] => {
155177
if (!subscriptions || !Array.isArray(subscriptions)) {
156178
return [];
157179
}
158180

159-
const today = new Date();
160-
const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000);
181+
const nowTs = Date.now();
161182

162-
return subscriptions
163-
.filter((sub) => sub.isActive)
183+
// Return cached result if input reference unchanged and cache is fresh
184+
if (
185+
_cache &&
186+
_cache.ref === subscriptions &&
187+
_cache.len === subscriptions.length &&
188+
nowTs - _cache.ts < CACHE_TTL_MS
189+
) {
190+
return _cache.result;
191+
}
192+
193+
const nextWeekTs = nowTs + SEVEN_DAYS_MS;
194+
195+
const result = subscriptions
164196
.filter((sub) => {
165-
const billingDate = new Date(sub.nextBillingDate);
166-
return billingDate >= today && billingDate <= nextWeek;
197+
if (!sub.isActive) return false;
198+
const ts = toTimestamp(sub.nextBillingDate);
199+
return ts >= nowTs && ts <= nextWeekTs;
167200
})
168-
.sort((a, b) => new Date(a.nextBillingDate).getTime() - new Date(b.nextBillingDate).getTime());
201+
.sort((a, b) => toTimestamp(a.nextBillingDate) - toTimestamp(b.nextBillingDate));
202+
203+
_cache = { ref: subscriptions, len: subscriptions.length, ts: nowTs, result };
204+
return result;
169205
};
170206

171207
export const getTotalMonthlySpending = (subscriptions: Subscription[]): number => {

0 commit comments

Comments
 (0)