# Notification Center — Implementation Plan

**Spec:** docs/specs/2026-05-31-notification-center.md  ·  **Slug:** notification-center  ·  **Wave:** 4
**Depends on:** app-shell, error-empty-states, foundation-auth-rbac, foundation-design-system, system-communications-notifications

## Goal
Build `/notifications`, the full notification history page for the current user within the current tenant. It extends the ambient header dropdown (`NotificationDropdown` from `app-shell`) into a paginated, filterable archive: type filter (tasks/invoices/tickets/mentions/system), unread-only filter, cursor pagination via a "Load more" button, optimistic mark-one-read and mark-all-read, RTL Hebrew layout, and per-row deep-linking to the source entity. It adds one new API endpoint (`GET /api/notifications/all`) and reuses the existing `notifications` table and `notifications_inbox` index — no schema changes.

## Architecture
- **Data source:** the upstream `notifications` table (defined by `system-communications-notifications`) with columns `id, tenant_id, user_id, type, title_key, body_key, params (JSONB), entity_type, entity_id, read_at, created_at`, and the existing index `notifications_inbox (tenant_id, user_id, read_at, created_at DESC)`. No new tables, no new indexes.
- **Localization reconciliation (load-bearing):** the table stores i18n keys (`title_key`, `body_key`) + `params`, never pre-rendered text. The spec's `NotificationRow` API contract returns rendered `title`/`body` strings. The new endpoint therefore renders keys → strings **server-side** in the request locale before responding. A small server-side translator (`renderNotificationText`) maps `(title_key, body_key, params, locale)` → `{ title, body }` using the catalog already shipped in `packages/ui/src/locales/{he,en}.json` (per `system-communications-notifications` §Notification body localization). This mirrors how the existing `GET /api/notifications` (inbox) and the `NotificationDropdown` render title/body.
- **New endpoint:** `GET /api/notifications/all` added to `apps/zync-api/src/routes/notifications.ts` alongside the existing `GET /api/notifications`, `POST /api/notifications/read-all`, `PATCH /api/notifications/:id/read`. Cursor-paginated (composite `(created_at, id)` DESC), filterable by type group and unread status. Enforces `user_id = auth.user_id` and `tenant_id = auth.tenant_id` — strictly own-user, no cross-user access.
- **Frontend:** a new React route `/notifications` in `apps/zync-app`, built from `packages/ui` primitives (`Select`, `Button`, `Skeleton`, `EmptyState`, `Card`). State via TanStack Query `useInfiniteQuery` keyed on `{ type, status }` read from the URL search params. Filter changes rewrite the query string and reset the cursor. Mark-read mutations are optimistic and invalidate the broad `['notifications']` key so the header dropdown badge stays in sync.
- **Shared helper extraction:** `resolveEntityHref(notification)` is extracted into its own module so both this page and `app-shell`'s `NotificationDropdown` use one source of truth for `entity_type`/type → URL mapping.
- **Consumed upstream exports:** `EmptyState` (error-empty-states / `packages/ui`), `Select`/`Button`/`Skeleton`/`Card` (foundation-design-system / `packages/ui`), `authMiddleware` + `Session` (foundation-auth-rbac), `tenantQuery` + `createDb`/`DB` (foundation-monorepo / `@zync/db`), `NotificationType` union (`@zync/types`, canonical in notification-preferences spec), and the `NotificationDropdown` host page from `app-shell`.

