Skip to content

Commit 9170ff7

Browse files
authored
test(models): verify timezone normalization and calendar data boundaries for notifications (JhaSourav07#3068)
## Description Fixes JhaSourav07#2900 Program: GSSoC 2026 This PR handles the implementation of an isolated unit and boundary integration test module tracking time-offset calculations and regional date shifts inside `models/Notification.ts`. Previously, distributed multi-region timeline handling lacked specific isolated timezone mapping tests, leaving system metrics susceptible to calculation drift or missing streaks across distinct display viewports (such as EST, IST, and JST). ### Changes Made * Created a brand new targeted boundary validation test file at `models/Notification.timezone-boundaries.test.ts`. * Designed exactly 5 distinct testing validations mapping cross-regional date rollover accuracy, Leap Year calendar safety (Feb 29 validation bounds), uniform ISO formatting patterns, structural shift transition boundaries (DST simulation frames), and out-of-bounds parameter fallback recoveries. * Eliminated global context leakage vulnerabilities by resetting tracking components inside the lifecycle hooks. ### Why this matters Guarantees data cohesion standards across global client footprints, making sure notification timelines and badge activity records render on identical calendar dates regardless of the user's localized physical tracking offset. ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`npm run test`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have starred the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [ ] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord server for faster collaboration, mentorship, and PR support.
2 parents 8183895 + ec84119 commit 9170ff7

1 file changed

Lines changed: 264 additions & 0 deletions

File tree

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { Notification } from './Notification';
3+
import { calculateMonthlyStats, calculateStreak, findTodayIndex } from '../lib/calculate';
4+
import type { ContributionCalendar, ContributionDay } from '../types';
5+
6+
type TimezoneLabel = 'UTC' | 'America/New_York' | 'Asia/Kolkata' | 'Asia/Tokyo';
7+
8+
const ALLOWED_TIMEZONES: ReadonlySet<TimezoneLabel> = new Set([
9+
'UTC',
10+
'America/New_York',
11+
'Asia/Kolkata',
12+
'Asia/Tokyo',
13+
]);
14+
15+
function buildCalendar(days: readonly ContributionDay[]): ContributionCalendar {
16+
return {
17+
totalContributions: days.reduce((sum, day) => sum + day.contributionCount, 0),
18+
weeks: [
19+
{
20+
contributionDays: days.map((day) => ({ ...day })),
21+
},
22+
],
23+
};
24+
}
25+
26+
function formatCalendarDate(instant: Date, locale: string, timeZone: string): string {
27+
return new Intl.DateTimeFormat(locale, {
28+
timeZone,
29+
year: 'numeric',
30+
month: 'long',
31+
day: '2-digit',
32+
}).format(instant);
33+
}
34+
35+
function getDatePartOrder(instant: Date, locale: string, timeZone: string): string[] {
36+
return new Intl.DateTimeFormat(locale, {
37+
timeZone,
38+
year: 'numeric',
39+
month: '2-digit',
40+
day: '2-digit',
41+
})
42+
.formatToParts(instant)
43+
.filter((part) => part.type !== 'literal')
44+
.map((part) => part.type);
45+
}
46+
47+
function getTimezoneOffsetMinutes(instant: Date, timeZone: string): number {
48+
const parts = new Intl.DateTimeFormat('en-US', {
49+
timeZone,
50+
year: 'numeric',
51+
month: '2-digit',
52+
day: '2-digit',
53+
hour: '2-digit',
54+
minute: '2-digit',
55+
second: '2-digit',
56+
hourCycle: 'h23',
57+
}).formatToParts(instant);
58+
59+
const values = Object.fromEntries(
60+
parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])
61+
) as Record<string, string>;
62+
63+
const zonedUtcTimestamp = Date.UTC(
64+
Number(values.year),
65+
Number(values.month) - 1,
66+
Number(values.day),
67+
Number(values.hour),
68+
Number(values.minute),
69+
Number(values.second)
70+
);
71+
72+
return Math.round((zonedUtcTimestamp - instant.getTime()) / 60000);
73+
}
74+
75+
function normalizeTimezoneLabel(input: string | null | undefined): TimezoneLabel {
76+
if (typeof input !== 'string') {
77+
return 'UTC';
78+
}
79+
80+
const normalized = input.trim();
81+
if (
82+
normalized.length === 0 ||
83+
normalized === '0' ||
84+
normalized === '+00:00' ||
85+
normalized === '-00:00'
86+
) {
87+
return 'UTC';
88+
}
89+
90+
return ALLOWED_TIMEZONES.has(normalized as TimezoneLabel) ? (normalized as TimezoneLabel) : 'UTC';
91+
}
92+
93+
afterEach(() => {
94+
vi.restoreAllMocks();
95+
vi.useRealTimers();
96+
if (typeof document !== 'undefined') {
97+
document.body.innerHTML = '';
98+
}
99+
});
100+
101+
describe('NotificationModel - Timezone Boundaries & Calendar Alignment', () => {
102+
it('maps commits onto the correct visual dates across UTC, EST, IST, and JST without boundary shifts', () => {
103+
const notification = new Notification({
104+
username: 'timezone-case-1',
105+
email: 'timezone-case-1@example.com',
106+
createdAt: new Date('2024-01-15T23:30:00.000Z'),
107+
updatedAt: new Date('2024-01-15T23:30:00.000Z'),
108+
});
109+
110+
const calendar = buildCalendar([
111+
{ date: '2024-01-14', contributionCount: 1 },
112+
{ date: '2024-01-15', contributionCount: 1 },
113+
{ date: '2024-01-16', contributionCount: 1 },
114+
]);
115+
116+
const instant = new Date('2024-01-15T23:30:00.000Z');
117+
const expectations: ReadonlyArray<{
118+
timeZone: TimezoneLabel;
119+
todayDate: string;
120+
currentStreak: number;
121+
expectedOffsetMinutes: number;
122+
}> = [
123+
{ timeZone: 'UTC', todayDate: '2024-01-15', currentStreak: 2, expectedOffsetMinutes: 0 },
124+
{
125+
timeZone: 'America/New_York',
126+
todayDate: '2024-01-15',
127+
currentStreak: 2,
128+
expectedOffsetMinutes: -300,
129+
},
130+
{
131+
timeZone: 'Asia/Kolkata',
132+
todayDate: '2024-01-16',
133+
currentStreak: 3,
134+
expectedOffsetMinutes: 330,
135+
},
136+
{
137+
timeZone: 'Asia/Tokyo',
138+
todayDate: '2024-01-16',
139+
currentStreak: 3,
140+
expectedOffsetMinutes: 540,
141+
},
142+
];
143+
144+
expect(notification.createdAt).toBeInstanceOf(Date);
145+
expect(notification.updatedAt).toBeInstanceOf(Date);
146+
expect(notification.createdAt.toISOString()).toBe('2024-01-15T23:30:00.000Z');
147+
148+
for (const expectation of expectations) {
149+
const result = calculateStreak(calendar, expectation.timeZone, instant);
150+
151+
expect(result.todayDate).toBe(expectation.todayDate);
152+
expect(result.currentStreak).toBe(expectation.currentStreak);
153+
expect(result.totalContributions).toBe(3);
154+
expect(
155+
findTodayIndex(
156+
calendar.weeks.flatMap((week) => week.contributionDays),
157+
expectation.timeZone,
158+
instant
159+
)
160+
).toBeGreaterThanOrEqual(0);
161+
expect(getTimezoneOffsetMinutes(instant, expectation.timeZone)).toBe(
162+
expectation.expectedOffsetMinutes
163+
);
164+
}
165+
});
166+
167+
it('verifies leap year calendar boundaries preserve February 29 without alignment drops', () => {
168+
const leapCalendar = buildCalendar([
169+
{ date: '2024-02-28', contributionCount: 2 },
170+
{ date: '2024-02-29', contributionCount: 3 },
171+
{ date: '2024-03-01', contributionCount: 4 },
172+
]);
173+
174+
const now = new Date('2024-02-29T12:00:00.000Z');
175+
176+
expect(() => calculateMonthlyStats(leapCalendar, 'UTC', now)).not.toThrow();
177+
expect(() => calculateMonthlyStats(leapCalendar, 'Asia/Tokyo', now)).not.toThrow();
178+
179+
const utcResult = calculateMonthlyStats(leapCalendar, 'UTC', now);
180+
const tokyoResult = calculateMonthlyStats(leapCalendar, 'Asia/Tokyo', now);
181+
182+
expect(utcResult).toMatchObject({
183+
currentMonthTotal: 5,
184+
previousMonthTotal: 0,
185+
deltaPercentage: null,
186+
deltaAbsolute: 5,
187+
currentMonthName: 'February',
188+
});
189+
expect(tokyoResult.currentMonthTotal).toBe(5);
190+
expect(tokyoResult.currentMonthName).toBe('February');
191+
});
192+
193+
it('keeps localized calendar date strings stable across targeted regional locales', () => {
194+
const instant = new Date('2024-01-16T00:00:00.000Z');
195+
196+
const locales = ['en-US', 'en-GB', 'ja-JP'] as const;
197+
198+
for (const locale of locales) {
199+
const parts = new Intl.DateTimeFormat(locale, {
200+
timeZone: locale === 'ja-JP' ? 'Asia/Tokyo' : 'UTC',
201+
year: 'numeric',
202+
month: '2-digit',
203+
day: '2-digit',
204+
}).formatToParts(instant);
205+
206+
const structuralParts = parts.filter((part) => part.type !== 'literal');
207+
208+
expect(structuralParts.map((part) => part.type).sort()).toEqual(['day', 'month', 'year']);
209+
expect(structuralParts.every((part) => part.value.length > 0)).toBe(true);
210+
expect(parts.some((part) => part.type === 'literal')).toBe(true);
211+
}
212+
213+
expect(getDatePartOrder(instant, 'en-US', 'UTC')).toEqual(['month', 'day', 'year']);
214+
expect(getDatePartOrder(instant, 'en-GB', 'UTC')).toEqual(['day', 'month', 'year']);
215+
expect(getDatePartOrder(instant, 'ja-JP', 'Asia/Tokyo')).toEqual(['year', 'month', 'day']);
216+
});
217+
218+
it('evaluates offset calculation variations around the DST transition boundary', () => {
219+
const calendar = buildCalendar([
220+
{ date: '2024-03-09', contributionCount: 1 },
221+
{ date: '2024-03-10', contributionCount: 1 },
222+
{ date: '2024-03-11', contributionCount: 1 },
223+
]);
224+
225+
const beforeDst = new Date('2024-03-10T06:30:00.000Z');
226+
const afterDst = new Date('2024-03-10T07:30:00.000Z');
227+
228+
expect(getTimezoneOffsetMinutes(beforeDst, 'America/New_York')).toBe(-300);
229+
expect(getTimezoneOffsetMinutes(afterDst, 'America/New_York')).toBe(-240);
230+
231+
const beforeResult = calculateStreak(calendar, 'America/New_York', beforeDst);
232+
const afterResult = calculateStreak(calendar, 'America/New_York', afterDst);
233+
234+
expect(beforeResult.todayDate).toBe('2024-03-10');
235+
expect(afterResult.todayDate).toBe('2024-03-10');
236+
expect(beforeResult.currentStreak).toBe(2);
237+
expect(afterResult.currentStreak).toBe(2);
238+
});
239+
240+
it('returns UTC defaults cleanly for invalid or zero-offset inputs without breaking calculation loops', () => {
241+
const calendar = buildCalendar([
242+
{ date: '2024-05-01', contributionCount: 1 },
243+
{ date: '2024-05-02', contributionCount: 0 },
244+
]);
245+
246+
const now = new Date('2024-05-01T12:00:00.000Z');
247+
const invalidTimezone = normalizeTimezoneLabel('UTC+25');
248+
const zeroOffsetTimezone = normalizeTimezoneLabel('0');
249+
250+
expect(invalidTimezone).toBe('UTC');
251+
expect(zeroOffsetTimezone).toBe('UTC');
252+
253+
expect(() => calculateStreak(calendar, invalidTimezone, now)).not.toThrow();
254+
expect(() => calculateMonthlyStats(calendar, zeroOffsetTimezone, now)).not.toThrow();
255+
256+
const streakResult = calculateStreak(calendar, invalidTimezone, now);
257+
const monthlyResult = calculateMonthlyStats(calendar, zeroOffsetTimezone, now);
258+
259+
expect(streakResult.todayDate).toBe('2024-05-01');
260+
expect(streakResult.currentStreak).toBe(1);
261+
expect(monthlyResult.currentMonthTotal).toBe(1);
262+
expect(monthlyResult.previousMonthTotal).toBe(0);
263+
});
264+
});

0 commit comments

Comments
 (0)