# Timezone Handling — Implementation Plan

**Spec:** docs/specs/2026-05-31-timezone-handling.md  ·  **Slug:** timezone-handling  ·  **Wave:** 11
**Depends on:** foundation-auth-rbac, reports-analytics, system-i18n, time-management

## Goal
Establish the canonical, cross-cutting timezone discipline for Zync: every timestamp is stored UTC (`TIMESTAMPTZ`); every value displayed to a user is rendered in that user's IANA timezone; every reporting period boundary, reminder window, invoice due-date, and cron business-hours decision is computed in the tenant's operations timezone. This spec delivers the two configuration columns (user display tz, tenant operations tz), the pure-`Intl` formatting + period-boundary helpers (no third-party date library), the user and admin settings routes/UI, and DST-correct cron windowing.

## Architecture
- **Storage rule (system-wide):** all `*_at` columns are `TIMESTAMPTZ` (already the dialect default). This plan adds no new business tables — it adds two configuration columns and shared library helpers consumed everywhere.
- **User display timezone:** lives on `user_preferences.timezone` (IANA name, default `'Asia/Jerusalem'`). `foundation-auth-rbac` already declares `timezone TEXT DEFAULT NULL` on user preferences (override semantics); this plan pins the column to `NOT NULL DEFAULT 'Asia/Jerusalem'` via an idempotent migration so display always resolves to a concrete zone. Edited at `/profile`, persisted through the existing `PATCH /api/user/preferences` route and `updateUserPreferencesSchema` (both upstream).
- **Tenant operations timezone:** the canonical column is `tenants.default_timezone` (already exported upstream per the locked interface sheet). This plan does NOT create a second `tenants.timezone` column — it reuses `default_timezone` as the operations timezone and ensures it is `NOT NULL DEFAULT 'Asia/Jerusalem'`. Edited at `/settings/locale` (admin), persisted through the new `PATCH /api/settings/locale` route.
- **Display path:** `packages/ui` exposes `formatLocalDate(utcDate, timezone, style)` built on `Intl.DateTimeFormat`. The timezone argument is supplied by `useLocale()` (owned by `hebrew-locale-dates`, spec 117), which this plan extends to return `{ locale, timezone }` instead of a bare locale string.
- **Reporting path:** `packages/api` exposes server-only period-boundary helpers (`todayBoundaries`, `weekBoundaries`, `monthBoundaries`, `quarterBoundaries`, `rangeBoundaries`) that take the tenant timezone and return UTC `{ start, end }` `Date` pairs, derived with `Intl.DateTimeFormat … timeZoneName: 'longOffset'`. `reports-analytics` date-range filters call these so "Today / This week / This month / This quarter" bucket on tenant-local calendar days regardless of where the requesting user sits.
- **Cron path:** business-hours crons (e.g. `invoice-payment-reminder`) keep a UTC schedule but call `isWithinTenantWindow(tenantTimezone, startHour, endHour)` to decide whether to fire for a given tenant, so DST shifts are handled by the IANA database, not by stored offsets.
- **Consumers:** `time-management` (time-entry display in user tz; period rollups in tenant tz), `reports-analytics` (period bucketing), `calendar-module` (event display), `invoices-core` (due-date calc), `settings-module` (locale settings surface).

