ui phase 10
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
# UI Phase 10 — Messaging & notifications — Report (2026-07-19)
|
||||
|
||||
## What was built
|
||||
|
||||
### Ticket inbox → a real inbox (`TicketInboxScreen`, `TicketListCard`)
|
||||
- **Status filter chips** (همه/باز/بسته) + **load-more paging**: `useMyTickets({ status, pageSize, page: 1 })`
|
||||
with a growing `pageSize` (the same "growing limit" pattern `NotificationCenter` already used), reset to
|
||||
`TICKETS_PAGE_SIZE` on a filter change. `keepPreviousData` (already set on the hook) means a chip switch
|
||||
never flashes.
|
||||
- **`TicketListCard` redesigned around activity:** bold subject + unread pill (unchanged behavior — it turns
|
||||
out `unreadCount`/`lastMessageAt` are **already real** on the wire, REQ-028 having been delivered since the
|
||||
audit was written — see "Stale audit note" below) **plus** a one-line last-message preview prefixed with
|
||||
the translated author label (e.g. "پرستار: ساعت ۵ عصر هماهنگ شد") and a **relative** last-activity time
|
||||
(`formatRelativeTime`, decaying to Shamsi past 7 days) instead of the old absolute
|
||||
`formatShamsiDateTime`. The preview/author-role fields don't exist on the wire summary yet — filed as
|
||||
**REQ-059** — so `TicketSummary.lastMessagePreview`/`lastAuthorRole` are optional and the card renders
|
||||
subject + status + time only when they're absent (no empty slot, never a fake value).
|
||||
- **Support-entry unread badge in the chrome:** a new `useSupportUnreadTotal()` read behind the `services/tickets`
|
||||
seam (mock sums its tickets' `unreadCount`; real returns `null` — REQ-059). Mounted as a `Badge` on the
|
||||
customer TopBar's support icon (only on the 5 root tabs) and on the nurse sidebar's support item (a new
|
||||
`LinkToPage.badgeCount` prop, rendered by `SideBarNavItem` — a minimal, intentional touch to the
|
||||
phase-2-owned `layout/` files, noted per the ownership rules).
|
||||
- **Emergency affordance right-sized:** the permanent alarm-red `EmergencyBanner` is gone from both ticket
|
||||
inboxes, replaced by a new **`EmergencyPlaybookRow`** — a compact, neutral, collapsed-by-default row that
|
||||
expands to rewritten copy (never instructs calling a number the inbox can't show) + the "open a ticket"
|
||||
action. `EmergencyBanner` itself is untouched and stays exactly where it belongs: the nurse's
|
||||
post-confirmation booking detail (`BookingSupportEntry`), the only surface with a real `tel:` contact.
|
||||
- Unified the messaging surface width (inbox now 720px, matching the thread — was 640/720 mismatched).
|
||||
|
||||
### Live thread (`useTicket`/`useTicketThread`, `useThreadScroll`, `TicketMessageList`)
|
||||
- **Polls while mounted:** `useTicket`/`useTicketThread` both got `refetchInterval: TICKET_THREAD_REFETCH_INTERVAL`
|
||||
(15s, new constant) — TanStack Query scopes this to active observers, so it's automatically off once the
|
||||
thread unmounts. No `refetchIntervalInBackground`. The 60s notification-count poll stays the only
|
||||
always-on poll (§5 posture unchanged).
|
||||
- **`useThreadScroll`** — a new, reusable scroll-orchestration hook (`components/messaging/useThreadScroll.ts`,
|
||||
exported from the barrel): opens the thread scrolled to the **newest** message (instant, on first load),
|
||||
always scrolls to a newly-sent (mine) message, and auto-scrolls on a received message only when the
|
||||
viewer is already within ~120px of the bottom — otherwise shows a floating «پیام جدید ↓» pill. Uses an
|
||||
`IntersectionObserver` on a bottom sentinel rather than tracking a specific scroll container, so it works
|
||||
whether the *page* or an inner box scrolls (guarded for environments without `IntersectionObserver`, e.g.
|
||||
jsdom). Deliberately exported as a consumable for **phase 11's admin thread**, which has the inverse bug
|
||||
(a 520px scrollbox that also opens at the top).
|
||||
- **Chat typography** (`TicketMessageList`): messages are grouped into date-separator / system-event /
|
||||
consecutive-same-author blocks (`buildBlocks`); centered Shamsi separators read امروز/دیروز/an absolute
|
||||
date (`formatDaySeparator`, new in `utils/date.ts`, string-equality comparison — never raw ms diffs, so
|
||||
it's calendar-exact); consecutive messages from the same author collapse under one author label; bubbles
|
||||
show **hh:mm only** (`formatShamsiTime`, new in `utils/date.ts`); `authorRole === 'system'` renders as a
|
||||
centered neutral chip, never a bubble.
|
||||
- **Fixed the bidi timestamp bug:** removed the forced `direction: 'ltr'` from the bubble's time label
|
||||
(`MessageBubble.tsx`) — with hh:mm-only Persian-digit stamps, no forced direction is needed; the Latin
|
||||
`referenceCode` keeps its forced LTR everywhere it's shown.
|
||||
- **Sticky composer separation:** the composer's sticky box now has a real `borderTop` (divider token)
|
||||
instead of a bare `bgcolor` block, so bubbles no longer scroll flush into the input.
|
||||
|
||||
### Composer (`MessageComposer`, `TicketConversationPanel`, `usePostMessage`, `useDiscardFailedMessage`)
|
||||
- **Enter semantics per input modality:** `useMediaQuery('(pointer: coarse)')` branches the composer's
|
||||
`onKeyDown` — desktop (fine pointer): Enter sends, Shift+Enter newlines (unchanged); touch (coarse
|
||||
pointer): Enter always inserts a newline, the send button is the only send path.
|
||||
- **Retry-in-place on failure** — the invariant-preserving rewrite:
|
||||
- `usePostMessage`'s `onMutate` is now **idempotent on `clientMessageId`**: a fresh send appends a pending
|
||||
bubble; a retry (the same id already in the cache as `sendStatus: 'failed'`) flips it back to `sending`
|
||||
in place instead of appending a duplicate.
|
||||
- `onError` **no longer rolls the thread back** — it flips the bubble to `sendStatus: 'failed'` and leaves
|
||||
it in place with its typed body.
|
||||
- `MessageBubble`'s failed state renders `role="alert"` (screen readers are told), the error-accented
|
||||
bubble, a «تلاش مجدد» action (re-mutates the same `clientMessageId`), and a discard (delete) action.
|
||||
- A new `useDiscardFailedMessage()` (not a mutation — nothing was ever sent) removes the failed message
|
||||
from the cached thread; `TicketConversationPanel` wires its `onDiscard` to also restore the message's
|
||||
body into the composer's draft — **a failure never loses typed text**, and `clientMessageId`
|
||||
reconciliation still never double-renders. Both invariants are proven by the updated
|
||||
`MessageBubble.test.tsx`.
|
||||
- **`TicketConversationPanel`** (new) is the component that makes this possible without leaking state
|
||||
across tickets: it owns the one `usePostMessage`/draft instance both `TicketMessageList`'s retry/discard
|
||||
and the composer's own send now share, and it's mounted `key={ticketId}` from `TicketThreadScreen` — so
|
||||
navigating thread→thread (the App Router reuses the `[id]` subtree) remounts the whole panel, exactly
|
||||
preserving the pre-existing "drafts/in-flight state never cross tickets" invariant (previously enforced
|
||||
by keying only `MessageComposer`).
|
||||
- **Attachment affordance — designed, gated:** the composer has an attachment icon button + the capability
|
||||
flag `TICKETS_ATTACHMENTS_ENABLED` (`services/tickets/constants.ts`, default `false`) — it renders nothing
|
||||
until the seam lands (REQ-060). No dead button ships.
|
||||
- **RTL send-icon mirror:** `send` added to `AppIcon`'s `DIRECTIONAL_ICONS` set (config.ts) — phase 0's
|
||||
auto-mirroring mechanism already existed, this was a one-line registry addition.
|
||||
|
||||
### Emergency affordance (recap)
|
||||
- Full alarm-red `EmergencyBanner` + `tel:` stays **only** on the nurse's post-confirmation booking read
|
||||
(untouched). Both ticket inboxes now show the compact `EmergencyPlaybookRow` instead.
|
||||
|
||||
### Notification center (`NotificationCenter`, `NotificationRow`, `notificationIcon.ts`)
|
||||
- **Day grouping:** امروز/دیروز/اینهفته, then a Shamsi date header for anything older — grouped **in list
|
||||
order** (the server's unread-first-then-newest ordering is preserved per §5's "keep the mark-read UX as
|
||||
is"; a bucket can recur if an older unread item sits above newer read ones — a deliberate, documented
|
||||
trade-off rather than silently reordering the list).
|
||||
- **Relative timestamps** (`formatRelativeTime`, decaying to Shamsi) replace the absolute
|
||||
`formatShamsiDateTime` on every row.
|
||||
- **Per-kind tinted icon container:** a new `notificationTint(kind)` helper (`notificationIcon.ts`) — booking
|
||||
teal, payout success-green, ticket/support terracotta (the deliberate "a human from Balinyaar" accent),
|
||||
refund info, nurse-trust the dedicated `--bal-trust` token, `none` fully neutral. All existing `--bal-*-soft`
|
||||
tokens — no new tokens needed.
|
||||
- **Non-navigable rows render non-interactive:** `NotificationRow` now takes a `navigable` prop (the caller
|
||||
computes it from `notificationDeepLink(...) != null`). Navigable rows are a real `ButtonBase` with a
|
||||
trailing chevron (`forward` icon) and a visible `:focus-visible` ring; non-navigable rows render as a
|
||||
plain, static surface (no ripple, no pointer cursor, no chevron) that still marks the notification read on
|
||||
click/Enter/Space (kept keyboard-operable via `role="button" tabIndex={0}`, even though visually inert).
|
||||
- **Kept unchanged:** mark-read-on-open (optimistic), mark-all-read, load-more.
|
||||
|
||||
### Bell behavior (`NotificationBell`, `NotificationBellPopover`, `NotificationBellView`)
|
||||
- **Desktop popover on the nurse shell:** `NotificationBell` now branches on `role === 'nurse' && isDesktop`
|
||||
(`useMediaQuery(theme.breakpoints.up('md'))`) — opens a new `NotificationBellPopover` (5 most recent,
|
||||
mark-all-read, «مشاهده همه» to the full center) instead of navigating. The popover fetches **on open**
|
||||
(`useNotifications(5, { enabled: open })` — `useNotifications` gained the optional `{enabled}` param),
|
||||
reusing the same `notificationKeys` cache the full center reads, never on the polled count's tick.
|
||||
Everywhere else (customer — always, mobile-first by design; nurse mobile) keeps direct navigation.
|
||||
- **Isolation preserved:** only `NotificationBell` (the container) calls `useUnreadCount()`; the popover's
|
||||
content and the shell around it never do. `NotificationBellView` now `forwardRef`s to its `IconButton` so
|
||||
the container can anchor the popover to it (via `anchorEl` state set through the ref callback — not a
|
||||
read-during-render, which the `react-hooks/refs` lint rule correctly flagged and blocked).
|
||||
- Badge pulse-on-increment (the phase's "optional polish") was **not** built this pass — noted under
|
||||
Follow-ups.
|
||||
|
||||
### Admin notifications — the phase-11 handshake (§3.7)
|
||||
- `admin/notifications/page.tsx` stays a `PlaceholderScreen` (untouched — building it is phase 11's call),
|
||||
but its **only entry point was the TopBar bell** (`AdminLayout` had no separate "notifications" nav item —
|
||||
the bell click was the dead link). Removed `<NotificationBell role="admin" />` from `AdminLayout` entirely
|
||||
— a documented, intentional removal (comment in `AdminLayout.tsx`), not a silent regression. Re-add it
|
||||
(reusing `NotificationBellPopover`, already exported from the barrel) once phase 11 ships a real admin
|
||||
feed.
|
||||
- `useThreadScroll` and `NotificationBellPopover` are both exported from their barrels specifically for
|
||||
phase 11 to consume for the admin thread's inverse scroll bug and (eventually) an admin bell popover.
|
||||
|
||||
## Stale-audit note (recorded for the record, not re-litigated)
|
||||
The phase doc's problem list (drawn from `audit/messaging-notifications.md`) describes `unreadCount`/
|
||||
`lastMessageAt` as "dead on the real API, REQ-028 gap, mock-only." That was true when the audit was
|
||||
written, but by the time this phase ran, **REQ-028 had already been delivered** — `USE_TICKETS_MOCK` and
|
||||
`USE_NOTIFICATIONS_MOCK` are both `false` in this codebase, and `ticketsClientApi.mapSummary` genuinely maps
|
||||
`unreadCount`/`lastMessageAt` off the wire (confirmed by reading the current code, not the audit). This
|
||||
phase's actual REQ-039/040-equivalent gaps turned out to be narrower than described — `lastMessagePreview`/
|
||||
`lastAuthorRole`/the unread-total badge (filed as **REQ-059**, since REQ-039/040 were already taken by
|
||||
ui-phase-3/4 by the time this phase ran — always check the tracker's live highest number, not a phase doc's
|
||||
indicative one) and the attachment affordance (**REQ-060**). This mirrors the same "audit predates a
|
||||
same-day fix" pattern the ui-phase-9 stale-audit memory already flagged — worth a standing reminder for
|
||||
future phases to verify audit claims against current code before treating them as gaps to close.
|
||||
|
||||
## What is now testable (and exactly how)
|
||||
1. `/fa` customer → پشتیبانی (`/support/tickets`): status chips filter the list without a flash; if there
|
||||
are >20 tickets in a scenario, «نمایش بیشتر» loads more; the compact «موارد اضطراری» row is collapsed by
|
||||
default and expands on tap; each card shows a bold subject, unread pill (mock scenario), one-line
|
||||
preview + relative time.
|
||||
2. Open a thread with a booking-linked coordination ticket → opens scrolled to the newest message,
|
||||
date-separated and author-grouped, `hh:mm` stamps, a centered system event line if present. Scroll up,
|
||||
wait ~15s for the poll (or use the mock's reply path) → a reply appears and, if scrolled up, shows
|
||||
«پیام جدید ↓»; tap it → scrolls to newest.
|
||||
3. Send a message → bubble appears instantly with "در حال ارسال…"; on confirm it shows `hh:mm`. Post the
|
||||
dev sentinel `/fail` as the body → the bubble turns error-accented with «تلاش مجدد» + a delete icon,
|
||||
`role="alert"` (verify with a screen reader or the accessibility tree); tap «تلاش مجدد» → succeeds (a
|
||||
real send) with **exactly one** bubble, never a duplicate. Try again and tap the delete icon instead →
|
||||
the bubble disappears and its text reappears in the composer.
|
||||
4. On a touch-emulated viewport (devtools device toolbar), Enter in the composer inserts a newline; on a
|
||||
desktop viewport, Enter sends. `/fa`: the send arrow points out of the field (toward the inline-end).
|
||||
5. `/nurse/support/tickets`: the sidebar's «پشتیبانی» item shows a small unread badge when the mock's
|
||||
`getUnreadTotal()` is positive.
|
||||
6. Notification center (`/fa` + `/en`, light + dark): امروز/دیروز/اینهفته day groups, «۵ دقیقه پیش» decaying
|
||||
to a Shamsi date past 7 days, tinted per-kind icon circles (teal/success/terracotta/info/trust); a
|
||||
non-deep-linking row (seeded via the mock's unknown-type row) doesn't ripple and has no chevron, but a
|
||||
click still marks it read; navigable rows show a trailing chevron and a visible focus ring on Tab.
|
||||
7. Nurse desktop (`≥md` viewport): the bell opens a popover (5 recent + mark-all + «مشاهده همه»); resize to
|
||||
mobile → the same bell now navigates to the full center. Customer shell: the bell always navigates,
|
||||
any viewport. Devtools Network tab: only the unread-count endpoint polls (60s) plus the thread endpoint
|
||||
while a thread screen is mounted (15s) — nothing else ticks.
|
||||
8. `/admin`: no notification bell in the TopBar at all (removed, not a dead link).
|
||||
|
||||
## What is mocked / waiting on a real service
|
||||
- `TicketsApi` (`services/tickets/apis/mockApi.ts`) — mock stays available for the two REQ-059 fields
|
||||
(`lastMessagePreview`/`lastAuthorRole`) + `getUnreadTotal()`; the real client maps the first two to `null`
|
||||
and the third to a constant `null`. See `mocks-registry.md`'s updated `TicketsApi` row.
|
||||
- The composer's attachment affordance is fully designed but inert (`TICKETS_ATTACHMENTS_ENABLED = false`)
|
||||
until REQ-060 ships an object-storage-backed attachment endpoint.
|
||||
|
||||
## Contracts
|
||||
- No contract consumed this phase (no new backend endpoint existed to consume — REQ-028 was already
|
||||
delivered before this phase started).
|
||||
- Requests filed in `dev/shared-working-context/frontend/requests/for-backend.md`:
|
||||
- **REQ-059** — Ticket inbox enrichment: `lastMessagePreview` + `lastAuthorRole` on `TicketSummaryDto`
|
||||
(extends REQ-028) + a cheap unread-total read for the chrome badge.
|
||||
- **REQ-060** — Ticket message photo attachments (object-storage-backed), gating the composer's
|
||||
already-built affordance.
|
||||
|
||||
## Docs updated
|
||||
- `client/CLAUDE.md` — the `messaging/` and `notifications/` Project Structure entries rewritten for the
|
||||
new components/hooks (`TicketConversationPanel`, `useThreadScroll`, `EmergencyPlaybookRow`,
|
||||
`NotificationBellPopover`, `notificationTint`); the `services/tickets`/`services/notifications` domain
|
||||
bullets updated (retry-in-place semantics, thread polling, `useSupportUnreadTotal`,
|
||||
`useDiscardFailedMessage`, REQ-028-delivered vs REQ-059-gap distinction); the `'tickets'`/`'notifications'`
|
||||
i18n namespace descriptions updated with the new keys; `CustomerLayout.tsx`/`NurseLayout.tsx`/
|
||||
`AdminLayout.tsx`/`SideBarNavItem.tsx` structure-tree lines updated for the badge + admin-bell-removal
|
||||
changes.
|
||||
- `dev/shared-working-context/reports/mocks-registry.md` — the `TicketsApi` row corrected (it was stale:
|
||||
marked "deliver REQ-028, then set flag false" when the flag was already `false`) and updated with the
|
||||
REQ-059/060 gaps.
|
||||
- `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-059/060 appended (see Contracts).
|
||||
|
||||
## Foundation extensions (minimal, per the ownership rules)
|
||||
- **`layout/` (phase 2-owned):** `LinkToPage` gained an optional `badgeCount` field; `SideBarNavItem`/
|
||||
`SideBarNavList` render it as a small `Badge`; `CustomerLayout`/`NurseLayout` wire it to
|
||||
`useSupportUnreadTotal()`; `AdminLayout` lost its notification bell (§3.7 handshake, documented above).
|
||||
- **`utils/date.ts`:** added `formatShamsiTime` (hh:mm-only) and `formatDaySeparator`
|
||||
(امروز/دیروز/absolute-date, calendar-exact via formatted-string comparison, not ms diffs).
|
||||
- **`AppIcon/config.ts`:** `send` added to `DIRECTIONAL_ICONS` (one-line registry addition, phase 0's
|
||||
existing mechanism).
|
||||
- **`services/notifications`:** `useNotifications` gained an optional `{ enabled }` param (for the popover's
|
||||
fetch-on-open); no breaking change to existing callers.
|
||||
|
||||
## Follow-ups for later phases
|
||||
- **Phase 11 (admin console):** build the real admin notification feed (or keep it hidden) — the bell +
|
||||
popover pattern (`NotificationBellPopover`) and the scroll hook (`useThreadScroll`) are ready to reuse for
|
||||
the admin thread's inverse scrollbox bug.
|
||||
- **REQ-059**/**REQ-060** as filed.
|
||||
- **Badge pulse-on-increment** (§3.6's "optional polish") and a document-title/favicon unread hint were not
|
||||
built this pass — low-risk, low-priority visual polish, safe to pick up whenever.
|
||||
- **Ticket lifecycle + trust affordances** (user-side close/reopen, an expected-response-time promise, a
|
||||
"support has seen this" state) — called out as an opportunity in the audit but out of this phase's
|
||||
explicit scope (§3 didn't ask for it); worth a future phase if support-response-time becomes a measured
|
||||
product metric.
|
||||
Reference in New Issue
Block a user