## Tech Stack
- **API app:** `apps/zync-api` — Hono on Cloudflare Workers; Drizzle ORM over Neon Postgres via Hyperdrive binding `DB`. Zod for query validation. `authMiddleware` from `@zync/auth`.
- **Web app:** `apps/zync-app` — Vite + React, React Router (`useSearchParams`), TanStack Query (`useInfiniteQuery`, `useMutation`, `useQueryClient`), `lucide-react` icons (already a dependency), `react-i18next` for UI chrome strings.
- **Packages:** `packages/ui` (Select, Button, Skeleton, EmptyState, Card), `packages/utils` (`formatRelativeTime`), `@zync/types` (`NotificationType`).
- **Bindings:** Hyperdrive `DB` (Neon Postgres). No new bindings, no KV, no queues, no Durable Objects (real-time push is owned upstream; this page is request/response + polling via TanStack staleTime).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 4a | 1 | `packages/utils/src/date.ts` | Yes (independent leaf) |
| 4b | 2 | `apps/zync-api/src/routes/notifications.ts` (+ i18n render helper) | Yes (independent of FE) |
| 4c | 3, 4 | `apps/zync-app/.../resolveEntityHref.ts`, `useNotificationsQuery.ts` | 3 and 4 parallel after 2 lands the contract |
| 4d | 5, 6, 7, 8 | `NotificationRow.tsx`, `NotificationsLoadingSkeleton.tsx`, `NotificationsFilterSidebar.tsx`, `NotificationsList.tsx` | Mostly parallel; 8 depends on 5+6 |
| 4e | 9 | `NotificationsPage.tsx` + router registration | No (composes everything) |
| 4f | 10 | i18n key audit (`packages/ui/src/locales/{he,en}.json`) | Yes (can run alongside 4e) |

## Tasks

### Task 1: Add `formatRelativeTime` to `packages/utils`
**Blocks:** 5  ·  **Blocked by:** —
**Files:**
- Modify: `packages/utils/src/date.ts`
**Steps:**
- [ ] Check whether `formatRelativeTime(date: Date): string` already exists in `packages/utils/src/date.ts`; if present and matching the contract below, skip implementation and only confirm the export.
- [ ] Implement/confirm `formatRelativeTime` using `Intl.RelativeTimeFormat` (`{ numeric: 'always' }`) for deltas under 7 days and `Intl.DateTimeFormat` for ≥ 7 days, both with locale `'he-IL'`.
- [ ] Buckets: `< 60s` → `"עכשיו"`; `< 60m` → `"לפני {n} דקות"`; `< 24h` → `"לפני {n} שעות"`; `< 7d` → `"לפני {n} ימים"`; `≥ 7d` → absolute Hebrew long date `"12 ביוני 2026"`.
- [ ] Export `formatRelativeTime` from the package barrel (`packages/utils/src/index.ts`) if not already exported.
**Schema / Interfaces:**
```ts
// packages/utils/src/date.ts
export function formatRelativeTime(date: Date): string {
  const now = Date.now();
  const diffMs = now - date.getTime();
  const sec = Math.floor(diffMs / 1000);
  const min = Math.floor(sec / 60);
  const hr = Math.floor(min / 60);
  const day = Math.floor(hr / 24);

  if (sec < 60) return 'עכשיו';

  const rtf = new Intl.RelativeTimeFormat('he-IL', { numeric: 'always' });
  if (min < 60) return rtf.format(-min, 'minute');   // "לפני N דקות"
  if (hr < 24)  return rtf.format(-hr, 'hour');       // "לפני N שעות"
  if (day < 7)  return rtf.format(-day, 'day');       // "לפני N ימים"

  return new Intl.DateTimeFormat('he-IL', {
    day: 'numeric', month: 'long', year: 'numeric',
  }).format(date);                                    // "12 ביוני 2026"
}
```
**Acceptance:**
- [ ] `formatRelativeTime(new Date())` returns `"עכשיו"`.
- [ ] A timestamp 3 hours ago renders a Hebrew "לפני 3 שעות"-style string via `Intl.RelativeTimeFormat`.
- [ ] A timestamp 10 days ago renders an absolute Hebrew long date (no "לפני").
- [ ] Exported from `packages/utils` barrel.

