# Hebrew Locale & Date Formatting — Implementation Plan

**Spec:** docs/specs/2026-05-31-hebrew-locale-dates.md  ·  **Slug:** hebrew-locale-dates  ·  **Wave:** 5
**Depends on:** foundation-auth-rbac, rtl-hebrew-ui

## Goal
Deliver the canonical date, time, number, and currency formatting layer for Zync, locale-aware for Hebrew (`he-IL`) and English-in-Israel (`en-IL`). All UI across the product consumes these utilities — never raw `Intl.*` calls or manual string concatenation — so dates render `31.05.2026`, currency renders `₪1,500` with correct bidi, and relative time renders `"לפני 3 ימים"`. This spec also wires the shared `react-day-picker v9` date-picker primitive with Hebrew locale and WCAG 2.1 AA keyboard/ARIA behavior, and exposes a `useLocale()` hook reading `user_preferences.locale`.

## Architecture
Pure frontend formatting layer in `packages/ui` — **no database tables, no API routes**. The locale value originates from the upstream `user_preferences.locale` column (`TEXT`, values `'he' | 'en'`, NULL = tenant default) defined in `foundation-auth-rbac`. The `useLocale()` hook reads it through the existing `useAuth()` session context (`user?.preferences?.locale`), defaulting to `'en'`. A pure `toFormattingLocale(stored)` expander converts the stored UI-language value to a BCP 47 *formatting* tag (`'he' → 'he-IL'`, `'en' → 'en-IL'`) so even English-locale Israeli tenants get `.`-separated Gregorian dates and `₪` currency.

