# FRONTEND-STABILITY-PLAN.md

> **Recovery-safe stability + bilingual + responsive plan** for the Dalseen frontend (`app/`).
>
> **This plan supersedes `FRONTEND-CONTINUATION-PLAN.md` for sequencing decisions.** That doc described *what* to build; this doc constrains *how* it must be built so the browser stops crashing.
>
> **Read this file first at the start of every session.**
>
> **Last updated:** 2026-05-04 (initial draft, no code changes yet)
> **Project root:** `app/` · Vite + React 18 + React Router + React Query + Tailwind

---

## §1 — Diagnosis: why the browser is crashing

### §1.1 Symptoms observed

- Page becomes unresponsive after navigating between Workspace tabs.
- Memory creeps up over time; tab eventually OOMs or the inspector freezes.
- Switching language (EN ↔ AR) sometimes triggers a long render storm.
- Hot-reload during development occasionally pegs the CPU.

### §1.2 Root causes (verified against the current source)

| # | Cause | Evidence in repo |
|---|---|---|
| **C1** | **Mega pages with multiple heavy tabs.** Every workspace module is a single `*Page.jsx` that imports 4–5 sub-tab components, each with its own `useQuery`. All hooks mount on first paint, even for hidden tabs. | `modules/hr/HRPage.jsx` (4 tabs / 4 queries), `modules/retail/RetailPage.jsx` (5 tabs / 5 queries), `modules/dine/DinePage.jsx` (3 tabs incl. polling KDS), `modules/accounting/AccountingPage.jsx` (5 tabs / 5 queries). |
| **C2** | **No route-level code splitting.** Every module's JSX is statically imported in `app/App.jsx`, so the initial bundle ships all of HR + Retail + Dine + Accounting + every Platform page. Long parse + execute on first load. | `app/src/app/App.jsx` lines 14–29 — every page is a top-level `import`. Zero `React.lazy` / `Suspense` anywhere in `app/src` (verified). |
| **C3** | **Auto-refresh polling on the KDS query.** `useKDS` runs on a 8 s `refetchInterval` even when the tab is in the background or the user is on a different page. Mock handlers re-run, fixtures re-allocate, React re-renders the whole `DinePage` tree. | `modules/dine/dine.hooks.js` line 6: `refetchInterval: 8000`. |
| **C4** | **All-in-one tables, no pagination, no virtualisation.** `Table` renders every row in one DOM tree. With ~200+ fixture rows on Audit / Tenants / Invoices, that's hundreds of `Badge`/`Icon` components per render. | `components/Table.jsx` — single `<tbody>` map; no windowing. |
| **C5** | **KPI strips recompute on every tab switch.** Each tab's KPI block reads from `q.data?.kpis`; switching tabs re-mounts the consumer and re-issues the query because `params` is a fresh object reference. | All workspace pages — `useProducts({ category, status })` etc. pass an inline object literal, defeating React Query's structural sharing. |
| **C6** | **Mock filtering work runs on every keystroke.** Filter pills change `params`; that fresh object reference triggers a new query, which executes a `setTimeout(180ms)` then re-runs all `.filter().reduce().reduce()` over the fixture. Cheap individually, expensive in aggregate during exploration. | `mocks/retail.mock.js`, `mocks/accounting.mock.js`. |
| **C7** | **No React Query cache cap.** `gcTime: 5 * 60_000` keeps every query result in memory for 5 minutes. Combine with C5/C6 (each filter combination is a unique cache key) and the cache balloons. | `core/api/queryClient.js` lines 22–23. |
| **C8** | **Locale switch re-mounts the entire tree.** `setLocale` writes to `document.documentElement.dir` *and* updates Zustand state — but no key prop forces a clean unmount, so every translated string and every memo cascades through reconciliation simultaneously. | `core/session/sessionStore.js` lines 34–38. |
| **C9** | **Layout is desktop-only.** Sidebar is `w-60` always-visible; main is `p-6` flex column. On mobile/tablet the sidebar steals 240 px and tables overflow horizontally without containment, forcing the page to scroll the entire viewport. | `app/AppShell.jsx` line 32 — `aside className="w-60 shrink-0 …"` (no breakpoint variant). |

### §1.3 Heaviest pages (in order)