### Task 2: `GET /api/notifications/all` endpoint with server-side i18n rendering
**Blocks:** 3, 4  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/src/routes/notifications.ts`
- Create: `apps/zync-api/src/routes/notifications.render.ts` (server-side notification text renderer + type-group map)
**Steps:**
- [ ] Add a Zod schema `notificationsAllQuerySchema` validating `cursor?: string`, `limit?: number` (default 50, **max 50**, integer), `type?` ∈ {`tasks`,`invoices`,`tickets`,`mentions`,`system`}, `status?` ∈ {`unread`}. Reject out-of-range `limit`, unknown `type`, or `status !== 'unread'` with HTTP 400.
- [ ] Apply `authMiddleware`. Resolve `tenantId` and `userId` from the auth context. Return 401 if unauthenticated, 403 if authenticated but no tenant context.
- [ ] Map the coarse `type` filter to concrete `NotificationType[]` via `typeGroupMap` (see interface). When `type` is omitted, pass `NULL` so the SQL `type` filter is bypassed.
- [ ] Decode `cursor` as `base64(created_at_iso + '|' + id)` → `{ cursorTs, cursorId }`. On first page (no cursor), pass `NULL` for both so the cursor predicate is skipped. Treat malformed cursors as 400.
- [ ] Run the paginated `SELECT` (DESC by `created_at, id`, `LIMIT limit + 1`) parameterized by `(tenantId, userId, typeArray|null, unreadOnly:boolean, cursorTs|null, cursorId|null, limit)`. If row count > `limit`, pop the extra row and encode `nextCursor` from the last kept row's `(created_at, id)`; otherwise `nextCursor = null`.
- [ ] Run a separate `COUNT(*)` `total` query with the same `WHERE` (type + unread) minus cursor/limit, and an `unreadCount` query (`read_at IS NULL`, ignoring filters). Compute both once per request.
- [ ] For each row, render display text: `renderNotificationText(title_key, body_key, params, locale)` → `{ title, body }`, where `locale` comes from the auth/session locale (fallback `'he-IL'`). Return `body: null` when `body_key` is null. Do **not** leak `title_key`/`body_key`/`params` to the client.
- [ ] Shape the response as `{ data: NotificationRow[], meta: { nextCursor, total, unreadCount } }`.
- [ ] Use the `@zync/db` query helper (`tenantQuery`/Drizzle) — do not issue raw Drizzle from inside the route body if the repo lint forbids it (`no-raw-drizzle-from-routes`); place the SQL in a `packages/db` query function if that rule is enforced, else inline parameterized SQL is acceptable here since the predicate is dynamic.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/routes/notifications.render.ts
import type { NotificationType } from '@zync/types';

// Coarse UI filter → concrete NotificationType values (server-side).
export const typeGroupMap: Record<string, string[]> = {
  tasks:    ['task_assigned', 'task_updated', 'task_comment', 'task_due_soon', 'task_overdue'],
  invoices: ['invoice_paid', 'invoice_overdue', 'invoice_approved', 'invoice_rejected'],
  tickets:  ['ticket_created', 'ticket_assigned', 'ticket_replied', 'ticket_sla_breach'],
  mentions: ['mention'],
  system:   ['system', 'member_invited', 'user_approved', 'user_frozen', 'role_changed', 'trial_expiring', 'quota_warning'],
};

// Renders i18n keys into display strings in the viewer's locale.
// Catalog source: packages/ui/src/locales/{he,en}.json (notification.* keys).
export function renderNotificationText(
  titleKey: string,
  bodyKey: string | null,
  params: Record<string, string>,
  locale: 'he-IL' | 'en-US',
): { title: string; body: string | null };

// apps/zync-api/src/routes/notifications.ts (Zod)
import { z } from 'zod';
export const notificationsAllQuerySchema = z.object({
  cursor: z.string().optional(),
  limit: z.coerce.number().int().min(1).max(50).default(50),
  type: z.enum(['tasks', 'invoices', 'tickets', 'mentions', 'system']).optional(),
  status: z.literal('unread').optional(),
});

export interface NotificationRow {
  id: string;
  type: string;                 // NotificationType value, e.g. 'task_assigned'
  title: string;
  body: string | null;
  entity_type: string | null;
  entity_id: string | null;
  read_at: string | null;       // ISO 8601 or null
  created_at: string;           // ISO 8601
}

export interface NotificationsAllResponse {
  data: NotificationRow[];
  meta: {
    nextCursor: string | null;
    total: number;
    unreadCount: number;
  };
}
```
```sql
-- Page query (Neon Postgres). cursor = base64(created_at_iso || '|' || id).
SELECT id, type, title_key, body_key, params, entity_type, entity_id, read_at, created_at
FROM notifications
WHERE tenant_id = $1
  AND user_id = $2
  AND ($3::text[] IS NULL OR type = ANY($3))   -- type-group filter
  AND ($4 = false OR read_at IS NULL)            -- status=unread filter
  AND (
    $5::timestamptz IS NULL                      -- first page
    OR (created_at, id) < ($5, $6::uuid)         -- cursor pagination, DESC
  )
ORDER BY created_at DESC, id DESC
LIMIT $7 + 1;                                     -- +1 to detect hasNextPage

-- total (same filters, no cursor/limit):
SELECT COUNT(*) FROM notifications
WHERE tenant_id = $1 AND user_id = $2
  AND ($3::text[] IS NULL OR type = ANY($3))
  AND ($4 = false OR read_at IS NULL);

-- unreadCount (ignores filters):
SELECT COUNT(*) FROM notifications
WHERE tenant_id = $1 AND user_id = $2 AND read_at IS NULL;
```
**Acceptance:**
- [ ] `GET /api/notifications/all` returns `{ data, meta: { nextCursor, total, unreadCount } }` for the authenticated user only; rows for other users are never returned.
- [ ] `limit > 50`, non-integer `limit`, unknown `type`, or `status` other than `unread` each return 400.
- [ ] Unauthenticated → 401; authenticated without tenant context → 403.
- [ ] With 51 matching rows and `limit=50`, response has 50 rows and a non-null `nextCursor`; following that cursor returns the remaining row with `nextCursor: null`.
- [ ] `type=tasks` restricts rows to the 5 task `NotificationType` values; `status=unread` restricts to `read_at IS NULL`.
- [ ] `title`/`body` are rendered strings (not keys); `body` is `null` when the source `body_key` is null; `title_key`/`params` are absent from the payload.
- [ ] `meta.total` reflects the filtered count; `meta.unreadCount` reflects all unread regardless of active filters.

