Files
baya-monorepo/archive/post-phase/ui/ui-phase-10-messaging-and-notifications.md
T
2026-08-02 18:48:32 +03:30

22 KiB
Raw Blame History

UI Phase 10 — Messaging & notifications

Mission: make support/coordination feel like a messaging app, not a form list. The optimistic-send plumbing underneath is excellent — but the surfaces betray it: a thread opens at the oldest message and never live-updates, the inbox cannot page past 20 tickets, the unread signals are dead on the real API (REQ-028 gap), every bubble carries a full Shamsi date-time, an alarm-red emergency banner with no phone number sits permanently on every inbox, and notifications are a flat absolute-timestamp list. This phase turns the platform's only sanctioned communication channel into a conversation.

Track: frontend · Depends on: Phases 02 · Unlocks: support/coordination feels like a messaging app, not a form list Before you start, read ../../phases/_shared/agent-operating-rules.md and invoke the frontend-designer skill — both are mandatory.

1. Context — where this sits

Messaging is ticket-based by product design (no free chat, no phone directory — see product/business/12-messaging-and-emergencies.md). A coordination ticket is auto-created for every confirmed booking, so an active nurse's inbox outgrows one page quickly. Both systems are functionally complete and real-API-wired (USE_TICKETS_MOCK = false) — what's missing is the messaging-app layer. Diagnosed, all verified in code:

  1. The inbox can't page or filter. TicketInboxScreen.tsx:35 calls useMyTickets({}) — page 1 / pageSize 20 forever, no load-more, no status chips — even though the hook (services/tickets/hooks/useMyTickets.ts:14) already accepts page/pageSize/status and keys the cache on the filter object.
  2. Unread signals are dead on the real path. unreadCount/lastMessageAt on TicketSummary are documented mock-only (services/tickets/types.ts:56-59, "REQ-028 gap"), so on the real API the unread pill (TicketListCard.tsx:44) never shows and times silently fall back to createdAt. There is no last-message preview in the type at all.
  3. Threads never live-update and open at the top. useTicket.ts:15-21 has staleTime/gcTime but no refetchInterval (contrast the polling useUnreadCount.ts:21); TicketMessageList.tsx:51-63 has no scroll logic — a long thread opens at the oldest message; a reply never appears until blur/refocus.
  4. Zero chat typography. Every bubble renders a full formatShamsiDateTime stamp (TicketMessageList.tsx:58); no date separators, no same-author grouping, system messages render as ordinary bubbles. MessageBubble.tsx:70 forces direction: 'ltr' on a Persian Shamsi string — bidi visually reorders the date/time segments.
  5. The composer mis-handles mobile and failure. MessageComposer.tsx:46-51 — Enter always sends, even on touch keyboards (no way to type a newline on mobile); on failure the optimistic bubble rolls back and the only trace is a small caption, no retry, no aria-live (MessageComposer.tsx:55-59). The send icon (AppIcon/config.ts:85,187) is never RTL-mirrored — stylis flips CSS, not SVG glyphs — so in fa the paper plane points back into the text field.
  6. Emergency affordance is wrong-sized. TicketInboxScreen.tsx:57 renders <EmergencyBanner> on every inbox with no contactPhone — and EmergencyBanner.tsx:58 only renders the call button when a phone exists, so the inbox banner tells users to "call the emergency contact" on a surface that can never show a number. The tel: contact only exists on the nurse's post-confirmation booking read.
  7. Notifications are a flat list. NotificationCenter.tsx:103 stamps every row with an absolute Shamsi date-time; no day grouping; non-navigable rows (deepLink → null) are still ButtonBase cards that ripple and appear to do nothing (NotificationCenter.tsx:46-50). The bell is navigation-only (NotificationBell.tsx:31) even on desktop; the support entry has no unread badge (CustomerLayout.tsx:48-53); admin notifications is a dead PlaceholderScreen (admin/notifications/page.tsx:4-8) the admin nav links to.

What already exists (do not rebuild):

  • The full messaging component set in client/src/components/messaging/ (TicketInboxScreen, TicketListCard, TicketThreadScreen, TicketMessageList, MessageBubble, MessageComposer, ContactSupportDialog, EmergencyBanner, BookingSupportEntry), all four-state, all tokenized.
  • The optimistic-send architecture: usePostMessage with clientMessageId reconciliation, draft cleared only on server confirm, composer remount-keyed per ticket (TicketThreadScreen.tsx:117-119).
  • The notifications system: polled auth-gated useUnreadCount (only the bell container re-renders), NotificationCenter (unread-first, optimistic mark-read, mark-all, load-more), role-aware null-safe services/notifications/deepLink.ts, per-kind icons in components/notifications/notificationIcon.ts.
  • The seams: services/tickets and services/notifications (hooks/apis/keys/constants/types), the is_internal-free user types, ticketKeys/notificationKeys cache-key factories.
  • Foundation from phases 02: theme/token system + icon registry (0), shared primitives + relative time + state kits (1), the per-actor chrome with its header/nav slots (2).