1. **DinePage** — KDS polling + 3 simultaneously-mounted tab queries + table + grid.
2. **AccountingPage** — 5 tab queries; Reports tab does heavy summary math client-side.
3. **RetailPage** — 5 tab queries; Inventory tab fans products × branches into a derived list inside the mock.
4. **HRPage** — 4 tab queries; lightest of the four but still over-fetches.
5. **Platform · Audit** — single tab, but largest fixture and most badges per row.

---

## §2 — Safe rendering strategy

### §2.1 Core rule: **one route = one screen = one primary query**

- A "page" renders **one** primary list/dashboard, not a tab grid of five.
- Sub-views become **child routes**, not tabs in a parent component.
- A page may have one secondary query (e.g. KPI strip) but **never** more than two `useQuery` calls mounted at once.

### §2.2 Lazy-load every route

- Every `*Page.jsx` becomes `React.lazy(() => import('…/Page.jsx'))`.
- A single `<Suspense fallback={<RouteFallback />}>` wraps the `<Outlet />` in `AppShell`.
- `RouteFallback` is a 4-line skeleton (no spinner library, no animation cost).

### §2.3 Split the workspace mega-pages into sibling routes

The four workspace mega-pages become **route groups**, one route per former tab:

```
/hr               → /hr/staff (default), /hr/leave, /hr/attendance, /hr/payroll
/retail           → /retail/sales (default), /retail/products, /retail/inventory,
                    /retail/customers, /retail/suppliers
/dine             → /dine/floor (default), /dine/kds, /dine/menu
/accounting       → /accounting/reports (default), /accounting/invoices,
                    /accounting/bills, /accounting/vat, /accounting/coa
```

Each former tab moves to its own file (`StaffPage.jsx`, `LeavePage.jsx`, …) with its own lazy import. The parent route (`/hr`) renders only a thin `ModuleShell` with the sub-nav bar and an `<Outlet />`. Sub-nav looks identical to today's tab bar; the implementation is a `<NavLink>` row, not local state.

### §2.4 Remove the always-on KDS polling

- KDS poll runs **only** when `/dine/kds` is the active route.
- Default `refetchInterval` is **off**. We add a manual `Refresh` button + `refetchInterval: visibleAndOnRoute ? 15000 : false` (15 s, not 8 s).
- Use `document.visibilityState` to halt polling when the tab is backgrounded.

### §2.5 Tame queries

- Wrap every hook param in `useMemo`: `useStaff(useMemo(() => ({ dept, status }), [dept, status]))` — kills the C5 reference-churn.
- Add `placeholderData: keepPreviousData` so filter changes don't blank the table mid-typing.
- Default `staleTime: 60_000` for catalog-style data; `staleTime: 0` only where freshness matters.
- Default `gcTime: 90_000` (down from 5 min) — caps cache footprint.

### §2.6 Tame tables

- Default page size **25 rows**; explicit "Load more" button reveals next 25.
- Mobile (≤ 640 px): table renders as **stacked cards** via a `Table` `mobileCard` render prop — no horizontal scroll.
- Tablet (641–1024 px): table is wrapped in a horizontally-scrollable container with sticky first column.
- No virtualisation library yet — pagination + responsive cards remove the need.

### §2.7 Locale switch without remount

- `setLocale` only writes the Zustand value + `<html dir>`; **no global re-key**.
- Translation function `t(en, ar)` already memo-friendly. Pages must not destructure `t` outside the component body.
- Drop the `body` font swap into pure CSS via `[dir="rtl"]` (already present in `globals.css`) — no JS-driven font reload.

---

## §3 — Performance rules (MANDATORY)

These are non-negotiable. Violating any is a build-blocker.