**Session source (resolved against upstream):** the locked interface sheet exposes no `useAuth`/`useSession`/`useUser` hook — the session is a JWT httpOnly cookie surfaced client-side via the real route `GET /api/auth/me` and read "from store" by the app shell. Therefore `useLocale()` reads the current user through the canonical `GET /api/auth/me` response (consumed with TanStack Query, the app's data layer), exactly as `useTheme`/`useThemeSync` obtain `user_preferences.ui_theme`. No new auth hook is introduced and no non-existent export is imported.

Direction (`dir="rtl"`) is owned upstream by `rtl-hebrew-ui` (sets `dir` on `<html>`); this spec consumes `useDirection()` from `@zync/ui` only for the date-picker's `dir` prop and re-exports nothing direction-related. Formatting functions are stateless and locale-tag-driven so they are equally callable from React components, the Astro `zync-www` site, and Worker-side serializers.

All new code lives in `@zync/ui` and is re-exported from its barrel so downstream specs (`invoice-payment-ux`, `public-proposal-view`, `recurring-invoices`, `timezone-handling`, and every date-displaying UI spec) import `formatDate`, `formatCurrency`, `formatNumber`, `relativeTime`, `toFormattingLocale`, `useLocale`, and `DatePicker` from `@zync/ui`.

## Tech Stack
- Package: `@zync/ui` (`packages/ui`) — React 19, TypeScript, Tailwind v4.
- Runtime APIs: native `Intl.DateTimeFormat`, `Intl.RelativeTimeFormat`, `Intl.NumberFormat` (zero-bundle, full support on Cloudflare Workers and modern browsers). No `date-fns`/`moment` locale bundles for formatting.
- Date picker: `react-day-picker` v9 + `date-fns/locale` (`he`) — `date-fns` used ONLY for the picker's locale object (month/day names), never for value formatting.
- Session/user source: the `GET /api/auth/me` route (locked-sheet route) read via TanStack Query — same mechanism `useTheme`/`useThemeSync` use for `user_preferences.ui_theme`. No `useAuth` export exists in the locked sheet; do not import one.
- Direction: `useDirection` from `@zync/ui` (upstream `rtl-hebrew-ui`).
- No Cloudflare bindings, no DB, no migrations.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 5a | 1, 2 | `packages/ui/src/lib/locale.ts`, `packages/ui/src/hooks/useLocale.ts` | Yes (both leaf utilities) |
| 5b | 3, 4, 5 | `format-date.ts`, `relative-time.ts`, `format-number.ts` | Yes (all depend only on Task 1's tag expander, independent of each other) |
| 5c | 6 | `packages/ui/src/primitives/date-picker.tsx` | No (consumes Tasks 2 & 4) |
| 5d | 7 | `packages/ui/src/index.ts` (barrel re-exports) | No (consumes all above) |

## Tasks

### Task 1: Locale tag expander (`toFormattingLocale`)
**Blocks:** 3, 4, 5, 6, 7  ·  **Blocked by:** —
**Files:**
- Create: `packages/ui/src/lib/locale.ts`
**Steps:**
- [ ] Define the stored-locale type and the formatting-tag expander; stored UI-language value (`'he' | 'en'`) maps to a regional formatting tag (`'he-IL' | 'en-IL'`).
- [ ] Export a `FORMATTING_LOCALE` const record so callers can reference tags without string literals.
- [ ] Handle a possibly-null/undefined stored locale by defaulting to `'en'` before expansion (callers pass through `useLocale()` which already defaults, but keep the function total).
- [ ] Add no `'en-US'` path: English-in-Israel always expands to `'en-IL'` (`.`-separated Gregorian, `₪` currency), never US conventions.
**Schema / Interfaces:**
```ts
// packages/ui/src/lib/locale.ts
export type StoredLocale = 'he' | 'en';
export type FormattingLocaleTag = 'he-IL' | 'en-IL';

export const FORMATTING_LOCALE: Record<StoredLocale, FormattingLocaleTag> = {
  he: 'he-IL',
  en: 'en-IL',
} as const;

/** Expand the stored UI-language value to a BCP 47 regional formatting tag.
 *  he -> he-IL, en -> en-IL (Israeli Gregorian: '.' separator, ₪ currency).
 *  Never returns en-US. */
export function toFormattingLocale(storedLocale: StoredLocale | null | undefined): FormattingLocaleTag {
  return storedLocale === 'he' ? 'he-IL' : 'en-IL';
}
```
**Acceptance:**
- [ ] `toFormattingLocale('he') === 'he-IL'`, `toFormattingLocale('en') === 'en-IL'`, `toFormattingLocale(null) === 'en-IL'`.
- [ ] No string literal `'en-US'` appears anywhere in the file.

### Task 2: `useLocale()` hook
**Blocks:** 6, 7  ·  **Blocked by:** —
**Files:**
- Create: `packages/ui/src/hooks/useLocale.ts`
**Steps:**
- [ ] Read the current user via the canonical `GET /api/auth/me` response using TanStack Query (`useQuery`), keyed `['auth', 'me']` — the same session source `useTheme`/`useThemeSync` consume for `user_preferences.ui_theme`. Do NOT import a `useAuth`/`useSession` hook; none exists in the locked interface set.
- [ ] Extract `data?.user?.preferences?.locale` from the response (`user_preferences.locale`, `TEXT`, `'he' | 'en' | null`).
- [ ] Default to `'en'` when locale is unset/NULL or while the query is loading (matching `user_preferences.locale` NULL semantics).
- [ ] Return the **stored** locale string (`'he' | 'en'`), NOT the expanded formatting tag — callers expand via `toFormattingLocale` at formatting time. (Downstream `timezone-handling` later extends this hook to return `{ locale, timezone }`; keep this version returning the bare string so that extension is additive.)
**Schema / Interfaces:**
```ts
// packages/ui/src/hooks/useLocale.ts
import { useQuery } from '@tanstack/react-query';
import type { StoredLocale } from '../lib/locale';

interface MeResponse {
  user?: { preferences?: { locale?: 'he' | 'en' | null } };
}

/** Active stored UI-language locale for the current user. 'he' | 'en', default 'en'.
 *  Source: GET /api/auth/me (httpOnly-JWT session), read via TanStack Query —
 *  the same session source as useTheme/useThemeSync. */
export function useLocale(): StoredLocale {
  const { data } = useQuery<MeResponse>({
    queryKey: ['auth', 'me'],
    queryFn: async () => (await fetch('/api/auth/me', { credentials: 'include' })).json(),
    staleTime: 60_000,
  });
  return data?.user?.preferences?.locale === 'he' ? 'he' : 'en';
}
```
**Acceptance:**
- [ ] Returns `'he'` when the `/api/auth/me` response has `user.preferences.locale === 'he'`; returns `'en'` for `'en'`, `undefined`, `null`, loading state, or any other value.
- [ ] Never returns a regional tag (`'he-IL'`/`'en-IL'`) — that is the formatter's job.
- [ ] Imports no `useAuth`/`useSession`; reads the session only via `GET /api/auth/me`.

### Task 3: Date/time formatting (`formatDate`)
**Blocks:** 7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/ui/src/lib/format-date.ts`
**Steps:**
- [ ] Implement the four canonical style option sets exactly per spec: `short` (DD.MM.YYYY), `long` (`31 במאי 2026`), `month` (`מאי 2026`), `datetime` (DD.MM.YYYY HH:mm).
- [ ] `formatDate(date, locale, style)` accepts a **formatting tag** (`he-IL`/`en-IL`) as `locale`; document that callers pass `toFormattingLocale(useLocale())`.
- [ ] Use `Intl.DateTimeFormat` only — no manual string concatenation, no `date-fns`.
- [ ] Gregorian calendar implied (`calendar: 'gregory'` default for `he-IL`); do NOT pass `-u-ca-hebrew` (future scope).
- [ ] Hour cycle: use 24-hour clock (`HH:mm`) — set `hour12: false` so both locales render `14:30`, matching Israeli convention.
**Schema / Interfaces:**
```ts
// packages/ui/src/lib/format-date.ts
const DATE_SHORT = { day: '2-digit', month: '2-digit', year: 'numeric' } as const;
const DATE_LONG  = { day: 'numeric', month: 'long', year: 'numeric' } as const;
const DATE_MONTH = { month: 'long', year: 'numeric' } as const;
const DATETIME   = { ...DATE_SHORT, hour: '2-digit', minute: '2-digit', hour12: false } as const;

export type DateStyle = 'short' | 'long' | 'month' | 'datetime';

export function formatDate(date: Date, locale: string, style: DateStyle = 'short'): string {
  const opts = style === 'short' ? DATE_SHORT
             : style === 'long'  ? DATE_LONG
             : style === 'month' ? DATE_MONTH
             : DATETIME;
  return new Intl.DateTimeFormat(locale, opts).format(date);
}
```
**Acceptance:**
- [ ] `formatDate(new Date('2026-05-31'), 'he-IL', 'short')` yields `31.5.2026`-style dot-separated output (engine-localized).
- [ ] `formatDate(new Date('2026-05-31'), 'he-IL', 'long')` contains the Hebrew month `במאי`.
- [ ] `datetime` style renders 24-hour time (`14:30`, never `2:30 PM`).
- [ ] No `date-fns`/`moment` import; no manual `${d}.${m}.${y}` concatenation.

### Task 4: Relative time (`relativeTime`)
**Blocks:** 6, 7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/ui/src/lib/relative-time.ts`
**Steps:**
- [ ] Implement `relativeTime(date, locale)` using `Intl.RelativeTimeFormat` with `{ numeric: 'auto' }` (so it yields `"אתמול"`/`"yesterday"` where applicable).
- [ ] Bucket by absolute diff: <60s → seconds, <3600s → minutes, <86400s → hours, else days. Round each bucket value.
- [ ] Accept a formatting tag as `locale`; callers pass `toFormattingLocale(useLocale())`.
- [ ] Preserve sign so past times render `"לפני 3 ימים"` and future times `"בעוד שעה"`.
**Schema / Interfaces:**
```ts
// packages/ui/src/lib/relative-time.ts
export function relativeTime(date: Date, locale: string): string {
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  const diffSec = (date.getTime() - Date.now()) / 1000;
  if (Math.abs(diffSec) < 60)    return rtf.format(Math.round(diffSec), 'second');
  if (Math.abs(diffSec) < 3600)  return rtf.format(Math.round(diffSec / 60), 'minute');
  if (Math.abs(diffSec) < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
  return rtf.format(Math.round(diffSec / 86400), 'day');
}
```
**Acceptance:**
- [ ] A date 3 days in the past with `'he-IL'` renders `"לפני 3 ימים"`.
- [ ] A date 1 hour in the future with `'he-IL'` renders `"בעוד שעה"`.
- [ ] Same call with `'en-IL'` renders English equivalents (`"3 days ago"`, `"in 1 hour"`).

### Task 5: Number & currency formatting (`formatNumber`, `formatCurrency`)
**Blocks:** 7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/ui/src/lib/format-number.ts`
**Steps:**
- [ ] Implement `formatNumber(value, locale)` via `Intl.NumberFormat`.
- [ ] Implement `formatCurrency(amount, currency, locale)` with `style: 'currency'`, `minimumFractionDigits: 0`, `maximumFractionDigits: 2` — so whole ILS amounts render `₪1,500` (no trailing `.00`) but fractional amounts keep up to 2 decimals.
- [ ] `currency` is the ISO 4217 code (`'ILS'`); pass it through regardless of UI language — number formatting is regional, not UI-language-dependent. Document that even English-locale Israeli tenants call `formatCurrency(v, 'ILS', 'he-IL')` to get `₪1,500` rather than `"ILS 1,500.00"`.
- [ ] Rely on `Intl` to emit the correct bidi/RTL mark for `₪` placement in `he-IL`; do not hand-insert directional marks.
**Schema / Interfaces:**
```ts
// packages/ui/src/lib/format-number.ts
export function formatNumber(value: number, locale: string): string {
  return new Intl.NumberFormat(locale).format(value);
}

export function formatCurrency(amount: number, currency: string, locale: string): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    minimumFractionDigits: 0,
    maximumFractionDigits: 2,
  }).format(amount);
}
```
**Acceptance:**
- [ ] `formatCurrency(1500, 'ILS', 'he-IL')` renders a `₪1,500` string (with engine bidi mark, no `.00`).
- [ ] `formatCurrency(1500.5, 'ILS', 'he-IL')` keeps the fractional part (`₪1,500.5`).
- [ ] `formatNumber(1234567, 'he-IL')` group-separates (`1,234,567`).

### Task 6: Locale-aware accessible date-picker primitive (`DatePicker`)
**Blocks:** 7  ·  **Blocked by:** 2, 4
**Files:**
- Create: `packages/ui/src/primitives/date-picker.tsx`
**Steps:**
- [ ] Wrap `react-day-picker` v9 `DayPicker` in a `DatePicker` component.
- [ ] Pass `locale={he}` (from `date-fns/locale`) when stored locale is `'he'`/`'he-IL'`, else `undefined` (default English). Hebrew month names must render in the header (ינואר, פברואר, מרץ, אפריל, מאי).
- [ ] Pass `dir` from `useDirection()` (`@zync/ui`, upstream `rtl-hebrew-ui`) so arrow-key navigation flips correctly (← next week LTR, → next week RTL). Fall back to deriving `dir` from the locale prop if `useDirection` is unavailable in the render context.
- [ ] Accept an `ariaLabel` prop for the grid (default `"Choose date"`; callers pass contextual labels like `"Choose invoice due date"`).
- [ ] Wire WCAG 2.1 AA structure: grid container `role="grid"` + `aria-label`; day cells `role="gridcell"` with `aria-label="{weekday}, {day} {month} {year}"`, `aria-selected` on selected, `aria-disabled` on out-of-range; prev/next buttons `aria-label="Previous month"`/`"Next month"`; month/year header inside an `aria-live="polite"` region.
- [ ] Keyboard: Left/Right = ±1 day, Up/Down = ±1 week, PageUp/PageDown = ±1 month, Home/End = first/last day of current week (react-day-picker v9 provides these; verify and do not override).
- [ ] Focus management: on open focus the selected date (or today if none); trap Tab within the open calendar; `Escape` closes and returns focus to the trigger input.
- [ ] Trigger input/button carries `aria-haspopup="dialog"` and `aria-expanded` reflecting open state.
- [ ] Respect `prefers-reduced-motion` for any open/close transition (no animation when reduced motion is requested).
**Schema / Interfaces:**
```tsx
// packages/ui/src/primitives/date-picker.tsx
import { DayPicker } from 'react-day-picker';
import { he } from 'date-fns/locale';
import { useDirection } from '../hooks/useDirection';
import { useLocale } from '../hooks/useLocale';

