# Timezone Bug Hunter

> Find timezone-related bugs in date handling, scheduling, and display logic by testing across multiple timezones and edge cases like DST transitions.

- Skill: `pramoddutta/timezone-bug-hunter` (Agent Skill)
- Install (CLI): `npx skillmds@latest add pramoddutta/timezone-bug-hunter`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pramoddutta/timezone-bug-hunter/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: PramodDutta (https://skillmd.com/u/pramoddutta)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/pramoddutta/timezone-bug-hunter

---


# Timezone Bug Hunter Skill

You are an expert QA engineer specializing in timezone and date/time testing. When the user asks you to write, review, or debug timezone-related tests, follow these detailed instructions to systematically detect incorrect date conversions, DST boundary failures, locale formatting bugs, UTC offset miscalculations, and server/client timezone mismatches that corrupt temporal data across application layers.

## Core Principles

1. **Store in UTC, display in local** -- All dates stored in databases and transmitted through APIs must be in UTC. Conversion to the user's local timezone should happen exclusively in the presentation layer. Any deviation from this principle is a bug that will manifest differently for every user depending on their geographic location and the time of year.

2. **DST transitions are not edge cases** -- Daylight Saving Time changes affect billions of users twice per year. The "spring forward" hour that does not exist and the "fall back" hour that occurs twice are predictable, recurring events that must be handled correctly in every date calculation, scheduling system, and duration computation. Treat these as first-class test scenarios, not afterthoughts.

3. **Timezone is not an offset** -- A timezone is a set of rules that define when offsets change (e.g., "America/New_York" transitions between UTC-5 and UTC-4 depending on DST). An offset is a fixed number at a specific point in time. Storing offsets instead of IANA timezone identifiers loses the ability to correctly handle future DST transitions, historical timezone changes, and governmental rule modifications.

4. **Date serialization must be unambiguous** -- A date string like "2025-03-09 02:30" is inherently ambiguous without timezone information. It could represent different instants depending on interpretation. All serialized dates must include explicit timezone designators (Z for UTC, or a full offset like +05:30). Ambiguous date strings are a category of bugs, not a formatting preference.

5. **Client and server clocks diverge** -- Never assume that the client's system clock is accurate or synchronized with the server. User devices may have incorrect timezone settings, drifted clocks, or manually overridden dates. Tests must account for clock skew between layers and validate that the application functions correctly even when client time differs from server time by minutes or hours.

6. **Locale affects more than language** -- The same date is displayed as "3/9/2025" in the US, "9/3/2025" in the UK, and "2025/3/9" in Japan. Date formatting must respect the user's locale, not the developer's locale or the server's locale. This extends beyond simple date ordering to include month names, day names, time formats (12-hour vs 24-hour), week start day, and calendar system.

7. **Calendar boundaries vary by timezone** -- "Today" is not the same date everywhere. At 11 PM UTC, it is already tomorrow in Asia and still today in the Americas. Any feature that references "today," "this week," or "this month" must clarify whose timezone defines the boundary. Failure to do so produces bugs that only affect users in certain timezones at certain times of day.

## Project Structure

```
tests/
  timezone/
    timezone-emulation.spec.ts        # Playwright timezone emulation across zones
    dst-boundary.spec.ts              # DST transition edge cases
    date-serialization.spec.ts        # Serialization/deserialization integrity
    cross-timezone-scheduling.spec.ts # Scheduling across multiple timezones
    date-display.spec.ts              # Date display formatting by locale
    server-client-mismatch.spec.ts    # Server/client timezone mismatch detection
    calendar-components.spec.ts       # Calendar widget timezone correctness
    relative-time.spec.ts             # Relative time display accuracy
    api/
      date-api.spec.ts               # API date handling tests (TypeScript)
      date-api.py                     # API date handling tests (Python)
    fixtures/
      timezone-helpers.ts             # Timezone emulation utilities
      date-factory.ts                 # Test date generation with known instants
      dst-dates.ts                    # Known DST transition dates by region
  playwright.config.ts
```

## Configuration

### Playwright Configuration for Timezone Testing

```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/timezone',
  fullyParallel: false, // Timezone tests may share browser state; run sequentially by default
  retries: 0,          // Timezone bugs are deterministic; retries mask real failures
  timeout: 30_000,
  use: {
    baseURL: process.env.TARGET_URL || 'http://localhost:3000',
    screenshot: 'only-on-failure',
    trace: 'retain-on-failure',
    timezoneId: 'UTC', // Default to UTC; individual tests override per scenario
  },
  projects: [
    {
      name: 'timezone-utc',
      use: { timezoneId: 'UTC' },
    },
    {
      name: 'timezone-est',
      use: { timezoneId: 'America/New_York' },
    },
    {
      name: 'timezone-pst',
      use: { timezoneId: 'America/Los_Angeles' },
    },
    {
      name: 'timezone-ist',
      use: { timezoneId: 'Asia/Kolkata' },
    },
    {
      name: 'timezone-jst',
      use: { timezoneId: 'Asia/Tokyo' },
    },
    {
      name: 'timezone-nzst',
      use: { timezoneId: 'Pacific/Auckland' },
    },
    {
      name: 'timezone-chatham',
      use: { timezoneId: 'Pacific/Chatham' }, // UTC+12:45, non-standard offset
    },
  ],
});
```

### Timezone Test Fixture Definitions

```typescript
// tests/timezone/fixtures/timezone-helpers.ts

export interface TimezoneTestCase {
  timezone: string;          // IANA timezone identifier
  utcOffset: string;         // Standard offset, e.g., "+05:30"
  dstOffset?: string;        // DST offset, e.g., "-04:00"
  abbreviation: string;      // Standard abbreviation, e.g., "IST"
  dstAbbreviation?: string;  // DST abbreviation, e.g., "EDT"
  hasDST: boolean;
  dstStart?: string;         // ISO date when DST starts (clocks spring forward)
  dstEnd?: string;           // ISO date when DST ends (clocks fall back)
}

export const TIMEZONE_TEST_CASES: TimezoneTestCase[] = [
  {
    timezone: 'America/New_York',
    utcOffset: '-05:00',
    dstOffset: '-04:00',
    abbreviation: 'EST',
    dstAbbreviation: 'EDT',
    hasDST: true,
    dstStart: '2025-03-09T02:00:00',
    dstEnd: '2025-11-02T02:00:00',
  },
  {
    timezone: 'America/Los_Angeles',
    utcOffset: '-08:00',
    dstOffset: '-07:00',
    abbreviation: 'PST',
    dstAbbreviation: 'PDT',
    hasDST: true,
    dstStart: '2025-03-09T02:00:00',
    dstEnd: '2025-11-02T02:00:00',
  },
  {
    timezone: 'Europe/London',
    utcOffset: '+00:00',
    dstOffset: '+01:00',
    abbreviation: 'GMT',
    dstAbbreviation: 'BST',
    hasDST: true,
    dstStart: '2025-03-30T01:00:00',
    dstEnd: '2025-10-26T02:00:00',
  },
  {
    timezone: 'Europe/Berlin',
    utcOffset: '+01:00',
    dstOffset: '+02:00',
    abbreviation: 'CET',
    dstAbbreviation: 'CEST',
    hasDST: true,
    dstStart: '2025-03-30T02:00:00',
    dstEnd: '2025-10-26T03:00:00',
  },
  {
    timezone: 'Asia/Kolkata',
    utcOffset: '+05:30',
    abbreviation: 'IST',
    hasDST: false,
  },
  {
    timezone: 'Asia/Tokyo',
    utcOffset: '+09:00',
    abbreviation: 'JST',
    hasDST: false,
  },
  {
    timezone: 'Pacific/Auckland',
    utcOffset: '+12:00',
    dstOffset: '+13:00',
    abbreviation: 'NZST',
    dstAbbreviation: 'NZDT',
    hasDST: true,
    dstStart: '2025-09-28T02:00:00',
    dstEnd: '2025-04-06T03:00:00',
  },
  {
    timezone: 'Asia/Kathmandu',
    utcOffset: '+05:45',
    abbreviation: 'NPT',
    hasDST: false,
  },
  {
    timezone: 'Pacific/Chatham',
    utcOffset: '+12:45',
    dstOffset: '+13:45',
    abbreviation: 'CHAST',
    dstAbbreviation: 'CHADT',
    hasDST: true,
  },
];

export function parseOffset(offset: string): number {
  const match = offset.match(/([+-])(\d{2}):(\d{2})/);
  if (!match) return 0;
  const sign = match[1] === '+' ? 1 : -1;
  return sign * (parseInt(match[2]) * 60 + parseInt(match[3]));
}

export const DST_EDGE_DATES = {
  // US DST 2025: Spring forward March 9, 2:00 AM -> 3:00 AM
  usSpringForward: {
    before: '2025-03-09T01:59:00-05:00',     // 1:59 AM EST, valid
    during: '2025-03-09T02:30:00',            // 2:30 AM -- this time does NOT exist in EST
    after: '2025-03-09T03:01:00-04:00',       // 3:01 AM EDT, valid
    utcBefore: '2025-03-09T06:59:00Z',
    utcAfter: '2025-03-09T07:01:00Z',
  },
  // US DST 2025: Fall back November 2, 2:00 AM -> 1:00 AM
  usFallBack: {
    firstOccurrence: '2025-11-02T01:30:00-04:00',  // 1:30 AM EDT (first time)
    secondOccurrence: '2025-11-02T01:30:00-05:00',  // 1:30 AM EST (second time)
    utcFirst: '2025-11-02T05:30:00Z',
    utcSecond: '2025-11-02T06:30:00Z',
  },
  // EU DST 2025: Spring forward March 30, 1:00 AM UTC -> 2:00 AM BST
  euSpringForward: {
    before: '2025-03-30T00:59:00+00:00',
    after: '2025-03-30T02:01:00+01:00',
    utcBefore: '2025-03-30T00:59:00Z',
    utcAfter: '2025-03-30T01:01:00Z',
  },
  // NZ DST 2025: Spring forward September 28, 2:00 AM -> 3:00 AM
  nzSpringForward: {
    before: '2025-09-28T01:59:00+12:00',
    after: '2025-09-28T03:01:00+13:00',
  },
};
```

### Test Date Factory

```typescript
// tests/timezone/fixtures/date-factory.ts

export function createTestDates() {
  return {
    // Standard reference dates
    newYear2025: '2025-01-01T00:00:00Z',
    midYear2025: '2025-07-01T12:00:00Z',

    // Near midnight UTC (tests date boundary behavior)
    justBeforeMidnightUTC: '2025-06-15T23:59:59Z',
    justAfterMidnightUTC: '2025-06-16T00:00:01Z',

    // Near DST transitions
    beforeUsDst: '2025-03-09T06:59:00Z',  // 1:59 AM EST
    afterUsDst: '2025-03-09T07:01:00Z',   // 3:01 AM EDT
    beforeEuDst: '2025-03-30T00:59:00Z',  // 12:59 AM GMT
    afterEuDst: '2025-03-30T01:01:00Z',   // 2:01 AM BST

    // Leap year dates
    leapDay2024: '2024-02-29T12:00:00Z',
    afterLeapDay2024: '2024-03-01T00:00:00Z',
    feb28NonLeap: '2025-02-28T23:59:59Z',

    // Year boundary
    yearEnd2024: '2024-12-31T23:59:59Z',
    yearStart2025: '2025-01-01T00:00:00Z',

    // Dates that change calendar day depending on timezone
    dateLineTest: '2025-06-15T22:00:00Z',  // June 16 in NZ, still June 15 in US
    asiaEveningUtc: '2025-06-15T18:00:00Z', // Already June 16 in IST/JST

    // Month boundary
    endOfJanuary: '2025-01-31T23:59:59Z',
    startOfFebruary: '2025-02-01T00:00:00Z',

    // End-of-month edge cases for scheduling
    march31: '2025-03-31T12:00:00Z',
    april30: '2025-04-30T12:00:00Z',
  };
}
```

## Timezone Emulation in Playwright

```typescript
// tests/timezone/timezone-emulation.spec.ts
import { test, expect, Browser, BrowserContext, Page } from '@playwright/test';
import { TIMEZONE_TEST_CASES, parseOffset } from './fixtures/timezone-helpers';
import { createTestDates } from './fixtures/date-factory';

async function createPageInTimezone(
  browser: Browser,
  timezone: string
): Promise<{ page: Page; context: BrowserContext }> {
  const context = await browser.newContext({ timezoneId: timezone });
  const page = await context.newPage();
  return { page, context };
}

test.describe('Timezone Emulation Verification', () => {
  const testDates = createTestDates();

  for (const tz of TIMEZONE_TEST_CASES) {
    test(`browser reports correct timezone for ${tz.timezone}`, async ({ browser }) => {
      const { page, context } = await createPageInTimezone(browser, tz.timezone);
      await page.goto('/', { waitUntil: 'networkidle' });

      const browserTimezone = await page.evaluate(() => {
        return Intl.DateTimeFormat().resolvedOptions().timeZone;
      });
      expect(browserTimezone).toBe(tz.timezone);

      await context.close();
    });

    test(`UTC date converts correctly in ${tz.timezone}`, async ({ browser }) => {
      const { page, context } = await createPageInTimezone(browser, tz.timezone);
      await page.goto('/');

      const displayedDate = await page.evaluate((utcDate) => {
        const date = new Date(utcDate);
        return {
          localString: date.toLocaleString(),
          isoString: date.toISOString(),
          timezoneOffset: date.getTimezoneOffset(),
          localDate: date.toLocaleDateString(),
          localTime: date.toLocaleTimeString(),
          hours: date.getHours(),
          utcHours: date.getUTCHours(),
        };
      }, testDates.midYear2025);

      // ISO string must always be UTC regardless of browser timezone
      expect(displayedDate.isoString).toContain('Z');

      // For non-DST timezones, verify the offset matches expected value exactly
      if (!tz.hasDST) {
        const expectedOffsetMinutes = parseOffset(tz.utcOffset);
        // getTimezoneOffset returns inverted sign: EST returns 300 (not -300)
        expect(displayedDate.timezoneOffset).toBe(-expectedOffsetMinutes);
      }

      await context.close();
    });
  }

  test('same UTC instant shows different local dates near the date line', async ({ browser }) => {
    const utcDate = testDates.dateLineTest; // 2025-06-15T22:00:00Z

    // In New Zealand (UTC+12), this is already June 16
    const { page: nzPage, context: nzCtx } = await createPageInTimezone(
      browser,
      'Pacific/Auckland'
    );
    await nzPage.goto('/');
    const nzResult = await nzPage.evaluate((d) => {
      const date = new Date(d);
      return { day: date.getDate(), month: date.getMonth() + 1 };
    }, utcDate);

    // In US Pacific (UTC-7 summer), this is still June 15
    const { page: usPage, context: usCtx } = await createPageInTimezone(
      browser,
      'America/Los_Angeles'
    );
    await usPage.goto('/');
    const usResult = await usPage.evaluate((d) => {
      const date = new Date(d);
      return { day: date.getDate(), month: date.getMonth() + 1 };
    }, utcDate);

    expect(nzResult.day).toBe(16);
    expect(usResult.day).toBe(15);

    await nzCtx.close();
    await usCtx.close();
  });

  test('non-standard offset timezones compute correct local time', async ({ browser }) => {
    const utcDate = '2025-06-15T12:00:00Z'; // Noon UTC

    // India: UTC+5:30 -> 5:30 PM local
    const { page: istPage, context: istCtx } = await createPageInTimezone(
      browser,
      'Asia/Kolkata'
    );
    await istPage.goto('/');
    const istResult = await istPage.evaluate((d) => {
      const date = new Date(d);
      return { hours: date.getHours(), minutes: date.getMinutes() };
    }, utcDate);
    expect(istResult.hours).toBe(17);
    expect(istResult.minutes).toBe(30);

    // Nepal: UTC+5:45 -> 5:45 PM local
    const { page: nptPage, context: nptCtx } = await createPageInTimezone(
      browser,
      'Asia/Kathmandu'
    );
    await nptPage.goto('/');
    const nptResult = await nptPage.evaluate((d) => {
      const date = new Date(d);
      return { hours: date.getHours(), minutes: date.getMinutes() };
    }, utcDate);
    expect(nptResult.hours).toBe(17);
    expect(nptResult.minutes).toBe(45);

    // Chatham Islands: UTC+12:45 -> 12:45 AM next day
    const { page: chatPage, context: chatCtx } = await createPageInTimezone(
      browser,
      'Pacific/Chatham'
    );
    await chatPage.goto('/');
    const chatResult = await chatPage.evaluate((d) => {
      const date = new Date(d);
      return { hours: date.getHours(), minutes: date.getMinutes(), day: date.getDate() };
    }, utcDate);
    expect(chatResult.minutes).toBe(45);
    expect(chatResult.day).toBe(16); // Crossed into next day

    await istCtx.close();
    await nptCtx.close();
    await chatCtx.close();
  });
});
```

## DST Boundary Testing

```typescript
// tests/timezone/dst-boundary.spec.ts
import { test, expect } from '@playwright/test';
import { DST_EDGE_DATES } from './fixtures/timezone-helpers';

test.describe('DST Boundary Testing', () => {
  test('spring forward: the non-existent hour is handled gracefully', async ({ browser }) => {
    const context = await browser.newContext({
      timezoneId: 'America/New_York',
    });
    const page = await context.newPage();
    await page.goto('/', { waitUntil: 'networkidle' });

    const result = await page.evaluate(() => {
      // 2:30 AM on March 9, 2025 does not exist in America/New_York
      // Clocks jump from 2:00 AM EST directly to 3:00 AM EDT
      const nonExistentTime = new Date('2025-03-09T02:30:00');
      const beforeDst = new Date('2025-03-09T01:30:00');
      const afterDst = new Date('2025-03-09T03:30:00');

      return {
        nonExistent: {
          toString: nonExistentTime.toString(),
          hours: nonExistentTime.getHours(),
          utcHours: nonExistentTime.getUTCHours(),
          offset: nonExistentTime.getTimezoneOffset(),
        },
        before: {
          toString: beforeDst.toString(),
          offset: beforeDst.getTimezoneOffset(),
        },
        after: {
          toString: afterDst.toString(),
          offset: afterDst.getTimezoneOffset(),
        },
        offsetChanged: beforeDst.getTimezoneOffset() !== afterDst.getTimezoneOffset(),
      };
    });

    // Verify the UTC offset actually changed (EST -> EDT)
    expect(result.offsetChanged, 'DST transition should change the UTC offset').toBe(true);

    // Before DST: EST (UTC-5, getTimezoneOffset returns 300)
    expect(result.before.offset).toBe(300);

    // After DST: EDT (UTC-4, getTimezoneOffset returns 240)
    expect(result.after.offset).toBe(240);

    await context.close();
  });

  test('fall back: the ambiguous hour produces two distinct UTC instants', async ({
    browser,
  }) => {
    const context = await browser.newContext({
      timezoneId: 'America/New_York',
    });
    const page = await context.newPage();
    await page.goto('/', { waitUntil: 'networkidle' });

    const result = await page.evaluate(() => {
      // 1:30 AM on November 2, 2025 occurs TWICE in America/New_York:
      // First at 1:30 AM EDT (UTC-4), then again at 1:30 AM EST (UTC-5)
      const firstOccurrence = new Date('2025-11-02T05:30:00Z');  // 1:30 AM EDT
      const secondOccurrence = new Date('2025-11-02T06:30:00Z'); // 1:30 AM EST

      return {
        first: {
          localHour: firstOccurrence.getHours(),
          localMinute: firstOccurrence.getMinutes(),
          offset: firstOccurrence.getTimezoneOffset(),
          utc: firstOccurrence.toISOString(),
        },
        second: {
          localHour: secondOccurrence.getHours(),
          localMinute: secondOccurrence.getMinutes(),
          offset: secondOccurrence.getTimezoneOffset(),
          utc: secondOccurrence.toISOString(),
        },
      };
    });

    // Both show 1:30 AM local time
    expect(result.first.localHour).toBe(1);
    expect(result.first.localMinute).toBe(30);
    expect(result.second.localHour).toBe(1);
    expect(result.second.localMinute).toBe(30);

    // But they represent different UTC instants (one hour apart)
    expect(result.first.utc).not.toBe(result.second.utc);

    await context.close();
  });

  test('duration calculations across DST spring forward produce 23-hour day', async ({
    browser,
  }) => {
    const context = await browser.newContext({
      timezoneId: 'America/New_York',
    });
    const page = await context.newPage();
    await page.goto('/', { waitUntil: 'networkidle' });

    const result = await page.evaluate(() => {
      // March 9, 2025: Day is only 23 hours long due to spring forward
      const dayStart = new Date('2025-03-09T05:00:00Z'); // Midnight EST (UTC-5)
      const dayEnd = new Date('2025-03-10T04:00:00Z');   // Midnight EDT next day (UTC-4)

      const actualHours = (dayEnd.getTime() - dayStart.getTime()) / (1000 * 60 * 60);

      return { springDayHours: actualHours };
    });

    expect(result.springDayHours).toBe(23);

    await context.close();
  });

  test('duration calculations across DST fall back produce 25-hour day', async ({ browser }) => {
    const context = await browser.newContext({
      timezoneId: 'America/New_York',
    });
    const page = await context.newPage();
    await page.goto('/', { waitUntil: 'networkidle' });

    const result = await page.evaluate(() => {
      // November 2, 2025: Day is 25 hours long due to fall back
      const dayStart = new Date('2025-11-02T04:00:00Z'); // Midnight EDT (UTC-4)
      const dayEnd = new Date('2025-11-03T05:00:00Z');   // Midnight EST next day (UTC-5)

      const actualHours = (dayEnd.getTime() - dayStart.getTime()) / (1000 * 60 * 60);

      return { fallDayHours: actualHours };
    });

    expect(result.fallDayHours).toBe(25);

    await context.close();
  });

  test('scheduled recurring event maintains correct local time across DST', async ({
    browser,
    request,
  }) => {
    const context = await browser.newContext({
      timezoneId: 'America/New_York',
    });
    const page = await context.newPage();

    // Create a recurring daily event at 9:00 AM local time
    const response = await request.post('/api/events', {
      data: {
        title: 'Daily Standup',
        startTime: '2025-03-08T09:00:00-05:00', // 9 AM EST, day before DST
        recurrence: 'daily',
        timezone: 'America/New_York',
      },
    });

    if (response.status() === 201) {
      const event = await response.json();

      // Get the occurrence for March 9 (DST transition day)
      const nextDayRes = await request.get(
        `/api/events/${event.id}/occurrences?date=2025-03-09`
      );
      if (nextDayRes.ok()) {
        const occurrence = await nextDayRes.json();
        // Must be 9:00 AM EDT (UTC-4), not 10:00 AM or 8:00 AM
        expect(occurrence.localTime).toBe('09:00');
        // The UTC time should shift from 14:00 to 13:00 due to DST
      }
    }

    await context.close();
  });

  test('EU DST transition at different date than US DST', async ({ browser }) => {
    // Between March 9 (US DST) and March 30 (EU DST), US is in EDT but EU is still in standard time
    const usCtx = await browser.newContext({ timezoneId: 'America/New_York' });
    const euCtx = await browser.newContext({ timezoneId: 'Europe/London' });

    const usPage = await usCtx.newPage();
    const euPage = await euCtx.newPage();

    await usPage.goto('/');
    await euPage.goto('/');

    const testDate = '2025-03-15T12:00:00Z'; // Between US and EU DST transitions

    const usOffset = await usPage.evaluate((d) => {
      return new Date(d).getTimezoneOffset();
    }, testDate);

    const euOffset = await euPage.evaluate((d) => {
      return new Date(d).getTimezoneOffset();
    }, testDate);

    // US is already in EDT (UTC-4, offset = 240)
    expect(usOffset).toBe(240);
    // UK is still in GMT (UTC+0, offset = 0)
    expect(euOffset).toBe(0);

    await usCtx.close();
    await euCtx.close();
  });
});
```

## Date Serialization and Deserialization

```typescript
// tests/timezone/date-serialization.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Date Serialization and Deserialization', () => {
  test('API responses include timezone information in all date fields', async ({ request }) => {
    const response = await request.get('/api/events');
    expect(response.ok()).toBe(true);
    const body = await response.json();
    const events = body.data || body;

    for (const event of events) {
      const dateFields = Object.entries(event).filter(([key]) =>
        key.match(/date|time|created|updated|start|end|at$/i)
      );

      for (const [field, value] of dateFields) {
        if (typeof value === 'string' && value.length > 0) {
          const hasTimezone =
            value.endsWith('Z') ||
            /[+-]\d{2}:\d{2}$/.test(value) ||
            /[+-]\d{4}$/.test(value);

          expect(
            hasTimezone,
            `Date field "${field}" lacks timezone designator: "${value}". ` +
            'All date strings in API responses must include Z or an explicit offset.'
          ).toBe(true);
        }
      }
    }
  });

  test('date round-trip preserves the exact UTC instant', async ({ request }) => {
    const testDate = '2025-06-15T14:30:00.000Z';

    const createRes = await request.post('/api/events', {
      data: {
        title: 'Round Trip Preservation Test',
        startTime: testDate,
        timezone: 'UTC',
      },
    });

    if (createRes.status() === 201) {
      const created = await createRes.json();
      const getRes = await request.get(`/api/events/${created.id}`);
      const retrieved = await getRes.json();

      const originalMs = new Date(testDate).getTime();
      const retrievedMs = new Date(retrieved.startTime).getTime();

      expect(
        retrievedMs,
        `Date shifted by ${retrievedMs - originalMs}ms during round-trip storage`
      ).toBe(originalMs);
    }
  });

  test('all valid ISO 8601 formats are accepted and normalized', async ({ request }) => {
    const validFormats = [
      '2025-06-15T14:30:00Z',
      '2025-06-15T14:30:00.000Z',
      '2025-06-15T14:30:00+00:00',
      '2025-06-15T14:30:00-05:00',
      '2025-06-15T19:30:00+05:00',
      '2025-06-15T20:00:00+05:30',   // IST offset
    ];

    const storedInstants: number[] = [];

    for (const format of validFormats) {
      const response = await request.post('/api/events', {
        data: {
          title: `Format test: ${format}`,
          startTime: format,
          timezone: 'UTC',
        },
      });

      expect(
        [200, 201].includes(response.status()),
        `ISO 8601 format rejected: "${format}" (status: ${response.status()})`
      ).toBe(true);

      if (response.ok()) {
        const body = await response.json();
        storedInstants.push(new Date(body.startTime).getTime());
      }
    }

    // All formats that represent the same instant should store identically
    // Formats 1-3 and 5 all represent the same instant (14:30 UTC)
    if (storedInstants.length >= 3) {
      expect(storedInstants[0]).toBe(storedInstants[1]);
      expect(storedInstants[1]).toBe(storedInstants[2]);
    }
  });

  test('ambiguous date strings without timezone are handled safely', async ({ request }) => {
    const ambiguousFormats = [
      '2025-06-15',            // Date only, no time, no timezone
      '06/15/2025',            // US format, ambiguous internationally
      '15/06/2025',            // EU format, ambiguous internationally
      '2025-06-15 14:30:00',   // Space separator, no timezone designator
      '1718458200',            // Unix timestamp as string (seconds vs milliseconds?)
    ];

    for (const format of ambiguousFormats) {
      const response = await request.post('/api/events', {
        data: {
          title: 'Ambiguous format test',
          startTime: format,
        },
      });

      if (response.ok()) {
        const body = await response.json();
        const stored = body.startTime;
        const hasTimezone = stored.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(stored);
        expect(
          hasTimezone,
          `Ambiguous input "${format}" was accepted but stored without timezone: "${stored}". ` +
          'The API must either reject ambiguous formats or normalize them to include a timezone.'
        ).toBe(true);
      }
    }
  });
});
```

## Server/Client Timezone Mismatch Detection

```typescript
// tests/timezone/server-client-mismatch.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Server/Client Timezone Mismatch', () => {
  test('"today" filter returns results for the user timezone, not UTC', async ({ browser }) => {
    // Test from a timezone where "today" differs from UTC's "today"
    const context = await browser.newContext({
      timezoneId: 'Pacific/Auckland', // UTC+12
    });
    const page = await context.newPage();

    // Pin the clock to a time where NZ date differs from UTC date
    // 11 PM UTC June 15 = 11 AM June 16 in NZ
    await page.clock.setFixedTime(new Date('2025-06-15T23:00:00Z'));
    await page.goto('/dashboard?filter=today', { waitUntil: 'networkidle' });

    const dateInfo = await page.evaluate(() => {
      const now = new Date();
      return {
        localDate: now.toLocaleDateString('en-NZ'),
        utcDate: now.toISOString().split('T')[0],
        localDay: now.getDate(),
        utcDay: now.getUTCDate(),
      };
    });

    // In NZ, it is June 16; in UTC, it is June 15
    expect(dateInfo.localDay).toBe(16);
    expect(dateInfo.utcDay).toBe(15);

    // The "today" filter should show items for June 16 (NZ local date)
    // If it shows June 15 items, the server is using UTC instead of user's timezone

    await context.close();
  });

  test('server timestamps render in user local time, not raw UTC', async ({ browser }) => {
    const testTimezones = [
      { tz: 'America/New_York', label: 'Eastern' },
      { tz: 'Asia/Tokyo', label: 'Japan' },
      { tz: 'Europe/London', label: 'London' },
      { tz: 'Asia/Kolkata', label: 'India' },
    ];

    for (const { tz, label } of testTimezones) {
      const context = await browser.newContext({ timezoneId: tz });
      const page = await context.newPage();
      await page.goto('/dashboard', { waitUntil: 'networkidle' });

      const timestamps = await page.evaluate(() => {
        const elements = document.querySelectorAll('[data-utc], time[datetime]');
        const results: { utc: string; displayed: string }[] = [];

        elements.forEach((el) => {
          const utc = el.getAttribute('data-utc') || el.getAttribute('datetime') || '';
          const displayed = el.textContent?.trim() || '';
          if (utc && displayed) {
            results.push({ utc, displayed });
          }
        });

        return results;
      });

      for (const ts of timestamps) {
        const utcDate = new Date(ts.utc);
        expect(utcDate.getTime()).not.toBeNaN();

        // The displayed text should not literally show "Z" or "+00:00"
        // unless the user is actually in UTC
        if (tz !== 'UTC') {
          const showsRawUtc = ts.displayed.includes(' UTC') || ts.displayed.endsWith('Z');
          if (showsRawUtc) {
            console.warn(
              `[${label}] Timestamp "${ts.displayed}" appears to show raw UTC to a non-UTC user.`
            );
          }
        }
      }

      await context.close();
    }
  });

  test('user timezone is transmitted to the API for date-relative queries', async ({
    browser,
  }) => {
    const context = await browser.newContext({
      timezoneId: 'Asia/Kolkata',
    });
    const page = await context.newPage();

    const apiRequests: { url: string; headers: Record<string, string>; body?: string }[] = [];
    page.on('request', (request) => {
      if (request.url().includes('/api/')) {
        apiRequests.push({
          url: request.url(),
          headers: request.headers(),
          body: request.postData() || undefined,
        });
      }
    });

    await page.goto('/dashboard?filter=today', { waitUntil: 'networkidle' });

    const hasTimezoneInRequest = apiRequests.some((req) => {
      const urlHasTz = req.url.includes('timezone=') || req.url.includes('tz=');
      const headerHasTz = !!(req.headers['x-timezone'] || req.headers['x-user-timezone']);
      const bodyHasTz = req.body?.includes('timezone') || req.body?.includes('Asia/Kolkata');
      return urlHasTz || headerHasTz || bodyHasTz;
    });

    if (!hasTimezoneInRequest) {
      console.warn(
        'BUG: No timezone information sent in API requests from India timezone. ' +
        'Date-relative queries (today, this week, this month) will produce incorrect ' +
        'results for users whose local date differs from the server date.'
      );
    }

    await context.close();
  });
});
```

## Date Display and Locale Formatting

```typescript
// tests/timezone/date-display.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Date Display Formatting', () => {
  test('dates respect user locale formatting conventions', async ({ browser }) => {
    const locales = [
      { locale: 'en-US', description: 'US (MM/DD/YYYY)' },
      { locale: 'en-GB', description: 'UK (DD/MM/YYYY)' },
      { locale: 'de-DE', description: 'Germany (DD.MM.YYYY)' },
      { locale: 'ja-JP', description: 'Japan (YYYY/MM/DD)' },
      { locale: 'ko-KR', description: 'Korea (YYYY. MM. DD.)' },
    ];

    for (const { locale, description } of locales) {
      const context = await browser.newContext({ locale });
      const page = await context.newPage();
      await page.goto('/dashboard', { waitUntil: 'networkidle' });

      const dateDisplay = await page.evaluate(() => {
        const dateElements = document.querySelectorAll('time, [data-date]');
        const dates: string[] = [];
        dateElements.forEach((el) => {
          const text = el.textContent?.trim() || '';
          if (text.match(/\d/)) {
            dates.push(text);
          }
        });
        return dates;
      });

      // Verify that at least some dates were found on the page
      if (dateDisplay.length > 0) {
        for (const dateText of dateDisplay) {
          expect(dateText.length).toBeGreaterThan(0);
        }
      }

      await context.close();
    }
  });

  test('12-hour vs 24-hour time format follows locale convention', async ({ browser }) => {
    // US uses 12-hour format by default
    const usCtx = await browser.newContext({ locale: 'en-US' });
    const usPage = await usCtx.newPage();
    await usPage.goto('/dashboard', { waitUntil: 'networkidle' });

    const usTimeFormat = await usPage.evaluate(() => {
      const timeStr = new Date('2025-06-15T15:30:00Z').toLocaleTimeString();
      return {
        has12Hour: /AM|PM/i.test(timeStr),
        formatted: timeStr,
      };
    });
    expect(usTimeFormat.has12Hour).toBe(true);

    // Germany uses 24-hour format by default
    const deCtx = await browser.newContext({ locale: 'de-DE' });
    const dePage = await deCtx.newPage();
    await dePage.goto('/dashboard', { waitUntil: 'networkidle' });

    const deTimeFormat = await dePage.evaluate(() => {
      const timeStr = new Date('2025-06-15T15:30:00Z').toLocaleTimeString();
      return {
        has12Hour: /AM|PM/i.test(timeStr),
        formatted: timeStr,
      };
    });
    expect(deTimeFormat.has12Hour).toBe(false);

    await usCtx.close();
    await deCtx.close();
  });
});
```

## Relative Time Display Testing

```typescript
// tests/timezone/relative-time.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Relative Time Display', () => {
  test('relative timestamps update as time progresses', async ({ browser }) => {
    const context = await browser.newContext({ timezoneId: 'UTC' });
    const page = await context.newPage();

    // Fix time to a known instant
    const now = new Date('2025-06-15T12:00:00Z');
    await page.clock.setFixedTime(now);
    await page.goto('/dashboard', { waitUntil: 'networkidle' });

    const relativeTimes = await page.evaluate(() => {
      const elements = document.querySelectorAll(
        '[data-relative-time], [class*="time-ago"], [class*="relative"]'
      );
      const results: { text: string; datetime: string | null }[] = [];

      elements.forEach((el) => {
        results.push({
          text: el.textContent?.trim() || '',
          datetime: el.getAttribute('datetime') || el.getAttribute('data-utc'),
        });
      });

      return results;
    });

    for (const item of relativeTimes) {
      if (item.datetime) {
        const date = new Date(item.datetime);
        const diffMs = now.getTime() - date.getTime();

        if (diffMs >= 0 && diffMs < 60_000) {
          expect(item.text).toMatch(/just now|seconds?|moment/i);
        } else if (diffMs >= 60_000 && diffMs < 3_600_000) {
          expect(item.text).toMatch(/minutes?/i);
        } else if (diffMs >= 3_600_000 && diffMs < 86_400_000) {
          expect(item.text).toMatch(/hours?/i);
        } else if (diffMs >= 86_400_000 && diffMs < 604_800_000) {
          expect(item.text).toMatch(/days?|yesterday/i);
        }
      }
    }

    await context.close();
  });

  test('relative time uses user timezone for "yesterday" boundary', async ({ browser }) => {
    // At 1 AM UTC on June 16, an event at 11 PM UTC on June 15 was 2 hours ago.
    // But in NZ (UTC+12), both times are on June 16, so it should NOT say "yesterday".
    // In US Pacific (UTC-7), the event is on June 15 and current time is June 15 -- also not "yesterday".

    const nzCtx = await browser.newContext({ timezoneId: 'Pacific/Auckland' });
    const nzPage = await nzCtx.newPage();
    await nzPage.clock.setFixedTime(new Date('2025-06-16T01:00:00Z'));
    await nzPage.goto('/');

    const nzEval = await nzPage.evaluate(() => {
      const now = new Date();
      const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000);
      return {
        nowLocalDay: now.getDate(),
        eventLocalDay: twoHoursAgo.getDate(),
        sameDay: now.getDate() === twoHoursAgo.getDate(),
      };
    });

    // In NZ, both are on June 16 -- same day, should show "2 hours ago" not "yesterday"
    expect(nzEval.sameDay).toBe(true);

    await nzCtx.close();
  });
});
```

## Calendar Component Testing

```typescript
// tests/timezone/calendar-components.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Calendar Component Testing', () => {
  test('calendar highlights correct "today" for the user timezone', async ({ browser }) => {
    // At 11 PM UTC on June 15, "today" in NZ is June 16
    const context = await browser.newContext({ timezoneId: 'Pacific/Auckland' });
    const page = await context.newPage();
    await page.clock.setFixedTime(new Date('2025-06-15T23:00:00Z'));
    await page.goto('/calendar', { waitUntil: 'networkidle' });

    const calendarDay = await page.evaluate(() => {
      const today = new Date();
      return {
        dayOfWeek: today.toLocaleDateString('en-US', { weekday: 'long' }),
        date: today.getDate(),
        month: today.getMonth() + 1,
      };
    });

    // In NZ timezone at this UTC time, it should be June 16
    expect(calendarDay.date).toBe(16);
    expect(calendarDay.month).toBe(6);

    await context.close();
  });

  test('week start day respects locale (Sunday in US, Monday in Europe)', async ({
    browser,
  }) => {
    const usContext = await browser.newContext({ locale: 'en-US' });
    const usPage = await usContext.newPage();
    await usPage.goto('/calendar', { waitUntil: 'networkidle' });

    const usFirstDay = await usPage.evaluate(() => {
      const headers = document.querySelectorAll(
        '[role="columnheader"], .calendar-header th, .day-header'
      );
      return headers[0]?.textContent?.trim() || '';
    });

    const deContext = await browser.newContext({ locale: 'de-DE' });
    const dePage = await deContext.newPage();
    await dePage.goto('/calendar', { waitUntil: 'networkidle' });

    const deFirstDay = await dePage.evaluate(() => {
      const headers = document.querySelectorAll(
        '[role="columnheader"], .calendar-header th, .day-header'
      );
      return headers[0]?.textContent?.trim() || '';
    });

    if (usFirstDay && deFirstDay) {
      // US should start with Sun/Sunday; DE should start with Mon/Montag
      expect(usFirstDay).not.toBe(deFirstDay);
    }

    await usContext.close();
    await deContext.close();
  });

  test('date picker input validates against user timezone', async ({ browser }) => {
    const context = await browser.newContext({ timezoneId: 'Asia/Tokyo' });
    const page = await context.newPage();
    await page.goto('/events/create', { waitUntil: 'networkidle' });

    // When a user in JST selects a date, the submitted value should include JST offset
    const datePicker = page.locator('input[type="date"], input[type="datetime-local"]').first();

…(truncated)
