// Home — wires to /api/v2/dashboard/home, /dashboard/trend, /tommy/pending-approvals,
// /events, /pipeline-counts. All KPIs, activity, and pipeline bars reflect real data.
function KPICard({ label, value, delta, deltaLabel, sub, sparkData, sparkColor, breakdown }) {
const numeric = typeof delta === 'number';
const up = numeric && delta >= 0;
return (
{label}
{sparkData && sparkData.length > 1 &&
}
{value ?? '—'}
{numeric && (
{up ? '▲' : '▼'} {up ? '+' : ''}{delta}{deltaLabel && deltaLabel.includes('%') ? '' : ''}
)}
{sub && {sub} }
{breakdown && breakdown.length > 0 && (
{breakdown.map(b => (
{b.label.toUpperCase()} {b.n}
))}
)}
);
}
function FollowUpRow({ c, onClick }) {
const d = parseDate(c.scheduled_at);
const short = d ? d.toLocaleDateString(undefined, { weekday:'short', month:'short', day:'numeric' }) : '';
const clock = d ? d.toLocaleTimeString(undefined, { hour:'numeric', minute:'2-digit' }) : '—';
return (
{clock}
{short}
{c.name}
{c.interest || (c.stage_raw || c.stage)} · {c.source || 'direct'}
{c.stage_raw || c.stage}
{c.hot
? hot
: — }
{fmtAgo(c.lastActivity)}
);
}
function TommyApprovalCard({ a, onApprove, onReject, onDismiss, onOpen }) {
const minutes = minutesAgo(a.created_at);
const initials = initialsOf(a.contact_name);
return (
{initials}
{a.contact_name || 'Unknown'}
{a.hold_reason || 'Needs review'} · {fmtAgo(minutes)}
needs approval
{a.inbound_message && (
Inbound
{a.inbound_message}
)}
{a.tommy_draft}
{a.status === 'context_requested' ? 'redrafting…' : 'draft #' + String(a.id).slice(-3)}
Open
Dismiss
Reject
Approve & send
);
}
// Card listing pending appointment_status_checks rows (5-min post-appointment
// "did they show?" nudges that haven't been resolved). Parallel surface to
// the Telegram channel ping — staff who missed the Telegram message or wants
// a persistent view of unresolved appointments uses this card. Resolving
// from here POSTs through the same helper as the Telegram inline buttons
// (see app/post_appointment.py:resolve_status_check), so the audit trail and
// stage transition path are identical across surfaces.
function ShowCheckCard({ checks, onResolve, onOpenLead }) {
if (!checks || checks.length === 0) {
// Hide the card entirely when nothing pending — staff doesn't need to
// see an empty inbox box on a typical day. Telegram is the primary
// surface; this is the catch-net.
return null;
}
// fmtClock from data.jsx renders the scheduled appointment time in gym-local ET.
// Relative "sent ... ago" uses the shared fmtAgoFrom so it rolls into days
// past 24h (1d, 2d, ...) like the rest of the app, instead of unbounded hours.
return (
— Did they show?
{checks.length} pending
{checks.slice(0, 6).map(c => (
c.contact_id && onOpenLead?.(c.contact_id)}>
{c.contact_name || c.contact_id}
{fmtClock(c.scheduled_at)} ET · sent {fmtAgoFrom(c.sent_at)}
onResolve(c.id, 'showed')}>✓
onResolve(c.id, 'noshow')}>✗
onResolve(c.id, 'reschedule')}>↻
onResolve(c.id, 'dismissed')}>—
))}
{checks.length > 6 && (
+{checks.length - 6} more pending — resolve via Telegram or open lead.
)}
);
}
function ActRow({ a }) {
const KIND = {
contact_created: 'new',
day_pass_booked: 'booked',
appointment_cancelled: 'cancel',
appointment_updated: 'upd',
tommy_escalation: 'tommy',
tommy_sent: 'sms',
sms_inbound: 'sms',
};
const label = KIND[a.event_type] || String(a.event_type || 'event').slice(0, 8);
const name = a.contact_name || 'System';
const title = a.title || a.event_type || '';
// Backend writes titles like "{contact_name} booked a Day Pass" — when the
// title already starts with the name, drop the redundant prefix instead of
// rendering "Dominique · Dominique booked a Day Pass".
const titleStartsWithName = name !== 'System' && title.toLowerCase().startsWith(name.toLowerCase());
return (
{label}
{titleStartsWithName
? {name} {title.slice(name.length)}
: {name} · {title} }
{fmtAgoFrom(a.created_at)}
);
}
const PERIOD_OPTS = [
{ key: '1d', label: '1D' },
{ key: 'l7', label: 'L7' },
{ key: 'l14', label: 'L14' },
{ key: 'l30', label: 'L30' },
{ key: 'mtd', label: 'MTD' },
{ key: 'ytd', label: 'YTD' },
];
const PERIOD_SUB = {
'1d': 'today',
'l7': 'last 7 days',
'l14': 'last 14 days',
'l30': 'last 30 days',
'mtd': 'month to date',
'ytd': 'year to date',
};
function fmtMonthLabel(ym) {
const [y, m] = ym.split('-').map(Number);
if (!y || !m) return ym;
const d = new Date(y, m - 1, 1);
return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
function lastNMonths(n) {
const out = [];
const now = new Date();
for (let i = 0; i < n; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
out.push({ ym, label: d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) });
}
return out;
}
function MonthPicker({ active, monthValue, onPick }) {
// The parent `.period-pill-row` uses `overflow: hidden` to clip its
// rounded corners over the active pill's dark background. That same
// overflow clip hides any absolutely-positioned child, so the popover
// is rendered with `position: fixed` and coords computed from the
// button's rect. This also means the popover stays put if the user
// scrolls while it's open — acceptable for a 24-row menu.
const [open, setOpen] = React.useState(false);
const [pos, setPos] = React.useState({ top: 0, left: 0 });
const btnRef = React.useRef(null);
const popRef = React.useRef(null);
const options = React.useMemo(() => lastNMonths(24), []);
const toggle = () => {
if (!open && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
// Right-align the 160px-wide popover to the button's right edge
setPos({ top: r.bottom + 6, left: r.right - 160 });
}
setOpen(v => !v);
};
React.useEffect(() => {
if (!open) return;
function onClick(e) {
if (btnRef.current?.contains(e.target)) return;
if (popRef.current?.contains(e.target)) return;
setOpen(false);
}
function onKey(e) { if (e.key === 'Escape') setOpen(false); }
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const label = active ? fmtMonthLabel(monthValue) : 'Month';
return (
<>
{label}
▾
{open && (
{options.map(o => (
{ onPick(o.ym); setOpen(false); }}
>
{o.label}
))}
)}
>
);
}
function HomeView({ onNavigate, onOpenCmd, onOpenLead, user }) {
const [period, setPeriod] = React.useState('1d');
const [monthValue, setMonthValue] = React.useState(() => {
// Default to the previous full month so the "Month" button picks
// something sensible the first time it's selected.
const d = new Date();
d.setDate(1);
d.setMonth(d.getMonth() - 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
});
const queryString = period === 'month'
? `period=month&month=${encodeURIComponent(monthValue)}`
: `period=${period}`;
const home = useAsync(() => api.get('/api/v2/dashboard/home?' + queryString), [queryString]);
const trend = useAsync(() => api.get('/api/v2/dashboard/trend?' + queryString), [queryString]);
const approvals = useAsync(() => api.get('/api/v2/tommy/pending-approvals'), []);
const events = useAsync(() => api.get('/api/v2/events?limit=10'), []);
const pipeline = useAsync(() => api.get('/api/v2/pipeline-counts'), []);
// Post-appointment "did they show?" checks. Persistent queue parallel to
// the Telegram nudges — staff who missed the Telegram ping or wants an
// audit view sees them here. Endpoint is GET-pending only; the sweeper
// in app/post_appointment.py is what populates the table.
const showChecks = useAsync(() => api.get('/api/v2/appointment-status-checks?status=pending'), []);
// Realtime: when pg_notify fires, reload activity + tommy + show checks
React.useEffect(() => {
function onEv() { events.reload(); approvals.reload(); home.reload(); showChecks.reload(); }
function onLead() { home.reload(); events.reload(); }
window.addEventListener('raw:event', onEv);
window.addEventListener('raw:lead-created', onLead);
return () => {
window.removeEventListener('raw:event', onEv);
window.removeEventListener('raw:lead-created', onLead);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const greeting = home.data?.greeting_name || (user?.name || 'there').split(' ')[0];
const kpis = home.data?.kpis || {};
// Build sparklines from trend buckets for each KPI
const sparkNew = (trend.data?.buckets || []).map(b => b.count || 0);
const followUps = React.useMemo(() => {
// Fetch follow-ups list from /leads?stage=DAY PASS SCHEDULED, sorted by scheduled_at.
return null;
}, []);
const [fupList, setFupList] = React.useState([]);
React.useEffect(() => {
api.get('/api/v2/leads?stage=DAY%20PASS%20SCHEDULED&limit=20')
.then(r => {
const rows = (r?.leads || []).map(adaptLead)
.filter(l => l.scheduled_at)
.sort((a, b) => (parseDate(a.scheduled_at) || 0) - (parseDate(b.scheduled_at) || 0));
cacheContacts(rows);
setFupList(rows);
})
.catch(() => setFupList([]));
}, []);
const approvalList = approvals.data?.approvals || [];
const eventList = events.data?.events || [];
const pipelineCounts = pipeline.data?.counts || {};
const stageOrder = ['NEW LEAD', 'INTERESTED', 'FOLLOW UP', 'DAY PASS SCHEDULED', 'NEEDS_RESCHEDULE', 'SHOWED'];
async function approveOne(a) {
try {
await api.post('/api/v2/tommy/approve', { approval_id: a.id, message: a.tommy_draft });
approvals.reload();
} catch (e) {
if (isQuietHoursError(e) && confirm(quietHoursPrompt(e, 'approve and send'))) {
try {
await api.post('/api/v2/tommy/approve', {
approval_id: a.id, message: a.tommy_draft, override_quiet_hours: true,
});
approvals.reload();
return;
} catch (e2) { alert(friendlyApiError(e2, 'Approve failed')); return; }
}
alert(friendlyApiError(e, 'Approve failed'));
}
}
async function rejectOne(a) {
try {
await api.post('/api/v2/tommy/reject', { approval_id: a.id });
approvals.reload();
} catch (e) { alert(friendlyApiError(e, 'Reject failed')); }
}
async function dismissOne(a) {
try {
await api.post('/api/v2/tommy/dismiss', { approval_id: a.id });
approvals.reload();
} catch (e) { alert(friendlyApiError(e, 'Dismiss failed')); }
}
async function resolveShowCheck(checkId, resolution) {
try {
const r = await api.post(`/api/v2/appointment-status-checks/${checkId}/resolve`,
{ resolution });
showChecks.reload();
// Two server-side failure signals can land on a successful POST.
// Both are silent in the response body unless we surface them —
// staff would otherwise see the row disappear and assume the
// customer-facing side effect happened.
// - ghl_sync_failed_detail: stage was updated locally, GHL tag
// push failed. BOOKED_STAGES preserve keeps local stable.
// - rebook_sms_sent=false on a reschedule: the customer never
// got the FDP rebook link.
const warnings = [];
if (resolution === 'reschedule' && r?.rebook_sms_sent === false) {
warnings.push('Rebook SMS failed — please send the FDP link to the customer manually.');
}
if (r?.ghl_sync_failed_detail) {
warnings.push(`GHL sync failed (${r.ghl_sync_failed_detail}). Verify the contact's tags in GHL.`);
}
if (warnings.length) alert(`Resolved locally.\n\n- ${warnings.join('\n- ')}`);
} catch (e) {
alert(friendlyApiError(e, 'Resolve failed'));
}
}
const now = new Date();
const dateLine = now.toLocaleDateString(undefined, { weekday:'long', month:'short', day:'numeric', year:'numeric' });
const kpiNew = kpis.day_passes?.value != null ? kpis.day_passes : { value: 0 };
const kpiFollowUps = kpis.follow_ups || { value: 0 };
const kpiJoined = kpis.joined || { value: 0 };
const kpiTommy = kpis.tommy_replies || { value: 0 };
return (
— {dateLine}
{now.getHours() < 12 ? 'Morning' : now.getHours() < 18 ? 'Afternoon' : 'Evening'}, {greeting}.
{home.data ? (
<>
{(kpiNew.breakdown || []).find(b => b.label === 'today')?.n ?? 0} day passes today · {approvalList.length} Tommy drafts waiting on you · {eventList.filter(e => !e.is_read).length} unread events
>
) : home.loading ? 'Loading…' : home.error?.message}
{PERIOD_OPTS.map(p => (
setPeriod(p.key)}
className="btn ghost sm"
style={{
borderRadius: 0,
background: period === p.key ? 'var(--ink)' : 'transparent',
color: period === p.key ? 'var(--bone)' : 'var(--ink)',
}}
>{p.label}
))}
{ setMonthValue(v); setPeriod('month'); }}
/>
⌘
K
Quick jump
onOpenLead?.()}>+ New lead
— Follow-ups · scheduled
{fupList.length} scheduled
onNavigate({view:'leads', filter:'booked'})}>See all →
{fupList.length === 0 &&
No scheduled follow-ups in range.
}
{fupList.slice(0, 8).map(c => (
onNavigate({view:'lead', id:c.id})} />
))}
— Recent activity
live · pg_notify
{eventList.length === 0 && !events.loading &&
No events yet.
}
{eventList.map(a =>
)}
— Tommy · waiting on you
{approvalList.length > 0 &&
{approvalList.length} pending }
{approvalList.length === 0 &&
All caught up. Tommy is auto-sending.
}
{approvalList.slice(0, 4).map(a => (
approveOne(a)}
onReject={() => rejectOne(a)}
onDismiss={() => dismissOne(a)}
onOpen={() => onNavigate({view:'lead', id:a.contact_id})}
/>
))}
{approvalList.length > 4 && (
onNavigate({view:'tommy'})}>Open Tommy inbox →
)}
onNavigate({view:'lead', id})}
/>
— Pipeline
{pipeline.data?.total || 0} contacts · {pipeline.data?.hot_leads || 0} hot
{stageOrder.map(stageRaw => {
const count = pipelineCounts[stageRaw] || 0;
const group = stageGroupOf(stageRaw);
const maxCount = Math.max(1, ...Object.values(pipelineCounts));
const pct = Math.max(4, (count / maxCount) * 100);
return (
{stageRaw.toLowerCase()}
{count}
);
})}
members · {pipeline.data?.member_count || 0}
cold · {pipeline.data?.cold_leads || 0}
);
}
Object.assign(window, { HomeView });