## Tech Stack
- **Packages:** `@zync/ui` (`packages/ui` — `format-date.ts`, `useLocale` hook, timezone picker primitive, profile + settings UI helpers), `@zync/api` (`apps/zync-api` Hono — `period-boundaries.ts`, `tenant-window.ts`, the two PATCH routes), `@zync/db` (`packages/db` — Drizzle schema deltas + migration), `@zync/types` (shared `IanaTimezone` type + IANA list).
- **Libraries:** none beyond the platform. All timezone math uses the runtime `Intl.DateTimeFormat` / `Intl.supportedValuesOf('timeZone')` — explicitly NO `@date-fns/tz`, `luxon`, `moment-timezone`, or locale bundles (mandated by spec 117).
- **Runtime/bindings:** Cloudflare Workers (Hono API on Neon Postgres via Hyperdrive; React app via Vite). Cron triggers already configured in `wrangler` for the business-hours jobs; this plan only adds the window check they call. Zod for body validation (`require-zod-validation-in-routes`). Drizzle ORM against Neon Postgres.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11.a Schema | 1 | `packages/db` schema + migration | No (root) |
| 11.b Shared types | 2 | `packages/types` | Yes (after 1, parallel with 3) |
| 11.c Display lib | 3, 4 | `packages/ui` format-date + useLocale | Yes (parallel with 2) |
| 11.d Server lib | 5, 6 | `packages/api` lib | Yes (after 1) |
| 11.e Routes | 7, 8 | `apps/zync-api` routes | After 1, 5 |
| 11.f UI | 9, 10 | `packages/ui` / app pages | After 2, 3, 4, 7, 8 |
| 11.g Cron wiring | 11 | `apps/zync-api` cron handlers | After 6 |
| 11.h Verification | 12 | test files across packages | After all |

## Tasks

### Task 1: Pin timezone columns (user_preferences + tenants)
**Blocks:** 5, 7, 8, 9, 10  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/auth.ts` (or wherever `user_preferences` is declared)
- Modify: `packages/db/src/schema/tenants.ts` (or wherever `tenants` is declared)
- Create: `packages/db/migrations/20260531000000_timezone_handling.sql`
**Steps:**
- [ ] Confirm the `user_preferences.timezone` Drizzle column reads `text('timezone').notNull().default('Asia/Jerusalem')`. This is the canonical declaration owned by `foundation-auth-rbac` (concrete non-null display zone, no tenant-inheritance) — this plan consumes it and does NOT re-declare or migrate it.
- [ ] `tenants.default_timezone` is owned by `system-i18n` (already `NOT NULL DEFAULT 'Asia/Jerusalem'`); a build dependency of this plan. Consume it as the operations timezone — do NOT re-declare or re-coerce it here, and do NOT add a `tenants.timezone` column.
- [ ] Write the idempotent SQL migration transcribed below.
- [ ] Run the migration against the Neon dev branch and confirm both columns are `NOT NULL` with the default.
**Schema / Interfaces:**
```sql
-- No DDL in this plan. Both timezone columns are owned upstream and consumed as-is:
--   * user_preferences.timezone — TEXT NOT NULL DEFAULT 'Asia/Jerusalem', owned by
--     foundation-auth-rbac (concrete display zone, no tenant inheritance).
--   * tenants.default_timezone — TEXT NOT NULL DEFAULT 'Asia/Jerusalem', owned by
--     system-i18n (the tenant operations timezone).
-- This plan delivers only the shared Intl helpers, routes, and UI over those columns.
```
Drizzle column definitions (TypeScript):
```ts
// user_preferences
timezone: text('timezone').notNull().default('Asia/Jerusalem'),
// tenants.default_timezone — owned/declared by system-i18n; shown for reference only, not redeclared here.
```
**Acceptance:**
- [ ] `\d user_preferences` shows `timezone TEXT NOT NULL DEFAULT 'Asia/Jerusalem'`.
- [ ] `\d tenants` shows `default_timezone TEXT NOT NULL DEFAULT 'Asia/Jerusalem'` and no second `timezone` column exists on `tenants`.
- [ ] Migration is idempotent (re-running is a no-op, no error).

### Task 2: Shared timezone type + IANA list
**Blocks:** 9, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/timezone.ts`
- Modify: `packages/types/src/index.ts` (export from barrel)
**Steps:**
- [ ] Define `IanaTimezone` as a branded `string` type alias for an IANA zone name (validation is runtime via `isValidTimezone`, not a literal union — the list is large and runtime-provided).
- [ ] Implement `isValidTimezone(tz: string): tz is IanaTimezone` using `Intl.supportedValuesOf('timeZone')` membership when available, falling back to a `try { Intl.DateTimeFormat(undefined, { timeZone: tz }) } catch` probe for runtimes that lack `supportedValuesOf`.
- [ ] Export `DEFAULT_TIMEZONE = 'Asia/Jerusalem'`.
- [ ] Export `listSupportedTimezones(): IanaTimezone[]` (sorted) and `groupTimezonesByContinent(zones): Record<string, IanaTimezone[]>` (splits on the `/` continent prefix) for the picker UI.
**Schema / Interfaces:**
```ts
// packages/types/src/timezone.ts
export type IanaTimezone = string & { readonly __brand: 'IanaTimezone' };
export const DEFAULT_TIMEZONE = 'Asia/Jerusalem' as IanaTimezone;

export function isValidTimezone(tz: string): tz is IanaTimezone {
  if (!tz) return false;
  try {
    // Throws RangeError for an invalid IANA name.
    new Intl.DateTimeFormat(undefined, { timeZone: tz });
    return true;
  } catch {
    return false;
  }
}

export function listSupportedTimezones(): IanaTimezone[] {
  const fn = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf;
  const zones = fn ? fn('timeZone') : [DEFAULT_TIMEZONE];
  return [...zones].sort() as IanaTimezone[];
}

export function groupTimezonesByContinent(
  zones: IanaTimezone[],
): Record<string, IanaTimezone[]> {
  const out: Record<string, IanaTimezone[]> = {};
  for (const z of zones) {
    const continent = z.includes('/') ? z.slice(0, z.indexOf('/')) : 'Other';
    (out[continent] ??= []).push(z);
  }
  return out;
}
```
**Acceptance:**
- [ ] `isValidTimezone('Asia/Jerusalem')` is `true`; `isValidTimezone('Mars/Olympus')` is `false`.
- [ ] `listSupportedTimezones()` includes `'Asia/Jerusalem'`, `'America/New_York'`, `'Europe/London'`.
- [ ] Exports are reachable from `@zync/types`.