| # | Rule | Enforcement |
|---|---|---|
| **P1** | **Every route uses `React.lazy`.** | Lint pass: any static `import …Page from …` inside `App.jsx` is a defect. |
| **P2** | **Mock fixtures ≤ 50 rows per endpoint.** | Audit: any `mocks/*.mock.js` array > 50 entries gets paginated server-side. |
| **P3** | **React Query cache cap.** `gcTime: 90_000`, `staleTime: 60_000` defaults. Per-query overrides only with a comment justifying. | `queryClient.js` review on every PR. |
| **P4** | **No polling unless explicitly required.** Polling hooks must accept an `enabled` prop and default to `false` off-route. | Code review: search for `refetchInterval` — must have an `enabled` guard alongside. |
| **P5** | **Auto-refresh isolated.** A polling query may not coexist with a non-polling query on the same page. If they must, split into two routes. | Architectural — see §2.3. |
| **P6** | **No more than 2 `useQuery` calls per page.** | Search for `useQuery|use[A-Z][a-zA-Z]+\(` count per page; > 2 = split. |
| **P7** | **No inline object literals as hook params.** Always wrap in `useMemo`. | Lint rule `react-hooks/exhaustive-deps` will catch most; manual review for the rest. |
| **P8** | **Translations stay inline (`t('EN', 'AR')`).** Do not introduce `i18next` or any large locale bundle. | If/when we have > 1000 strings, revisit; until then, inline beats a 50 kB dependency. |
| **P9** | **No new dependencies.** | `package.json` is frozen unless a separate batch with explicit user approval. |
| **P10** | **No animation libraries.** Tailwind `transition` classes only. | Review on PR. |

---

## §4 — Refactor strategy

### §4.1 Phase R — Stabilise (before any new module)

| # | Refactor | Files | Effort |
|---|---|---|---|
| **R1** | Introduce `React.lazy` + `<Suspense>` for every route in `App.jsx`. Add `RouteFallback`. | `App.jsx`, new `components/RouteFallback.jsx` | 1 file new, 1 edit |
| **R2** | Tighten `queryClient` defaults (P3) + kill the KDS auto-poll (`refetchInterval` off-route). | `core/api/queryClient.js`, `dine/dine.hooks.js` | 2 edits |
| **R3** | Make `AppShell` responsive: collapsible sidebar, mobile drawer, header reflow. | `AppShell.jsx`, possibly `components/Drawer.jsx` | 1 main edit |
| **R4** | Add `Table` mobile/tablet variants + 25-row pagination + sticky first column. | `components/Table.jsx` only | 1 edit |
| **R5** | Wrap all hook params in `useMemo` across the 4 workspace pages (mechanical sweep, ≤ 8 lines per page). | the 4 `*Page.jsx` files | 4 small edits |

R1–R4 are blocking for §4.2. R5 happens *during* the split in §4.2 — no need to do it twice.

### §4.2 Phase S — Split mega-pages into sub-routes

