// Growth — new-member sign-ups, month over month. // // Wired to /api/v2/analytics/signups-mom. Source is contacts.became_member_at, // stamped on the is_member false->true transition during GHL sync (membership // is tag-derived in GHL). It is FORWARD-LOOKING: GHL has no historical signup // date, so months before tracking began read 0. We surface `tracking_since` // and say so explicitly rather than implying a real zero. function monthLabel(ym) { if (!ym) return ''; const d = new Date(ym + '-01T00:00:00'); return isNaN(d) ? ym : d.toLocaleString('en-US', { month: 'short', year: 'numeric' }); } // Vertical MoM bar chart. Last bar is accented; zero/empty months render as a // faint stub so the time axis stays honest about gaps. function GrowthBars({ buckets, height = 280 }) { if (!buckets || buckets.length === 0) { return
no data yet
; } const width = 820, padB = 30, padT = 26; const plotH = height - padB - padT; const n = buckets.length; const max = Math.max(1, ...buckets.map(b => b.count || 0)); const slot = width / n; const barW = Math.min(46, slot * 0.62); return ( {buckets.map((b, i) => { const c = b.count || 0; const h = (c / max) * plotH; const x = i * slot + (slot - barW) / 2; const y = height - padB - h; const isLast = i === n - 1; return ( 0 ? y : height - padB - 2} width={barW} height={c > 0 ? h : 2} rx="3" fill={isLast ? 'var(--signal)' : 'var(--ink)'} opacity={c > 0 ? 1 : 0.15} /> {c > 0 && ( {c} )} {b.label} ); })} ); } function GrowthView() { const [months, setMonths] = React.useState(12); const res = useAsync(() => api.get('/api/v2/analytics/signups-mom?source=gymmaster&months=' + months), [months]); const d = res.data || {}; const buckets = d.buckets || []; const latest = d.latest; const tracked = d.total_tracked ?? 0; const deltaPct = latest?.delta_pct; const deltaAbs = latest?.delta_abs; const deltaColor = deltaPct == null ? 'var(--muted-2)' : deltaPct >= 0 ? 'var(--signal)' : 'var(--muted)'; return (
— New members · month over month

Growth

New member sign-ups per month, so you can see change and growth.
{/* Inline-style the active state (not .btn.primary — that class is reserved for primary CTAs and goes full-width on mobile). */} {[6, 12, 24].map(m => ( ))}
{res.error &&
Sign-ups data unavailable.
}
This month · new members
{latest ? latest.count : '—'}
{latest && deltaPct != null ? ( {deltaPct >= 0 ? '+' : ''}{deltaPct}% vs last month ({deltaAbs >= 0 ? '+' : ''}{deltaAbs}) ) : ( no prior month to compare )}
Last {months} months
{d.window_total ?? '—'}
sign-ups in window
Tracked all-time
{tracked}
since tracking began
Tracking since
{d.tracking_since ? monthLabel(d.tracking_since) : '—'}
first recorded sign-up
Sign-ups · new members per month
last {months} months · America/New_York
{/* Honesty banner: this metric only exists going forward. Shown loudly while there's little/no history so nobody reads an empty chart as "zero new members". */} {!res.loading && tracked === 0 && (
No data yet. New member sign-ups come from the GymMaster roster (Revive Gym) — members who joined in a month with a real membership. If this is empty, the GymMaster sync hasn't completed yet; it refreshes a few times a day. The chart fills in once the sync runs.
)} {buckets.length > 0 && (
By month
MonthNew membersvs prev
{buckets.map((b, i) => { const prev = i > 0 ? buckets[i - 1].count : null; const da = prev == null ? null : b.count - prev; const dp = prev && prev > 0 ? Math.round((da / prev) * 100) : null; const col = da == null || da === 0 ? 'var(--muted-2)' : da > 0 ? 'var(--signal)' : 'var(--muted)'; return (
{monthLabel(b.ym)} {b.count} {da == null ? '—' : dp == null ? `${da >= 0 ? '+' : ''}${da}` : `${dp >= 0 ? '+' : ''}${dp}%`}
); })}
)}
); } Object.assign(window, { GrowthView });