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>
217 lines
8.5 KiB
JavaScript
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`
|
|
}
|