Files
baya-monorepo/archive/build-chain/working-context/reports/ui-phase-10-report.md
T
2026-08-02 18:48:32 +03:30

18 KiB

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 forwardRefs 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 deliveredUSE_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.