### Task 3: `resolveEntityHref` shared helper
**Blocks:** 5  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/pages/notifications/resolveEntityHref.ts`
- Modify: `apps/zync-app/src/components/NotificationDropdown.tsx` (or wherever `app-shell` defines it) to import from the new module instead of an inline copy
**Steps:**
- [ ] Implement `resolveEntityHref(n)` exactly per spec: first resolve type-level deep links (`bulk_action_complete` → `/settings/data`, `exchange_rates_updated` → `/settings/business`), then the `entity_type` map, returning `null` when neither matches or when `entity_type`/`entity_id` is missing.
- [ ] Refactor `app-shell`'s `NotificationDropdown` to consume this single exported function (remove its inline duplicate) so navigation behavior is identical between the dropdown and the page.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/pages/notifications/resolveEntityHref.ts
import type { NotificationRow } from './useNotificationsQuery';

export function resolveEntityHref(n: NotificationRow): string | null {
  // Type-level deep links (no entity_type/entity_id on these notifications):
  const typeLinks: Record<string, string> = {
    bulk_action_complete:  '/settings/data',
    exchange_rates_updated: '/settings/business',
  };
  if (typeLinks[n.type]) return typeLinks[n.type];

  if (!n.entity_type || !n.entity_id) return null;
  const map: Record<string, string> = {
    task:        `/tasks/${n.entity_id}`,
    invoice:     `/invoices/${n.entity_id}`,
    ticket:      `/support/${n.entity_id}`,
    project:     `/projects/${n.entity_id}`,
    lead:        `/crm/leads/${n.entity_id}`,
    proposal:    `/proposals/${n.entity_id}`,
    contract:    `/contracts/${n.entity_id}`,
    expense:     `/expenses/${n.entity_id}`,
    time_entry:  `/time/${n.entity_id}`,
    payment:     `/invoices?highlight=${n.entity_id}`,
    customer:    `/customers/${n.entity_id}`,
    user:        `/settings/users`,
    member:      `/settings/users`,
    kb_article:  `/kb/${n.entity_id}`,
    import:      `/settings/data`,
    api_usage:   `/settings/api`,
    subscription: `/settings/plan`,
  };
  return map[n.entity_type] ?? null;
}
```
**Acceptance:**
- [ ] `resolveEntityHref` returns the correct URL for each `entity_type` in the map and for the two type-level links.
- [ ] Returns `null` for a `system` notification with no `entity_type`/`entity_id`.
- [ ] `app-shell`'s `NotificationDropdown` imports and uses this function (no inline duplicate remains).

