# Timezone Handling

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 123  
**Tier:** All tiers  
**Depends on:** `foundation-auth-rbac`, `system-i18n`, `time-management`, `reports-analytics`  
**Referenced by:** `time-management`, `reports-analytics`, `calendar-module`, `invoices-core`, `settings-module`

---

## Overview

This is the canonical timezone reference. `user_preferences.timezone` is first introduced by `staff-portal-detail` (spec 99); this spec documents it as a cross-cutting rule. `tenants.timezone` is new here. All timestamps stored UTC in Postgres (`TIMESTAMPTZ`). All display uses the user's configured timezone. The tenant timezone is used for reporting period boundaries, invoice due dates, and cron-generated jobs.

---

## Storage Rule

**All timestamps stored as UTC.** Never store local time in the database. No exceptions.

```sql
-- Correct:
started_at TIMESTAMPTZ DEFAULT now()

-- Never:
started_at TIMESTAMP DEFAULT now()  -- no timezone = ambiguous
```

---

## Timezone Configuration

### User Timezone (display timezone)

Each user has a `timezone` field in their profile:

```sql
-- user_preferences.timezone is owned by foundation-auth-rbac:
--   timezone TEXT NOT NULL DEFAULT 'Asia/Jerusalem'   (IANA name; concrete display zone)
-- Consumed here as the cross-cutting display rule. No DDL added by this spec.
-- Default 'Asia/Jerusalem' covers Israel Standard Time / IDT.
```

Set via `PATCH /api/user/preferences` body `{ timezone: 'Asia/Jerusalem' }`.

Editable at `/profile` → Timezone dropdown.

### Tenant Timezone (operations timezone)

```sql
-- The tenant operations timezone is the canonical tenants.default_timezone, owned by
-- system-i18n (TEXT NOT NULL DEFAULT 'Asia/Jerusalem'). This spec does NOT add a second
-- tenants.timezone column — it consumes default_timezone for:
--   reporting period boundaries, reminder send times, cron business-hours logic,
--   invoice due date calculation.
```

Set via `/settings/locale` → Timezone field (admin only).

**When user and tenant timezones differ:** user's own time entries display in the user's timezone; reports and invoices use the tenant timezone for period bucketing.

---

## Display Rules

All date/time displayed to users must be converted to the **user's timezone** using `Intl.DateTimeFormat` (spec 117).

```ts
// packages/ui/src/lib/format-date.ts

export function formatLocalDate(utcDate: Date, timezone: string, style: 'short' | 'long' | 'datetime' = 'short'): string {
  const opts: Intl.DateTimeFormatOptions =
    style === 'datetime' ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: timezone }
    : style === 'long'   ? { year: 'numeric', month: 'long', day: 'numeric', timeZone: timezone }
    : { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: timezone };
  return new Intl.DateTimeFormat(locale, opts).format(utcDate);
}
```

`timezone` comes from `useLocale()` (spec 117) extended to return `{ locale, timezone }`.

---

## Reporting Period Boundaries

Reports filter by date range. "Today", "This week", "This month" computed using the **tenant timezone**:

```ts
// packages/api/src/lib/period-boundaries.ts

export function todayBoundaries(tenantTimezone: string): { start: Date; end: Date } {
  const now = new Date();
  // Local date in tenant tz (produces 'YYYY-MM-DD' via en-CA locale)
  const localDate = now.toLocaleDateString('en-CA', { timeZone: tenantTimezone });
  // Get UTC offset at noon UTC on that date (noon avoids DST transitions at midnight).
  // 'longOffset' timeZoneName produces 'GMT+03:00' or 'GMT-05:00'.
  const offsetRaw = new Intl.DateTimeFormat('en', {
    timeZone: tenantTimezone,
    timeZoneName: 'longOffset',
  }).formatToParts(new Date(`${localDate}T12:00:00Z`))
    .find(p => p.type === 'timeZoneName')!.value;  // e.g. 'GMT+03:00'
  const offset = offsetRaw.slice(3);  // '+03:00' or '-05:00'
  return {
    start: new Date(`${localDate}T00:00:00${offset}`),
    end:   new Date(`${localDate}T23:59:59.999${offset}`),
  };
}
```

Server-side date boundary computation uses `Intl.DateTimeFormat` with `timeZoneName: 'longOffset'` to derive UTC offsets without any third-party library. Pure `Intl.*` — consistent with spec 117. **Not** exposed on the client.

---

## Calendar Events

`calendar_events` timestamps (`start_time`, `end_time`) stored UTC. Client converts to user timezone for display. Full-day events stored as `DATE` (no time component) — no conversion needed; display as-is.

---

## Cron Jobs

Tenant crons that run at "business hours" (reminder emails, report generation) fire at the configured time in the **tenant timezone**. The cron schedule itself is UTC; the Worker converts to tenant TZ to decide if it's within the target window.

Example: `invoice-payment-reminder` cron runs daily at 09:00 UTC. Worker checks: is it between 07:00–11:00 in tenant timezone? If yes, send reminders. Handles DST automatically.

---

## DST Handling

IANA timezone names (e.g. `'Asia/Jerusalem'`) encode DST rules. Using `Intl.DateTimeFormat` with IANA names handles DST automatically — no manual offset arithmetic.

Israel DST: clocks advance last Friday before April 2, revert before Yom Kippur. `'Asia/Jerusalem'` encodes this correctly.

---

## Settings UI

### User Profile — Timezone

`/profile` → **Display preferences** section:

```
Timezone:  [Asia/Jerusalem (Israel Standard Time) ▾]
           Affects how dates and times are shown to you.
```

Dropdown: IANA timezone list, grouped by continent, searchable. Saves immediately on select.

### Tenant Settings — Operations Timezone

`/settings/locale` → **Timezone** field:

```
Operations timezone:  [Asia/Jerusalem ▾]
Affects report periods, reminder send times, and cron-generated invoices.
```

---

## API

```
PATCH /api/user/preferences
      body: { timezone?: string }   -- IANA timezone name
      Requires: authenticated

PATCH /api/settings/locale
      body: { timezone?: string }   -- tenant operations timezone
      Requires: admin
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| UTC storage | Not local time | Unambiguous across DST transitions; no data corruption when tenant changes timezone |
| IANA names | Not fixed UTC offsets | IANA encodes DST rules; `+02:00` would be wrong half the year for Israel |
| User tz for display, tenant tz for reporting | Not single global tz | Distributed teams need personal display; financial reports need consistent period boundaries |
| `Intl.DateTimeFormat longOffset` server-side | Not `@date-fns/tz` | Spec 117 mandates Intl-only (no locale bundle dependencies); `longOffset` timeZoneName gives `GMT+03:00` offset string usable in ISO 8601 Date constructor — no library needed |
| Default Asia/Jerusalem | Not UTC default | Product is Israel-first; default that requires no setup for the majority of users |
