cleanup phases 6
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
# UI Phase 1 — Shared primitives & app-wide states — Report (2026-07-17)
|
||||
|
||||
## What was built
|
||||
|
||||
### State kit — `EmptyState`, `ErrorState`, `QueryStateGate`
|
||||
`components/common/EmptyState` and `ErrorState` promote/replace `AdminEmptyState`/
|
||||
`AdminErrorState` (now thin re-exports) with a branded, calm look (phase-0 surface/radius
|
||||
tokens, registry icon slot, soft-primary icon roundel) — retiring the dashed-border `Paper`
|
||||
pattern hand-rolled across the app. Both are **presentational with caller-owned copy**;
|
||||
`ErrorState` requires `retryLabel`/`onRetry` so a query failure can never render without a
|
||||
working retry. `QueryStateGate` wraps the `isLoading`/`isError`/`isEmpty` branching in one
|
||||
fixed order (skeleton → error → empty → children) for pages that want the component instead
|
||||
of hand-branching. The mechanical sweep replaced the dashed-`Paper` blocks with these
|
||||
primitives; `border: '1px dashed'` under `client/src/app` is now zero (the one remaining hit
|
||||
in `client/src`, `DocumentUpload`'s upload dropzone, is an interactive drop-target affordance,
|
||||
not an empty state, and is outside `app/` — a deliberate, not missed, exception).
|
||||
|
||||
**The six error→false-empty defects from the audit are fixed and verified**, each now renders
|
||||
`ErrorState` with a working retry:
|
||||
- `(customer)/HomeScreen.tsx` (formerly `page.tsx`) — the patients gate no longer spinner-hangs
|
||||
on error.
|
||||
- `(customer)/patients/page.tsx` — `isEmpty` now excludes the error case.
|
||||
- `nurse/requests/page.tsx` — an errored inbox no longer reads as "no requests."
|
||||
- `nurse/services/MyServicesList.tsx` — an errored variant list no longer shows the
|
||||
first-service CTA.
|
||||
- `(customer)/profile/page.tsx` — an errored profile renders `ErrorState`, never a blank,
|
||||
save-capable form.
|
||||
- `nurse/services/VariantBuilder.tsx` — a failed `optionGroupsQuery` now blocks progression
|
||||
via `ErrorState` instead of silently treating the category as having no required options.
|
||||
|
||||
Both silent mutations from the audit now toast on failure: `nurse/profile/page.tsx`'s avatar
|
||||
upload and profile save each carry an `onError` toast. The convention — *an errored query must
|
||||
never render as empty; every mutation needs an `onError` toast* — is written into
|
||||
`client/CLAUDE.md` (Unit Testing / Toast Notifications sections).
|
||||
|
||||
### `PageHeader` + `ConfirmDialog` promotions
|
||||
`PageHeader` generalizes `AdminPageHeader` (`title`/`subtitle`/`actions`/`backTo`/`backLabel`,
|
||||
RTL-flippable back chevron via the new `forward`/`back` icon pair) and was adopted mechanically
|
||||
wherever pages hand-rolled the identical h5 + subtitle block. `ConfirmDialog` moved from
|
||||
`components/admin/` to `components/common/`, preserving its exact contract (required-reason
|
||||
gating, busy-state disabling both buttons) — now usable by any actor, not just the backoffice.
|
||||
|
||||
### Card kit — `SurfaceCard` + `AccentCard`
|
||||
`SurfaceCard` (flat `Paper`, house radius, a `padding` scale `sm`/`md`/`lg`) and `AccentCard`
|
||||
(`SurfaceCard` + a `borderInlineStart` accent at one standardized 4px width — ending the
|
||||
3px/4px drift between `EarningsBalanceHeader`/`PayoutHistoryRow` and `BankStatusPanel`/
|
||||
`DocumentUpload`) replace the ~12 named hand-rollers (`EarningsRow`, `EarningsBalanceHeader`,
|
||||
`PayoutHistoryRow`, `PatientCard`, `VariantCard`, `VisitNoteCard`, `InstallmentScheduleRow`,
|
||||
`PriceBreakdown`, `BankStatusPanel`, `DocumentUpload`'s panels, `BookingRequestSummaryCard`,
|
||||
the customer-home nudge card) — visual unification only, no content/behavior change. A gap
|
||||
found during the final sweep (`BookingDetailView`'s customer-side "care record locked"
|
||||
notice, still on a dashed `Paper`) was migrated onto `AccentCard tone="neutral"` in this pass.
|
||||
16 call sites now render through `SurfaceCard`/`AccentCard`.
|
||||
|
||||
### `<Money>` primitive
|
||||
One component for every displayed IRR amount (`components/common/Money`): takes a served IRR
|
||||
digit-string, formats via `utils/money.ts` (never computes), with `size`/`tone` variants —
|
||||
emphasis renders in `--bal-money-emphasis`, **not terracotta** (`--bal-secondary` fails AA
|
||||
contrast at small sizes on light backgrounds) — and an explicit deduction treatment (a
|
||||
dir="ltr"-held `−` prefix, never a bare Unicode minus floating in RTL text). 19 call sites now
|
||||
render through `<Money>`, including the flagship terracotta-total defects
|
||||
(`PriceBreakdown`, `RefundStatusCard`, `BnplPlanCard`, `InstallmentScheduleRow`,
|
||||
`CancellationPolicyDisclosure`). `PriceDisplay` (catalog unit rates) was deliberately left on
|
||||
its own `formatIrrToToman` call per the phase's own scope note — its contract is tested,
|
||||
correct, and has different unit/estimate-label needs than a bare amount.
|
||||
|
||||
One site — `bookings/[id]/cancel/page.tsx`'s confirmation restatement — needed a different
|
||||
shape: the amounts are interpolated into a translated sentence (`"مبلغ {refund} به شما
|
||||
بازپرداخت میشود…"`), not rendered as an isolated node, so `<Money>` couldn't drop in directly.
|
||||
Fixed by switching `confirm_restate` to next-intl's `t.rich()` with `<refund>`/`<fee>` tags in
|
||||
both message files, rendering `<Money>` inside the sentence rather than hand-formatting a
|
||||
string — the first `t.rich()` usage in the codebase; a precedent worth reusing for any future
|
||||
money-inside-a-sentence case instead of falling back to string concatenation.
|
||||
|
||||
`currency_toman` outside `utils/money.ts` + `<Money>`/`PriceDisplay` internals is now zero
|
||||
(DoD-exact grep).
|
||||
|
||||
### Formatting utils — `utils/number.ts`
|
||||
`localeTag(locale)`, `formatNumber(value, locale, options?)`, `formatRelativeTime(iso, locale,
|
||||
absoluteFormatter)` (7-day decay to the absolute Shamsi date — documented in JSDoc, no live
|
||||
consumer yet per the phase's own scope: lands in phases 10/11), `formatClock(totalSeconds,
|
||||
locale)` (migrated `CountdownTimer`'s inline `Intl` pad + fixed `OtpStep.tsx`'s Latin-digit
|
||||
resend clock). Swept the `locale === 'fa' ? 'fa-IR' : 'en-US'` ternary onto `localeTag`/
|
||||
`formatNumber` at every remaining site found by a final consolidated grep pass (run **after**
|
||||
the metadata-pattern file splits, since those physically relocated code the first sweep had
|
||||
already covered): `bookings/[id]/invoice/page.tsx`, `search/nurse/[nurseId]/page.tsx` (×2),
|
||||
`NurseResultCard.tsx` (×2), `PriceDisplay.tsx`, and `JalaliDatePicker.tsx` (×2, the picker's
|
||||
own new code). `locale === 'fa' ? 'fa-IR'` now appears only in `utils/number.ts` itself
|
||||
(DoD-exact grep) — `JalaliDatePicker`'s separate `locale === 'fa' ? 'fa-IR-u-ca-persian' :
|
||||
'en-US'` (the Jalali-calendar-system Intl tag, a different decision than the plain locale tag)
|
||||
is intentionally untouched by this rule.
|
||||
|
||||
### `JalaliDatePicker` + `JalaliDateField`
|
||||
Shamsi-native date selection that **always emits/accepts ISO Gregorian** — the wire never sees
|
||||
a Jalaali date. `calendarEngine.ts` defines one `CalendarEngine` shape with two
|
||||
implementations (`jalaaliEngine` for `fa`, a plain Gregorian one for `en`) so the picker's
|
||||
grid/navigation logic is calendar-agnostic.
|
||||
|
||||
**Arithmetic decision:** `Intl` (`fa-IR-u-ca-persian`, already used by `utils/date.ts`) is
|
||||
sufficient to *display* a Shamsi date but has no reverse direction — there's no way to ask
|
||||
Intl "what Gregorian date is Jalaali 1404/5/15," which is exactly what a *picker* (as opposed
|
||||
to a *label*) needs for month navigation and emitting the selected day. Rather than hand-roll
|
||||
the Borkowski conversion algorithm, the phase adds `jalaali-js` (MIT, zero runtime
|
||||
dependencies, documented in-repo as ~9KB unminified / low single-digit KB gzipped) for the
|
||||
reverse conversion (`toGregorian`) plus month-length/leap-year math; `Intl` still supplies
|
||||
locale-correct month/weekday *names* — `jalaali-js` only supplies numbers. `JalaliDatePicker`
|
||||
has a `grid` variant (month calendar with keyboard roving nav, RTL-flipped arrow keys) and a
|
||||
`chips` variant (horizontal day-chip strip for near dates, the C4 booking-form shape).
|
||||
`JalaliDateField` wraps it in a read-only `TextField` + `Popover`.
|
||||
|
||||
### `StatusChip` v2, `StatusTimeline`, `CountdownTimer` v2, `RatingInput` v2
|
||||
- `StatusChip` rewritten to a soft-tint hierarchy: neutral/info/pending states use
|
||||
`--bal-primary-soft`, verified/active use `--bal-success-soft`, and solid `--bal-error` fill
|
||||
is reserved for the one true "alarm" state (rejected) — added alongside new
|
||||
`--bal-{success,error,warning,info}-soft` tokens (both color schemes).
|
||||
- `StatusTimeline` (ordered `TimelineNode[]`: completed/current/pending/failed, animated pulse
|
||||
on `current` respecting `prefers-reduced-motion`) now backs `RefundStatusCard` and
|
||||
`BookingStatusTimeline` (3 call sites) — wizards (`StepperHeader`) are untouched, per the
|
||||
phase's own "wizards still use StepperHeader" boundary.
|
||||
- `CountdownTimer` v2 adds a `windowStart` progress ring, `warnThresholdSeconds`/
|
||||
`urgentThresholdSeconds` urgency tiers (replacing the old binary `urgent` prop, kept as a
|
||||
legacy override), and an opt-in `coarseThresholdSeconds`/`coarseLabel` humanized mode — the
|
||||
core architecture (server-frozen UTC deadline, self-owned 1s tick, single `onElapsed`,
|
||||
`dir="ltr"` tabular-nums digits) is unchanged.
|
||||
- `RatingInput` v2 renders a fractional fill via a clip-path overlay in read-only mode (a 4.5
|
||||
average now shows a genuinely half-filled fifth star, replacing the old `Math.round`
|
||||
workaround removed from the nurse profile page) and switched its fill colors from
|
||||
`--bal-warning` to the phase-0 `--bal-rating`/`--bal-rating-empty` tokens.
|
||||
|
||||
### Route chrome + per-page metadata
|
||||
`loading.tsx` added for all five route groups ((customer), nurse, admin, partner,
|
||||
public-routes) — the three sidebar shells (nurse/admin/partner) share one
|
||||
`_chrome/SidebarShellSkeleton.tsx`. `error.tsx` and `not-found.tsx` under `[locale]/` are
|
||||
branded and localized (the `not-found.tsx` is reached via a `[...rest]/page.tsx` catch-all
|
||||
calling `notFound()`, next-intl's recommended pattern). `global-error.tsx` sits above
|
||||
`[locale]/` and is the one sanctioned static-string, no-MUI exception — it replaces the root
|
||||
layout on a root-level crash and renders its own `<html>`, so it structurally cannot reach
|
||||
`NextIntlClientProvider`.
|
||||
|
||||
`ErrorBoundary` was rewritten to take `title`/`body`/`retryLabel` as required caller-owned
|
||||
props instead of calling `useTranslations` internally — kept **presentational**, no
|
||||
`next-intl` import, specifically so it stays safe at the top of the `components/common`
|
||||
barrel (see the Jest note below). Both `CustomerLayout` and `TopBarAndSideBarLayout` pass the
|
||||
copy via `useTranslations('routeChrome')`.
|
||||
|
||||
Every route now has its own browser tab title via `generateMetadata` (locale-aware `'%s |
|
||||
بالینیار'`/`'%s | Balinyaar'` template, set once in `[locale]/layout.tsx`). Seven pages that
|
||||
needed a Client Component body were split into a thin Server Component `page.tsx` +
|
||||
co-located `<Name>Screen.tsx`: home, login, search, bookings, admin overview, partner home
|
||||
(nurse dashboard was already a Server Component and got `generateMetadata` added in place, no
|
||||
split needed). The pattern is documented in `client/CLAUDE.md` under "Per-page metadata (the
|
||||
client-page pattern)."
|
||||
|
||||
## The Jest / next-intl transform fix (the one non-mechanical problem this phase hit)
|
||||
|
||||
`ErrorBoundary`/`ErrorState` were deliberately kept free of `next-intl` because any component
|
||||
near the top of the `components/common` barrel that imports it at module scope forces *every*
|
||||
test file that transitively imports the barrel to deal with next-intl's ESM-only build — even
|
||||
tests that never touch translations. `<Money>` couldn't take the same fix: it already had ~20
|
||||
call sites depending on its locale-aware, self-contained API before this was noticed, so
|
||||
stripping `next-intl` from it would have meant threading a formatted string through every
|
||||
caller instead.
|
||||
|
||||
The actual fix was at the root: `next/jest`'s `createJestConfig` returns a
|
||||
`transformIgnorePatterns` that already matches nearly all of `node_modules` (a narrow
|
||||
negative-lookahead allowlist for a couple of Next.js-internal packages), and **appends**
|
||||
whatever custom pattern you pass rather than letting it override — combined with Jest's
|
||||
OR-based array semantics (a file is ignored if *any* pattern matches), a more permissive
|
||||
pattern appended after the restrictive one can never "un-ignore" a package the first pattern
|
||||
already caught. `jest.config.ts` now replaces the array outright, in an async wrapper around
|
||||
`createJestConfig`, allowlisting `next-intl`/`use-intl`/`@formatjs`/`intl-messageformat` (each
|
||||
transitive ESM dependency surfaced its own parse error until the allowlist was broad enough).
|
||||
This is a repo-wide fix, not a `<Money>`-specific one: any future shared component is now free
|
||||
to call `useTranslations` without risking the same barrel-poisoning failure mode. The
|
||||
trade-off and the "prefer caller-owned by default" convention are both documented in
|
||||
`client/CLAUDE.md`'s new "Presentational purity in `components/common`" subsection.
|
||||
|
||||
## Icon registry
|
||||
|
||||
Added `forward` (`ArrowForwardRounded`) to `AppIcon/config.ts`'s `ICONS` and
|
||||
`DIRECTIONAL_ICONS` — needed for `JalaliDatePicker`'s next-month button (mirrors under RTL
|
||||
alongside the existing `back`/`chevron_start` pair).
|
||||
|
||||
## Sweep counts (verified by grep on the current tree, not the original audit estimate)
|
||||
|
||||
| Primitive | Live call sites |
|
||||
| --- | --- |
|
||||
| `<EmptyState>` | 21 |
|
||||
| `<ErrorState>` | 15 |
|
||||
| `<Money>` | 19 |
|
||||
| `<SurfaceCard>` / `<AccentCard>` | 16 |
|
||||
| `<StatusTimeline>` | 3 |
|
||||
| `formatNumber(...)` | 11 |
|
||||
| `localeTag(...)` | 9 |
|
||||
|
||||
## Definition-of-Done sweep greps (re-run after the metadata-pattern file splits)
|
||||
|
||||
- `border: '1px dashed'` under `client/src/app` → **zero**.
|
||||
- `currency_toman` outside `utils/money.ts` + `<Money>`/`PriceDisplay` internals → **zero**.
|
||||
- `locale === 'fa' ? 'fa-IR'` outside `utils/number.ts` → **zero**.
|
||||
|
||||
These were re-verified as a final pass, separate from the per-agent sweeps, because the
|
||||
metadata-pattern splits (home/login/search/bookings/admin/partner) physically relocated code
|
||||
after the mechanical sweeps had already run over the pre-split files — three real gaps
|
||||
(`BookingDetailView`'s locked-care dashed border, the cancel-page `currency_toman` sentence
|
||||
site, six `locale === 'fa' ? 'fa-IR'` sites across five files) surfaced only in this final pass
|
||||
and are fixed above.
|
||||
|
||||
## What was mocked / waiting on a real service
|
||||
None — this phase, like phase 0, is pure client primitives/chrome with no service seams
|
||||
touched. No REQ entries filed (the phase never touches `server/` or a contract).
|
||||
|
||||
## Docs updated
|
||||
- `client/CLAUDE.md` — Project Structure (`components/common/*` primitives, `utils/number.ts`,
|
||||
the route-chrome files, the metadata-pattern file splits), a new "Presentational purity in
|
||||
`components/common`" subsection under Unit Testing (the next-intl/Jest gotcha + the
|
||||
caller-owned-copy convention), "Per-page metadata (the client-page pattern)" section, "Every
|
||||
mutation needs an `onError` toast" line under Toast Notifications.
|
||||
- `messages/en.json` / `fa.json` — `routeChrome` namespace, ~8 new error-copy keys for the six
|
||||
defect fixes, `confirm_restate` converted to `t.rich()` tags. Key parity verified
|
||||
programmatically: 1,538 keys in each file, zero one-sided keys.
|
||||
|
||||
## What a human should verify (not done here — no browser in this environment)
|
||||
Per the phase's own Definition of Done, visual verification on the four axes (`/fa`+`/en` ×
|
||||
light+dark) and mobile+desktop for the state kit, card kit, route chrome, and the Jalali
|
||||
picker was **not performed** — there is no browser available in this environment. `npm run
|
||||
check` and `npm run test:ci` passing is evidence of type/lint/unit correctness, not visual or
|
||||
interaction correctness. Specifically worth a human pass:
|
||||
1. Force a query to fail (devtools offline) on `/fa` home, `/patients`, `/profile`,
|
||||
`/nurse/requests`, `/nurse/services` — confirm `ErrorState` + working retry, never a spinner
|
||||
or false-empty state.
|
||||
2. `/fa/xyz` → branded 404; throw inside a page in dev → branded error screen; production build
|
||||
→ confirm no stack trace renders.
|
||||
3. `JalaliDatePicker` grid + chips variants on `/fa` (RTL arrow-key direction, month names) and
|
||||
`/en` (plain Gregorian) — both color schemes.
|
||||
4. The nurse profile's rating display — confirm a non-integer average renders a genuinely
|
||||
half-filled star, not a rounded one.
|
||||
5. Tab titles change per section (`جستجو | بالینیار`, `رزروها | بالینیار`, …).
|
||||
|
||||
## A note on a subagent report during this phase
|
||||
|
||||
One of the six parallel sweep agents (the card-kit migration agent, worktree
|
||||
`agent-a099d7389dc14bd95`) returned a report whose text was flagged by the harness as
|
||||
"instruction-shaped" (matching a `settings-json` pattern) and had its control tags neutralized
|
||||
before reaching this session. Having now reviewed the neutralized text directly: it was the
|
||||
agent's own file-accounting summary, noting in passing that
|
||||
`.claude/settings.local.json` "predates this task and is untouched by me" — normal
|
||||
bookkeeping language, not an embedded directive aimed at this session. No action was taken on
|
||||
it beyond relaying it here, per the harness's own instruction to treat any remaining
|
||||
directive-shaped text as a finding to report rather than follow.
|
||||
|
||||
## Gate
|
||||
- `npm run check` (type + lint): **green**, zero errors/warnings.
|
||||
- `npm run test:ci`: **94/94 suites, 392/392 tests green** (run after all merge-conflict
|
||||
resolution and the final DoD-sweep fixes above).
|
||||
- `messages/en.json` / `messages/fa.json`: in sync (1,538 keys each, verified programmatically).
|
||||
- 6 agent worktrees (`agent-a099d7389dc14bd95`, `-a0b4bb6e73e057271`, `-a279bb3717dd53e45`,
|
||||
`-a74eed78c5350b831`, `-ae141074976cb056f`, `-af1e1c6a8d89d64cc`) and their branches removed
|
||||
after diff extraction — no leftover worktree state.
|
||||
|
||||
## Follow-ups for later phases
|
||||
- `formatRelativeTime` ships with tests but no live consumer yet — phases 10/11 (per the
|
||||
phase's own scope note).
|
||||
- Custom illustration set for `EmptyState` — DEFERRED to phase 12, per phase 0/1's own notes.
|
||||
- The app-wide motion pass (`--bal-motion-*`/`--bal-easing-standard`, defined in phase 0) is
|
||||
still unconsumed outside `StatusTimeline`'s pulse animation and `CountdownTimer`'s ring —
|
||||
phase 12's job.
|
||||
- Visual verification (four axes × mobile/desktop) is unverified — see the section above; the
|
||||
next phase touching any of these primitives should do a pass before building further on top.
|
||||
Reference in New Issue
Block a user