diff --git a/lib/validations.normalizeTimezone.test.ts b/lib/validations.normalizeTimezone.test.ts new file mode 100644 index 000000000..aa878d03f --- /dev/null +++ b/lib/validations.normalizeTimezone.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeTimezone } from './validations'; +import { streakParamsSchema } from './validations'; + +describe('normalizeTimezone', () => { + it('maps GMT+N to Etc/GMT-N (inverted sign convention)', () => { + expect(normalizeTimezone('GMT+1')).toBe('Etc/GMT-1'); + expect(normalizeTimezone('GMT+5')).toBe('Etc/GMT-5'); + expect(normalizeTimezone('GMT+14')).toBe('Etc/GMT-14'); + }); + + it('maps GMT-N to Etc/GMT+N (inverted sign convention)', () => { + expect(normalizeTimezone('GMT-1')).toBe('Etc/GMT+1'); + expect(normalizeTimezone('GMT-5')).toBe('Etc/GMT+5'); + expect(normalizeTimezone('GMT-12')).toBe('Etc/GMT+12'); + }); + + it('maps UTC+N to Etc/GMT-N', () => { + expect(normalizeTimezone('UTC+3')).toBe('Etc/GMT-3'); + expect(normalizeTimezone('UTC+8')).toBe('Etc/GMT-8'); + }); + + it('maps UTC-N to Etc/GMT+N', () => { + expect(normalizeTimezone('UTC-3')).toBe('Etc/GMT+3'); + expect(normalizeTimezone('UTC-8')).toBe('Etc/GMT+8'); + }); + + it('maps GMT+0 and UTC+0 to UTC', () => { + expect(normalizeTimezone('GMT+0')).toBe('UTC'); + expect(normalizeTimezone('UTC+0')).toBe('UTC'); + }); + + it('maps GMT-0 and UTC-0 to UTC', () => { + expect(normalizeTimezone('GMT-0')).toBe('UTC'); + expect(normalizeTimezone('UTC-0')).toBe('UTC'); + }); + + it('is case-insensitive', () => { + expect(normalizeTimezone('gmt+5')).toBe('Etc/GMT-5'); + expect(normalizeTimezone('utc-3')).toBe('Etc/GMT+3'); + expect(normalizeTimezone('Gmt+1')).toBe('Etc/GMT-1'); + expect(normalizeTimezone('Utc-7')).toBe('Etc/GMT+7'); + }); + + it('returns the input unchanged for IANA timezone names', () => { + expect(normalizeTimezone('America/New_York')).toBe('America/New_York'); + expect(normalizeTimezone('Asia/Kolkata')).toBe('Asia/Kolkata'); + expect(normalizeTimezone('UTC')).toBe('UTC'); + expect(normalizeTimezone('Etc/GMT+5')).toBe('Etc/GMT+5'); + }); + + it('returns the input unchanged for out-of-range offsets', () => { + // UTC+14 is the max positive offset; +15 is out of range + expect(normalizeTimezone('GMT+15')).toBe('GMT+15'); + // UTC-12 is the max negative offset; -13 is out of range + expect(normalizeTimezone('GMT-13')).toBe('GMT-13'); + }); + + it('returns the input unchanged for fractional offsets (unsupported)', () => { + // Fractional offsets like UTC+5:30 don't match the whole-hour regex + expect(normalizeTimezone('UTC+5:30')).toBe('UTC+5:30'); + expect(normalizeTimezone('GMT+5:45')).toBe('GMT+5:45'); + }); + + it('returns the input unchanged for invalid strings', () => { + expect(normalizeTimezone('Invalid/Zone')).toBe('Invalid/Zone'); + expect(normalizeTimezone('')).toBe(''); + expect(normalizeTimezone('GMT')).toBe('GMT'); + expect(normalizeTimezone('UTC')).toBe('UTC'); + }); +}); + +describe('streakParamsSchema — tz field with raw GMT/UTC offsets', () => { + const baseParams = { user: 'octocat' }; + + it('accepts standard IANA timezone names', () => { + const result = streakParamsSchema.safeParse({ ...baseParams, tz: 'America/New_York' }); + expect(result.success).toBe(true); + }); + + it('accepts Etc/GMT±N format', () => { + const result = streakParamsSchema.safeParse({ ...baseParams, tz: 'Etc/GMT+5' }); + expect(result.success).toBe(true); + }); + + it('accepts raw GMT+N offsets', () => { + const result = streakParamsSchema.safeParse({ ...baseParams, tz: 'GMT+1' }); + expect(result.success).toBe(true); + }); + + it('accepts raw GMT-N offsets', () => { + const result = streakParamsSchema.safeParse({ ...baseParams, tz: 'GMT-5' }); + expect(result.success).toBe(true); + }); + + it('accepts raw UTC+N offsets', () => { + const result = streakParamsSchema.safeParse({ ...baseParams, tz: 'UTC+8' }); + expect(result.success).toBe(true); + }); + + it('accepts raw UTC-N offsets', () => { + const result = streakParamsSchema.safeParse({ ...baseParams, tz: 'UTC-3' }); + expect(result.success).toBe(true); + }); + + it('accepts GMT+0 as valid', () => { + const result = streakParamsSchema.safeParse({ ...baseParams, tz: 'GMT+0' }); + expect(result.success).toBe(true); + }); + + it('rejects out-of-range offsets', () => { + const result = streakParamsSchema.safeParse({ ...baseParams, tz: 'GMT+15' }); + expect(result.success).toBe(false); + }); + + it('rejects completely invalid timezone strings', () => { + const result = streakParamsSchema.safeParse({ ...baseParams, tz: 'Not/A/Timezone' }); + expect(result.success).toBe(false); + }); + + it('accepts undefined tz (optional parameter)', () => { + const result = streakParamsSchema.safeParse({ ...baseParams }); + expect(result.success).toBe(true); + }); +}); diff --git a/lib/validations.ts b/lib/validations.ts index 749fb1ea1..487ee112f 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -127,12 +127,54 @@ function dimensionParam(name: string, min: number, max: number) { .transform(toDimensionValue); } +/** + * Maps raw GMT/UTC offset strings (e.g. "GMT+5", "UTC-3") to the + * Etc/GMT±N format that Intl.DateTimeFormat accepts. + * + * Note: The Etc/GMT sign convention is *inverted* relative to the + * common GMT± notation — Etc/GMT+5 means UTC−5. This function + * performs that inversion automatically. + * + * Returns the original string unchanged if it doesn't match a raw + * offset pattern, so callers can pass any timezone string through. + */ +export function normalizeTimezone(tz: string): string { + // Match patterns: GMT+N, GMT-N, UTC+N, UTC-N (whole hours 0-14) + const match = tz.match(/^(?:GMT|UTC)([+-])(\d{1,2})$/i); + if (!match) return tz; + + const sign = match[1]; + const offset = parseInt(match[2], 10); + + // Validate offset range: UTC-12 to UTC+14 + if (offset > 14 || (sign === '-' && offset > 12)) return tz; + + // GMT+0 / UTC+0 → UTC (avoids the Etc/GMT-0 / Etc/GMT+0 ambiguity) + if (offset === 0) return 'UTC'; + + // Invert sign for Etc/GMT convention: GMT+5 → Etc/GMT-5 + const invertedSign = sign === '+' ? '-' : '+'; + return `Etc/GMT${invertedSign}${offset}`; +} + function isValidTimeZone(tz?: string): boolean { if (!tz) return true; + // First try the timezone as-is (covers IANA names and Etc/GMT±N) try { Intl.DateTimeFormat(undefined, { timeZone: tz }); return true; + } catch { + // Fall through to normalization + } + + // Try normalizing raw GMT/UTC offsets to Etc/GMT format + const normalized = normalizeTimezone(tz); + if (normalized === tz) return false; // No normalization happened, it's invalid + + try { + Intl.DateTimeFormat(undefined, { timeZone: normalized }); + return true; } catch { return false; }