// 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
+{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 (
);
}
// 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 (
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'}.
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.