### Task 3: `formatLocalDate` display helper
**Blocks:** 9, 10  ·  **Blocked by:** —
**Files:**
- Modify: `packages/ui/src/lib/format-date.ts`
**Steps:**
- [ ] Add `formatLocalDate(utcDate, timezone, style)` alongside the existing `formatDate` (owned by spec 117). It MUST accept a `locale` argument so it never hardcodes `'en'`; signature below threads locale through. Provide a 4-arg overload `(utcDate, timezone, locale, style)` and keep `style` defaulting to `'short'`.
- [ ] Implement the three styles (`short`, `long`, `datetime`) exactly as the spec's option maps, always passing `timeZone: timezone`.
- [ ] Guard against an invalid `timezone` by falling back to `DEFAULT_TIMEZONE` (from `@zync/types`) so a corrupt preference never throws in render.
**Schema / Interfaces:**
```ts
// packages/ui/src/lib/format-date.ts
import { DEFAULT_TIMEZONE, isValidTimezone } from '@zync/types';

export function formatLocalDate(
  utcDate: Date,
  timezone: string,
  locale: string,
  style: 'short' | 'long' | 'datetime' = 'short',
): string {
  const tz = isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE;
  const opts: Intl.DateTimeFormatOptions =
    style === 'datetime'
      ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: tz }
      : style === 'long'
      ? { year: 'numeric', month: 'long', day: 'numeric', timeZone: tz }
      : { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: tz };
  return new Intl.DateTimeFormat(locale, opts).format(utcDate);
}
```
**Acceptance:**
- [ ] `formatLocalDate(new Date('2026-01-01T22:30:00Z'), 'Asia/Jerusalem', 'en', 'datetime')` renders the next-local-day clock time consistent with the +02:00 winter offset (i.e. `01/01/2026, 00:30`).
- [ ] An invalid timezone string returns a value (does not throw) using the Jerusalem fallback.