export interface DatePickerProps {
  selected?: Date;
  onSelect?: (date: Date | undefined) => void;
  disabled?: (date: Date) => boolean;
  /** Contextual grid label, e.g. "Choose invoice due date". Default "Choose date". */
  ariaLabel?: string;
}

export function DatePicker(props: DatePickerProps): JSX.Element {
  const locale = useLocale();              // 'he' | 'en'
  const isHebrew = locale === 'he';
  const dir = useDirection();              // 'rtl' | 'ltr' from rtl-hebrew-ui
  return (
    <DayPicker
      mode="single"
      selected={props.selected}
      onSelect={props.onSelect}
      disabled={props.disabled}
      locale={isHebrew ? he : undefined}
      dir={dir}
      aria-label={props.ariaLabel ?? 'Choose date'}
    />
  );
}
```
**Acceptance:**
- [ ] With stored locale `'he'`, calendar header shows Hebrew month names and `dir="rtl"`; arrow-right moves to the previous week.
- [ ] Calendar grid exposes `role="grid"` with the contextual `aria-label`; day cells expose `role="gridcell"` and per-day `aria-label` `"{weekday}, {day} {month} {year}"`.
- [ ] `Escape` closes the calendar and returns focus to the trigger; Tab is trapped while open.
- [ ] No open/close animation runs under `prefers-reduced-motion: reduce`.

### Task 7: Barrel exports from `@zync/ui`
**Blocks:** —  ·  **Blocked by:** 1, 2, 3, 4, 5, 6
**Files:**
- Modify: `packages/ui/src/index.ts`
**Steps:**
- [ ] Re-export the formatting utilities: `formatDate`, `DateStyle`, `formatNumber`, `formatCurrency`, `relativeTime`.
- [ ] Re-export the locale layer: `useLocale`, `toFormattingLocale`, `FORMATTING_LOCALE`, and types `StoredLocale`, `FormattingLocaleTag`.
- [ ] Re-export the date-picker primitive: `DatePicker`, `DatePickerProps`.
- [ ] Confirm `useDirection` remains exported (owned by `rtl-hebrew-ui`); do not redefine it here.
- [ ] Verify no downstream consumer (`invoice-payment-ux`, `public-proposal-view`, `recurring-invoices`, `timezone-handling`) needs a name not in this export set.
**Schema / Interfaces:**
```ts
// packages/ui/src/index.ts (additions)
export { formatDate } from './lib/format-date';
export type { DateStyle } from './lib/format-date';
export { formatNumber, formatCurrency } from './lib/format-number';
export { relativeTime } from './lib/relative-time';
export { toFormattingLocale, FORMATTING_LOCALE } from './lib/locale';
export type { StoredLocale, FormattingLocaleTag } from './lib/locale';
export { useLocale } from './hooks/useLocale';
export { DatePicker } from './primitives/date-picker';
export type { DatePickerProps } from './primitives/date-picker';
```
**Acceptance:**
- [ ] `import { formatDate, formatCurrency, formatNumber, relativeTime, useLocale, toFormattingLocale, DatePicker } from '@zync/ui'` type-checks.
- [ ] `packages/ui` builds with no unresolved exports.
- [ ] No new `Intl.*`-wrapping function is left unexported from the barrel.
