The contact object: what the build already knows about every lead and customer, and why no screen shows it
The Console is the phone-shaped app that operates this build, and two of its screens show people: Leads, which lists 87,048 clinics nobody has contacted, and Loop, which lists the customers of Loop Bio Labs, the supplement business the
The Console is the phone-shaped app that operates this build, and two of its screens show people: Leads, which lists 87,048 clinics nobody has contacted, and Loop, which lists the customers of Loop Bio Labs, the supplement business the build runs. Triple Whale is the ad-attribution service that says which channel an order came from; BigCommerce is the store those orders are placed in; Klaviyo is the email and SMS system. Both screens are not under-designed. They are under-connected. Every number you asked for is already in the database, already refreshed on a schedule, and already reachable through routes this build wrote itself. The two screens that show people — Leads and Loop — read about eight columns between them and throw the rest away.
Measured today against the live database, not inferred.
The Leads screen is showing you the worst 86% of the list on purpose
There are 87,048 leads. The Leads tab is hard-filtered to segment IN ('chiropractor','chiropractic clinic'), so it shows 74,800 of them and hides 12,248.
| rows shown | score ≥ 50 | has an email | has a written reason | |
|---|---|---|---|---|
| What the Leads tab shows | 74,800 | 40 | 3,593 | 3,524 |
| What it hides | 12,248 | 1,455 | 3,626 | 3,522 |
Forty good leads in the list you look at. Fourteen hundred and fifty-five in the list you cannot open. Half of every enriched lead in the build is behind that filter — medical spas, hormone clinics, regenerative medicine, longevity, testosterone. Those segments average a score of 27 to 33. Chiropractors average 3.
That alone explains "it looks like spam." You are looking at 67,428 rows marked new, most with no email, most scoring 2.
The enrichment you are asking for is already written down. Nothing renders it.
Every lead row has a context column — populated on 87,021 of 87,048 rows — holding the site title and meta description. And the enriched ones have a notes column in a fixed grammar:
emails:contact@… mx:ok icp:100 buyer:medspa_owner vol:high
why:Explicitly lists 'peptide therapy' alongside Semaglutide and Botox. Perfect fit.That is your WHY THIS LEAD block. Already computed. Already stored. The Leads row renders name, city, email, stage, score. The detail sheet adds last touch, open/click/reply, and then hands you a compose box. why, icp, buyer, vol, mx, segment, address, website, and the site description are never drawn anywhere on any screen.
The score is shown as a bare integer with nothing behind it. 85 next to a clinic name reads as noise. 85 — men's health clinic, peptides on the menu, MX valid, practitioner-owned reads as a reason.
On the customer side, three separate things are wrong
One. The customer sheet iterates raw row keys and prints them. That is the Object.keys(row).forEach in the Loop tab. It is why the thing feels like a database viewer: because it is one.
Two. The first-order and last-order dates on screen are false. They are read from persons.first_seen_at / last_seen_at, which are ingest timestamps, not order dates. 14,823 of 15,123 person records carry 2026-08-07 — the day the import ran. 2,213 of 2,238 buying customers display a wrong first-order date. One top customer shows "first order 2026-08-07, last order today" on a row whose own days_since_last_order says 9. Their real first order was 2025-09-16.
Three. The same person has two different lifetime values depending on which route you ask. The console reads customer_scores and says 69 orders / $54,404. The build's own LOOP_CUSTOMER capability says 65 orders / $48,814 — the difference is nine cancelled orders that one expression excludes and the other does not. Both numbers are live right now. That has to become one number before it goes on a card.
What the build already holds per person, and shows none of
| Held | Coverage | Source | On screen today |
|---|---|---|---|
| LTV, lifetime orders, AOV, revenue/month, days since last order, value tier | 13,309 people | BigCommerce → customer_scores | partial, and one figure disputed |
| Intent score, relationship score, retargeting score, recommended channel, recommended next message, suppress-ads flag | 13,309 people | ours | nothing |
| Own reorder cadence, opens, clicks, SMS clicks, checkouts, carts, product views, unsubscribes, spam, email/SMS consent | 13,309 people | Klaviyo → score_reason_json | nothing |
| First-click source, last-click source, linear source, touch count, campaign / adset / ad id, click time — per order | 7,827 of 11,043 orders, 1,958 of 2,238 buyers | Triple Whale → order_attribution | nothing |
| Raw event stream | 1,444,346 events over 13,299 people | Klaviyo | nothing |
| Subscription plan, tier, status, interval, next charge, cancel date | 173 subs over 149 people | Stripe live | nothing |
| Line items, SKU, category, quantity, unit price | every order | BigCommerce | items list only |
| Charges, fees, net, card brand, refunds, disputes | from 2026-05-11 | Stripe | nothing |
For scale, here is the channel mix sitting unused in order_attribution, last click:
| Channel | Orders | Revenue |
|---|---|---|
| Klaviyo | 3,695 | $1,715,846 |
| Non-attributed | 1,899 | $818,956 |
| Direct | 1,037 | $543,053 |
| Organic and social | 527 | $314,363 |
| Facebook ads | 399 | $207,701 |
That table is the Triple Whale import you said you cannot see. It is there. It has never been drawn on a person.
Where I agree with the shape you described, and the one place I would change it
The contact-card framing is right, and one component rendering both a cold lead and a repeat buyer is right — the fill level differs, the object does not.
Two changes.
The top block is the next action, not the avatar. You already compute a recommended channel and a recommended next message for 13,309 people. Putting "slipping — reorder reminder now" under the name turns a profile into something you act on. An avatar and a phone number is a Rolodex.
The journey chain must be able to say UNKNOWN. 1,899 orders are literally labelled Non-attributed and attribution only starts 2025-11-22. A vertical "discovered via Meta → visited → purchased" chain drawn from that data would be fiction for a quarter of your revenue. The chain renders what is known and prints Not attributed where it is not. Same rule for the 3,216 orders with no attribution row at all.
On provenance: agreed, and it should be a tap, not a label. Every source chip opens the row it came from — this build already writes a receipt for every tenant read, so the chip has somewhere real to land.
What to build, in order
First, correctness. A card that shows a wrong date is worse than a table that shows none.
functions/_lib/tenant.js→listCustomers: takefirst_orderandlast_orderfrommin(order_date)/max(order_date)on the orders table, never frompersons.first_seen_at.- Settle one LTV expression — cancelled excluded — and make
customer_scores,LOOP_CUSTOMERand the console all read it. One number, one definition, quoted the same everywhere. - Unescape HTML entities when rendering
context. It is stored asMen's Healthand will render that way the moment you put it on screen.
Second, one endpoint that composes the person.
functions/api/console/person/[[path]].js — GET ?lead=<id> or ?person=<person_id|email>, answering one shape whichever you pass:
identity name, company, role pills, location, email, phone, channels
value ltv, orders, aov, revenue_per_month, first_order, last_order, refunds, subscription
acquisition first_click, last_click, campaign, touch_count, new_vs_returning, discovered_from
engagement last_message, opens, clicks, sms_clicks, carts, checkouts, views, consent
relationship stage, next_action, recommended_channel, why, icp, buyer_type, volume, mx
timeline merged orders + klaviyo events + outreach, newest first
orders line items
raw everything, behind one tap
provenance { field: bigcommerce | triplewhale | klaviyo | stripe | google-places | ours }Every figure carries its source in provenance. That is the only way the icons can be honest rather than decorative.
Third, one renderer.
public/console/profile.js — header, the four blocks above, orders, timeline, and More → Raw object last. Both tabs call it. Nothing else renders a person.
Fourth, wire the two tabs.
public/console/loop.js:openRowfor a customer calls the profile. Delete the raw key dump.public/console/leads.js: tapping a lead opens the profile. The compose box moves to an Outreach section inside it, below the reason to contact them. Stage chips stay as filters. The chiropractor filter becomes a segment chip instead of a hard gate, so the 1,455 good leads become reachable.
Fifth, the icons.
public/console-logos.js already holds monochrome marks and already carries Meta, Apple and Cloudflare. Add Triple Whale, BigCommerce, Klaviyo, Stripe, Gorgias and Google Places in the same shape, and bind each to its provenance key.
The one-line version
Nothing here needs a new data source, a new import, or a new integration. Every number you asked for exists, is current, and is already paid for. Four files stand between it and the screen.