Skip to content

Commit cab2bd8

Browse files
authored
test(StudentProfileModel-timezone-boundaries): verify Timezone Normalization & Calendar Data Boundary Alignment (Variation 8) (JhaSourav07#3177)
## Description Closes JhaSourav07#2910 Adds the missing timezone normalisation and calendar data boundary alignment coverage for `models/StudentProfile.ts`. The new test file verifies the visual date mapping for `createdAt` / `updatedAt`, the streak and monthly math across UTC, EST, IST, and JST, leap year boundaries, localised date part ordering, DST transitions, and invalid offset fallback behaviour. ## Pillar - [x] 🛠️ Other (Test coverage for timezone normalisation & calendar data boundary alignment) ## What changed - Added `models/StudentProfile.timezone-boundaries.test.ts` with 5 test cases covering: - `createdAt` / `updatedAt` round-trip and visual date mapping for UTC, America/New_York, Asia/Kolkata, and Asia/Tokyo - Leap year boundary preservation (2024-02-29) across UTC and Asia/Tokyo - Localised calendar date part ordering across en-US, en-GB, and ja-JP - DST transition offsets (2024-03-10 in America/New_York) and streak continuity - Invalid and zero-offset timezone labels normalised to UTC without breaking calculation loops ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`npx vitest run models/StudentProfile.timezone-boundaries.test.ts`). - [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): ...`). - [x] I have made sure that I have only one commit to merge in this PR. ## What this fixes `models/StudentProfile.ts` did not have any test coverage verifying that the timestamps attached to a profile (and the contribution calendar math that depends on them) stay aligned when rendered in different timezones. A profile created at the end of the day in UTC currently looks like it was created the next day for viewers in IST or JST, and the streak/monthly calculation routines in `lib/calculate.ts` are exactly what power those visualisations, so any boundary shift there propagates into the student profile surface. ## Root cause The timezone offset handling only existed inside `calculateStreak`, `calculateMonthlyStats`, and `findTodayIndex` in `lib/calculate.ts`. The `StudentProfile` model itself does not perform any timezone normalisation — it stores `createdAt` / `updatedAt` as raw `Date` instances. That means the rendering layer (and any consumer that calls the calendar helpers) is fully responsible for picking the right offset, and the model had no regression net to catch a future change that moved a date onto the wrong visual day (e.g. 2024-01-15 23:30 UTC being shown as 2024-01-16 in Asia/Kolkata). ## How to verify 1. Checkout the branch: `git fetch && git checkout test/student-profile-timezone-boundaries` 2. Install dependencies if needed: `npm install` 3. Run the new test file in isolation: `npx vitest run models/StudentProfile.timezone-boundaries.test.ts` 4. Run the full suite to make sure nothing regresses: `npm test` 5. Run the lint/format gate: `npm run lint && npm run format:check` ## Edge cases considered - **DST spring-forward**: covered via the `America/New_York` transition on 2024-03-10. `calculateStreak` is expected to keep the streak alive across the missing hour and report the same `todayDate` on both sides of the transition. - **Invalid timezone label**: `UTC+25`, `0`, `+00:00`, `-00:00`, and empty strings are all normalised to `UTC` before being handed to the calendar helpers so the streak/monthly routines do not throw. - **Leap year boundary**: 2024-02-29 is included in the calendar and the month name is asserted to remain `February` even when the viewer is in `Asia/Tokyo`. - **Localised date order**: `en-US` (`month/day/year`), `en-GB` (`day/month/year`), and `ja-JP` (`year/month/day`) are all asserted so a future i18n change is caught. - **Out of scope**: The fix does not introduce any normalisation on the model itself (e.g. writing back a timezone-normalised `createdAt`). That is intentional — timezone is a presentation concern and the helpers in `lib/calculate.ts` already accept it as a parameter.
2 parents 3653479 + 4e2177a commit cab2bd8

1 file changed

Lines changed: 284 additions & 0 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { StudentProfile } from './StudentProfile';
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('StudentProfileModel - Timezone Boundaries & Calendar Alignment', () => {
102+
it('maps student profile createdAt and updatedAt onto the correct visual dates across UTC, EST, IST, and JST without boundary shifts', () => {
103+
const profile = new StudentProfile({
104+
githubUsername: 'timezone-case-1',
105+
name: 'Timezone Tester',
106+
email: 'timezone-case-1@example.com',
107+
createdAt: new Date('2024-01-15T23:30:00.000Z'),
108+
updatedAt: new Date('2024-01-15T23:30:00.000Z'),
109+
});
110+
111+
const calendar = buildCalendar([
112+
{ date: '2024-01-14', contributionCount: 1 },
113+
{ date: '2024-01-15', contributionCount: 1 },
114+
{ date: '2024-01-16', contributionCount: 1 },
115+
]);
116+
117+
const instant = new Date('2024-01-15T23:30:00.000Z');
118+
const expectations: ReadonlyArray<{
119+
timeZone: TimezoneLabel;
120+
todayDate: string;
121+
currentStreak: number;
122+
expectedOffsetMinutes: number;
123+
}> = [
124+
{ timeZone: 'UTC', todayDate: '2024-01-15', currentStreak: 2, expectedOffsetMinutes: 0 },
125+
{
126+
timeZone: 'America/New_York',
127+
todayDate: '2024-01-15',
128+
currentStreak: 2,
129+
expectedOffsetMinutes: -300,
130+
},
131+
{
132+
timeZone: 'Asia/Kolkata',
133+
todayDate: '2024-01-16',
134+
currentStreak: 3,
135+
expectedOffsetMinutes: 330,
136+
},
137+
{
138+
timeZone: 'Asia/Tokyo',
139+
todayDate: '2024-01-16',
140+
currentStreak: 3,
141+
expectedOffsetMinutes: 540,
142+
},
143+
];
144+
145+
expect(profile.createdAt).toBeInstanceOf(Date);
146+
expect(profile.updatedAt).toBeInstanceOf(Date);
147+
expect(profile.createdAt.toISOString()).toBe('2024-01-15T23:30:00.000Z');
148+
149+
for (const expectation of expectations) {
150+
const result = calculateStreak(calendar, expectation.timeZone, instant);
151+
152+
expect(result.todayDate).toBe(expectation.todayDate);
153+
expect(result.currentStreak).toBe(expectation.currentStreak);
154+
expect(result.totalContributions).toBe(3);
155+
expect(
156+
findTodayIndex(
157+
calendar.weeks.flatMap((week) => week.contributionDays),
158+
expectation.timeZone,
159+
instant
160+
)
161+
).toBeGreaterThanOrEqual(0);
162+
expect(getTimezoneOffsetMinutes(instant, expectation.timeZone)).toBe(
163+
expectation.expectedOffsetMinutes
164+
);
165+
}
166+
});
167+
168+
it('verifies leap year calendar boundaries preserve February 29 for student profiles without alignment drops', () => {
169+
const leapCalendar = buildCalendar([
170+
{ date: '2024-02-28', contributionCount: 2 },
171+
{ date: '2024-02-29', contributionCount: 3 },
172+
{ date: '2024-03-01', contributionCount: 4 },
173+
]);
174+
175+
const now = new Date('2024-02-29T12:00:00.000Z');
176+
177+
const profile = new StudentProfile({
178+
githubUsername: 'leap-year-user',
179+
name: 'Leap Year User',
180+
email: 'leap@example.com',
181+
graduationYear: 2024,
182+
createdAt: now,
183+
updatedAt: now,
184+
});
185+
186+
expect(() => calculateMonthlyStats(leapCalendar, 'UTC', now)).not.toThrow();
187+
expect(() => calculateMonthlyStats(leapCalendar, 'Asia/Tokyo', now)).not.toThrow();
188+
189+
const utcResult = calculateMonthlyStats(leapCalendar, 'UTC', now);
190+
const tokyoResult = calculateMonthlyStats(leapCalendar, 'Asia/Tokyo', now);
191+
192+
expect(utcResult).toMatchObject({
193+
currentMonthTotal: 5,
194+
previousMonthTotal: 0,
195+
deltaPercentage: null,
196+
deltaAbsolute: 5,
197+
currentMonthName: 'February',
198+
});
199+
expect(tokyoResult.currentMonthTotal).toBe(5);
200+
expect(tokyoResult.currentMonthName).toBe('February');
201+
expect(profile.graduationYear).toBe(2024);
202+
});
203+
204+
it('keeps localized calendar date strings stable across targeted regional locales for student profile timestamps', () => {
205+
const instant = new Date('2024-01-16T00:00:00.000Z');
206+
207+
const locales = ['en-US', 'en-GB', 'ja-JP'] as const;
208+
209+
for (const locale of locales) {
210+
const parts = new Intl.DateTimeFormat(locale, {
211+
timeZone: locale === 'ja-JP' ? 'Asia/Tokyo' : 'UTC',
212+
year: 'numeric',
213+
month: '2-digit',
214+
day: '2-digit',
215+
}).formatToParts(instant);
216+
217+
const structuralParts = parts.filter((part) => part.type !== 'literal');
218+
219+
expect(structuralParts.map((part) => part.type).sort()).toEqual(['day', 'month', 'year']);
220+
expect(structuralParts.every((part) => part.value.length > 0)).toBe(true);
221+
expect(parts.some((part) => part.type === 'literal')).toBe(true);
222+
}
223+
224+
expect(getDatePartOrder(instant, 'en-US', 'UTC')).toEqual(['month', 'day', 'year']);
225+
expect(getDatePartOrder(instant, 'en-GB', 'UTC')).toEqual(['day', 'month', 'year']);
226+
expect(getDatePartOrder(instant, 'ja-JP', 'Asia/Tokyo')).toEqual(['year', 'month', 'day']);
227+
});
228+
229+
it('evaluates offset calculation variations around the DST transition boundary for student profile activity windows', () => {
230+
const calendar = buildCalendar([
231+
{ date: '2024-03-09', contributionCount: 1 },
232+
{ date: '2024-03-10', contributionCount: 1 },
233+
{ date: '2024-03-11', contributionCount: 1 },
234+
]);
235+
236+
const beforeDst = new Date('2024-03-10T06:30:00.000Z');
237+
const afterDst = new Date('2024-03-10T07:30:00.000Z');
238+
239+
expect(getTimezoneOffsetMinutes(beforeDst, 'America/New_York')).toBe(-300);
240+
expect(getTimezoneOffsetMinutes(afterDst, 'America/New_York')).toBe(-240);
241+
242+
const beforeResult = calculateStreak(calendar, 'America/New_York', beforeDst);
243+
const afterResult = calculateStreak(calendar, 'America/New_York', afterDst);
244+
245+
expect(beforeResult.todayDate).toBe('2024-03-10');
246+
expect(afterResult.todayDate).toBe('2024-03-10');
247+
expect(beforeResult.currentStreak).toBe(2);
248+
expect(afterResult.currentStreak).toBe(2);
249+
});
250+
251+
it('returns UTC defaults cleanly for invalid or zero-offset timezone inputs on student profile queries without breaking calculation loops', () => {
252+
const calendar = buildCalendar([
253+
{ date: '2024-05-01', contributionCount: 1 },
254+
{ date: '2024-05-02', contributionCount: 0 },
255+
]);
256+
257+
const now = new Date('2024-05-01T12:00:00.000Z');
258+
const invalidTimezone = normalizeTimezoneLabel('UTC+25');
259+
const zeroOffsetTimezone = normalizeTimezoneLabel('0');
260+
261+
const profile = new StudentProfile({
262+
githubUsername: 'invalid-tz-user',
263+
name: 'Invalid TZ User',
264+
email: 'invalid-tz@example.com',
265+
createdAt: now,
266+
updatedAt: now,
267+
});
268+
269+
expect(invalidTimezone).toBe('UTC');
270+
expect(zeroOffsetTimezone).toBe('UTC');
271+
272+
expect(() => calculateStreak(calendar, invalidTimezone, now)).not.toThrow();
273+
expect(() => calculateMonthlyStats(calendar, zeroOffsetTimezone, now)).not.toThrow();
274+
275+
const streakResult = calculateStreak(calendar, invalidTimezone, now);
276+
const monthlyResult = calculateMonthlyStats(calendar, zeroOffsetTimezone, now);
277+
278+
expect(streakResult.todayDate).toBe('2024-05-01');
279+
expect(streakResult.currentStreak).toBe(1);
280+
expect(monthlyResult.currentMonthTotal).toBe(1);
281+
expect(monthlyResult.previousMonthTotal).toBe(0);
282+
expect(formatCalendarDate(profile.createdAt, 'en-US', invalidTimezone)).toBe('May 01, 2024');
283+
});
284+
});

0 commit comments

Comments
 (0)