### Task 4: Extend `useLocale()` to return `{ locale, timezone }`
**Blocks:** 9, 10  ·  **Blocked by:** —
**Files:**
- Modify: `packages/ui/src/hooks/useLocale.ts`
**Steps:**
- [ ] Change `useLocale()` (currently returns a bare locale string, owned by spec 117) to return `{ locale, timezone }`, reading `timezone` from `user?.preferences?.timezone` with `DEFAULT_TIMEZONE` fallback.
- [ ] Keep backward source-compat by also exposing the locale; update spec-117 call sites that destructure a string to `const { locale } = useLocale()` (search-and-replace within `packages/ui`).
- [ ] Document that all date components now read `timezone` from this hook — never hardcode a zone.
**Schema / Interfaces:**
```ts
// packages/ui/src/hooks/useLocale.ts
import { DEFAULT_TIMEZONE } from '@zync/types';

export function useLocale(): { locale: string; timezone: string } {
  const { user } = useAuth();
  return {
    locale: user?.preferences?.locale ?? 'en',
    timezone: user?.preferences?.timezone ?? DEFAULT_TIMEZONE,
  };
}
```
**Acceptance:**
- [ ] `useLocale()` returns both `locale` and `timezone`.
- [ ] No `packages/ui` call site breaks after the destructure migration (typecheck passes).