2. Required reading (do this first)

3. Scope — build this

3.1 Ticket inbox → a real inbox

  • Pagination + filters. Expose what useMyTickets already accepts: status filter chips (همه / باز / بسته — map to the TicketStatus values) and load-more/paging past pageSize 20. keepPreviousData is already set, so chip switches must not flash.
  • TicketListCard redesigned around unread + recency: bold subject + unread pill (existing behavior) plus a one-line last-message preview and a relative last-activity time (phase-1 relative-time formatter, decaying to Shamsi). unreadCount/lastMessageAt are mock-only and lastMessagePreview/ last-author-role don't exist at all — extend the contract via REQ (§4) and build mock-tolerant fallbacks: when the enrichment fields are absent (the real path today), the card degrades gracefully to subject + status chip + createdAt time — never an empty slot, never a fake "0 unread". referenceCode stays prominent.
  • Support-entry unread badge in the chrome. Add a useSupportUnreadTotal() (or equivalent) read behind the services/tickets seam — the mock sums its unreadCounts; the real implementation returns nothing until the REQ lands, and the badge renders only when a signal exists. Mount it as a Badge on the phase-2 customer TopBar support entry (CustomerLayout.tsx:48-53) and the nurse nav's support item — a minimal touch to phase-2-owned layout/ files; note it in your report per the ownership rules.

3.2 Live thread

  • Poll while mounted. Give useTicket/useTicketThread a refetchInterval (new TICKET_THREAD_REFETCH_INTERVAL constant in services/tickets/constants.ts; ~15s is proportionate) — TanStack Query only polls while the query has active observers, so this is automatically scoped to the mounted thread screen. Do not set refetchIntervalInBackground. SSE replaces this later; the seam stays. The global polling posture (§5) is unchanged.
  • Scroll orchestration. Thread opens scrolled to the newest message. On send: always scroll to the new bubble. On receive: auto-scroll only when the user is already near-bottom (~120px); otherwise a floating «پیام جدید ↓» pill that scrolls-to-newest on tap and dismisses on reaching bottom. Build this as a reusable hook (e.g. useThreadScroll) — phase 11's admin thread has the inverse bug (a 520px scrollbox that also opens at the top) and will consume it (§3.7).
  • Chat typography. In TicketMessageList: centered Shamsi date separators (امروز / دیروز / ۲۵ تیر — derive day labels from the existing Intl fa-IR-u-ca-persian plumbing in utils/date.ts; add a time-only formatShamsiTime there as a minimal foundation extension); consecutive same-author messages group under one author label; bubbles show hh:mm only (the full date lives on the separator); authorRole === 'system' renders as a centered neutral event line (chip-style), not a bubble — a coordination thread must read as a timeline, not a stranger's messages.
  • Fix the bidi timestamp bug. Remove direction: 'ltr' from the bubble time label (MessageBubble.tsx:70) — with hh:mm-only Persian-digit stamps no forced direction is needed. Keep the forced LTR only on the Latin referenceCode.
  • Sticky composer separation. The sticky strip (TicketThreadScreen.tsx:100-107) is a bare bgcolor block — give it a real top hairline (divider) or a phase-0 elevation token so bubbles no longer scroll flush into the input. While there, align the messaging surface widths (inbox 640 / thread 720) to one.

3.3 Composer

  • Enter semantics per input modality. Enter=send + Shift+Enter=newline on desktop only; on touch (coarse pointer — matchMedia('(pointer: coarse)') or useIsMobile()), Enter inserts a newline and the explicit send button is the only send path. The customer shell is mobile-first; touch keyboards have no Shift+Enter.
  • Retry-in-place on failure. Today usePostMessage rolls the optimistic bubble back, leaving only caption text. Instead: keep the failed bubble in place with sendStatus: 'failed' (the MessageSendStatus union already includes it — types.ts:40), error-token accented, with «تلاش مجدد» (re-mutates with the same clientMessageId) and a delete affordance that restores the text to the composer. Announce the failure via role="alert"/aria-live — screen readers are currently never told. The invariant that survives any mechanism change: a failure never loses typed text, and clientMessageId reconciliation never double-renders (§5). Update the co-located tests to prove both.
  • Attachment affordance — designed, gated. Refund/coordination tickets need photo evidence; the object-storage seam exists server-side (verification docs). Design the composer attachment button + pending-upload chip now, but render it only when the contract lands (REQ in §4) — behind a capability flag in services/tickets/constants.ts, default off. No dead buttons in production.
  • Mirror the send icon in RTL. stylis-plugin-rtl flips CSS, not SVG glyphs; Material's own RTL list names Send as must-mirror. Use the phase-0 auto-mirroring icon strategy for the send registry entry (AppIcon/config.ts:85,187); if phase 0 shipped no such mechanism, add a minimal registry-level mirror (scaleX(-1) under dir="rtl") and note the foundation extension in your report.

