// Renewals — members whose membership ends this month or next (issue #81), // worked as a shared team worklist (issue #90). // // Wired to /api/v2/analytics/expiring-members?months=2, which reads the // gymmaster_members mirror: membership_end = latest Gatekeeper enddate across // the member's real (non-hidden/casual) memberships. Month-to-month members // have no end date — they only appear once a cancellation is scheduled. When // a member renews, the end date moves out of the window and they drop off on // the next sync, so the list self-cleans; nobody has to mark anything done. // // The "contacted" mark (renewal_outreach) is shared across the whole team and // scoped to the member's CURRENT end date — a renewed member who reappears in // a later window comes back un-contacted. Toggling is optimistic; a failed // POST reverts the row and toasts. function rnMonthLabel(ym) { if (!ym) return ''; const d = new Date(ym + '-01T00:00:00'); return isNaN(d) ? ym : d.toLocaleString('en-US', { month: 'long', year: 'numeric' }); } function rnTodayET() { // Anchor "today" to the gym's timezone so the day-count agrees with the // server-computed `expired` flag (both ET) for viewers in other timezones. // en-CA formats as YYYY-MM-DD. const ymd = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(new Date()); return new Date(ymd + 'T00:00:00'); } function rnEndPhrase(endDate, expired) { const d = new Date(endDate + 'T00:00:00'); if (isNaN(d)) return { txt: endDate, days: null }; const days = Math.round((d - rnTodayET()) / 86400000); const nice = d.toLocaleString('en-US', { month: 'short', day: 'numeric' }); if (expired || days < 0) return { txt: `ended ${nice} · ${Math.abs(days)}d ago`, days }; if (days === 0) return { txt: `ends today (${nice})`, days }; return { txt: `ends ${nice} · in ${days}d`, days }; } // Compact Home-dashboard surface for the same endpoint — mirrors the tab's // urgency language (same rnEndPhrase, same 7-day warn threshold) so Home and // the tab never disagree. Un-contacted members surface first: the card is a // "who still needs a call" prompt, not a report. Hidden entirely while // loading, on error, or when nothing ends in the window — same convention as // the "Did they show?" card: staff doesn't need an empty box on a typical // day; the tab stays reachable from the sidebar and G R. function RenewalsHomeCard({ onNavigate }) { const res = useAsync(() => api.get('/api/v2/analytics/expiring-members?months=2'), []); const d = res.data || {}; const members = d.members || []; if (res.loading || res.error || members.length === 0) return null; const [m0, m1] = d.window || []; const byMonth = d.by_month || {}; const ordered = members.filter(m => !m.contacted_at).concat(members.filter(m => m.contacted_at)); const SHOW = 5; return (
Renewals · ending soon
{byMonth[m0] ?? 0} this month · {byMonth[m1] ?? 0} next
{ordered.slice(0, SHOW).map(m => { const end = rnEndPhrase(m.end_date, m.expired); const urgent = m.expired || (end.days != null && end.days <= 7); return (
m.contact_id ? onNavigate({ view: 'lead', id: m.contact_id }) : onNavigate({ view: 'renewals' })}>
{m.contacted_at && } {m.name || ('Member #' + m.member_id)}
{m.membership_type || 'membership'}
{end.txt}
); })} {members.length > SHOW && (
+{members.length - SHOW} more ending in this window.
)}
); } // Round toggle for the shared "contacted" mark. Green check when set; the // tooltip carries the attribution so the state is explorable without extra // chrome. function RnContactedToggle({ c, onToggle }) { return ( ); } function RenewalRow({ m, c, fu, onToggle, onFollowUp, onNavigate }) { const end = rnEndPhrase(m.end_date, m.expired); const urgent = m.expired || (end.days != null && end.days <= 7); return (
onToggle(m)} />
{m.name || ('Member #' + m.member_id)} {m.expired && ( EXPIRED )} {m.contact_stage && ( {String(m.contact_stage).toLowerCase()} )}
{m.membership_type || 'membership'} · {end.txt} {c && ( {' '}· ✓ {c.contacted_by || 'staff'} · {fmtAgoFrom(c.contacted_at)} )}
{m.phone ? {m.phone} : no phone} {m.email && (
{m.email}
)}
{m.contact_id ? (
{fu ? ( ✓ in Tommy ) : ( )}
) : ( not in CRM )}
); } // Small temporal histogram: how many memberships end on each day of the // window. One thin bar per day, anchored to the baseline; "today" splits // already-ended (win-back, --warn) from upcoming (--ink), mapping straight to // the "Already ended" vs "Expiring" KPIs above. Single measure (a daily // count), so no categorical palette — the two colors are a temporal state // (past/future), paired with the today divider and a legend so meaning never // rides on color alone. Hover a day for its exact count; the peak day is // labeled directly so the biggest cluster reads without interaction. function RnDayLabel(key) { const d = new Date(key + 'T00:00:00'); return isNaN(d) ? key : d.toLocaleString('en-US', { month: 'short', day: 'numeric' }); } function RenewalsTimeline({ members, window_ }) { const [hover, setHover] = React.useState(null); if (!members || members.length === 0 || !window_ || window_.length === 0) return null; // Day axis: first day of the first window month → last day of the last one. const [ey, em] = window_[window_.length - 1].split('-').map(Number); const start = new Date(window_[0] + '-01T00:00:00Z'); const end = new Date(Date.UTC(ey, em, 0)); // day 0 of month em (1-based) = last day of that month const counts = {}; members.forEach(m => { counts[m.end_date] = (counts[m.end_date] || 0) + 1; }); const days = []; for (let t = new Date(start); t <= end; t.setUTCDate(t.getUTCDate() + 1)) { const key = t.toISOString().slice(0, 10); days.push({ key, count: counts[key] || 0, dom: t.getUTCDate(), month: t.getUTCMonth() }); } const max = Math.max(1, ...days.map(d => d.count)); const todayKey = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(new Date()); const anyEnded = days.some(d => d.count > 0 && d.key < todayKey); const W = 1000, H = 52, PAD_T = 16, PAD_B = 16; const totalH = H + PAD_T + PAD_B; const baseY = PAD_T + H; const dayW = W / days.length; const barW = Math.max(1.5, dayW - 2); const todayIdx = days.findIndex(d => d.key >= todayKey); const todayX = todayIdx >= 0 ? todayIdx * dayW : null; // Month boundary labels: first day of each distinct month in the window. const monthMarks = []; days.forEach((d, i) => { if (d.dom === 1 || i === 0) { monthMarks.push({ x: i * dayW, label: new Date(d.key + 'T00:00:00').toLocaleString('en-US', { month: 'short' }) }); } }); const hd = hover != null ? days[hover] : null; return (
Expiring by day
{hd ? {RnDayLabel(hd.key)} · {hd.count} {hd.count === 1 ? 'membership' : 'memberships'} : {members.length} across {monthMarks.map(m => m.label).join('–')} · peak {max}/day}
{/* baseline */} {days.map((d, i) => { const h = d.count > 0 ? Math.max(3, (d.count / max) * H) : 0; const ended = d.key < todayKey; const dim = hover != null && hover !== i; const cx = i * dayW + dayW / 2; // Direct-label the peak, but not when it would collide with the // TODAY label near the divider — the header caption states the // peak either way. const isPeak = d.count === max && d.count > 0 && (todayX == null || Math.abs(cx - todayX) > 56); return ( {d.count > 0 && ( )} {isPeak && hover == null && ( {d.count} )} {/* full-height hit target — bars are thin, the day column is not */} setHover(i)} onMouseLeave={() => setHover(null)}> {`${RnDayLabel(d.key)} · ${d.count} expiring`} ); })} {/* today divider */} {todayX != null && ( TODAY )} {/* month labels */} {monthMarks.map(m => ( {m.label.toUpperCase()} ))} {anyEnded && (
ENDED · WIN-BACK UPCOMING
)}
); } function RenewalsView({ onNavigate }) { const res = useAsync(() => api.get('/api/v2/analytics/expiring-members?months=2'), []); const [q, setQ] = React.useState(''); const [filter, setFilter] = React.useState('all'); // Optimistic overrides for the contacted mark, keyed by member_id: // {contacted_at, contacted_by} = marked, null = unmarked, absent = server // state. Kept local (no reload) so toggling doesn't jump the scroll. const [marks, setMarks] = React.useState({}); // Per-member follow-up draft state: 'queued' | 'already' once a renewal // draft has been pushed to the Tommy approval queue this session. const [followups, setFollowups] = React.useState({}); async function draftFollowup(m) { try { const r = await api.post(`/api/v2/analytics/expiring-members/${m.member_id}/draft-followup`, {}); const st = r.status === 'already_queued' ? 'already' : 'queued'; setFollowups(f => ({ ...f, [m.member_id]: st })); toast(st === 'already' ? `${m.name || 'Member'} already has a draft waiting in Tommy` : `Renewal draft queued — approve it in Tommy to send`, { kind: 'success' }); } catch (e) { const detail = e && e.payload && e.payload.detail; const msg = (detail && detail.message) || friendlyApiError(e, 'Could not queue the draft'); toast(msg, { kind: 'error' }); } } const d = res.data || {}; const members = d.members || []; const window_ = d.window || []; const byMonth = d.by_month || {}; const lastSync = d.last_synced ? new Date(d.last_synced) : null; const contactedOf = m => ( m.member_id in marks ? marks[m.member_id] : (m.contacted_at ? { contacted_at: m.contacted_at, contacted_by: m.contacted_by } : null) ); async function toggleContacted(m) { const next = contactedOf(m) ? null : { contacted_at: new Date().toISOString(), contacted_by: 'you' }; const prev = contactedOf(m); setMarks(ms => ({ ...ms, [m.member_id]: next })); try { const r = await api.post( `/api/v2/analytics/expiring-members/${m.member_id}/contacted`, { contacted: !!next }, ); if (next && r?.contacted_by) { setMarks(ms => ({ ...ms, [m.member_id]: { ...next, contacted_by: r.contacted_by } })); } toast(next ? `${m.name || 'Member #' + m.member_id} marked contacted` : `${m.name || 'Member #' + m.member_id} unmarked`, { kind: 'success' }); } catch (e) { setMarks(ms => ({ ...ms, [m.member_id]: prev })); toast(friendlyApiError(e, 'Could not save — try again'), { kind: 'error' }); } } const contactedN = members.filter(m => contactedOf(m)).length; const expiredN = members.filter(m => m.expired).length; const FILTERS = [ { id: 'all', label: 'All', count: members.length }, { id: 'todo', label: 'To contact', count: members.length - contactedN }, { id: 'this', label: 'This month', count: byMonth[window_[0]] ?? 0 }, { id: 'next', label: 'Next month', count: byMonth[window_[1]] ?? 0 }, { id: 'ended', label: 'Ended', count: expiredN }, ]; const needle = q.trim().toLowerCase(); const matches = m => { if (filter === 'todo' && contactedOf(m)) return false; if (filter === 'this' && m.ym !== window_[0]) return false; if (filter === 'next' && m.ym !== window_[1]) return false; if (filter === 'ended' && !m.expired) return false; if (!needle) return true; return [m.name, m.phone, m.email, m.membership_type] .some(v => v && String(v).toLowerCase().includes(needle)); }; const shown = members.filter(matches); const isNarrowed = needle !== '' || filter !== 'all'; return (
— Memberships ending soon

Renewals

Members whose membership ends this month or next — reach out before they lapse.
{res.error &&
Renewals data unavailable.
}
{window_.slice(0, 2).map((ym, i) => (
{i === 0 ? 'Expiring this month' : 'Expiring next month'}
{res.loading ? '—' : (byMonth[ym] ?? 0)}
{rnMonthLabel(ym)}
))}
Already ended
{res.loading ? '—' : expiredN}
this month · win-back calls
Contacted
{res.loading ? '—' : `${contactedN}/${members.length}`}
this window · shared with the team
{!res.loading && !res.error && members.length > 0 && ( )} {!res.loading && !res.error && members.length > 0 && (
{FILTERS.map(f => ( ))}
setQ(e.target.value)} placeholder="Filter by name, phone, email, type…" style={{ height: 26, padding: '0 10px', fontSize: 12, border: '1px solid var(--line)', background: 'var(--bone)', borderRadius: 14, outline: 'none', minWidth: 240, fontFamily: 'var(--sans)', }} /> {shown.length} of {members.length}
)} {res.loading && (
{[0, 1, 2].map(i => (
))}
)} {!res.loading && !res.error && members.length === 0 && (
No memberships end in this window. Term memberships (6/12-month, PIF, yearly) appear here as their end date approaches; month-to-month members only show up once a cancellation is scheduled. Data comes from the GymMaster sync, which refreshes a few times a day {lastSync ? ` — last synced ${lastSync.toLocaleString('en-US')}` : ' — first sync after this deploy hasn’t completed yet'}.
)} {!res.loading && members.length > 0 && shown.length === 0 && (
Nothing matches the current filter{needle ? ` and “${q.trim()}”` : ''}.
)} {window_.map(ym => { const rows = shown.filter(m => m.ym === ym); if (members.length === 0) return null; if (isNarrowed && rows.length === 0) return null; return (
{rnMonthLabel(ym)} · {rows.length}{isNarrowed ? ` of ${byMonth[ym] ?? 0}` : ''} expiring
sorted by end date
{rows.length === 0 &&
Nobody expires this month.
} {rows.map(m => ( ))}
); })} {!res.loading && members.length > 0 && (
Source: GymMaster membership end dates, synced every few hours {lastSync ? ` (last: ${lastSync.toLocaleString('en-US')})` : ''}. Renewed members drop off automatically once GymMaster shows their new end date. Month-to-month members are not listed — they have no end date until a cancellation is scheduled. "not in CRM" = no GHL contact shares the member's phone number. The ✓ contacted mark is shared with the whole team and resets automatically when a member renews into a new end date.
)}
); } Object.assign(window, { RenewalsView, RenewalsHomeCard });