### Task 4: TanStack Query hooks (`useNotificationsQuery`, mark-read mutations)
**Blocks:** 8, 9  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/pages/notifications/useNotificationsQuery.ts`
**Steps:**
- [ ] Implement `fetchNotificationsAll({ cursor, type, status, limit })` calling `GET /api/notifications/all` with the params as query string (omitting undefined params); parse JSON into `NotificationsAllResponse`.
- [ ] Implement `useNotificationsQuery()` reading `type`/`status` from `useSearchParams()`, returning a `useInfiniteQuery` keyed `['notifications', 'all', { type, status }]`, `getNextPageParam: (last) => last.meta.nextCursor ?? undefined`, `initialPageParam: undefined`, `staleTime: 30_000`.
- [ ] Implement `useMarkAllReadMutation()`: `POST /api/notifications/read-all`; `onMutate` optimistically stamps every visible row's `read_at` (only if currently null) to `now` and sets `meta.unreadCount = 0` across all `['notifications', 'all']` pages; `onSettled` invalidates the broad `['notifications']` key.
- [ ] Implement `useMarkOneReadMutation()`: `PATCH /api/notifications/:id/read`; `onMutate` stamps only the matching row's `read_at` if null; `onSettled` invalidates `['notifications']`.
- [ ] Re-export the `NotificationRow` type for consumers (rows, list, resolveEntityHref).
**Schema / Interfaces:**
```ts
// apps/zync-app/src/pages/notifications/useNotificationsQuery.ts
export interface NotificationRow {
  id: string;
  type: string;
  title: string;
  body: string | null;
  entity_type: string | null;
  entity_id: string | null;
  read_at: string | null;
  created_at: string;
}

export interface NotificationsAllResponse {
  data: NotificationRow[];
  meta: { nextCursor: string | null; total: number; unreadCount: number };
}

export function fetchNotificationsAll(args: {
  cursor?: string; type?: string; status?: string; limit?: number;
}): Promise<NotificationsAllResponse>;

