Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions lib/validations.normalizeTimezone.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 42 additions & 0 deletions lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading