zurich_kotak_pwa/src/api/thread.js
Yashas 6455579cc2 The Lead Desk, written for a phone
A separate app from the desktop console, talking to the same platform with
the same client. Not a breakpoint on the old one: the two answer different
questions. The console answers "what is the state of the book?" across a
wide grid; a phone answers "what needs me, and what happened to this one?"
in a column you scroll with a thumb.

The old console at 390px showed why. Tables scrolled sideways, nine queues
stacked above the content ate the first screenful, and the action you came
to perform sat below the fold.

So every row of every table is a CARD — customer, stage, when it is due,
who is holding it, and one line about what is happening. Everything else is
one tap away. The queues live in a drawer. The action a lead is waiting on
is pinned to the bottom of the screen, where a thumb already is.

Shared with the console, because a divergence would be two apps disagreeing
about the same lead: the whole api/ layer, ActivityForm, and Timeline —
whose audit-row merging (one submission writes three rows) is hard-won and
must not be reimplemented twice.

Written fresh: the shell, the three screens, and the stylesheet.

The colour rule is unchanged and is the product in four colours: blue the
AI holds it, amber a person, teal the customer, red risk and nothing else.

A PWA, so Add to Home Screen gives a full-screen app; the layout pads for
the notch and the home indicator.

Verified at 402x874: login, overview, drawer, a queue, and a lead, with no
horizontal overflow on any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:50:48 +05:30

217 lines
8.5 KiB
JavaScript

