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:
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user