Phase 4 Stage 6: chat-room countdown UX + voice-call mode pill

Customer chat screen:
- Voice-call header pill (mode == 'call' renders accent-colored pill;
  chat mode renders no pill).
- HaloSnackbar fires once per session at 180s remaining ('sisa 3 menit
  lagi ya 🤍'), driven by the backend session_warning WS event.
- Last-2-min danger styling: timer pill flips to HaloTokens.danger +
  bold JetBrainsMono when remaining <= 120s.
- Floating ChatExpiredBanner widget injected above the input bar when
  remaining hits 0 in closing-grace state. perpanjang -> existing
  pricing bottom sheet.
- pricing_bottom_sheet.dart rewritten to the 5-option layout with
  chat|call mode toggle (mirrors duration-pick from Stage 3).

Mitra chat screen: voice-call header pill only (no countdown UX per PRD).

Backend:
- session.service.js getSessionById JOINs payment_sessions so mode +
  expires_at ship in /api/shared/chat/:id/info.
- session-timer.service.js onThreeMinuteWarning now emits expires_at +
  remaining_seconds for client resync.
- Dev-only POST /internal/_test/force-session-expires-at clears the
  3-min flag, reschedules the timer, and broadcasts WS resync. Lets
  the Maestro flow drive 175s -> 90s -> 0s without waiting live.

New chatRemainingSeconds StreamProvider derived from expiresAt, fed by
session_warning / session_timer / session_expired resync messages
(plan referenced a secondsLeftProvider that didn't actually exist).

Maestro 06_chat_countdown.yaml + force_session_expires_at.js helper.

Out of scope: meet.google.com URL launching - url_launcher isn't a
client_app dependency and message bubbles render plain Text. Defer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 17:25:11 +08:00
parent f170d54535
commit 14b5cc966b
14 changed files with 902 additions and 75 deletions

View File

@@ -7,6 +7,7 @@
import { peekStubOtp } from '../../services/otp.service.js'
import { expirePairingRequest } from '../../services/pairing.service.js'
import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js'
import { getDb } from '../../db/client.js'
import { PairingFailureCause, SessionStatus } from '../../constants.js'
@@ -108,4 +109,49 @@ export const internalTestRoutes = async (fastify) => {
await expirePairingRequest(target, PairingFailureCause.NO_MITRA_AVAILABLE)
return { ok: true, session_id: target }
})
// Force-set the expires_at of an active chat_session to drive Phase 4
// Stage 6 countdown UX (3-min snackbar, last-2-min danger, expired banner)
// without waiting in real time. Reschedules the in-memory session timer so
// `session_warning` / `session_timer` / `session_expired` WS events fire on
// the new schedule.
//
// Body shape:
// { seconds_from_now: 175 } → expire latest active session in N seconds
// { session_id: '<uuid>', seconds_from_now } → expire specific session
fastify.post('/force-session-expires-at', async (request, reply) => {
const { session_id, seconds_from_now } = request.body ?? {}
if (typeof seconds_from_now !== 'number') {
return reply.code(400).send({ error: 'seconds_from_now (number) required' })
}
let target = session_id
if (!target) {
const [row] = await sql`
SELECT id FROM chat_sessions
WHERE status = ${SessionStatus.ACTIVE}
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_active_session' })
}
target = row.id
}
const [updated] = await sql`
UPDATE chat_sessions
SET expires_at = NOW() + (${seconds_from_now} || ' seconds')::interval
WHERE id = ${target} AND status = ${SessionStatus.ACTIVE}
RETURNING id, expires_at
`
if (!updated) {
return reply.code(404).send({ error: 'no_active_session_for_id', session_id: target })
}
// Allow the 3-min warning to fire again on the new schedule.
_resetThreeMinFiredForTest(updated.id)
startSessionTimer(updated.id, updated.expires_at)
// Push an immediate WS resync so the customer UI's local ticker tracks
// the new schedule without waiting for the next scheduled event.
_broadcastTimerResyncForTest(updated.id, updated.expires_at)
return { ok: true, session_id: updated.id, expires_at: updated.expires_at }
})
}

View File

@@ -12,6 +12,33 @@ const sql = getDb()
// (e.g. extension reschedule). This matches the Phase 4 spec: "fire once".
const sessionTimers = new Map()
/**
* Dev/test-only — clear the per-session "3-min warning already fired" flag so
* the warning can fire again after `force-session-expires-at` reschedules a
* session backwards. Production code never needs this.
*/
export const _resetThreeMinFiredForTest = (sessionId) => {
const timers = sessionTimers.get(sessionId)
if (timers) timers.threeMinFired = false
}
/**
* Dev/test-only — push an immediate WS resync of the timer state so a Maestro
* flow can drive the customer UI through the danger pill / expired banner
* states without waiting for the next scheduled tick. Production code drives
* UX off the scheduled `session_timer` / `session_warning` / `session_expired`
* events instead.
*/
export const _broadcastTimerResyncForTest = (sessionId, expiresAt) => {
const remaining = Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000))
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_TIMER,
remaining_seconds: remaining,
expires_at: expiresAt,
session_id: sessionId,
})
}
export const startSessionTimer = (sessionId, expiresAt) => {
const now = Date.now()
const expiresMs = new Date(expiresAt).getTime()
@@ -89,15 +116,23 @@ const onSessionWarning = (sessionId) => {
/**
* Phase 4 — 3-min warning. Customer-only event (mitra has no countdown UI).
* Idempotent per session via the `threeMinFired` flag captured by startSessionTimer.
*
* Includes `remaining_seconds` and `expires_at` so the client can resync its
* local ticker against the server's view of when the session ends. The
* customer-side ticker drives the last-2-min danger pill + expired banner,
* neither of which the server emits a discrete event for.
*/
const onThreeMinuteWarning = (sessionId) => {
const onThreeMinuteWarning = async (sessionId) => {
const timers = sessionTimers.get(sessionId)
if (timers?.threeMinFired) return // belt-and-braces — should not happen
if (timers) timers.threeMinFired = true
const [row] = await sql`SELECT expires_at FROM chat_sessions WHERE id = ${sessionId}`
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_WARNING,
kind: 'three_minutes_left',
session_id: sessionId,
remaining_seconds: 180,
expires_at: row?.expires_at ?? null,
})
}

View File

@@ -149,15 +149,20 @@ export const listSessions = async ({ page = 1, limit = 20, status, topic_sensiti
}
export const getSessionById = async (sessionId) => {
// `mode` lives on payment_sessions (chat | call), introduced in Phase 4.1.
// The chat header pill needs it, so surface it on every session.info read.
// Falls back to 'chat' for pre-3.7 rows where payment_session_id is null.
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics,
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes,
COALESCE(ps.mode, 'chat') AS mode,
c.display_name AS customer_display_name,
m.display_name AS mitra_display_name
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
LEFT JOIN mitras m ON m.id = cs.mitra_id
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
WHERE cs.id = ${sessionId}
`
return session