Skip to content

Commit ec84119

Browse files
committed
test(models): verify timezone normalization and calendar data boundaries for notifications
1 parent 714e4dc commit ec84119

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)