export function useNotificationsQuery(): ReturnType<typeof import('@tanstack/react-query').useInfiniteQuery>;
export function useMarkAllReadMutation(): ReturnType<typeof import('@tanstack/react-query').useMutation>;
export function useMarkOneReadMutation(): ReturnType<typeof import('@tanstack/react-query').useMutation>;
```
**Acceptance:**
- [ ] `useNotificationsQuery` keys cache on `{ type, status }` and paginates via `meta.nextCursor`.
- [ ] `useMarkAllReadMutation` optimistically clears all visible unread dots and zeroes `meta.unreadCount`, then invalidates `['notifications']`.
- [ ] `useMarkOneReadMutation` optimistically marks exactly one row read, then invalidates `['notifications']`.
- [ ] Mutation failures do not throw to the UI (silent; corrected on next refetch).

### Task 5: `NotificationRow` component
**Blocks:** 8  ·  **Blocked by:** 1, 3, 4
**Files:**
- Create: `apps/zync-app/src/pages/notifications/NotificationRow.tsx`
**Steps:**
- [ ] Render a single RTL row: `[unread dot | type icon] [title + body] [relative time]` with `dir="rtl"` and `text-align: start`.
- [ ] Unread dot: 8px circle `bg-accent`, shown only when `read_at` is null; when read, render it `invisible` (keeps spacing/alignment).
- [ ] Type icon: 20px `lucide-react` icon chosen by the type-to-icon map (see interface), color token `--ink-faint`.
- [ ] Title: `text-sm font-medium text-ink`, single line, truncated. Body: `text-xs text-ink-faint`, single line, truncated, hidden when null. Relative time: `text-xs text-ink-faint`, `whitespace-nowrap`, pushed to inline-end via `formatRelativeTime(new Date(created_at))`.
- [ ] Row background: `--surface` when unread, `--bg` when read.
- [ ] Click handler: if `read_at` is null, fire `onMarkRead(id)` (optimistic); then resolve `resolveEntityHref(notification)` and navigate (React Router `useNavigate`) when non-null; when null, only mark read (no navigation).
- [ ] Use logical spacing utilities only (`ps-*`/`pe-*`/`ms-*`/`me-*`) — no `pl/pr/ml/mr`.
- [ ] A11y: the row is a button/keyboard-activatable element (`role="button"`, `tabIndex={0}`, Enter/Space activate); the unread dot carries an `aria-label` (e.g. "לא נקרא") only when unread; the type icon is `aria-hidden`.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/pages/notifications/NotificationRow.tsx
import {
  CheckSquare, FileText, MessageSquare, AtSign, CreditCard, FileSignature,
  FileCheck, UserPlus, Receipt, TrendingUp, User, Clock, RefreshCw, Bell,
} from 'lucide-react';
import type { NotificationRow } from './useNotificationsQuery';

export interface NotificationRowProps {
  notification: NotificationRow;
  onMarkRead: (id: string) => void;
}

// type → icon (fallback Bell). Keys are NotificationType values.
const ICON_BY_TYPE: Record<string, typeof Bell> = {
  task_assigned: CheckSquare, task_updated: CheckSquare, task_comment: CheckSquare,
  task_due_soon: CheckSquare, task_overdue: CheckSquare,
  invoice_paid: FileText, invoice_overdue: FileText, invoice_approved: FileText, invoice_rejected: FileText,
  ticket_created: MessageSquare, ticket_assigned: MessageSquare, ticket_replied: MessageSquare, ticket_sla_breach: MessageSquare,
  mention: AtSign,
  payment_received: CreditCard, dunning_step_reached: CreditCard,
  contract_signed: FileSignature, contract_expiring: FileSignature, contract_voided: FileSignature, contract_all_signed: FileSignature,
  proposal_viewed: FileCheck, proposal_accepted: FileCheck, proposal_rejected: FileCheck, proposal_expiring: FileCheck,
  lead_created: UserPlus, lead_assigned: UserPlus, lead_stage_updated: UserPlus, lead_converted: UserPlus, lead_reengagement_due: UserPlus,
  expense_submitted: Receipt, expense_approved: Receipt, expense_rejected: Receipt,
  project_budget_alert: TrendingUp,
  member_invited: User, user_approved: User, user_frozen: User, role_changed: User,
  time_entry_approved: Clock, time_period_locked: Clock,
  import_complete: RefreshCw, bulk_action_complete: RefreshCw, exchange_rates_updated: RefreshCw,
  system: Bell, trial_expiring: Bell, quota_warning: Bell,
};
export function iconForType(type: string): typeof Bell { return ICON_BY_TYPE[type] ?? Bell; }

export function NotificationRow(props: NotificationRowProps): JSX.Element;
```
**Acceptance:**
- [ ] Unread rows show the accent dot and `--surface` background; read rows hide the dot (spacing preserved) and use `--bg`.
- [ ] Correct `lucide-react` icon per type-to-icon group; unknown types fall back to `Bell`.
- [ ] Clicking an unread row marks it read optimistically and navigates to `resolveEntityHref`; a `null` href marks read without navigation.
- [ ] Title/time always render; body hidden when null; all three truncate to one line.
- [ ] Keyboard-activatable (Enter/Space), icon `aria-hidden`, unread dot has an `aria-label` only when unread; only logical (`ps/pe/ms/me`) spacing used.

### Task 6: `NotificationsLoadingSkeleton` component
**Blocks:** 8  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/pages/notifications/NotificationsLoadingSkeleton.tsx`
**Steps:**
- [ ] Render skeleton rows that mirror `NotificationRow` layout exactly: `[8px circle] [20px square] [~60% width 14px-tall bar] [~40% width 12px-tall bar] [64px-wide bar]`.
- [ ] Accept a `count` prop; default 8. Background `--surface`; skeleton blocks use `background: var(--ink-faint)` at `opacity: 0.5`.
- [ ] **No shimmer / no pulse / no keyframe animation** (per error-empty-states §4 and this spec's loading-state constraint) — static blocks only.
- [ ] Support the two documented usages: 8 rows for initial/filter-change load, 3 rows appended during "Load more".
**Schema / Interfaces:**
```ts
// apps/zync-app/src/pages/notifications/NotificationsLoadingSkeleton.tsx
export interface NotificationsLoadingSkeletonProps {
  count?: number; // default 8
}
export function NotificationsLoadingSkeleton(props: NotificationsLoadingSkeletonProps): JSX.Element;
```
**Acceptance:**
- [ ] Renders `count` static skeleton rows (default 8) matching the real row's block geometry.
- [ ] No animation classes (`animate-pulse`/`animate-shimmer`/keyframes) present.
- [ ] Skeleton blocks use `--ink-faint` at 0.5 opacity on a `--surface` background.

### Task 7: `NotificationsFilterSidebar` component
**Blocks:** 9  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/pages/notifications/NotificationsFilterSidebar.tsx`
**Steps:**
- [ ] Render two `Select` primitives (from `packages/ui`): a **type** filter and a **status** filter, with the exact Hebrew option labels from the spec.
- [ ] On change, call `setSearchParams()` to update `type`/`status` and **reset** `cursor` (drop it from the query string). An empty value clears that param.
- [ ] Below the filters, render a total-count label `{n} התראות` using `meta.total` passed in via props.
- [ ] Layout: 200px fixed-width sidebar at `inline-start` (right in RTL); on viewport `< 768px` the sidebar is hidden and the same two `Select`s render as a top filter bar (handled by the parent passing a `variant` or by responsive classes).
- [ ] Logical spacing only; `text-align: start`.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/pages/notifications/NotificationsFilterSidebar.tsx
export interface NotificationsFilterSidebarProps {
  total: number;
  variant?: 'sidebar' | 'bar'; // 'bar' for < 768px top filter row
}
export function NotificationsFilterSidebar(props: NotificationsFilterSidebarProps): JSX.Element;