import { AGENTS } from './agents.js'
import { baseFieldId } from './config.js'
/**
* THE WHATSAPP THREAD, BUILT ONCE.
*
* The conversation was being reconstructed twice — the timeline read it per
* audit row, the dialog read it per field — and the two disagreed. The trail
* printed one entry per turn, so a four-message exchange took more of the page
* than the entire underwriting chain, and a message the workflow recorded from
* inside a trigger (the payment request, the issuance note) appeared in the
* dialog and nowhere in the trail. One builder, two readers, one answer.
*
* WHY A THREAD IS NOT A LIST OF ROWS. The audit trail records activities; the
* conversation is a thing that persists ACROSS them. Three facts make it awkward
* and they are all handled here rather than in each screen's own guess:
*
* 1. The customer's turns and ours are separate activities — `customer-reply`
* is performed by the channel, `answer-customer` by Engage — so a turn is
* a FIELD on a row, not a row.
* 2. Two of our sends are not chat activities at all. Request Premium and the
* issuance step compose their message inside a trigger and write it to
* `customer_answer` alongside their own work (see 98/99/100 in the seed
* SQL). They belong in the thread and stay on their own step in the trail.
* 3. One submission is recorded up to three times — the trigger commit, its
* repeat, and the settle — so the same sentence arrives three times.
*
* `episodes` is what makes this renderable as a timeline entry. The thread is
* one conversation, but it happens in bursts: the customer argues about the
* quote, then KYC and underwriting run for twenty minutes, then the premium
* request reopens the same thread. An episode is one burst. Collapsing the
* whole thread into a single entry would date the payment exchange to the
* moment of the quote objection and destroy the chronology the trail exists
* for; leaving every turn as its own entry is what it did before. A burst is
* the unit that is both honest and short.
*/
/** The two activities that ARE the thread — one turn each, either direction. */
export const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
const INBOUND = 'customer_reply'
const OUTBOUND = 'customer_answer'
/**
* The fields the thread OWNS. A step that writes one is not asked to print it
* as well — the message belongs to the conversation, and quoting it on the step
* too is how the payment request came to say the same sentence twice.
*/
export const THREAD_FIELDS = new Set([INBOUND, OUTBOUND])
/** Bookkeeping rows. They interrupt nothing: the platform writing a field is
* not a step that broke off a conversation. */
const SYSTEM_ACT = 'DATA_UPDATE'
/**
* Last resort for a step whose activity_name did not come back — without it the
* trail prints a raw slug like "zk-act-doc-reminder" in the middle of an
* otherwise readable story. A guess, but one that reads as English.
*/
export function prettyUid(uid) {
if (!uid) return 'Step'
return String(uid)
.replace(/^zk-act-/, '')
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
/** What to call a step. The server's own name first; the slug, tidied, after. */
export function stepName(row) {
return row?.activity_name || prettyUid(row?.activity_id)
}
/**
* `fields[]` is the platform's typed rendering of a submission; `data` is the
* raw fallback for a row the workflow could not resolve.
*/
function fieldsOf(row) {
return Array.isArray(row.fields) && row.fields.length
? row.fields
: Object.entries(row.data || {}).map(([k, v]) => ({ field_id: k, value: v }))
}
/** Who sent an outbound turn. Usually Engage; sometimes a person answering by
* hand, which must not be attributed to the machine. */
function senderOf(row) {
const key = (row.user_roles || []).find((r) => AGENTS[r]) || null
return { agentKey: key, by: key ? AGENTS[key].short : (row.user_name || 'Zurich Kotak') }
}
/** The turns carried by one audit row, in field order. */
function turnsInRow(row) {
const out = []
for (const f of fieldsOf(row)) {
const base = baseFieldId(f.field_id)
if (!THREAD_FIELDS.has(base)) continue
const text = typeof f.value === 'string' ? f.value.trim() : ''
if (!text) continue
out.push({ side: base === INBOUND ? 'them' : 'us', text })
}
return out
}
/**
* The thread, its bursts, and which audit row carries what.
*
* Returns:
* turns every message, oldest first
* episodes contiguous bursts, each with the steps that preceded it
* byRow row id -> { episodeId, sent, received }, so a step that sent a
* message can link into the thread instead of quoting it
*/
export function buildThread(rows) {
const empty = { turns: [], episodes: [], byRow: new Map() }
if (!Array.isArray(rows) || !rows.length) return empty
// Sorted on (timestamp, id). One submission writes three rows in the same
// second, so a timestamp alone leaves their order to the sort's stability and
// puts a reply before the message it answers whenever the two land together.
const ordered = [...rows].sort((a, b) => {
const t = String(a.created_at).localeCompare(String(b.created_at))
return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0)
})
const turns = []
const episodes = []
const byRow = new Map()
let current = null
// The steps seen since the last message, and whether any of them broke off
// the conversation. `pending` accumulates even before the first message so
// that the first episode can drop it: there is nothing to bridge yet.
let pending = []
let broke = false
for (const row of ordered) {
const mine = turnsInRow(row)
if (!mine.length) {
// A chat activity that recorded no text is not a step that interrupted
// anything — it is an empty row. Bookkeeping is not a step either.
if (row.activity_id === SYSTEM_ACT || CHAT_ACTS.has(row.activity_id)) continue
if (current) broke = true
const name = stepName(row)
if (name && !pending.includes(name)) pending.push(name)
continue
}
const who = senderOf(row)
for (const t of mine) {
const prev = turns[turns.length - 1]
// De-duped against the PREVIOUS turn only. A customer who asks the same
// thing twice because the first went unanswered has said it twice, and a
// thread that showed it once would hide exactly the impatience an
// operator needs to see. Only an immediate repeat is the platform
// recording one submission three times.
if (prev && prev.side === t.side && prev.text === t.text) continue
if (!current || broke) {
current = {
id: `ep${episodes.length + 1}`,
turns: [],
// What ran between this burst and the one before it. Empty on the
// first, which begins the conversation rather than resuming it.
after: episodes.length ? pending : [],
anchorRowId: row.id,
anchorIsChat: CHAT_ACTS.has(row.activity_id),
}
episodes.push(current)
broke = false
}
pending = []
const turn = {
key: `${row.id}-${t.side}-${turns.length}`,
side: t.side,
text: t.text,
at: row.created_at,
rowId: row.id,
episodeId: current.id,
agentKey: t.side === 'us' ? who.agentKey : null,
by: t.side === 'us' ? who.by : 'the customer',
}
current.turns.push(turn)
turns.push(turn)
const tally = byRow.get(row.id) || { episodeId: current.id, sent: 0, received: 0 }
if (t.side === 'us') tally.sent += 1
else tally.received += 1
byRow.set(row.id, tally)
}
}
for (const ep of episodes) {
ep.count = ep.turns.length
ep.from = ep.turns[0].at
ep.to = ep.turns[ep.turns.length - 1].at
// The last exchange, which is where the conversation GOT to — what an
// operator scanning the trail is actually looking for.
ep.preview = ep.turns.slice(-2)
}
return { turns, episodes, byRow }
}
/**
* "Verify KYC and Underwriting Screen", "A, B +2 more" — a bridge, not a list.
* Used for the steps that interrupted a conversation and for the voices in it.
*/
export function listOf(names, max = 2) {
if (!names || !names.length) return ''
if (names.length <= max) {
return names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
}
return `${names.slice(0, max).join(', ')} +${names.length - max} more`
}