# Hebrew Locale & Date Formatting

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 117  
**Tier:** All tiers  
**Depends on:** `rtl-hebrew-ui`, `foundation-auth-rbac`  
**Referenced by:** `rtl-hebrew-ui`, all date-displaying UI specs

---

## Overview

Spec 35 (`rtl-hebrew-ui`) defines RTL layout switching via `dir="rtl"` on `<html>`. This spec defines the date, time, and number formatting rules for Hebrew locale (`he-IL`), including Gregorian calendar display, Hebrew numeral support, currency formatting for ILS, and locale-aware relative time strings.

---

## Locale Detection

Active locale from `user_preferences.locale` (existing column). Hebrew locale: `'he'` or `'he-IL'`.

When `locale = 'he'` or `locale = 'he-IL'`:
- `dir="rtl"` on `<html>` (spec 35)
- All `Intl.*` calls use `'he-IL'` as locale tag
- Date separator: `.` (Israeli convention: DD.MM.YYYY)

---

## Date Formatting

Use `Intl.DateTimeFormat` — never manual string concatenation.

### Canonical formats

| Context | Format | Example |
|---------|--------|---------|
| Short date (tables, cards) | `dd.MM.yyyy` | `31.05.2026` |
| Long date (detail views) | `d בMMMM yyyy` | `31 במאי 2026` |
| Month+year (invoices, reports) | `MMMM yyyy` | `מאי 2026` |
| Date+time (audit log, notifications) | `dd.MM.yyyy HH:mm` | `31.05.2026 14:30` |
| Time only | `HH:mm` | `14:30` |

```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' } as const;

export function formatDate(date: Date, locale: string, style: 'short'|'long'|'month'|'datetime' = 'short') {
  const opts = style === 'short' ? DATE_SHORT
             : style === 'long'  ? DATE_LONG
             : style === 'month' ? DATE_MONTH
             : DATETIME;
  return new Intl.DateTimeFormat(locale, opts).format(date);
}
```

---

## Relative Time

```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 diffMs = date.getTime() - Date.now();
  const diffSec = diffMs / 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');
}
```

Output in Hebrew: `"לפני 3 ימים"`, `"בעוד שעה"`, `"אתמול"`.

---

## Number Formatting

```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);
}
```

ILS (Israeli New Shekel) in `he-IL`: `₪1,500` — the `₪` symbol is placed on the right in Hebrew locale.

Example: `formatCurrency(1500, 'ILS', 'he-IL')` → `"‏₪1,500"` (with RTL mark for correct bidi).

---

## Hebrew Calendar Note

Zync uses **Gregorian calendar only** — no Hebrew calendar (Tishrei months etc.) in v1. Hebrew locale means language/formatting, not calendar system. `calendar: 'gregory'` implied by default when locale = `'he-IL'`.

If Hebrew calendar support is needed in future, pass `new Intl.DateTimeFormat('he-IL-u-ca-hebrew', opts)` — this is a future scope item, not in this spec.

---

## Ordinal Day Numbers

Hebrew does not use ordinal suffixes (1st, 2nd). `Intl.DateTimeFormat` handles this correctly via `day: 'numeric'`.

---

## Fiscal Year Note

Israeli fiscal year = calendar year (January–December). No fiscal offset needed.

---

## Locale Utility Hook

```ts
// packages/ui/src/hooks/useLocale.ts

export function useLocale() {
  const { user } = useAuth();
  return user?.preferences?.locale ?? 'en';
}
```

All date/number formatting components consume this hook — never hardcode `'en'` or `'he-IL'`.

---

## Date Picker Library Locale Configuration

UI date pickers (used in invoice create, task due-date, report period selectors) must receive a locale prop. The date picker library used across `packages/ui` is **react-day-picker v9** (Radix-compatible).

Configure Hebrew locale:

```tsx
import { DayPicker } from 'react-day-picker'
import { he } from 'date-fns/locale'

// packages/ui/src/primitives/date-picker.tsx
<DayPicker
  locale={locale === 'he' || locale === 'he-IL' ? he : undefined}
  dir={locale === 'he' || locale === 'he-IL' ? 'rtl' : 'ltr'}
  // ...
/>
```

`date-fns/locale/he` provides Hebrew month names, day names, and ordinal formatting for the calendar widget. The `dir` prop controls arrow-key navigation direction (← next week in LTR; → next week in RTL).

Month names in calendar header must display in Hebrew: ינואר, פברואר, מרץ... (verified via `he` locale).

## Date Picker Accessibility

WCAG 2.1 AA requirements for the calendar popup, regardless of which date picker library is used:

**ARIA structure:**
- Calendar grid container: `role="grid"` `aria-label="Choose date"` (or contextual label, e.g., `"Choose invoice due date"`)
- Day cells: `role="gridcell"` `aria-label="{weekday}, {day} {month} {year}"` (e.g., `"Sunday, 15 June 2025"`)
  - Selected: `aria-selected="true"`
  - Out-of-range: `aria-disabled="true"`
- Previous/Next month buttons: `aria-label="Previous month"` / `aria-label="Next month"`
- Month/year header: wrapped in an `aria-live="polite"` region (announces month changes)

**Keyboard navigation:**
- `Left`/`Right Arrow`: move one day
- `Up`/`Down Arrow`: move one week (same column, adjacent row)
- `Page Up`/`Page Down`: navigate one month
- `Home`/`End`: jump to first/last day of the current week

**Focus management:**
- On open: focus moves to the selected date, or to today if none selected
- Focus trap: Tab cycles within the open calendar only
- `Escape`: closes calendar, returns focus to the trigger input

**Trigger input:**
- `aria-haspopup="dialog"` `aria-expanded="true|false"` on the input or trigger button

## `en-IL` Locale Tag

`en-IL` is a valid BCP 47 locale tag for English-language formatting with Israeli regional conventions (currency `₪`, date separator `.`). It is NOT defined as a stored value in the DB (`user_preferences.locale` accepts `'he'` or `'en'`).

When rendering formatted values for an `en`-locale tenant operating in Israel:
- Currency: use `'ILS'` as currency code regardless of locale → `Intl.NumberFormat('en-US', { style: 'currency', currency: 'ILS' })` → `"ILS 1,500.00"` (non-standard). Prefer `₪1,500` (achieved by `formatCurrency(v, 'ILS', 'he-IL')` even for English-locale tenants — the number formatting is regional, not UI-language-dependent).
- Dates: `en`-locale tenants receive `DD/MM/YYYY` (Israeli Gregorian convention) not `MM/DD/YYYY` (US). Achieved via `Intl.DateTimeFormat('en-IL', ...)` — the `en-IL` tag is valid in all modern JS engines and returns `.`-separated Gregorian dates.

Stored locale value `'en'` → at formatting time, expand to `'en-IL'` for date/currency formatting (not `'en-US'`):

```ts
export function toFormattingLocale(storedLocale: 'he' | 'en'): string {
  return storedLocale === 'he' ? 'he-IL' : 'en-IL'
}
```

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| `Intl.*` API only | Not `date-fns` / `moment` locale bundles | `Intl` is runtime-native (zero bundle cost); date-fns locale bundles add ~80KB per locale; Cloudflare Workers ship with full `Intl` support |
| Gregorian only in v1 | Not Hebrew calendar | Most Israeli SaaS users work in Gregorian for business; Hebrew calendar adds complexity with minimal business value at this stage |
| `he-IL` locale tag | Not `he` alone | `he-IL` specifies regional formatting (`.` separator, `₪` currency) that `he` alone doesn't guarantee |