// Type filter options:
//   ''         "כל הסוגים"
//   'tasks'    "משימות"
//   'invoices' "חשבוניות"
//   'tickets'  "כרטיסי תמיכה"
//   'mentions' "אזכורים"
//   'system'   "מערכת"
// Status filter options:
//   ''         "כל הסטטוסים"
//   'unread'   "לא נקראו"
```
**Acceptance:**
- [ ] Two `Select`s render with the exact Hebrew labels/values listed above.
- [ ] Changing either filter updates the URL query string and removes any existing `cursor` param.
- [ ] Selecting the empty option removes that filter param entirely.
- [ ] `{total} התראות` count label renders below the filters.

### Task 8: `NotificationsList` component
**Blocks:** 9  ·  **Blocked by:** 4, 5, 6
**Files:**
- Create: `apps/zync-app/src/pages/notifications/NotificationsList.tsx`
**Steps:**
- [ ] Render the list of `NotificationRow`s from `items`, separated by a hairline divider, inside the list column of the shared card.
- [ ] When `isLoading` (initial/filter-change): render `NotificationsLoadingSkeleton count={8}` in place of the list.
- [ ] When `isFetchingNextPage`: append `NotificationsLoadingSkeleton count={3}` below existing rows and disable the "Load more" button.
- [ ] Render a "Load more" (`טען עוד`) button when `hasNextPage`; on click call `onLoadMore()`. Hide it when there is no next page.
- [ ] Empty states (via `EmptyState` from `packages/ui`):
  - No active filters AND `items.length === 0` → `heading="עדיין לא קיבלת התראות"`, no action.
  - At least one filter active AND `items.length === 0` → `heading="לא נמצאו התראות עבור הסינון הנוכחי"`, `action={ label: "נקה סינון", onClick: clearFilters }` where `clearFilters` → `setSearchParams({})`.
- [ ] Pass `onMarkRead` through to each `NotificationRow`.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/pages/notifications/NotificationsList.tsx
import type { NotificationRow } from './useNotificationsQuery';

export interface NotificationsListProps {
  items: NotificationRow[];
  isLoading: boolean;
  isFetchingNextPage: boolean;
  hasNextPage: boolean;
  onLoadMore: () => void;
  onMarkRead: (id: string) => void;
  hasActiveFilters: boolean;     // true if type or status param is present
  onClearFilters: () => void;    // setSearchParams({})
}
export function NotificationsList(props: NotificationsListProps): JSX.Element;
```
**Acceptance:**
- [ ] Initial load shows 8 skeleton rows; "Load more" shows 3 appended skeleton rows and disables the button while fetching.
- [ ] Fresh account (no filters, zero items) shows the "עדיין לא קיבלת התראות" empty state with no action.
- [ ] Zero items under an active filter shows the "לא נמצאו..." empty state with a working "נקה סינון" action that clears the query string.
- [ ] "Load more" appears only when `hasNextPage` and calls `onLoadMore`.

