// 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 (
    <div className="card">
      <div className="hd">
        <div className="title">— <b>Renewals</b> · ending soon</div>
        <div className="aside">
          <span style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)' }}>
            {byMonth[m0] ?? 0} this month · {byMonth[m1] ?? 0} next
          </span>
          <button className="btn sm ghost" onClick={() => onNavigate({ view: 'renewals' })}>See all →</button>
        </div>
      </div>
      <div className="bd p0">
        {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 (
            <div key={m.member_id}
              style={{
                padding: '10px 14px', borderTop: '1px solid var(--line-2)',
                display: 'grid', gridTemplateColumns: '1fr auto', gap: 8,
                alignItems: 'center', cursor: 'pointer',
                opacity: m.contacted_at ? 0.55 : 1,
              }}
              title={m.contact_id ? 'Open lead' : 'Open Renewals'}
              onClick={() => m.contact_id
                ? onNavigate({ view: 'lead', id: m.contact_id })
                : onNavigate({ view: 'renewals' })}>
              <div style={{ minWidth: 0 }}>
                <div style={{
                  fontWeight: 500, fontSize: 13,
                  whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                }}>
                  {m.contacted_at && <span style={{ color: 'var(--green)', marginRight: 6 }}>✓</span>}
                  {m.name || ('Member #' + m.member_id)}
                </div>
                <div style={{
                  fontFamily: 'var(--mono)', fontSize: 10,
                  letterSpacing: '0.06em', color: 'var(--muted-2)', marginTop: 2,
                }}>
                  {m.membership_type || 'membership'}
                </div>
              </div>
              <div style={{
                fontFamily: 'var(--mono)', fontSize: 11, textAlign: 'right',
                color: urgent ? 'var(--warn)' : 'var(--muted)',
              }}>
                {end.txt}
              </div>
            </div>
          );
        })}
        {members.length > SHOW && (
          <div style={{
            padding: '10px 14px', borderTop: '1px solid var(--line-2)',
            fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)',
          }}>
            +{members.length - SHOW} more ending in this window.
          </div>
        )}
      </div>
    </div>
  );
}

// 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 (
    <button
      onClick={onToggle}
      title={c ? `Contacted by ${c.contacted_by || 'staff'} — click to unmark` : 'Mark as contacted'}
      aria-label={c ? 'Unmark contacted' : 'Mark as contacted'}
      aria-pressed={!!c}
      style={{
        width: 22, height: 22, borderRadius: '50%', cursor: 'pointer', padding: 0,
        border: '1px solid ' + (c ? 'var(--green)' : 'var(--line)'),
        background: c ? 'var(--green-soft)' : 'var(--bone)',
        color: 'var(--green)', fontSize: 12, lineHeight: 1,
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
      }}
    >{c ? '✓' : ''}</button>
  );
}

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 (
    <div style={{
      display: 'grid', gridTemplateColumns: '26px 1fr auto auto', gap: 10,
      padding: '7px 0', borderBottom: '1px solid var(--bone-2)', alignItems: 'center',
    }}>
      <RnContactedToggle c={c} onToggle={() => onToggle(m)} />
      <div style={{ minWidth: 0, opacity: c ? 0.55 : 1 }}>
        <div style={{ fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
          {m.name || ('Member #' + m.member_id)}
          {m.expired && (
            <span style={{ marginLeft: 8, fontSize: 10, fontFamily: 'var(--mono)', color: 'var(--warn)', border: '1px solid var(--warn)', borderRadius: 3, padding: '1px 5px' }}
              title="Membership already ended — still worth a win-back call.">EXPIRED</span>
          )}
          {m.contact_stage && (
            <span className={'chip stage-' + stageGroupOf(m.contact_stage)}
              style={{ marginLeft: 8, verticalAlign: 'middle' }}
              title="Stage of the matched CRM contact">
              <span className="dot" />{String(m.contact_stage).toLowerCase()}
            </span>
          )}
        </div>
        <div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'var(--mono)' }}>
          {m.membership_type || 'membership'} · <span style={{ color: urgent ? 'var(--warn)' : 'var(--muted)' }}>{end.txt}</span>
          {c && (
            <span style={{ color: 'var(--green)' }}>
              {' '}· ✓ {c.contacted_by || 'staff'} · {fmtAgoFrom(c.contacted_at)}
            </span>
          )}
        </div>
      </div>
      <div style={{ textAlign: 'right', fontFamily: 'var(--mono)', fontSize: 11, opacity: c ? 0.55 : 1 }}>
        {m.phone ? <a href={'tel:' + m.phone} style={{ color: 'var(--ink)', textDecoration: 'none' }}>{m.phone}</a>
          : <span style={{ color: 'var(--muted-2)' }}>no phone</span>}
        {m.email && (
          <div style={{ fontSize: 10 }}>
            <a href={'mailto:' + m.email} style={{ color: 'var(--muted)', textDecoration: 'none' }}>{m.email}</a>
          </div>
        )}
      </div>
      {m.contact_id ? (
        <div style={{ display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'flex-end' }}>
          {fu ? (
            <span style={{ fontSize: 10, fontFamily: 'var(--mono)', color: 'var(--green)' }}
              title={fu === 'already'
                ? 'A draft is already waiting in the Tommy approval queue'
                : 'Renewal SMS drafted — approve it in Tommy to send'}>
              ✓ in Tommy
            </span>
          ) : (
            <button className="btn sm ghost" style={{ opacity: c ? 0.55 : 1 }}
              title="Draft a renewal SMS into the Tommy approval queue (a human approves before it sends)"
              onClick={() => onFollowUp && onFollowUp(m)}>
              follow up
            </button>
          )}
          <button className="btn sm ghost" style={{ opacity: c ? 0.55 : 1 }}
            onClick={() => onNavigate && onNavigate({ view: 'lead', id: m.contact_id })}>
            open lead →
          </button>
        </div>
      ) : (
        <span style={{ fontSize: 10, fontFamily: 'var(--mono)', color: 'var(--muted-2)' }}
          title="No CRM contact shares this member's phone number — reach out by phone/email directly.">
          not in CRM
        </span>
      )}
    </div>
  );
}