3.4 Emergency affordance right-sizing

  • Keep the full EmergencyBanner (error accent + tel: click-to-call) only where the phone exists: the nurse post-confirmation booking read (BookingSupportEntry). That placement is untouched.
  • In both ticket inboxes, replace the permanent banner (TicketInboxScreen.tsx:57) with a compact, neutral «موارد اضطراری» row (collapsed by default, expands to the playbook copy + "open a ticket"). Rewrite the customer-side copy so it no longer instructs calling a number the customer can never see. tel:-only stays the law — no VoIP, no phone directory, nothing new out-of-band (§5).

3.5 Notification center

  • Day grouping: section headers امروز / دیروز / این هفته, then Shamsi date headers for older items.
  • Relative timestamps via the phase-1 relative-time formatter («۵ دقیقه پیش»), decaying to Shamsi for older rows — replacing the absolute formatShamsiDateTime on every row (NotificationCenter.tsx:103).
  • Per-kind visual identity: soft-tinted icon containers — booking teal, payout success, alert warning — from --bal-* tokens only (add -soft tokens in both scheme blocks + colors.ts mirror if phase 0 didn't ship them; note the extension).
  • Non-navigable rows rendered non-interactive: when notificationDeepLink returns null, render a plain surface (no ButtonBase, no ripple, no pointer cursor) that still supports mark-read; navigable rows get a trailing chevron (registry icon from phase 0/1 — register one if missing) and a visible :focus-visible style. Keep the mark-read UX as is — per-row mark-read-on-open and mark-all-read.

3.6 Bell behavior

  • Desktop popover preview on the nurse shell (and the admin shell once phase 11 gives it a feed — §3.7): the bell opens a Popover with the 5 most recent notifications, mark-all-read, and «مشاهده همه» linking to the full center. The popover fetches the list on open (reusing the notificationKeys cache), never on the poll tick.
  • Mobile keeps direct navigation to the notification center — no popover on the customer shell.
  • Optional polish: a one-shot badge pulse when the count increases (respect prefers-reduced-motion).
  • Do not regress the isolation: ONLY the bell container subscribes to the polled count (useUnreadCount) — the shell and the popover contents never do.

3.7 Admin notifications placeholder — phase 11 handshake

admin/notifications/page.tsx is a PlaceholderScreen the admin nav links to. Phase 11 owns the admin tree and decides whether to build an admin alert feed. This phase's job: if phase 11 hasn't shipped that feed when this runs, hide the dead admin nav entry (a "coming soon" page in a staff backoffice erodes trust) and record the handshake in your report. Export useThreadScroll (§3.2) and the bell popover as consumables — phase 11's admin thread scrollbox needs the same scroll fix. Do not build admin surfaces here.

4. Mocks & seams in this phase

No new mocks or seams. Everything stays behind the existing services/tickets and services/notifications seams; the mock ticket store keeps supplying the enrichment fields so the full inbox design is demonstrable offline, and the real path degrades gracefully per §3.1.

Backend gaps become REQ entries appended to ../../shared-working-context/frontend/requests/for-backend.md. REQ-001…038 are taken — check the tracker's highest number at execution time and number onward:

  • REQ-039 (indicative) — Ticket inbox enrichment, extension of REQ-028: re-assert unreadCount + lastMessageAt on TicketSummaryDto, add lastMessagePreview (first ~80 chars, internal notes excluded server-side) + the last message's author role, plus an unread-total read for the chrome badge. Cross-reference REQ-028 rather than duplicating its rationale.
  • REQ-040 (indicative) — Ticket message photo attachments: upload + serve via the existing object-storage seam, message-attachment linkage, size/type limits. Gates the §3.3 attachment affordance.

5. Critical rules you must not get wrong

  • The optimistic-send architecture stays. clientMessageId reconciliation (never a double bubble), no typed text ever lost on failure, composer remount-keyed per ticketId so drafts/in-flight state never cross threads. §3.3 changes the failure presentation, not these invariants — tests must prove them.
  • is_internal NEVER appears in user-app types or UI. The user-side services/tickets types don't model it and no component renders it; the REQ you file must keep internal notes excluded from lastMessagePreview server-side. Airtight — do not regress.
  • referenceCode prominence stays — inbox card, thread header, creation-success dialog, LTR-forced.
  • Polling stays polite. The 60s auth-gated count poll remains the only global poll; the thread poll is strictly while-mounted; lists refetch on focus/invalidation, never on an interval.
  • Emergency is tel:-only, nurse-post-confirmation-only. No VoIP, no new phone surfaces, no contact directory — the anti-disintermediation rule is product law.
  • Design-contract non-negotiables: every new string in both catalogs (ICU plurals for unread counts); colors from --bal-* tokens / palette keys, never hexes; logical properties only (the bubble-tail borderStartEndRadius pattern is the house style); verify dark mode on every new tint; MUI v9 API only; icon registry, not raw imports; co-located *.test.tsx for every touched/new shared component; clientFetch/cookie rules untouched.

6. Definition of Done

On top of the shared definition-of-done.md:

  • npm run check green; npm run test:ci green including updated messaging/notification component tests; en.json/fa.json in sync.
  • Inbox: status chips filter, paging reaches ticket #21+, cards show preview/unread/relative-time from the mock and degrade gracefully (no empty slots) on the real API.
  • Thread: opens at the newest message; a reply arriving while mounted appears within the poll interval; scrolled-up + new message shows «پیام جدید ↓»; date separators + grouping + hh:mm stamps + centered system events render; the fa timestamp no longer bidi-scrambles.
  • Composer: touch Enter newlines, desktop Enter sends; a failed send leaves a retry-able failed bubble (aria-live announced) and retry never duplicates; the send icon points out of the field in fa.
  • Inboxes show the compact emergency row (no permanent red banner); the nurse booking-detail tel: banner is unchanged.
  • Notification center is day-grouped with relative times and per-kind tints; null-deepLink rows don't ripple; mark-read/mark-all still work. Nurse desktop bell opens the popover; customer mobile bell still navigates.
  • Admin nav no longer links to a placeholder (hidden, or phase 11's feed exists).
  • Visual verification on the four axes — /fa + /en × light + dark — mobile and desktop.
  • REQs filed in the tracker with correct next numbers; no server/ edits.

7. How to test (what a human can verify after this phase)

  1. Flip USE_TICKETS_MOCK = true, open /fa customer → پشتیبانی: inbox shows unread pills, previews, relative times; filter by بسته; page past 20 tickets; the TopBar support entry shows the unread badge.
  2. Flip the mock off (real API): cards show subject + status + Shamsi time — nothing broken or blank; the badge simply doesn't render.
  3. Open a long thread → opens at the newest message of a date-separated, author-grouped conversation with centered system events. Scroll up, have the other side reply → «پیام جدید ↓»; tap → scrolls to newest.
  4. Send a message → bubble appears instantly, hh:mm stamp on confirm. Kill the API and send → failed bubble with «تلاش مجدد»; restore the API, retry → exactly one bubble.
  5. On a touch viewport (devtools emulation), Enter in the composer inserts a newline; on desktop, Enter sends. In /fa, the send arrow points out of the field.
  6. Inbox shows a compact «موارد اضطراری» row that expands to the playbook; the red click-to-call banner appears only on the nurse's confirmed-booking detail.
  7. Notification center (/fa + /en, light + dark): امروز/دیروز groups, «۵ دقیقه پیش» decaying to Shamsi, tinted per-kind icons; a null-deepLink row doesn't ripple; navigable rows show a chevron.
  8. Nurse desktop: bell opens the popover (5 recent + mark-all + «مشاهده همه»); customer mobile: bell navigates. In devtools: only the count endpoint polls, plus the thread endpoint while a thread is open.
  9. Admin shell: no dead "notifications — coming soon" nav entry.

8. Hand off & document (close the phase)

  • Update client/CLAUDE.md "Project Structure" if you added components/hooks folders (thread scroll hook, popover, emergency row) — same change, per the working agreements.
  • Write the report at dev/shared-working-context/reports/ui-phase-10-report.md: what shipped, the REQ numbers filed (with the REQ-028 cross-reference), the foundation files minimally extended (layout badge slots, formatShamsiTime, icon mirror, -soft tokens — per the README ownership rules), the phase-11 handshake state, and the mock-tolerant degradations that light up when REQ-039 lands.
  • Save a memory note per operating-rules §8: messaging/notifications are now chat-grade; the surviving invariants (optimistic-send, is_internal boundary, polite polling, tel:-only emergency); open REQ gates.