### Task 9: `NotificationsPage` + route registration
**Blocks:** —  ·  **Blocked by:** 4, 7, 8
**Files:**
- Create: `apps/zync-app/src/pages/notifications/NotificationsPage.tsx`
- Modify: the `apps/zync-app` router (e.g. `apps/zync-app/src/router.tsx` / route registry) to register `/notifications`
**Steps:**
- [ ] Read `type`/`status`/`cursor` from `useSearchParams()`. Drive data with `useNotificationsQuery()`; flatten `pages[].data` into a single `items` array and read `meta.total`/`meta.unreadCount` from the first page.
- [ ] Render the PageHeader above the two-column card: title `התראות`; a "סמן הכל כנקרא" `Button variant="outline" size="sm"` disabled (`opacity-50`) when `meta.unreadCount === 0` → on click runs `useMarkAllReadMutation`; a "הגדרות התראות" link rendered via `Button asChild variant="ghost" size="sm"` as `<a href="/profile/notifications">` (forward reference to spec 97), always visible.
- [ ] Render the single shared `Card` containing `NotificationsFilterSidebar` (inline-start, 200px) and `NotificationsList` separated by a vertical divider. On `< 768px`, hide the sidebar and render the filter bar variant at the top.
- [ ] Wire `onLoadMore` → `fetchNextPage()`, `onMarkRead` → `useMarkOneReadMutation().mutate(id)`, `hasActiveFilters` → `!!(type || status)`, `onClearFilters` → `setSearchParams({})`.
- [ ] Page root inherits `dir="rtl"` from the shell `<html>`; do not override. Use logical spacing throughout.
- [ ] Register the route `/notifications` inside the authenticated app-shell layout so the header/bell remain present; all roles (OWNER/ADMIN/MEMBER/VIEWER/CONTRACTOR) may access it (no role gate — API enforces own-user scoping).
**Schema / Interfaces:**
```ts
// apps/zync-app/src/pages/notifications/NotificationsPage.tsx
export function NotificationsPage(): JSX.Element;
// Route: path "/notifications", element <NotificationsPage/>, inside the app-shell authenticated layout.
```
**Acceptance:**
- [ ] Navigating to `/notifications` renders the header, page title `התראות`, the filter sidebar, and the list (or skeleton/empty state).
- [ ] "סמן הכל כנקרא" is disabled when `unreadCount === 0` and otherwise marks all read optimistically and refreshes the header badge.
- [ ] "הגדרות התראות" links to `/profile/notifications`.
- [ ] Filters in the URL (`?type=tasks&status=unread`) are honored on load and are shareable; changing a filter resets pagination.
- [ ] "Load more" fetches and appends the next page; the page works for all five roles.

### Task 10: i18n key coverage audit for notification text
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `packages/ui/src/locales/he.json`
- Modify: `packages/ui/src/locales/en.json`
**Steps:**
- [ ] Enumerate every `NotificationType` value from the canonical union (`@zync/types`, defined by notification-preferences) and confirm each has a `notification.<type>.title` key (and a `notification.<type>.body` key where the type carries a body).
- [ ] Add any missing `notification.*` keys to both `he.json` and `en.json` so `renderNotificationText` (Task 2) never falls back to a raw key for a known type. Hebrew is the primary locale; English is the secondary.
- [ ] Confirm the chrome strings used by the page UI (filter labels, buttons, empty-state headings, `התראות`, `טען עוד`, `סמן הכל כנקרא`, `הגדרות התראות`, `{n} התראות`) exist in `he.json` (and `en.json`) or are rendered as literals consistent with the spec's Hebrew copy.
**Schema / Interfaces:** —
**Acceptance:**
- [ ] Every `NotificationType` has matching `notification.<type>.title` keys in both `he.json` and `en.json`; types with bodies also have `.body` keys.
- [ ] `renderNotificationText` resolves a real string for every known type in both locales (no raw-key leakage).
- [ ] All page chrome Hebrew strings from the spec are present.

## Out of Scope (per spec)
- Notification preferences page `/profile/notifications` — owned by spec 97 (`notification-preferences`); this plan only forward-links to it.
- Bulk delete / archive, notification grouping — v2.
- Browser/mobile push delivery — owned by `system-communications-notifications` (Web Push adapter) / separate spec.
- Admin view of other users' notifications — explicitly disallowed; enforced at the API layer (own-user scoping).
- New DB tables or indexes — none; the existing `notifications` table and `notifications_inbox` index are sufficient.