### Task 5: Server-only period-boundary helpers
**Blocks:** 7, 11, 12  ·  **Blocked by:** 1
**Files:**
- Create: `packages/api/src/lib/period-boundaries.ts` (server-only; never imported from client bundles)
**Steps:**
- [ ] Implement `tzOffsetFor(localDate, timezone)` returning the `'+03:00'`/`'-05:00'` offset string via `Intl.DateTimeFormat … timeZoneName: 'longOffset'`, evaluated at noon UTC of `localDate` (noon avoids midnight DST edge cases), per the spec.
- [ ] Implement `todayBoundaries(tenantTimezone)` exactly as the spec; derive the tenant-local date with `toLocaleDateString('en-CA', { timeZone })` so the string is `YYYY-MM-DD`.
- [ ] Implement `weekBoundaries`, `monthBoundaries`, `quarterBoundaries`, and `rangeBoundaries(startLocalDate, endLocalDate, tenantTimezone)` for the reports-analytics date-range presets (Last 7d / 30d / 90d / This month / This quarter / Custom). Week start follows ISO-Monday for `he`/`en` Israeli convention; document the choice in a comment.
- [ ] Add a file-top comment + lint pragma asserting this module is server-only (consistent with the spec's "Not exposed on the client"); do not re-export it from any client barrel.
**Schema / Interfaces:**
```ts
// packages/api/src/lib/period-boundaries.ts  (SERVER ONLY — never bundled to the client)

export interface PeriodBoundaries { start: Date; end: Date }

/** UTC offset (e.g. '+03:00') for a tenant tz on a given local calendar date. */
export function tzOffsetFor(localDate: string, timezone: string): string {
  const offsetRaw = new Intl.DateTimeFormat('en', {
    timeZone: timezone,
    timeZoneName: 'longOffset',
  })
    .formatToParts(new Date(`${localDate}T12:00:00Z`))
    .find((p) => p.type === 'timeZoneName')!.value; // 'GMT+03:00'
  return offsetRaw.slice(3); // '+03:00' | '-05:00'  (GMT -> '')
}

export function todayBoundaries(tenantTimezone: string): PeriodBoundaries {
  const localDate = new Date().toLocaleDateString('en-CA', { timeZone: tenantTimezone });
  const offset = tzOffsetFor(localDate, tenantTimezone);
  return {
    start: new Date(`${localDate}T00:00:00${offset}`),
    end: new Date(`${localDate}T23:59:59.999${offset}`),
  };
}

/** ISO week (Mon–Sun) containing the current tenant-local day. */
export function weekBoundaries(tenantTimezone: string): PeriodBoundaries {
  const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: tenantTimezone });
  const today = new Date(`${todayStr}T12:00:00Z`);
  const dow = (today.getUTCDay() + 6) % 7; // 0 = Monday
  const monday = new Date(today); monday.setUTCDate(today.getUTCDate() - dow);
  const sunday = new Date(monday); sunday.setUTCDate(monday.getUTCDate() + 6);
  const startStr = monday.toISOString().slice(0, 10);
  const endStr = sunday.toISOString().slice(0, 10);
  return {
    start: new Date(`${startStr}T00:00:00${tzOffsetFor(startStr, tenantTimezone)}`),
    end: new Date(`${endStr}T23:59:59.999${tzOffsetFor(endStr, tenantTimezone)}`),
  };
}

export function monthBoundaries(tenantTimezone: string): PeriodBoundaries {
  const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: tenantTimezone });
  const [y, m] = todayStr.split('-').map(Number);
  const startStr = `${y}-${String(m).padStart(2, '0')}-01`;
  const endDate = new Date(Date.UTC(y, m, 0)); // day 0 of next month = last day
  const endStr = endDate.toISOString().slice(0, 10);
  return {
    start: new Date(`${startStr}T00:00:00${tzOffsetFor(startStr, tenantTimezone)}`),
    end: new Date(`${endStr}T23:59:59.999${tzOffsetFor(endStr, tenantTimezone)}`),
  };
}

export function quarterBoundaries(tenantTimezone: string): PeriodBoundaries {
  const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: tenantTimezone });
  const [y, m] = todayStr.split('-').map(Number);
  const qStartMonth = Math.floor((m - 1) / 3) * 3 + 1;
  const startStr = `${y}-${String(qStartMonth).padStart(2, '0')}-01`;
  const endDate = new Date(Date.UTC(y, qStartMonth + 2, 0));
  const endStr = endDate.toISOString().slice(0, 10);
  return {
    start: new Date(`${startStr}T00:00:00${tzOffsetFor(startStr, tenantTimezone)}`),
    end: new Date(`${endStr}T23:59:59.999${tzOffsetFor(endStr, tenantTimezone)}`),
  };
}

/** Inclusive custom range from two tenant-local 'YYYY-MM-DD' dates. */
export function rangeBoundaries(
  startLocalDate: string,
  endLocalDate: string,
  tenantTimezone: string,
): PeriodBoundaries {
  return {
    start: new Date(`${startLocalDate}T00:00:00${tzOffsetFor(startLocalDate, tenantTimezone)}`),
    end: new Date(`${endLocalDate}T23:59:59.999${tzOffsetFor(endLocalDate, tenantTimezone)}`),
  };
}
```
**Acceptance:**
- [ ] `todayBoundaries('Asia/Jerusalem')` returns a `start`/`end` whose UTC instants are exactly one tenant-local day apart and that bracket the current moment.
- [ ] `monthBoundaries('America/New_York')` returns first-of-month 00:00 and last-of-month 23:59:59.999 in New York local time, expressed as correct UTC instants across a DST boundary (e.g. a March range).
- [ ] The module is not reachable from any client/`packages/ui` import (verified by a bundle/import check).

### Task 6: Tenant business-hours window helper (cron)
**Blocks:** 11, 12  ·  **Blocked by:** —
**Files:**
- Create: `packages/api/src/lib/tenant-window.ts` (server-only)
**Steps:**
- [ ] Implement `tenantLocalHour(timezone, at?)` returning the current (or `at`) hour 0–23 in the tenant timezone via `Intl.DateTimeFormat('en', { hour: '2-digit', hour12: false, timeZone })`.
- [ ] Implement `isWithinTenantWindow(timezone, startHour, endHour, at?)` returning whether the tenant-local hour falls in `[startHour, endHour)`. Handle wrap-around windows (e.g. 22→06) correctly.
- [ ] Document that the cron schedule stays UTC; this helper is the per-tenant gate (DST handled by the IANA name, never by a stored offset).
**Schema / Interfaces:**
```ts
// packages/api/src/lib/tenant-window.ts  (SERVER ONLY)

export function tenantLocalHour(timezone: string, at: Date = new Date()): number {
  const hh = new Intl.DateTimeFormat('en', {
    hour: '2-digit', hour12: false, timeZone: timezone,
  }).format(at);
  return Number(hh) % 24; // '24' midnight edge -> 0
}

/** True if the tenant-local hour is in [startHour, endHour); supports wrap-around. */
export function isWithinTenantWindow(
  timezone: string,
  startHour: number,
  endHour: number,
  at: Date = new Date(),
): boolean {
  const h = tenantLocalHour(timezone, at);
  return startHour <= endHour
    ? h >= startHour && h < endHour
    : h >= startHour || h < endHour;
}
```
**Acceptance:**
- [ ] At a UTC instant that is 09:00 in `Asia/Jerusalem`, `isWithinTenantWindow('Asia/Jerusalem', 7, 11)` is `true`; for a tenant in `America/Los_Angeles` at the same instant it is `false`.
- [ ] A wrap-around window `isWithinTenantWindow(tz, 22, 6)` is `true` at 23:00 local and `false` at 12:00 local.

### Task 7: `PATCH /api/user/preferences` accepts `timezone`
**Blocks:** 9  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/user.ts` (existing `PATCH /api/user/preferences` handler — upstream)
- Modify: the upstream `updateUserPreferencesSchema` (Zod) source
**Steps:**
- [ ] Extend `updateUserPreferencesSchema` with an optional `timezone` field validated against `isValidTimezone` (Zod `.refine`). Reject invalid IANA names with 400.
- [ ] In the existing handler, persist `timezone` to `user_preferences.timezone` for the authenticated `token.user_id`. No new route — reuse the locked `PATCH /api/user/preferences`.
- [ ] Return the updated preferences object including `timezone`.
**Schema / Interfaces:**
```ts
// extend updateUserPreferencesSchema (upstream)
import { z } from 'zod';
import { isValidTimezone } from '@zync/types';

timezone: z
  .string()
  .refine(isValidTimezone, { message: 'Invalid IANA timezone' })
  .optional(),
```
Route (unchanged surface): `PATCH /api/user/preferences  body: { timezone?: string }  — authenticated`.
**Acceptance:**
- [ ] `PATCH /api/user/preferences { timezone: 'Europe/London' }` persists and echoes the value.
- [ ] `PATCH /api/user/preferences { timezone: 'Not/AZone' }` returns 400.

### Task 8: `PATCH /api/settings/locale` sets tenant operations timezone (admin)
**Blocks:** 10  ·  **Blocked by:** 1
**Files:**
- Create/Modify: `apps/zync-api/src/routes/settings-locale.ts` (mount under the settings router)
- Modify: `apps/zync-api/src/index.ts` (route registration, if new file)
**Steps:**
- [ ] Add `PATCH /api/settings/locale` guarded by `authMiddleware` + `requirePermission('settings:modules:write')` — the canonical admin-settings write permission from the locked interface sheet (no separate `settings:locale:*` permission exists upstream). This enforces the spec's "admin only" requirement on the `/settings` surface.
- [ ] Validate body with a Zod schema `updateTenantLocaleSchema` containing optional `timezone` (refined by `isValidTimezone`), plus pass-through of any sibling locale fields the settings router already manages (do not strip them).
- [ ] Persist `timezone` to `tenants.default_timezone` for the caller's tenant via the tenant-scoped query helper (`tenantQuery`); never write raw Drizzle from the route (`no-raw-drizzle-from-routes` — go through a repo function).
- [ ] Return the updated tenant locale settings.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/routes/settings-locale.ts
import { z } from 'zod';
import { isValidTimezone } from '@zync/types';

export const updateTenantLocaleSchema = z.object({
  timezone: z
    .string()
    .refine(isValidTimezone, { message: 'Invalid IANA timezone' })
    .optional(),
});

// PATCH /api/settings/locale
//   body: { timezone?: string }   -- tenant operations timezone (tenants.default_timezone)
//   Requires: admin
```
**Acceptance:**
- [ ] Admin `PATCH /api/settings/locale { timezone: 'Asia/Jerusalem' }` updates `tenants.default_timezone`.
- [ ] A non-admin member receives 403.
- [ ] Invalid IANA name returns 400.

### Task 9: Timezone picker primitive + Profile timezone control
**Blocks:** —  ·  **Blocked by:** 2, 4, 7
**Files:**
- Create: `packages/ui/src/primitives/timezone-select.tsx`
- Modify: the `/profile` "Display preferences" section (app page that renders profile, e.g. `apps/zync-app/src/routes/profile.tsx`)
**Steps:**
- [ ] Build `TimezoneSelect` on the existing `Select`/`Command` primitives: searchable combobox, options grouped by continent via `groupTimezonesByContinent(listSupportedTimezones())`, current value highlighted. Label each option with the IANA name (the spec's mock shows `Asia/Jerusalem (Israel Standard Time)` — append the human label via `Intl.DateTimeFormat(locale, { timeZone, timeZoneName: 'long' })` when cheap; otherwise show the IANA name).
- [ ] Accessibility: combobox `role="combobox"` with `aria-expanded`, listbox `role="listbox"`, options `role="option"` and `aria-selected`; the trigger has an accessible label ("Timezone"). Honor `prefers-reduced-motion` for the open/close transition (no animation when set). Full keyboard operation (type-to-filter, arrow navigation, Enter to select, Esc to close). RTL: the dropdown and search field inherit `dir` from the document so Hebrew renders right-aligned.
- [ ] Wire the `/profile` control: read current value from `useLocale().timezone`, save immediately on select via `PATCH /api/user/preferences { timezone }`, show a toast on success. Helper text: "Affects how dates and times are shown to you."
**Schema / Interfaces:**
```tsx
// packages/ui/src/primitives/timezone-select.tsx
export interface TimezoneSelectProps {
  value: string;
  onChange: (tz: string) => void;
  locale: string;
  disabled?: boolean;
  'aria-label'?: string;
}
export function TimezoneSelect(props: TimezoneSelectProps): JSX.Element;
```
**Acceptance:**
- [ ] `/profile` shows the timezone combobox defaulting to the user's saved zone (`Asia/Jerusalem` for new users).
- [ ] Selecting a zone persists immediately and a success toast appears; reload shows the new value.
- [ ] Keyboard-only operation works; axe/pa11y reports no combobox violations; component renders correctly under `dir="rtl"`.

### Task 10: Tenant operations-timezone control on `/settings/locale`
**Blocks:** —  ·  **Blocked by:** 2, 8
**Files:**
- Modify: the `/settings/locale` page (e.g. `apps/zync-app/src/routes/settings/locale.tsx`, owned by `settings-module`)
**Steps:**
- [ ] Add the "Operations timezone" field using the same `TimezoneSelect` primitive (Task 9). Visible/editable only to admins; render read-only (disabled) for non-admins.
- [ ] Read current value from the tenant settings query (`tenants.default_timezone`); save on select via `PATCH /api/settings/locale { timezone }`; toast on success.
- [ ] Helper text per spec: "Affects report periods, reminder send times, and cron-generated invoices."
**Acceptance:**
- [ ] Admin sees an editable operations-timezone selector reflecting `tenants.default_timezone`.
- [ ] Saving updates the tenant value and re-renders reports/period selectors against the new zone.
- [ ] Non-admin sees the field disabled (no write path).

### Task 11: Wire boundary + window helpers into reports and crons
**Blocks:** —  ·  **Blocked by:** 5, 6
**Files:**
- Modify: reports date-range resolution in `reports-analytics` API (e.g. `apps/zync-api/src/routes/reports.ts`)
- Modify: business-hours cron handlers (e.g. `apps/zync-api/src/cron/invoice-payment-reminder.ts`)
- Modify: time-management period rollup query site (where "this week/month" totals are bucketed)
**Steps:**
- [ ] In the reports date-range resolver, map the preset (`today`/`week`/`month`/`quarter`/`custom`) to the corresponding `*Boundaries` helper, passing the tenant's `default_timezone`; feed the resulting UTC `start`/`end` into the existing SQL `WHERE … BETWEEN` filters. Custom ranges pass tenant-local `YYYY-MM-DD` strings to `rangeBoundaries`.
- [ ] In each business-hours cron, after loading per-tenant config, gate the send with `isWithinTenantWindow(tenant.default_timezone, 7, 11)` (the `invoice-payment-reminder` example), so a 09:00 UTC schedule only fires for tenants currently inside their local morning window. DST is automatic.
- [ ] In time-management rollups, ensure "this week/month" aggregations bucket on tenant-tz boundaries (display of individual entries stays in the user's tz via `formatLocalDate`).
**Acceptance:**
- [ ] A report's "This month" total for a `America/New_York` tenant matches New-York-local month boundaries, not UTC month boundaries.
- [ ] `invoice-payment-reminder` fires for a tenant only when it is 07:00–11:00 in that tenant's timezone; verified across a DST transition date.
- [ ] Individual time entries still display in each user's own timezone.

### Task 12: Tests — boundaries, windows, formatting, DST
**Blocks:** —  ·  **Blocked by:** 3, 5, 6
**Files:**
- Create: `packages/api/src/lib/period-boundaries.test.ts`
- Create: `packages/api/src/lib/tenant-window.test.ts`
- Create: `packages/ui/src/lib/format-date.test.ts`
- Create: `packages/types/src/timezone.test.ts`
**Steps:**
- [ ] period-boundaries: assert `todayBoundaries`/`monthBoundaries`/`quarterBoundaries` produce correct UTC instants for `Asia/Jerusalem` (winter +02:00 and summer +03:00) and `America/New_York` (EST/EDT). Include a DST-transition month so the `+offset` differs at month start vs end and confirm the boundary is still the local midnight.
- [ ] tenant-window: assert in-window/out-of-window and wrap-around cases at fixed UTC instants for multiple zones.
- [ ] format-date: assert `formatLocalDate` renders the correct local clock time for a UTC input across zones, and that an invalid timezone falls back without throwing.
- [ ] timezone types: assert `isValidTimezone`, `listSupportedTimezones` membership, and continent grouping.
**Acceptance:**
- [ ] All four test files pass.
- [ ] A deliberately UTC-vs-tenant-tz mismatched boundary case fails if the tenant timezone is ignored (guards against regressions to naive UTC bucketing).

## Cross-Cutting Compliance
- **Security:** server-only `period-boundaries.ts` and `tenant-window.ts` are never shipped to the client; `PATCH /api/settings/locale` is admin-guarded; both routes validate with Zod and reject invalid IANA names (no unbounded string into `Intl`). Repo-function persistence only (`no-raw-drizzle-from-routes`).
- **i18n / RTL:** all formatting goes through `Intl` with the user's `locale` (never hardcoded `en`); `TimezoneSelect` inherits document `dir` for Hebrew RTL; consistent with spec 117's Intl-only mandate (no locale bundles, no third-party tz library).
- **a11y:** `TimezoneSelect` exposes combobox/listbox/option roles, `aria-selected`, accessible labels, and full keyboard control; passes pa11y/axe.
- **Performance:** zero third-party timezone library; boundary math is O(1) `Intl` calls; report queries reuse existing indexed `*_at BETWEEN` filters.
- **Reduced motion:** `TimezoneSelect` open/close animation is suppressed under `prefers-reduced-motion`.