// 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 (
    <div className="card" style={{ marginBottom: 16 }}>
      <div className="hd">
        <div className="title">— <b>Expiring by day</b></div>
        <div className="aside" style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)' }}>
          {hd
            ? <span><b style={{ color: 'var(--ink)' }}>{RnDayLabel(hd.key)}</b> · {hd.count} {hd.count === 1 ? 'membership' : 'memberships'}</span>
            : <span>{members.length} across {monthMarks.map(m => m.label).join('–')} · peak {max}/day</span>}
        </div>
      </div>
      <div className="bd">
        <svg viewBox={`0 0 ${W} ${totalH}`} width="100%" height={totalH}
             preserveAspectRatio="none" role="img"
             aria-label={`Memberships expiring per day; peak ${max} on a single day`}>
          {/* baseline */}
          <line x1="0" y1={baseY} x2={W} y2={baseY} stroke="var(--bone-2)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
          {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 (
              <g key={d.key}>
                {d.count > 0 && (
                  <rect
                    x={i * dayW + (dayW - barW) / 2} y={baseY - h}
                    width={barW} height={h}
                    rx={Math.min(3, barW / 2)}
                    fill={ended ? 'var(--warn)' : 'var(--ink)'}
                    opacity={dim ? 0.35 : 1}
                  />
                )}
                {isPeak && hover == null && (
                  <text x={i * dayW + dayW / 2} y={baseY - h - 4}
                        textAnchor="middle" fontSize="11" fontFamily="var(--mono)"
                        fill={ended ? 'var(--warn)' : 'var(--ink)'}>{d.count}</text>
                )}
                {/* full-height hit target — bars are thin, the day column is not */}
                <rect x={i * dayW} y={PAD_T} width={dayW} height={H} fill="transparent"
                      onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
                  <title>{`${RnDayLabel(d.key)} · ${d.count} expiring`}</title>
                </rect>
              </g>
            );
          })}
          {/* today divider */}
          {todayX != null && (
            <g>
              <line x1={todayX} y1={PAD_T - 6} x2={todayX} y2={baseY}
                    stroke="var(--ink-2)" strokeWidth="1" strokeDasharray="2 2" vectorEffect="non-scaling-stroke" />
              <text x={todayX + 4} y={PAD_T - 2} fontSize="10" fontFamily="var(--mono)"
                    fill="var(--muted-2)" letterSpacing="0.08em">TODAY</text>
            </g>
          )}
          {/* month labels */}
          {monthMarks.map(m => (
            <text key={m.label + m.x} x={m.x + 2} y={totalH - 4}
                  fontSize="10" fontFamily="var(--mono)" fill="var(--muted-2)" letterSpacing="0.08em">
              {m.label.toUpperCase()}
            </text>
          ))}
        </svg>
        {anyEnded && (
          <div style={{ display: 'flex', gap: 16, marginTop: 8, fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', letterSpacing: '0.06em' }}>
            <span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: 'var(--warn)', marginRight: 5, verticalAlign: 'middle' }} />ENDED · WIN-BACK</span>
            <span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: 'var(--ink)', marginRight: 5, verticalAlign: 'middle' }} />UPCOMING</span>
          </div>
        )}
      </div>
    </div>
  );
}

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 (
    <div className="app-main">
      <div className="page-hd">
        <div>
          <div className="kicker" style={{ marginBottom: 8 }}>— Memberships ending soon</div>
          <h1>Renewals</h1>
          <div className="sub">Members whose membership ends this month or next — reach out before they lapse.</div>
        </div>
      </div>

      {res.error && <div className="empty" style={{ marginBottom: 16 }}>Renewals data unavailable.</div>}

      <div className="kpi-grid" style={{ marginBottom: 16 }}>
        {window_.slice(0, 2).map((ym, i) => (
          <div className="kpi" key={ym}>
            <div className="kpi-label">{i === 0 ? 'Expiring this month' : 'Expiring next month'}</div>
            <div className="kpi-value">{res.loading ? '—' : (byMonth[ym] ?? 0)}</div>
            <div className="kpi-foot"><span style={{ fontSize: 12, color: 'var(--muted)' }}>{rnMonthLabel(ym)}</span></div>
          </div>
        ))}
        <div className="kpi">
          <div className="kpi-label">Already ended</div>
          <div className="kpi-value">{res.loading ? '—' : expiredN}</div>
          <div className="kpi-foot"><span style={{ fontSize: 12, color: 'var(--muted)' }}>this month · win-back calls</span></div>
        </div>
        <div className="kpi">
          <div className="kpi-label">Contacted</div>
          <div className="kpi-value">{res.loading ? '—' : `${contactedN}/${members.length}`}</div>
          <div className="kpi-foot"><span style={{ fontSize: 12, color: 'var(--muted)' }}>this window · shared with the team</span></div>
        </div>
      </div>

      {!res.loading && !res.error && members.length > 0 && (
        <RenewalsTimeline members={members} window_={window_} />
      )}

      {!res.loading && !res.error && members.length > 0 && (
        <div className="leads-toolbar" style={{ marginBottom: 16 }}>
          {FILTERS.map(f => (
            <button
              key={f.id}
              className={'flt' + (filter === f.id ? ' active' : '')}
              onClick={() => setFilter(f.id)}
            >
              {f.label} <span className="cnt">{f.count}</span>
            </button>
          ))}
          <div className="spacer" />
          <input
            value={q}
            onChange={e => 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)',
            }}
          />
          <span className="mono-meta">{shown.length} of {members.length}</span>
        </div>
      )}

      {res.loading && (
        <div className="card" style={{ marginBottom: 16 }}>
          <div className="bd">
            {[0, 1, 2].map(i => (
              <div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, padding: '10px 0', alignItems: 'center' }}>
                <div>
                  <Skeleton w={180} h={13} />
                  <Skeleton w={120} h={10} style={{ marginTop: 6 }} />
                </div>
                <Skeleton w={90} h={11} />
              </div>
            ))}
          </div>
        </div>
      )}

      {!res.loading && !res.error && members.length === 0 && (
        <div className="card" style={{ marginBottom: 16 }}>
          <div className="bd" style={{ fontSize: 13, lineHeight: 1.5, color: 'var(--muted)' }}>
            <b style={{ color: 'var(--ink)' }}>No memberships end in this window.</b> 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'}.
          </div>
        </div>
      )}

      {!res.loading && members.length > 0 && shown.length === 0 && (
        <div className="card" style={{ marginBottom: 16 }}>
          <div className="bd">
            <div className="empty">Nothing matches the current filter{needle ? ` and “${q.trim()}”` : ''}.</div>
          </div>
        </div>
      )}

      {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 (
          <div className="card" key={ym} style={{ marginBottom: 16 }}>
            <div className="hd">
              <div className="title">
                — <b>{rnMonthLabel(ym)}</b> · {rows.length}{isNarrowed ? ` of ${byMonth[ym] ?? 0}` : ''} expiring
              </div>
              <div className="aside" style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)' }}>
                sorted by end date
              </div>
            </div>
            <div className="bd">
              {rows.length === 0 && <div className="empty">Nobody expires this month.</div>}
              {rows.map(m => (
                <RenewalRow key={m.member_id} m={m} c={contactedOf(m)} fu={followups[m.member_id]}
                  onToggle={toggleContacted} onFollowUp={draftFollowup} onNavigate={onNavigate} />
              ))}
            </div>
          </div>
        );
      })}

      {!res.loading && members.length > 0 && (
        <div style={{ fontSize: 10, color: 'var(--muted-2)', lineHeight: 1.5, marginBottom: 16 }}>
          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.
        </div>
      )}
    </div>
  );
}

Object.assign(window, { RenewalsView, RenewalsHomeCard });