One module at a time, in this order: **HR → Dine → Retail → Accounting**. (HR first because it's the lightest and validates the pattern; Dine second because removing the polling is highest-impact for crash frequency; Retail and Accounting last because they have the most sub-tabs and the mechanical work is most error-prone.)

For each module the pattern is identical:

1. Create `modules/<m>/<M>ModuleShell.jsx` — sub-nav `<NavLink>` row + `<Outlet />`. ~40 LOC.
2. For each former tab, create `modules/<m>/<Sub>Page.jsx` (e.g. `StaffPage.jsx`, `LeavePage.jsx`). Lift the existing tab JSX verbatim — no behavioural changes.
3. Replace the single `Route path="hr/*"` with a parent + 4 child routes.
4. Delete the old `<M>Page.jsx` mega component.
5. Smoke-test: each sub-route loads independently, no console errors, sub-nav active state correct.
6. Commit.

### §4.3 What we will NOT do during stabilisation

- No new modules (Pay / Owner / Settings).
- No new mutations (the dead Approve/Bump buttons stay dead until §4.4).
- No backend wiring.
- No design-system changes.

### §4.4 After stabilisation

Resume `FRONTEND-CONTINUATION-PLAN.md` Phase P1 onwards. Each batch picks up the smallest sub-route and adds its mutations, drawer, etc.

---

## §5 — Bilingual implementation (safe path)

We already have a working primitive (`useT`). The plan **does not** introduce a heavy i18n library.

### §5.1 Rules

1. **Source of truth:** every visible string is `t('English text', 'النص العربي')` inline at the call site. No external JSON catalogue, no `t('keys.like.this')`, no `react-i18next`.
2. **Direction:** `<html dir>` is set imperatively in `setLocale`; CSS handles all flipping via Tailwind's `rtl:` variant where needed (sidebar margins, table alignment, drawer slide-in side).
3. **Locale switcher:** stays in the header (already there). Toggling sets `dir` + persists to Zustand. **No tree remount.**
4. **No locale bundles:** there is nothing to lazy-load — strings ship with the components that use them.
5. **Numbers and currency** go through `formatSAR(n, locale)` / `formatNumber(n, locale)`. Already in `core/utils/format.js`.
6. **Date formatting** goes through `formatDate(d, locale)`. Already present.

### §5.2 RTL audit checklist (per page)

When a page is built, walk it with `dir="rtl"` toggled and verify:

- [ ] Sidebar slides from the right; chevrons mirror.
- [ ] Tables align numbers to the **right** in LTR and the **left** in RTL (use `text-end` not `text-right`).
- [ ] Drawers slide in from the **inline-end** edge in both directions (use `inset-inline-end-0`).
- [ ] Icon-with-text rows reverse order via `flex` natural reordering — no manual `flex-row-reverse`.
- [ ] Forms: labels and placeholders flip; date picker calendar reads right-to-left.

### §5.3 Performance guarantees

- Locale toggle is O(1) — one Zustand write + one DOM attribute write.
- No string catalogue parsing, no async `import()` for translations.
- No re-render of pages that don't subscribe to `locale`.

---

## §6 — Responsive design (safe path)

### §6.1 Breakpoints (Tailwind defaults)

| Breakpoint | Range | Layout |
|---|---|---|
| `mobile` | ≤ 640 px | Sidebar collapses to off-canvas drawer behind hamburger. Tables render as **stacked cards**. KPI strips wrap to 2-col grid. |
| `sm` | 641–768 px | Same as mobile but tables can use horizontal scroll if cards aren't suitable. |
| `md` | 769–1024 px | **Tablet.** Sidebar is icon-only rail (48 px wide). Tables scroll horizontally with sticky first column. |
| `lg+` | ≥ 1025 px | **Desktop.** Sidebar full-width (current `w-60`). Tables full layout. |

### §6.2 Component-level rules

- **Sidebar:** add `lg:w-60 md:w-12 max-md:hidden` + a hamburger-triggered overlay drawer for mobile. Drawer is a single new component (`MobileNavDrawer.jsx`) ≤ 50 LOC.
- **Header:** search box hides on `< md`. Locale + bell + help collapse into a single overflow menu on `< sm`.
- **Tables:** new `mobileCard` render prop on `<Table>`. When viewport `< md`, render `<div>` cards instead of `<tr>`s. KPI strip uses `grid-cols-2 md:grid-cols-4`.
- **Drawers / modals:** full-screen on mobile, side-sheet on tablet+, dialog on desktop. Single `<Drawer>` / `<Modal>` component handles all three via prop.
- **Forms:** stack to single column on mobile; two-column on `md+`.

### §6.3 Performance guarantees

- No JS resize listeners. Everything is CSS media queries via Tailwind variants (`md:`, `lg:`, `max-md:`).
- No conditional component swap based on viewport state — same React tree, different CSS.
- Mobile cards reuse the same row data; no extra fetches.

### §6.4 Mobile-specific perf

- Tables paginate to **10 rows** on mobile (vs 25 on desktop) — controlled by a single `useMatchMedia` shim used **only** for pagination size, not for layout.
- Charts (when added) lazy-load on intersection — never on first paint.

---

## §7 — Batch execution plan (stability-first)

**Hard rules:**
- Maximum **1 module** per batch.
- Maximum **1–2 pages** per batch.
- Maximum **3 files edited or created** per batch (excluding the doc update).
- After each batch: stop, manually test, reload browser, confirm no freeze, then commit.

### §7.1 Phase R — Stabilise (must complete before Phase S)

| Batch | Scope | Files |
|---|---|---|
| **R1** | Lazy-load every route + `RouteFallback`. | `App.jsx`, new `components/RouteFallback.jsx` |
| **R2** | Tighten `queryClient` defaults; gate KDS polling behind active-route + visibility. | `core/api/queryClient.js`, `dine/dine.hooks.js` |
| **R3** | Responsive `AppShell` + `MobileNavDrawer`. | `AppShell.jsx`, new `MobileNavDrawer.jsx`, possibly `Icon.jsx` (hamburger glyph) |
| **R4** | Responsive `Table` (mobile cards + 25-row pagination + sticky first column). | `components/Table.jsx` only |

### §7.2 Phase S — Split workspace mega-pages

| Batch | Scope | Files |
|---|---|---|
| **S1** | HR module: `HRModuleShell` + 4 sub-pages, retire `HRPage`. | `hr/*` (5 new, 1 deleted), `App.jsx`, `nav.config.jsx` |
| **S2** | Dine module: `DineModuleShell` + 3 sub-pages, retire `DinePage`. | `dine/*` (4 new, 1 deleted), `App.jsx`, `nav.config.jsx` |
| **S3** | Retail module: `RetailModuleShell` + 5 sub-pages, retire `RetailPage`. | `retail/*` (6 new, 1 deleted), `App.jsx`, `nav.config.jsx` |
| **S4** | Accounting module: `AccountingModuleShell` + 5 sub-pages, retire `AccountingPage`. | `accounting/*` (6 new, 1 deleted), `App.jsx`, `nav.config.jsx` |

S2 swaps in for `DinePage` only after R2 has landed (otherwise the polling regression returns).

### §7.3 Phase T — Per-module sub-route fan-out limit

S1–S4 each split into **two batches** if file count > 3:
- **Sa** — Module shell + first 2 sub-pages.
- **Sb** — Remaining sub-pages + `App.jsx`/nav update + delete old mega-page.

### §7.4 After Phase S

Stability work is done. Resume `FRONTEND-CONTINUATION-PLAN.md` from Batch B1 (HR Leave mutations).

---

## §8 — Crash recovery

### §8.1 Per-batch ritual

```
1. Implement the batch (≤ 3 files).
2. Stop. Run npm run build — must succeed.
3. Run npm run dev. Open the affected route(s).
4. Click every interactive control. Toggle locale. Resize the viewport.
5. Open devtools Performance tab; record a 5-second interaction:
     - No frame > 200 ms.
     - Memory does not climb monotonically.
     - No console errors or warnings.
6. Hard-reload (Cmd+Shift+R). Confirm same behaviour after reload.
7. If all checks pass → commit. Update this MD file's "Last completed batch".
8. If any check fails → revert this batch ONLY (`git checkout -- <files>`).
   Do not touch other modules. File the failure as a note at the bottom of
   this MD and stop the session.
```

### §8.2 Crash mid-batch

```
1. On restart, read this file (you're doing it now).
2. git status — uncommitted work survives.
3. git diff — read it before adding more.
4. If the diff is coherent → finish the batch from the §3/§7 checklist.
5. If the diff is incoherent → git stash; redo the batch on a clean tree.
6. Never "catch up" by combining two batches into one.
```

### §8.3 Repeat crash on the same route

If the same route crashes the browser twice in a row after a successful build:

1. Revert the most recent module batch.
2. Add the route to "§9 Quarantine" with the failing scenario.
3. Move to the next batch in §7. Do not retry the failed batch in the same session.

### §8.4 Forbidden during recovery

- Wholesale rewrites.
- "While I'm in there" cleanups.
- Touching files outside the failing module.
- Adding dependencies "to fix the issue".
- Disabling React StrictMode to mask a bug.

---

## §9 — Definition of stable page

A page is **stable** when **all** of the following hold, measured locally on a mid-tier laptop with devtools open:

| Metric | Target |
|---|---|
| Time-to-interactive (cold cache) | ≤ 1 s |
| Time-to-interactive (warm cache) | ≤ 300 ms |
| First paint | ≤ 500 ms |
| No long task (> 200 ms) during a 5-second interaction recording. |
| Heap growth over 30 s of idle | ≤ 2 MB |
| `useQuery` calls mounted | ≤ 2 |
| Network calls per visit (first load, no filter changes) | ≤ 2 |
| Console errors / warnings | 0 |
| Locale toggle re-render cost | ≤ 50 ms total |
| Mobile (≤ 640 px) usable without horizontal scroll | yes |
| RTL layout matches LTR pixel-for-pixel mirrored | yes |

A page that fails any of these is **not done** regardless of feature completeness.

---

## §10 — Quarantine

Pages that have crashed twice and need a dedicated batch later. (Empty — populate on first failure.)

| Route | Failure mode | Date |
|---|---|---|
| _(none yet)_ | | |

---

## Appendix — Mapping to the continuation plan

`FRONTEND-CONTINUATION-PLAN.md` numbered batches B1–B23 still apply. They are paused until **Phase R + Phase S of this document complete.** When stability work is done, the new map is:

```
Phase R (R1–R4)   ← stability foundation
Phase S (S1–S4)   ← split workspace mega-pages
Phase B (B1–B23)  ← original feature batches, against the new sub-route layout
```

Both docs are updated in the same commit when a batch completes. Neither is the "main" doc on its own — read both at session start.
