// ─────────────────────────────────────────────────────────────
// LifeWheel Coach — Dashboard (Today First)
// Data adapter — translates raw LWDATA into shapes the UI needs.
// ─────────────────────────────────────────────────────────────

// Anchor "now" — Tue May 5 2026 4:12pm local
const LW_NOW = new Date(2026, 4, 5, 16, 12, 0);

function parseSessionStart(iso) {
  // accepts "today HH:MM", "yest HH:MM", "wed HH:MM", weekday names mon..sun
  const [dayPart, timePart] = iso.split(' ');
  const [hh, mm] = timePart.split(':').map(Number);
  const base = new Date(LW_NOW); base.setHours(hh, mm, 0, 0);
  const dayOfWeek = ['sun','mon','tue','wed','thu','fri','sat'];
  if (dayPart === 'today') return base;
  if (dayPart === 'yest' || dayPart === 'yesterday') { base.setDate(base.getDate() - 1); return base; }
  if (dayPart === 'tomorrow') { base.setDate(base.getDate() + 1); return base; }
  const idx = dayOfWeek.indexOf(dayPart);
  if (idx >= 0) {
    const todayDow = LW_NOW.getDay();
    let diff = idx - todayDow;
    if (diff <= 0) diff += 7; // treat weekday name as upcoming
    base.setDate(base.getDate() + diff);
    return base;
  }
  return base;
}

function timeStr(d) {
  const h = d.getHours(), m = d.getMinutes();
  const ap = h >= 12 ? 'pm' : 'am';
  const h12 = ((h + 11) % 12) + 1;
  return `${h12}${m ? ':' + String(m).padStart(2, '0') : ''}${ap}`;
}
function untilStr(d) {
  const diff = d.getTime() - LW_NOW.getTime();
  if (diff < 0) {
    const past = Math.abs(diff);
    const m = Math.round(past / 60000);
    if (m < 60) return `${m}m ago`;
    return `${Math.round(m/60)}h ago`;
  }
  const m = Math.round(diff / 60000);
  if (m < 60) return `in ${m}m`;
  const h = Math.floor(m / 60), rem = m % 60;
  return rem ? `in ${h}h ${rem}m` : `in ${h}h`;
}

// derive a UI-friendly client view
function deriveClient(raw) {
  const moodValid = raw.moodWeek.filter(v => v != null);
  const moodNow = moodValid.length ? moodValid[moodValid.length - 1] : null;
  const moodDelta = moodValid.length >= 2 ? moodValid[moodValid.length-1] - moodValid[0] : null;
  const lastEntryAt = new Date(LW_NOW.getTime() - raw.lastActiveHrs * 3600 * 1000);
  return {
    ...raw,
    moodNow, moodDelta,
    lastEntryAt,
    focus: raw.tags && raw.tags.length ? raw.tags.map(t => t[0].toUpperCase() + t.slice(1)).join(' · ') : '—',
    cadence: raw.status === 'new' ? 'onboarding' : 'biweekly',
  };
}

const STATE_META = {
  attention:    { label: 'needs check-in', tone: 'flame'  },
  active:       { label: 'on track',       tone: 'subtle' },
  new:          { label: 'just joined',    tone: 'accent' },
  stable:       { label: 'steady',         tone: 'subtle' },
};

// Session brief generator (varies by client/topic) — written long-form,
// not auto-generated, but seeded from the data.
const SESSION_BRIEFS = {
  s1: {
    brief: "5 days quiet. Mood down to 2/10 — Joy sphere slid 3 → 1 last night. The note she wrote feels heavier than usual. Lead gently. Last session she set a boundary about email after 7pm; consider asking how that's holding.",
    flags: ['Joy 3→1', '5 days quiet', 'asked: sleep'],
  },
  s2: {
    brief: "Coming in lit up. Wants to talk about a 6-week sabbatical idea. Streak at 23 days. Last week he asked for a stretch goal — this might be it. Help him pressure-test the timing without dampening the energy.",
    flags: ['breakthrough mood', 'streak 23d', 'topic: sabbatical'],
  },
  s3: {
    brief: "Shared a hard journal entry today: rough morning, 4h sleep, meeting went sideways. She rarely shares. Open with acknowledgement of the share itself, not the content. Family pressure is the unfinished thread from last week.",
    flags: ['shared journal', 'topic: family pressure'],
  },
  s4: {
    brief: "Three-month checkpoint. Career sphere ticked 7→8 yesterday — note says \"Said yes to the offer.\" She wants to talk about timing the leap. Bring the wheel from week 1 for contrast.",
    flags: ['Career 7→8', 'topic: career pivot'],
  },
  s5: {
    brief: "4 days quiet. Streak broke at 11. He hasn't logged mood since Sunday. Last note: \"Streak broken — energy low.\" He doesn't share journal. Topic queued is boundaries with team.",
    flags: ['streak broken', '4 days quiet', 'topic: boundaries'],
  },
  s6: {
    brief: "First real session after intake. Sam joined 2 days ago, took the first wheel today (mood 7). Spend the half-hour on what they want to work on — don't push the wheel yet. Phone, not video.",
    flags: ['intake', 'phone'],
  },
};

function deriveSession(raw, clientById) {
  const start = parseSessionStart(raw.startISO);
  const client = clientById[raw.clientId];
  const meta = SESSION_BRIEFS[raw.id] || { brief: 'No prep brief yet.', flags: [] };
  return {
    ...raw, start, client,
    duration: raw.minutes,
    location: raw.mode === 'video' ? 'Zoom' : raw.mode === 'phone' ? 'Phone' : 'In person',
    brief: meta.brief, flags: meta.flags,
  };
}

// ── Hero ──
function DashHero({ name, summary }) {
  const { c: cBase, fonts: fontsBase, t, lang } = useLW();
  const isBrut = !!(window.__brutMode && window.__brutC);
  const c = isBrut ? window.__brutC : cBase;
  const fonts = isBrut ? (window.__brutFonts || fontsBase) : fontsBase;
  const now = new Date();
  const hour = now.getHours();
  const greetKey = hour < 12 ? 'dash.greeting_am' : hour < 18 ? 'dash.greeting_pm' : 'dash.greeting_eve';
  const dateLocale = lang === 'ru' ? 'ru-RU' : undefined;
  const date = now.toLocaleDateString(dateLocale, { weekday: 'long', month: 'long', day: 'numeric' });
  const firstName = (name || '').split(' ')[0] || (name || '');
  const greeting = t(greetKey, { name: firstName });

  // Adaptive line two — pick the one that's most actionable for right now.
  // Priority: sessions today > sessions tomorrow > attention queue > quiet day.
  let accent = '';
  let after = '';
  const todayCount = (summary && summary.todayCount) || 0;
  const tomorrowCount = (summary && summary.tomorrowCount) || 0;
  const attentionCount = (summary && summary.attentionCount) || 0;
  const firstSession = summary && summary.firstTodaySession;
  if (todayCount > 0) {
    accent = pluralLabel(t, lang, 'dash.hero_today', todayCount);
    if (firstSession) {
      const minsUntil = Math.round((firstSession - Date.now()) / 60000);
      if (minsUntil > 0 && minsUntil < 60) after = ' ' + t('dash.hero_first_in_min', { n: minsUntil });
      else if (minsUntil >= 60 && minsUntil < 1440) after = ' ' + t('dash.hero_first_in_hr', { n: Math.round(minsUntil / 60) });
    }
  } else if (tomorrowCount > 0) {
    accent = t('dash.hero_tomorrow', { n: tomorrowCount });
  } else if (attentionCount > 0) {
    accent = t('dash.hero_no_session_attention', { n: attentionCount });
  } else {
    accent = t('dash.hero_quiet');
  }

  return (
    <div style={{ marginBottom: 28 }}>
      <div style={{
        fontFamily: isBrut ? fonts.mono : fonts.body,
        fontSize: 11, fontWeight: isBrut ? 600 : 700,
        letterSpacing: '0.18em', textTransform: 'uppercase',
        color: c.textTertiary, marginBottom: 12,
      }}>
        {isBrut ? `—— ${date}` : date}
      </div>
      <h1 style={{
        margin: 0,
        fontFamily: isBrut ? fonts.mono : fonts.display,
        fontWeight: isBrut ? 700 : 500,
        fontSize: isBrut ? 'clamp(28px, 3.4vw, 40px)' : 'clamp(34px, 4.2vw, 52px)',
        lineHeight: isBrut ? 1.1 : 1.06,
        letterSpacing: '-0.02em',
        color: c.textPrimary, maxWidth: 920, textWrap: 'pretty',
        textTransform: isBrut ? 'uppercase' : 'none',
      }}>
        {greeting}{' '}
        {isBrut ? (
          <span style={{
            color: c.accentInk,
            textDecoration: 'underline',
            textDecorationColor: c.accentInk,
            textDecorationThickness: '3px',
            textUnderlineOffset: '6px',
            textDecorationSkipInk: 'none',
          }}>{accent}{after}</span>
        ) : (
          <em style={{ fontStyle: 'italic', color: c.accent, fontWeight: 500 }}>
            {accent}{after}
          </em>
        )}
      </h1>
    </div>
  );
}

// RU plural picker for 1 / 2-4 / 5+ keys.
function pluralLabel(t, lang, baseKey, n) {
  if (lang === 'ru' || lang === 'uk') {
    const m10 = n % 10, m100 = n % 100;
    if (m10 === 1 && m100 !== 11) return t(`${baseKey}_one`, { n });
    if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return t(`${baseKey}_few`, { n });
    return t(`${baseKey}_many`, { n });
  }
  return n === 1 ? t(`${baseKey}_one`, { n }) : t(`${baseKey}_many`, { n });
}

// ── Today sessions ──
function TodaySessions({ sessions }) {
  const { c, fonts } = useLW();
  const today0 = new Date(LW_NOW); today0.setHours(0,0,0,0);
  const tomorrow0 = new Date(today0); tomorrow0.setDate(tomorrow0.getDate() + 1);
  const todays = sessions.filter(s => s.start >= today0 && s.start < tomorrow0)
                         .sort((a,b) => a.start - b.start);

  if (todays.length === 0) {
    return (
      <Card padding={32} style={{ textAlign: 'center' }}>
        <div style={{ fontFamily: fonts.display, fontStyle: 'italic', fontSize: 22, color: c.textSecondary, marginBottom: 8 }}>
          No sessions today
        </div>
        <div style={{ fontFamily: fonts.body, fontSize: 14, color: c.textTertiary }}>
          A good day to read what your clients have been writing.
        </div>
      </Card>
    );
  }

  const next = sessions.filter(s => s.start > LW_NOW).sort((a,b) => a.start - b.start)[0];
  return (
    <div style={{ display: 'grid', gap: 14 }}>
      {todays.map(s => <SessionRow key={s.id} session={s} isNext={next && next.id === s.id} />)}
    </div>
  );
}

function SessionRow({ session, isNext }) {
  const { c, fonts, brutMode } = useLW();
  const isBrut = !!brutMode;
  const { isMobile } = useViewport();
  const client = session.client;
  return (
    <Card padding={0} style={{
      overflow: 'hidden',
      border: isNext
        ? (isBrut ? `1.5px solid ${c.ink || c.accent}` : `1px solid ${hexToRgba(c.accent, 0.45)}`)
        : (isBrut ? `1.5px solid ${c.ink}` : `1px solid ${c.borderSubtle}`),
      background: isNext ? (isBrut ? c.bg : hexToRgba(c.accent, 0.04)) : c.card,
    }}>
      <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 1fr 200px', alignItems: 'stretch' }}>
        <div style={{
          padding: isMobile ? '14px 16px' : '20px 22px',
          borderRight: isMobile ? 'none' : `1px solid ${c.borderSubtle}`,
          borderBottom: isMobile ? `1px solid ${c.borderSubtle}` : 'none',
          display: 'flex', flexDirection: isMobile ? 'row' : 'column',
          alignItems: isMobile ? 'baseline' : 'flex-start',
          gap: isMobile ? 12 : 0,
          justifyContent: isMobile ? 'space-between' : 'center',
        }}>
          <div style={{
            fontFamily: isBrut ? '"IBM Plex Mono", monospace' : fonts.display,
            fontWeight: isBrut ? 700 : 600,
            fontSize: 26,
            letterSpacing: '-0.01em',
            color: c.textPrimary, lineHeight: 1,
          }}>
            {timeStr(session.start)}
          </div>
          <div style={{ fontFamily: fonts.body, fontSize: 12, color: c.textTertiary, marginTop: 6 }}>
            {session.duration} min · {session.location}
          </div>
          <div style={{
            marginTop: 10, fontFamily: fonts.app, fontWeight: 700, fontSize: 11,
            letterSpacing: '0.14em', textTransform: 'uppercase',
            color: isNext ? c.accent : c.textTertiary,
          }}>
            {isNext ? `next · ${untilStr(session.start)}` : untilStr(session.start)}
          </div>
        </div>
        <div style={{ padding: isMobile ? '14px 16px' : '20px 22px', display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
            <Avatar name={client.name} hue={client.avatarHue} size={36} />
            <div style={{ display: 'flex', flexDirection: 'column' }}>
              <div style={{ fontFamily: fonts.app, fontSize: 16, fontWeight: 700, color: c.textPrimary, letterSpacing: '-0.005em' }}>{client.name}</div>
              <div style={{ fontFamily: fonts.body, fontSize: 12, color: c.textTertiary }}>
                {client.focus} · {client.cadence}
              </div>
            </div>
            <div style={{ flex: 1 }} />
            {client.status === 'attention' && <Chip tone="flame">⚑ {client.attentionReason || 'needs attention'}</Chip>}
          </div>
          <div style={{
            fontFamily: fonts.body, fontSize: 14, lineHeight: 1.55,
            color: c.textSecondary,
            paddingLeft: 12, borderLeft: `2px solid ${hexToRgba(c.accent, 0.4)}`,
          }}>
            <span style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary, display: 'block', marginBottom: 5 }}>
              Brief
            </span>
            {session.brief}
          </div>
          {session.flags && session.flags.length > 0 && (
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
              {session.flags.map((f, i) => <Chip key={i} tone="accent">{f}</Chip>)}
            </div>
          )}
        </div>
        <div style={{
          padding: isMobile ? '12px 16px 16px' : 18,
          display: 'flex',
          flexDirection: isMobile ? 'row' : 'column',
          flexWrap: 'wrap',
          gap: 8, justifyContent: 'center',
          borderLeft: isMobile ? 'none' : `1px solid ${c.borderSubtle}`,
          borderTop: isMobile ? `1px solid ${c.borderSubtle}` : 'none',
        }}>
          {isNext ? (
            <a href={`Session.html?id=${session.id}`} style={{ textDecoration: 'none', flex: isMobile ? 1 : undefined, minWidth: isMobile ? 160 : undefined }}>
              <Button variant="primary" size="md" style={{ width: '100%' }} icon={<span aria-hidden style={{ fontSize: 12 }}>▸</span>}>
                Start session
              </Button>
            </a>
          ) : null}
          <a href={`Client.html?id=${client.id}&view=prep`} style={{ textDecoration: 'none', flex: isMobile ? 1 : undefined, minWidth: isMobile ? 160 : undefined }}>
            <Button variant={isNext ? 'ghost' : 'ghost'} size="md" style={{ width: '100%' }}>
              {isNext ? 'Prep brief' : 'Open prep brief'}
            </Button>
          </a>
          {(() => {
            const cp = client.commPref || { channel: 'telegram', url: '#' };
            const labels = { telegram: 'Open Telegram', whatsapp: 'Open WhatsApp', zoom: 'Join Zoom', meet: 'Join Meet', phone: 'Call', sms: 'Text' };
            return (
              <a href={cp.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none', flex: isMobile ? 1 : undefined, minWidth: isMobile ? 160 : undefined }}>
                <Button variant="quiet" size="sm" style={{ width: '100%' }} icon={<span aria-hidden style={{ fontSize: 14 }}>↗</span>}>
                  {labels[cp.channel] || 'Open chat'}
                </Button>
              </a>
            );
          })()}
          {!isMobile && <Button variant="quiet" size="sm" icon={<span aria-hidden style={{ fontSize: 14 }}>✎</span>}>Reschedule</Button>}
        </div>
      </div>
    </Card>
  );
}

// ── Week strip ──
function WeekStrip({ sessions }) {
  const { c, fonts } = useLW();
  const { isMobile } = useViewport();
  const today0 = new Date(LW_NOW); today0.setHours(0,0,0,0);
  const days = Array.from({ length: 7 }, (_, i) => {
    const d = new Date(today0); d.setDate(today0.getDate() + i);
    const dStr = d.toDateString();
    const dayS = sessions.filter(s => s.start.toDateString() === dStr && s.status === 'upcoming')
                         .sort((a,b)=> a.start - b.start);
    return { date: d, sessions: dayS };
  });
  return (
    <Card padding={0} style={{ overflow: 'hidden' }}>
      <div style={{
        display: 'grid',
        gridTemplateColumns: isMobile ? 'repeat(7, minmax(120px, 1fr))' : 'repeat(7, 1fr)',
        overflowX: isMobile ? 'auto' : 'visible',
        WebkitOverflowScrolling: 'touch',
      }}>
        {days.map((day, i) => {
          const isToday = i === 0;
          return (
            <div key={i} style={{
              padding: '14px 12px',
              borderRight: i < 6 ? `1px solid ${c.borderSubtle}` : 'none',
              background: isToday ? hexToRgba(c.accent, 0.06) : 'transparent',
              minHeight: 120,
            }}>
              <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 8 }}>
                <div style={{
                  fontFamily: fonts.body, fontSize: 10, fontWeight: 700,
                  letterSpacing: '0.16em', textTransform: 'uppercase',
                  color: isToday ? c.accent : c.textTertiary,
                }}>
                  {['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][day.date.getDay()]}
                </div>
                <div style={{
                  fontFamily: fonts.display, fontSize: 18, fontWeight: 600, letterSpacing: '-0.01em',
                  color: isToday ? c.accent : c.textSecondary,
                }}>
                  {day.date.getDate()}
                </div>
              </div>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
                {day.sessions.length === 0 && (
                  <div style={{ fontFamily: fonts.body, fontStyle: 'italic', fontSize: 11, color: c.textTertiary }}>—</div>
                )}
                {day.sessions.slice(0,3).map(s => (
                  <div key={s.id} style={{
                    padding: '4px 7px', borderRadius: 6,
                    background: c.cardHover,
                    border: `1px solid ${c.borderSubtle}`,
                    fontFamily: fonts.body, fontSize: 11,
                  }}>
                    <div style={{ color: c.textTertiary, fontFamily: fonts.app, fontWeight: 700, fontSize: 10, letterSpacing: '0.04em' }}>
                      {timeStr(s.start)}
                    </div>
                    <div style={{ color: c.textSecondary, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                      {s.client.name.split(' ')[0]}
                    </div>
                  </div>
                ))}
                {day.sessions.length > 3 && (
                  <div style={{ fontFamily: fonts.body, fontSize: 10, color: c.textTertiary }}>+{day.sessions.length - 3} more</div>
                )}
              </div>
            </div>
          );
        })}
      </div>
    </Card>
  );
}

// ── Roster ──
function RosterGrid({ clients }) {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 280px), 1fr))', gap: 14 }}>
      {clients.map(cl => <ClientCard key={cl.id} client={cl} />)}
    </div>
  );
}

function ClientCard({ client }) {
  const { c, fonts } = useLW();
  const meta = STATE_META[client.status] || STATE_META.active;
  const daysAgo = Math.round((LW_NOW - client.lastEntryAt) / 86400000);
  const lastEntryStr = daysAgo === 0
    ? `${Math.round((LW_NOW - client.lastEntryAt) / 3600000)}h ago`
    : daysAgo === 1 ? 'yesterday'
    : `${daysAgo}d ago`;

  // What we're working on — session topic + concrete agreed work
  const workingOn = (client.sessionTopics && client.sessionTopics.length)
    ? client.sessionTopics.join(' · ')
    : (client.tags && client.tags.length ? client.tags.join(' · ') : 'first session pending');

  // Biggest sphere mover since last wheel
  const sphereMover = (() => {
    if (!client.scores || !client.scoresPrev) return null;
    let bestI = -1, bestAbs = 0;
    for (let i = 0; i < client.scores.length; i++) {
      const d = client.scores[i] - client.scoresPrev[i];
      if (Math.abs(d) > bestAbs) { bestAbs = Math.abs(d); bestI = i; }
    }
    if (bestI < 0 || bestAbs === 0) return null;
    const localizedNames = (window.LWLang && window.LWLang.sphereNames) ? window.LWLang.sphereNames() : LWDATA.SPHERE_NAMES;
    return {
      key: LWDATA.SPHERE_KEYS[bestI],
      name: localizedNames[bestI],
      from: client.scoresPrev[bestI],
      to: client.scores[bestI],
      delta: client.scores[bestI] - client.scoresPrev[bestI],
      color: c.spheres[LWDATA.SPHERE_KEYS[bestI]],
    };
  })();

  // Goals progress (steps complete across all goals)
  const extras = (window.LW_CLIENT_EXTRAS && window.LW_CLIENT_EXTRAS[client.id]) || {};
  const goals = extras.goals || [];
  const goalProgress = (() => {
    if (!goals.length) return null;
    let done = 0, total = 0;
    goals.forEach(g => {
      (g.tasks || []).forEach(t => { total++; if (t.done) done++; });
    });
    return { done, total, count: goals.length };
  })();

  // Habits done this week
  const habits = client.habitsThisWeek;
  const habitsRatio = habits.total ? habits.done / habits.total : null;

  // Mood arrow
  const moodTone = client.moodDelta > 0 ? c.accent : client.moodDelta < 0 ? c.flame : c.textTertiary;
  const moodGlyph = client.moodDelta > 0 ? '↑' : client.moodDelta < 0 ? '↓' : '·';

  return (
    <Card hoverable onClick={() => { (window.LWRouter ? window.LWRouter.go(`Client.html?id=${client.id}`) : (window.location.href = `Client.html?id=${client.id}`)); }}
          padding={0}
          style={{ overflow: 'hidden' }}>
      {/* Header */}
      <div style={{
        padding: '14px 16px 12px',
        background: client.status === 'attention' ? hexToRgba(c.flame, 0.08)
                  : client.status === 'new' ? hexToRgba(c.accent, 0.08)
                  : 'transparent',
        borderBottom: `1px solid ${c.borderSubtle}`,
        display: 'flex', alignItems: 'center', gap: 12,
      }}>
        <Avatar name={client.name} hue={client.avatarHue} size={40}
                status={client.status === 'attention' ? 'attn' : client.status === 'new' ? 'live' : null} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontFamily: fonts.app, fontSize: 15, fontWeight: 700, color: c.textPrimary, letterSpacing: '-0.005em' }}>
            {client.name}
          </div>
          <div style={{ fontFamily: fonts.body, fontSize: 11, color: c.textTertiary, marginTop: 2, fontWeight: 500 }}>
            {client.cadence} · {lastEntryStr}
          </div>
        </div>
        <Chip tone={meta.tone}>{meta.label}</Chip>
      </div>

      {/* Working on — current goals */}
      <div style={{ padding: '14px 16px 6px' }}>
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 6, gap: 8 }}>
          <span style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: c.textTertiary }}>
            Working on
          </span>
          {goalProgress && (
            <span style={{ fontFamily: fonts.app, fontSize: 11, fontWeight: 700, color: c.textSecondary, letterSpacing: '-0.005em' }}>
              {goalProgress.done}<span style={{ color: c.textTertiary, fontWeight: 400 }}>/{goalProgress.total}</span> <span style={{ color: c.textTertiary, fontWeight: 500 }}>steps</span>
            </span>
          )}
        </div>
        {goals.length > 0 ? (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
            {goals.slice(0, 2).map(g => {
              const sphereColor = g.sphere && c.spheres[g.sphere] ? c.spheres[g.sphere] : c.textTertiary;
              const tasks = g.tasks || [];
              const gDone = tasks.filter(t => t.done).length;
              const gPct = tasks.length ? gDone / tasks.length : 0;
              return (
                <div key={g.id}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
                    {g.sphere && <SphereIcon sphere={g.sphere} size={11} color={sphereColor} />}
                    <span style={{ fontFamily: fonts.display, fontStyle: 'italic', fontSize: 13.5, fontWeight: 500, color: c.textPrimary, lineHeight: 1.3, letterSpacing: '-0.005em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>
                      {g.title}
                    </span>
                  </div>
                  <div style={{ height: 3, background: c.borderSubtle, borderRadius: 2, overflow: 'hidden' }}>
                    <div style={{ width: `${gPct * 100}%`, height: '100%', background: sphereColor, borderRadius: 2 }} />
                  </div>
                </div>
              );
            })}
            {goals.length > 2 && (
              <div style={{ fontFamily: fonts.body, fontSize: 11, color: c.textTertiary, fontStyle: 'italic' }}>
                +{goals.length - 2} more
              </div>
            )}
          </div>
        ) : (
          <div style={{ fontFamily: fonts.display, fontStyle: 'italic', fontSize: 14, color: c.textTertiary, lineHeight: 1.3 }}>
            {client.status === 'new' ? 'first session pending' : 'no goals set yet'}
          </div>
        )}
      </div>

      {/* Since last session */}
      <div style={{ padding: '12px 16px 16px' }}>
        <div style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: c.textTertiary, marginBottom: 8 }}>
          Since last session
        </div>
        {client.status === 'new' ? (
          <div style={{ fontFamily: fonts.body, fontSize: 13, color: c.textSecondary, fontStyle: 'italic' }}>
            Just joined — first wheel not in yet.
          </div>
        ) : (
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
            {/* Mood */}
            <div>
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
                <span style={{ fontFamily: fonts.display, fontSize: 18, fontWeight: 600, color: c.textPrimary, letterSpacing: '-0.01em', lineHeight: 1 }}>
                  {client.moodNow != null ? client.moodNow : '—'}
                </span>
                {client.moodDelta != null && client.moodDelta !== 0 && (
                  <span style={{ fontFamily: fonts.app, fontSize: 11, fontWeight: 700, color: moodTone }}>
                    {moodGlyph}{Math.abs(client.moodDelta)}
                  </span>
                )}
              </div>
              <div style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.textTertiary, marginTop: 3 }}>
                Mood
              </div>
            </div>
            {/* Habits */}
            <div>
              <div style={{ fontFamily: fonts.display, fontSize: 18, fontWeight: 600, color: c.textPrimary, letterSpacing: '-0.01em', lineHeight: 1 }}>
                {habits.done}<span style={{ color: c.textTertiary, fontWeight: 400 }}>/{habits.total}</span>
              </div>
              <div style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.textTertiary, marginTop: 3 }}>
                Habits
              </div>
            </div>
            {/* Sphere mover */}
            <div>
              {sphereMover ? (
                <>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
                    <SphereIcon sphere={sphereMover.key} size={13} color={sphereMover.color} />
                    <span style={{ fontFamily: fonts.app, fontSize: 13, fontWeight: 700, color: c.textPrimary, letterSpacing: '-0.005em' }}>
                      {sphereMover.from}<span style={{ color: c.textTertiary, fontWeight: 400, margin: '0 2px' }}>→</span>{sphereMover.to}
                    </span>
                  </div>
                  <div style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.textTertiary, marginTop: 3 }}>
                    {sphereMover.name}
                  </div>
                </>
              ) : (
                <>
                  <div style={{ fontFamily: fonts.display, fontSize: 18, fontWeight: 600, color: c.textTertiary, letterSpacing: '-0.01em', lineHeight: 1 }}>·</div>
                  <div style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.textTertiary, marginTop: 3 }}>
                    Wheel steady
                  </div>
                </>
              )}
            </div>
          </div>
        )}
      </div>

      {/* Footer — attention reason if flagged, else last note */}
      <div style={{
        padding: '11px 16px', borderTop: `1px solid ${c.borderSubtle}`,
        display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
        background: c.cardHover,
      }}>
        <div style={{ fontFamily: fonts.body, fontSize: 12, color: client.status === 'attention' ? c.flame : c.textSecondary, lineHeight: 1.4, flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6 }}>
          {client.status === 'attention' && <span style={{ flexShrink: 0 }}>⚑</span>}
          <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontStyle: client.status === 'attention' ? 'normal' : 'italic' }}>
            {client.status === 'attention' ? (client.attentionReason || 'needs attention') : `"${client.lastNote}"`}
          </span>
        </div>
        <button
          onClick={(e) => { e.stopPropagation(); }}
          aria-label="Send nudge"
          title="Send a nudge"
          style={{
            width: 30, height: 30, borderRadius: 8,
            background: 'transparent', border: `1px solid ${c.borderDefault}`,
            color: c.textSecondary, cursor: 'pointer', flexShrink: 0,
            fontFamily: fonts.app, fontSize: 14,
          }}
        >✉</button>
      </div>
    </Card>
  );
}

// ── Activity feed ──
function ActivityFeed({ events, clientById }) {
  const { c, fonts } = useLW();
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
      {events.slice(0, 12).map((ev, i) => {
        const cl = clientById[ev.clientId];
        const isLast = i === Math.min(events.length, 12) - 1;
        const tone = ev.kind === 'mood' && ev.flagged ? c.flame
                   : ev.kind === 'journal' && ev.flagged ? c.flame
                   : ev.kind === 'sphere' ? c.accent
                   : ev.kind === 'habits' ? c.flame
                   : c.textTertiary;
        const icon = ev.kind === 'journal' ? '✎'
                   : ev.kind === 'journal-private' ? '◌'
                   : ev.kind === 'mood' ? '◐'
                   : ev.kind === 'sphere' ? '◎'
                   : ev.kind === 'habits' ? '◧'
                   : '·';
        return (
          <a key={i} href={`Client.html?id=${ev.clientId}`} style={{
            display: 'grid', gridTemplateColumns: '28px 1fr auto',
            gap: 10, padding: '12px 0',
            borderBottom: isLast ? 'none' : `1px solid ${c.borderSubtle}`,
            textDecoration: 'none', color: 'inherit',
          }}>
            <div style={{
              width: 28, height: 28, borderRadius: '50%',
              background: hexToRgba(tone, 0.12),
              color: tone, border: `1px solid ${hexToRgba(tone, 0.3)}`,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              fontFamily: fonts.app, fontSize: 13, fontWeight: 700,
            }}>{icon}</div>
            <div style={{ minWidth: 0 }}>
              <div style={{ fontFamily: fonts.body, fontSize: 13, color: c.textPrimary, lineHeight: 1.4 }}>
                <strong style={{ fontWeight: 700 }}>{cl.name.split(' ')[0]}</strong>
                <span style={{ color: c.textSecondary, fontWeight: 400 }}>
                  {' '}{ev.kind === 'journal' ? 'shared a journal entry' : ev.kind === 'mood' ? 'logged mood' : ev.kind === 'sphere' ? 'updated a sphere' : ev.kind === 'habits' ? 'logged habits' : 'wrote privately'}
                </span>
              </div>
              {ev.text && (
                <div style={{ marginTop: 4, fontFamily: fonts.body, fontStyle: ev.kind === 'journal' ? 'italic' : 'normal', fontSize: 12, color: c.textTertiary, lineHeight: 1.5,
                              overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
                  {ev.kind === 'journal' || ev.kind === 'journal-private' ? `“${ev.text}”` : ev.text}
                </div>
              )}
            </div>
            <div style={{ fontFamily: fonts.body, fontSize: 11, color: c.textTertiary, whiteSpace: 'nowrap', alignSelf: 'flex-start', paddingTop: 2 }}>
              {ev.ts.replace('today ','').replace('yest ','y · ')}
            </div>
          </a>
        );
      })}
    </div>
  );
}

// ── Messages rail ──
function MessagesRail({ messages, clientById }) {
  const { c, fonts } = useLW();
  const items = messages.slice(0, 5);
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
      {items.map((m, i) => {
        const cl = clientById[m.clientId];
        const isLast = i === items.length - 1;
        return (
          <a key={m.id} href={`Client.html?id=${m.clientId}#messages`} style={{
            display: 'grid', gridTemplateColumns: '36px 1fr', gap: 10, padding: '12px 0',
            borderBottom: isLast ? 'none' : `1px solid ${c.borderSubtle}`,
            textDecoration: 'none', color: 'inherit', alignItems: 'flex-start',
          }}>
            <Avatar name={cl.name} hue={cl.avatarHue} size={32} status={m.unread ? 'attn' : null} />
            <div style={{ minWidth: 0 }}>
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginBottom: 4 }}>
                <span style={{ fontFamily: fonts.app, fontSize: 13, fontWeight: 700, color: c.textPrimary }}>{cl.name.split(' ')[0]}</span>
                {m.kind === 'question' && (
                  <span style={{
                    fontFamily: fonts.body, fontSize: 9, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase',
                    color: c.accent, padding: '1px 6px', borderRadius: 4,
                    background: c.accentMuted, border: `1px solid ${hexToRgba(c.accent, 0.3)}`,
                  }}>asked</span>
                )}
                {m.kind === 'reply' && (
                  <span style={{
                    fontFamily: fonts.body, fontSize: 9, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase',
                    color: c.textTertiary,
                  }}>↩ replied</span>
                )}
                <span style={{ flex: 1 }} />
                <span style={{ fontFamily: fonts.body, fontSize: 11, color: c.textTertiary, whiteSpace: 'nowrap' }}>
                  {m.ts.replace('today ','').replace('yest ','y · ')}
                </span>
              </div>
              <div style={{
                fontFamily: fonts.body, fontSize: 13, lineHeight: 1.5, color: c.textSecondary,
                fontStyle: m.kind === 'question' ? 'italic' : 'normal',
                overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
              }}>
                {m.kind === 'question' ? `“${m.preview}”` : m.preview}
              </div>
              {m.replies > 0 && (
                <div style={{ marginTop: 4, fontFamily: fonts.body, fontSize: 11, color: c.textTertiary }}>
                  {m.replies} repl{m.replies === 1 ? 'y' : 'ies'} in thread
                </div>
              )}
            </div>
          </a>
        );
      })}
      <a href="#" style={{
        marginTop: 6, padding: '10px 0',
        fontFamily: fonts.body, fontSize: 12, color: c.textSecondary, textDecoration: 'none',
        borderTop: `1px solid ${c.borderSubtle}`,
      }}>Open inbox →</a>
    </div>
  );
}

// ── Pings rail (one-tap nudges + recent log) ──
function PingsRail({ suggested, recent, clientById }) {
  const { c, fonts } = useLW();
  const [tab, setTab] = useState('suggested');
  const items = tab === 'suggested' ? suggested : recent;
  const kindMeta = {
    'check-in':  { icon: '◌', label: 'check-in',  color: c.flame  },
    'celebrate': { icon: '✦', label: 'celebrate', color: c.accent },
    'welcome':   { icon: '◐', label: 'welcome',   color: c.accent },
    'prompt':    { icon: '?', label: 'prompt',    color: c.textSecondary },
  };
  return (
    <div>
      {/* tabs */}
      <div style={{ display: 'flex', gap: 4, marginBottom: 10, padding: 3, background: c.cardHover, border: `1px solid ${c.borderSubtle}`, borderRadius: 10 }}>
        {[['suggested','Suggested'],['recent','Recent']].map(([k,l]) => (
          <button key={k} onClick={() => setTab(k)} style={{
            flex: 1, padding: '7px 10px', borderRadius: 7, border: 'none', cursor: 'pointer',
            background: tab === k ? c.card : 'transparent',
            color: tab === k ? c.textPrimary : c.textSecondary,
            fontFamily: fonts.body, fontSize: 12, fontWeight: 600, letterSpacing: '0.02em',
            boxShadow: tab === k ? `0 1px 0 ${c.borderSubtle}` : 'none',
          }}>{l}</button>
        ))}
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
        {items.map((p, i) => {
          const cl = clientById[p.clientId];
          const isLast = i === items.length - 1;
          const meta = kindMeta[p.kind] || kindMeta.prompt;
          const isSuggested = tab === 'suggested';
          return (
            <div key={p.id} style={{
              display: 'grid', gridTemplateColumns: '32px 1fr', gap: 10, padding: '12px 0',
              borderBottom: isLast ? 'none' : `1px solid ${c.borderSubtle}`,
            }}>
              <Avatar name={cl.name} hue={cl.avatarHue} size={28} />
              <div style={{ minWidth: 0 }}>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginBottom: 4 }}>
                  <span style={{ fontFamily: fonts.app, fontSize: 13, fontWeight: 700, color: c.textPrimary }}>{cl.name.split(' ')[0]}</span>
                  <span style={{
                    fontFamily: fonts.body, fontSize: 9, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase',
                    color: meta.color, padding: '1px 6px', borderRadius: 4,
                    background: hexToRgba(meta.color, 0.12), border: `1px solid ${hexToRgba(meta.color, 0.3)}`,
                  }}>{meta.icon} {meta.label}</span>
                  <span style={{ flex: 1 }} />
                  {!isSuggested && (
                    <span style={{ fontFamily: fonts.body, fontSize: 11, color: c.textTertiary, whiteSpace: 'nowrap' }}>
                      {p.ts.replace('today ','').replace('yest ','y · ')}
                    </span>
                  )}
                </div>
                {isSuggested && (
                  <div style={{ fontFamily: fonts.body, fontSize: 11, color: c.textTertiary, marginBottom: 6, letterSpacing: '0.02em' }}>
                    {p.reason}
                  </div>
                )}
                <div style={{
                  fontFamily: fonts.body, fontStyle: 'italic', fontSize: 13, lineHeight: 1.5, color: c.textSecondary,
                  paddingLeft: 10, borderLeft: `2px solid ${hexToRgba(meta.color, 0.4)}`,
                  marginBottom: isSuggested ? 8 : 0,
                  overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical',
                }}>
                  “{isSuggested ? p.preset : p.text}”
                </div>
                {isSuggested && (
                  <div style={{ display: 'flex', gap: 6 }}>
                    <Button size="sm" variant="primary" icon={<span style={{ fontSize: 12 }}>↗</span>}>Send ping</Button>
                    <Button size="sm" variant="quiet">Edit</Button>
                  </div>
                )}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ── KPI ──
function KPI({ label, value, sub, tone }) {
  const { c, fonts, brutMode } = useLW();
  const isBrut = !!brutMode;
  const t = tone === 'flame' ? c.flame : tone === 'accent' ? c.accent : c.textPrimary;
  return (
    <Card padding={16} style={{ flex: 1, minWidth: 0 }}>
      <div style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary, marginBottom: 8 }}>
        {label}
      </div>
      <div style={{
        fontFamily: isBrut ? '"IBM Plex Mono", monospace' : fonts.display,
        fontSize: 30, fontWeight: isBrut ? 700 : 600,
        letterSpacing: '-0.02em', color: t, lineHeight: 1, marginBottom: 6,
      }}>
        {value}
      </div>
      <div style={{ fontFamily: fonts.body, fontSize: 12, color: c.textSecondary, lineHeight: 1.4 }}>
        {sub}
      </div>
    </Card>
  );
}

// Subscribe to /coach_clients/{auth.uid} → realtime roster.
// Returns: { status: 'loading' | 'empty' | 'ready', clients: [...] }
function useRealRoster() {
  const [state, setState] = useState({ status: 'loading', clients: [] });
  useEffect(() => {
    if (!window.LWAuth || !window.LWFB) return;
    let unsubAuth = null, ref = null;
    unsubAuth = window.LWAuth.onAuthChanged((u) => {
      if (ref) { ref.off(); ref = null; }
      if (!u) { setState({ status: 'loading', clients: [] }); return; }
      ref = window.LWFB.db.ref(`/coach_clients/${u.uid}`);
      ref.on('value', (snap) => {
        if (!snap.exists()) { setState({ status: 'empty', clients: [] }); return; }
        const obj = snap.val() || {};
        const list = Object.entries(obj).map(([userUid, v]) => ({
          id: userUid,
          userUid,
          name: v.displayName || v.name || 'Client',
          initials: v.initials || (window.LWAuth ? window.LWAuth.initialsOf(v.displayName || 'Client') : '·'),
          avatarHue: v.avatarHue ?? 140,
          connectedAt: v.connectedAt || 0,
          channel: v.channel || 'direct',
          channelHandle: v.channelHandle || '',
          status: 'active',
          tags: v.tags || [],
        }));
        setState({ status: list.length ? 'ready' : 'empty', clients: list });
      });
    });
    return () => { if (unsubAuth) unsubAuth(); if (ref) ref.off(); };
  }, []);
  return state;
}

// ── Page ──
// One unified surface — Today + Clients merged into the dashboard. Order:
//   1. DashHero (adaptive copy, never zero-counters)
//   2. Today's session rail (only if any session today)
//   3. Attention queue (only if any signals fire)
//   4. Full roster grid (always rendered when there's at least one client)
// The `view` param is accepted for legacy bookmarks (#/clients) but no longer
// branches the render — the practice grid lives in the same screen as the
// signals so the coach answers all three jobs ("plate / who needs me / state
// of practice") without tab-switching. See:
// design/coach_dashboard_today_signals_2026_05_06.md
function Dashboard({ view }) {
  const { c: cBase, fonts: fontsBase, t } = useLW();
  const { isMobile, isTablet } = useViewport();
  const stackBody = isMobile || isTablet;
  const activeView = view === 'clients' ? 'clients' : 'today';

  // Brutalism theme override — same cream/dark toggle as marketing pages.
  // Inner sub-components inherit cream/dark via the c-token override.
  const [brutMode, setBrutMode] = window.LWBrutal.useBrutMode();
  const c = window.LWBrutal.makeC(cBase, brutMode);
  const fonts = { ...fontsBase, ...window.LWBrutal.fonts };

  const [authedCoach, setAuthedCoach] = useState(null);
  useEffect(() => window.LWAuth ? window.LWAuth.onAuthChanged(setAuthedCoach) : undefined, []);
  const coachName = (authedCoach && authedCoach.profile && authedCoach.profile.displayName) || (authedCoach && authedCoach.email) || (LWDATA.COACH && LWDATA.COACH.name) || 'Coach';

  // Make brutMode available to sub-components via a context-like pass.
  // PageShell wraps everything below; we attach to window for any deeper component.
  window.__brutMode = brutMode;
  window.__brutSetMode = setBrutMode;
  window.__brutC = c;
  window.__brutFonts = fonts;

  const roster = useRealRoster();
  // A coach can also be a client themselves — when their own iOS user has an
  // active coach_link, we want to surface a self-preview card on the dashboard
  // even if their /coach_clients list is empty. So if a self-preview exists,
  // route to RealDashboardBody (which will render the preview block) instead
  // of falling back to EmptyDashboard.
  const selfPreview = useSelfAsClient();
  if (roster.status === 'loading') {
    return <LoadingDashboard coachName={coachName} view={activeView} />;
  }
  if (roster.status === 'empty' && !selfPreview) {
    return <EmptyDashboard coachName={coachName} view={activeView} />;
  }
  return <RealDashboardBody coachName={coachName} clients={roster.clients || []} view={activeView} selfPreview={selfPreview} />;
}

// The interactive dashboard body — splits hero, attention queue, today's sessions,
// and the full client grid. Keeps roster + sessions + per-client signals in one place
// so DashHero can render context-aware copy (today/tomorrow/quiet/attention).
function RealDashboardBody({ coachName, clients, view, selfPreview }) {
  const { c, fonts, t, lang } = useLW();
  const sessionsState = useTodaySessions();
  // Enrich the roster + the optional self-preview entry together so the
  // self card shows the same wheel/mood/last-activity readout as a regular
  // client card (rules grant self-read; coach-side scope filtering still
  // applies inside ClientDetail).
  const allForEnrich = React.useMemo(
    () => (selfPreview ? [...clients, selfPreview] : clients),
    [clients, selfPreview]
  );
  const enrichments = useEnrichedRoster(allForEnrich, sessionsState.byUid);
  const todaySessions = sessionsState;

  // Compute per-client attention signals (only fires when enrichments arrive).
  const attentionEntries = clients.map(cl => {
    const e = enrichments[cl.userUid] || {};
    return { client: cl, enrichment: e, signals: computeAttentionSignals(cl, e) };
  });
  const attentionList = attentionEntries.filter(x => x.signals.length > 0);

  const summary = {
    todayCount: todaySessions.today.length,
    tomorrowCount: todaySessions.tomorrow.length,
    attentionCount: attentionList.length,
    firstTodaySession: todaySessions.today[0] ? Number(todaySessions.today[0].startedAt) : null,
  };

  return (
    <PageShell active="dashboard">
      <DashHero name={coachName} summary={summary} />

      <ProfileNudge />

      {todaySessions.today.length > 0 && (
        <TodaySessionsRail sessions={todaySessions.today} clients={clients} />
      )}

      {attentionList.length > 0 && (
        <AttentionQueue entries={attentionList} />
      )}

      {(clients.length > 0 || selfPreview) && (
        <div style={{ marginTop: 28 }}>
          <SectionHead
            eyebrow={t('dash.connected_eyebrow')}
            title={t('dash.your')}
            accent={t('dash.clients_accent')}
            right={<Chip tone="subtle">{t('dash.total', { n: clients.length + (selfPreview ? 1 : 0) })}</Chip>}
          />
          <RealRosterGrid
            clients={selfPreview ? [selfPreview, ...clients] : clients}
            enrichments={enrichments}
          />
        </div>
      )}
    </PageShell>
  );
}


// Subscribe to /sessions filtered by signed-in coach, return { today, tomorrow,
// byUid }. byUid groups by userUid for per-client lookups (last-session
// timestamp drives the dashboard mini-pulse).
function useTodaySessions() {
  const [state, setState] = useState({ today: [], tomorrow: [], byUid: {} });
  useEffect(() => {
    if (!window.LWFB || !window.LWAuth) return;
    const auth = window.LWAuth.currentCoach && window.LWAuth.currentCoach();
    if (!auth) return;
    const ref = window.LWFB.db.ref('/sessions').orderByChild('coachId').equalTo(auth.uid);
    const handler = (snap) => {
      if (!snap.exists()) { setState({ today: [], tomorrow: [], byUid: {} }); return; }
      const list = Object.entries(snap.val()).map(([id, s]) => ({ id, ...s }));
      const todayStart = startOfDay(new Date()).getTime();
      const tomorrowStart = todayStart + 86_400_000;
      const dayAfter = todayStart + 2 * 86_400_000;
      const today = list
        .filter(s => Number(s.startedAt) >= todayStart && Number(s.startedAt) < tomorrowStart && !s.endedAt)
        .sort((a, b) => Number(a.startedAt) - Number(b.startedAt));
      const tomorrow = list
        .filter(s => Number(s.startedAt) >= tomorrowStart && Number(s.startedAt) < dayAfter)
        .sort((a, b) => Number(a.startedAt) - Number(b.startedAt));
      const byUid = {};
      list.forEach(s => {
        if (!s.userUid) return;
        (byUid[s.userUid] = byUid[s.userUid] || []).push(s);
      });
      Object.values(byUid).forEach(arr => arr.sort((a, b) => Number(b.startedAt || 0) - Number(a.startedAt || 0)));
      setState({ today, tomorrow, byUid });
    };
    ref.on('value', handler, () => setState({ today: [], tomorrow: [], byUid: {} }));
    return () => ref.off('value', handler);
  }, []);
  return state;
}
function startOfDay(d) {
  const x = new Date(d);
  x.setHours(0, 0, 0, 0);
  return x;
}

// Detects whether the signed-in coach is *also* a client (their own iOS user
// has an active coach_link to someone else, or to themselves). Returns a
// synthetic "client" entry the dashboard can render as a preview card so the
// coach sees what their coach sees of them — same scope + coachShare filters
// flow through ClientDetail because the security rule `auth.uid === $uid`
// grants self-read access on every protected /users/{uid}/* path.
function useSelfAsClient() {
  const [self, setSelf] = useState(null);
  useEffect(() => {
    if (!window.LWAuth || !window.LWFB) return;
    let unsubAuth = null;
    let off = null;
    unsubAuth = window.LWAuth.onAuthChanged(async (u) => {
      if (off) { off(); off = null; }
      if (!u) { setSelf(null); return; }
      const linkRef = window.LWFB.db.ref(`/users/${u.uid}/coach_link`);
      const linkHandler = async (snap) => {
        if (!snap.exists()) { setSelf(null); return; }
        const link = snap.val() || {};
        if (link.status !== 'active') { setSelf(null); return; }
        // Pull a sensible display name. Prefer the iOS-side profile display
        // name if present; otherwise fall back to the coach profile we just
        // read at sign-in (Apple display name for federated users).
        let displayName = (u.profile && u.profile.displayName) || (u.email ? u.email.split('@')[0] : 'You');
        try {
          const profileSnap = await window.LWFB.db.ref(`/users/${u.uid}/profile/displayName`).get();
          if (profileSnap.exists() && profileSnap.val()) displayName = String(profileSnap.val());
        } catch {}
        setSelf({
          id: u.uid,
          userUid: u.uid,
          name: displayName,
          initials: window.LWAuth.initialsOf(displayName),
          avatarHue: 200,
          connectedAt: link.claimedAt || 0,
          channel: 'self',
          channelHandle: '',
          status: 'active',
          tags: [],
          isSelfPreview: true,
          previewCoachName: link.coachName || '',
        });
      };
      linkRef.on('value', linkHandler);
      off = () => linkRef.off('value', linkHandler);
    });
    return () => { if (unsubAuth) unsubAuth(); if (off) off(); };
  }, []);
  return self;
}

// Per-client attention signals. Each is an object { kind, severity, label }.
// Severity: 0 muted, 1 normal, 2 strong (used for sort + tone).
function computeAttentionSignals(client, enrichment) {
  const out = [];
  const e = enrichment || {};
  // 1. Outbox stale: items pushed > 2 days ago, no client action. The waiting
  //    count comes from summarizeOutbox; we don't have age detail there yet,
  //    so fall back to "any waiting" as a soft signal.
  if (e.outbox && e.outbox.waiting > 0) {
    out.push({ kind: 'outbox', severity: 1, n: e.outbox.waiting });
  }
  // 2. Quiet: no journal/mood for 7+ days AND has been connected longer than 3 days.
  const connectedAt = client.connectedAt ? Number(client.connectedAt) : null;
  const connectedDays = connectedAt ? Math.floor((Date.now() - connectedAt) / 86_400_000) : 0;
  if (e.lastActivity && connectedDays >= 3) {
    const quietDays = Math.floor((Date.now() - e.lastActivity) / 86_400_000);
    if (quietDays >= 7) {
      out.push({ kind: 'quiet', severity: 2, n: quietDays });
    }
  } else if (!e.lastActivity && connectedDays >= 7) {
    // No data at all after a week of being connected — soft "quiet".
    out.push({ kind: 'quiet', severity: 2, n: connectedDays });
  }
  // 3. Mood drop: mood week trends down by 1.5+ points.
  const week = e.mood ? buildMoodWeek(e.mood) : null;
  if (week) {
    const valid = week.filter(v => v != null);
    if (valid.length >= 4) {
      const early = valid.slice(0, Math.floor(valid.length / 2));
      const late = valid.slice(Math.floor(valid.length / 2));
      const earlyAvg = early.reduce((s, v) => s + v, 0) / early.length;
      const lateAvg = late.reduce((s, v) => s + v, 0) / late.length;
      const delta = lateAvg - earlyAvg;
      if (delta <= -1.5) {
        out.push({ kind: 'mood_drop', severity: 2, n: Math.abs(delta).toFixed(1) });
      }
    }
  }
  return out;
}

function LoadingDashboard({ coachName, view }) {
  const { c: cBase, fonts: fontsBase } = useLW();
  const isBrut = !!(window.__brutMode && window.__brutC);
  const c = isBrut ? window.__brutC : cBase;
  const fonts = isBrut ? (window.__brutFonts || fontsBase) : fontsBase;
  const lang = window.LWLang ? window.LWLang.lang() : 'en';
  return (
    <PageShell active={view === 'clients' ? 'clients' : 'dashboard'}>
      <div style={{
        marginTop: 32,
        padding: 36, textAlign: 'center',
        borderRadius: isBrut ? 2 : 16,
        background: c.card,
        border: isBrut ? `1.5px solid ${c.ink}` : `1px solid ${c.borderSubtle}`,
        boxShadow: isBrut ? `4px 4px 0 0 ${c.ink}` : 'none',
        fontFamily: isBrut ? fonts.mono : fonts.display,
        fontStyle: isBrut ? 'normal' : 'italic',
        fontSize: 14, color: c.textSecondary,
        letterSpacing: isBrut ? '0.06em' : 'normal',
        textTransform: isBrut ? 'uppercase' : 'none',
      }}>
        {isBrut ? `—— ${lang === 'ru' ? 'Загружаем список клиентов' : 'Loading your roster'} ——` : 'Loading your roster…'}
      </div>
    </PageShell>
  );
}

// Plain stroked arrow — keeps the CTA glyph as a typographic accent rather
// than a full emoji-rendered icon. Inherits color from `currentColor` unless
// `color` is set so it picks up the link's foreground naturally.
function ArrowGlyph({ color }) {
  return (
    <svg width={16} height={16} viewBox="0 0 24 24" fill="none"
         stroke={color || 'currentColor'} strokeWidth={2}
         strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <line x1="4" y1="12" x2="20" y2="12" />
      <polyline points="14 6 20 12 14 18" />
    </svg>
  );
}

function EmptyDashboard({ coachName, view }) {
  const { c: cBase, fonts: fontsBase, t } = useLW();
  const isBrut = !!(window.__brutMode && window.__brutC);
  const c = isBrut ? window.__brutC : cBase;
  const fonts = isBrut ? (window.__brutFonts || fontsBase) : fontsBase;
  const open = (e) => {
    e.preventDefault();
    if (window.LWRouter) window.LWRouter.navigate('invite');
    else window.location.hash = '#/invite';
  };
  return (
    <PageShell active={view === 'clients' ? 'clients' : 'dashboard'}>
      <div style={{
        padding: 36,
        borderRadius: isBrut ? 2 : 20,
        background: c.card,
        border: isBrut ? `1.5px solid ${c.ink}` : `1px solid ${c.borderSubtle}`,
        boxShadow: isBrut ? `6px 6px 0 0 ${c.ink}` : '0 12px 36px rgba(0,0,0,0.18)',
        marginBottom: 20, marginTop: 16,
      }}>
        <div style={{
          fontFamily: isBrut ? fonts.mono : fonts.body,
          fontSize: 11, fontWeight: isBrut ? 600 : 700,
          letterSpacing: '0.16em', textTransform: 'uppercase',
          color: isBrut ? c.textTertiary : c.accent, marginBottom: 12,
        }}>{isBrut ? `—— ${t('dash.welcome')}` : t('dash.welcome')}</div>
        <h2 style={{
          fontFamily: isBrut ? fonts.mono : fonts.display,
          fontSize: isBrut ? 28 : 32,
          fontWeight: isBrut ? 700 : 600,
          lineHeight: 1.1, letterSpacing: '-0.015em',
          color: c.textPrimary, margin: 0, marginBottom: 10,
          textTransform: isBrut ? 'uppercase' : 'none',
        }}>
          {t('dash.empty_title')} {isBrut ? (
            <span style={{
              color: c.accentInk,
              textDecoration: 'underline',
              textDecorationColor: c.accentInk,
              textDecorationThickness: '3px',
              textUnderlineOffset: '6px',
              textDecorationSkipInk: 'none',
            }}>{t('dash.empty_yet')}</span>
          ) : (
            <em style={{ fontStyle: 'italic', color: c.accent }}>{t('dash.empty_yet')}</em>
          )}
        </h2>
        <p style={{
          fontFamily: isBrut ? fonts.mono : fonts.display,
          fontStyle: 'italic',
          fontSize: isBrut ? 14 : 17,
          color: c.textSecondary, margin: 0, marginBottom: 24, maxWidth: 560,
          lineHeight: 1.55,
          ...(isBrut ? { borderLeft: `2px solid ${c.ink}`, paddingLeft: 14 } : {}),
        }}>
          {t('dash.empty_body_short')}
        </p>

        {/* Primary: coach generates the invite link */}
        <a href="#/invite" onClick={open} className={isBrut ? 'br-cta' : ''} style={isBrut ? {
          display: 'flex', alignItems: 'center', gap: 16,
          padding: '18px 22px', textDecoration: 'none',
          marginBottom: 14, fontSize: 13,
        } : {
          display: 'flex', alignItems: 'center', gap: 16,
          padding: '20px 22px', borderRadius: 14,
          background: c.accent, color: c.textOnAccent,
          textDecoration: 'none', cursor: 'pointer',
          marginBottom: 12,
          boxShadow: `0 8px 24px ${hexToRgba(c.accent, 0.30)}`,
          transition: 'transform 120ms ease, box-shadow 120ms ease',
        }} {...(!isBrut ? {
          onMouseEnter: (e) => e.currentTarget.style.transform = 'translateY(-1px)',
          onMouseLeave: (e) => e.currentTarget.style.transform = 'none',
        } : {})}>
          <span style={{ flex: 1 }}>
            <span style={{
              display: 'block',
              fontFamily: isBrut ? fonts.mono : fonts.body,
              fontSize: isBrut ? 13 : 16,
              fontWeight: 700, marginBottom: 4,
              letterSpacing: isBrut ? '0.10em' : 'normal',
              textTransform: isBrut ? 'uppercase' : 'none',
            }}>
              {isBrut ? `[ ${t('dash.empty_cta_invite_title')} → ]` : t('dash.empty_cta_invite_title')}
            </span>
            <span style={{
              display: 'block',
              fontFamily: isBrut ? fonts.mono : fonts.body,
              fontSize: isBrut ? 11 : 13,
              opacity: isBrut ? 1 : 0.85,
              color: isBrut ? c.textOnAccent : 'currentColor',
              letterSpacing: isBrut ? '0.04em' : 'normal',
              fontWeight: isBrut ? 500 : 'inherit',
              textTransform: isBrut ? 'none' : 'none',
            }}>
              {t('dash.empty_cta_invite_sub')}
            </span>
          </span>
          {!isBrut && <ArrowGlyph />}
        </a>

        {/* Secondary: client sends the invite from their iOS app */}
        <a href="#/invite" onClick={open} className={isBrut ? 'br-btn' : ''} style={isBrut ? {
          display: 'flex', alignItems: 'center', gap: 16,
          padding: '16px 22px', textDecoration: 'none',
          fontSize: 13,
        } : {
          display: 'flex', alignItems: 'center', gap: 16,
          padding: '18px 22px', borderRadius: 14,
          background: 'transparent', color: c.textPrimary,
          border: `1px solid ${c.borderDefault}`,
          textDecoration: 'none', cursor: 'pointer',
          transition: 'border-color 120ms ease, background 120ms ease',
        }} {...(!isBrut ? {
          onMouseEnter: (e) => { e.currentTarget.style.borderColor = c.borderStrong; e.currentTarget.style.background = c.bgSubtle; },
          onMouseLeave: (e) => { e.currentTarget.style.borderColor = c.borderDefault; e.currentTarget.style.background = 'transparent'; },
        } : {})}>
          <span style={{ flex: 1 }}>
            <span style={{
              display: 'block',
              fontFamily: isBrut ? fonts.mono : fonts.body,
              fontSize: isBrut ? 13 : 15,
              fontWeight: isBrut ? 700 : 600, marginBottom: 4,
              letterSpacing: isBrut ? '0.10em' : 'normal',
              textTransform: isBrut ? 'uppercase' : 'none',
            }}>
              {isBrut ? `[ ${t('dash.empty_cta_inverse_title')} → ]` : t('dash.empty_cta_inverse_title')}
            </span>
            <span style={{
              display: 'block',
              fontFamily: isBrut ? fonts.mono : fonts.body,
              fontSize: isBrut ? 11 : 13,
              color: c.textTertiary,
              letterSpacing: isBrut ? '0.04em' : 'normal',
              fontWeight: isBrut ? 500 : 'inherit',
            }}>
              {t('dash.empty_cta_inverse_sub')}
            </span>
          </span>
          {!isBrut && <ArrowGlyph color={c.textTertiary} />}
        </a>
      </div>

      <div style={{
        padding: 18,
        borderRadius: isBrut ? 2 : 14,
        background: c.bgSubtle,
        border: isBrut ? `1.5px dashed ${c.ink}` : `1px dashed ${c.borderDefault}`,
        fontFamily: isBrut ? fonts.mono : fonts.body,
        fontSize: 12, color: c.textTertiary,
        fontStyle: isBrut ? 'italic' : 'normal',
        letterSpacing: isBrut ? '0.04em' : 'normal',
      }}>
        {t('dash.empty_test_hint')}
      </div>
    </PageShell>
  );
}

// Subscribe per-client to spheres + areas + mood so each card can render a
// real mini wheel and mood arrow. Returns roster augmented with `_enriched`.
function useEnrichedRoster(rosterClients, sessionsByUid) {
  const [enrichments, setEnrichments] = useState({});
  useEffect(() => {
    if (!window.LWFB || !rosterClients || !rosterClients.length) return;
    const coach = window.LWAuth && window.LWAuth.currentCoach && window.LWAuth.currentCoach();
    const coachUid = coach && coach.uid;
    const cleanups = [];
    rosterClients.forEach((cl) => {
      const uid = cl.userUid;
      if (!uid) return;
      const acc = { spheres: null, areas: null, mood: null, journal: null, goals: null, outboxRaw: null, executionLog: null, wheels: null, activeWheelId: null };
      const apply = () => {
        // Filter all wheel-scoped data to the user's currently-active wheel
        // (multi-wheel v0). Helpers come from client.jsx via window globals.
        const wheelCtx = (typeof resolveActiveWheel === 'function')
          ? resolveActiveWheel(acc.wheels, acc.activeWheelId)
          : { activeId: null, isDefault: true, defaultId: null };
        const filterMap = (typeof filterMapToActiveWheel === 'function')
          ? filterMapToActiveWheel : (m) => m;
        const goalsFilter = (typeof goalsFromMap === 'function')
          ? (raw) => {
              // Reuse the client.jsx filter (paused/completed/active-wheel)
              // and re-key to the original map shape downstream consumers
              // (summarizeOutbox, pulse) expect.
              const list = goalsFromMap(raw, wheelCtx);
              const map = {};
              list.forEach(g => { map[g.id] = g; });
              return map;
            }
          : (m) => m;
        const spheresOnActive = filterMap(acc.spheres, wheelCtx);
        const areasOnActive   = filterMap(acc.areas, wheelCtx);
        const goalsOnActive   = goalsFilter(acc.goals);

        const moodLast = latestMoodTs(acc.mood);
        const journalLast = latestJournalTs(acc.journal);
        const lastActivity = Math.max(moodLast || 0, journalLast || 0) || null;
        const outboxSummary = summarizeOutbox(acc.outboxRaw, coachUid);
        const sessionList = (sessionsByUid && sessionsByUid[uid]) || [];
        const lastSession = sessionList.find(s => s.endedAt && s.coachId === coachUid);
        const pulse = pulseSinceLastSession({
          lastEnded: lastSession ? Number(lastSession.endedAt) : null,
          mood: acc.mood,
          journal: acc.journal,
          goals: goalsOnActive,
          outboxRaw: acc.outboxRaw,
          executionLog: acc.executionLog,
          coachUid,
        });
        setEnrichments(prev => ({
          ...prev,
          [uid]: {
            ...acc,
            spheres: spheresOnActive,
            areas:   areasOnActive,
            goals:   goalsOnActive,
            moodLast,
            journalLast,
            lastActivity,
            outbox: outboxSummary,
            pulse,
          },
        }));
      };
      const sphR = window.LWFB.db.ref(`/users/${uid}/spheres`);
      const areaR = window.LWFB.db.ref(`/users/${uid}/areas`);
      const wheelsR = window.LWFB.db.ref(`/users/${uid}/wheels`);
      const activeR = window.LWFB.db.ref(`/users/${uid}/activeWheelId`);
      const moodR = window.LWFB.db.ref(`/users/${uid}/mood`);
      const jrnR  = window.LWFB.db.ref(`/users/${uid}/journal`);
      const goalsR = window.LWFB.db.ref(`/users/${uid}/goals`);
      const exR  = window.LWFB.db.ref(`/users/${uid}/TodoExecutionLog`);
      const outR  = window.LWFB.db.ref(`/coach_outbox/${uid}`);
      sphR.on('value',  (s) => { acc.spheres = s.exists() ? s.val() : null; apply(); }, () => {});
      areaR.on('value', (s) => { acc.areas   = s.exists() ? s.val() : null; apply(); }, () => {});
      wheelsR.on('value', (s) => { acc.wheels = s.exists() ? s.val() : null; apply(); }, () => {});
      activeR.on('value', (s) => { acc.activeWheelId = s.exists() ? s.val() : null; apply(); }, () => {});
      moodR.on('value', (s) => { acc.mood    = s.exists() ? s.val() : null; apply(); }, () => {});
      jrnR.on('value',  (s) => { acc.journal = s.exists() ? s.val() : null; apply(); }, () => {});
      goalsR.on('value', (s) => { acc.goals  = s.exists() ? s.val() : null; apply(); }, () => {});
      exR.on('value',   (s) => { acc.executionLog = s.exists() ? s.val() : null; apply(); }, () => {});
      outR.on('value',  (s) => { acc.outboxRaw = s.exists() ? s.val() : null; apply(); }, () => {});
      cleanups.push(() => { sphR.off(); areaR.off(); wheelsR.off(); activeR.off(); moodR.off(); jrnR.off(); goalsR.off(); exR.off(); outR.off(); });
    });
    return () => cleanups.forEach(fn => { try { fn(); } catch {} });
  }, [rosterClients, sessionsByUid]);
  return enrichments;
}

// Mini-pulse summary used by the dashboard card. Takes the same inputs as the
// full Pulse panel on Client.html but emits compact counts instead of full
// per-item rendering. When `lastEnded` is null (no session yet) this returns
// nulls and the card falls back to the existing activity line.
function pulseSinceLastSession({ lastEnded, mood, journal, goals, outboxRaw, executionLog, coachUid }) {
  if (!lastEnded) return null;
  let journalSince = 0, moodSince = 0, accepted = 0, pending = 0, newGoals = 0, habitCompletions = 0;
  if (journal) {
    Object.values(journal).forEach(j => {
      if (!j) return;
      // Privacy: count only entries explicitly shared with the coach.
      const v = j.coachShare;
      let shared = false;
      if (v === true) shared = true;
      else if (typeof v === 'string') {
        const s = v.toLowerCase();
        shared = (s === 'shared' || s === 'true' || s === '1' || s === 'yes' || s === 'on');
      } else if (typeof v === 'number') shared = v === 1;
      if (!shared) return;
      const ts = Number(j.creationDate || j.modificationDate || 0);
      const ms = ts > 1e12 ? ts : ts * 1000;
      if (ms >= lastEnded) journalSince += 1;
    });
  }
  if (mood) {
    Object.values(mood).forEach(m => {
      if (!m) return;
      const ts = Number(m.creationDate || m.ts || 0);
      const ms = ts > 1e12 ? ts : ts * 1000;
      if (ms >= lastEnded) moodSince += 1;
    });
  }
  if (goals) {
    Object.values(goals).forEach(g => {
      if (!g) return;
      const ct = parseFloat(g.creationTime || 0);
      if (!ct) return;
      const ms = ct > 1e12 ? ct : ct * 1000;
      if (ms >= lastEnded) newGoals += 1;
    });
  }
  if (outboxRaw) {
    Object.entries(outboxRaw).forEach(([id, it]) => {
      if (id === '_meta' || !it || it.fromCoachId !== coachUid) return;
      if (it.acceptedAt && Number(it.acceptedAt) >= lastEnded) accepted += 1;
      else if (!it.acceptedAt && !it.skippedAt) pending += 1;
    });
  }
  if (executionLog) {
    Object.values(executionLog).forEach(goalRecord => {
      const dates = goalRecord && goalRecord.executionDates;
      if (!dates) return;
      Object.values(dates).forEach(record => {
        if (!record || typeof record !== 'object') return;
        Object.entries(record).forEach(([dateStr, action]) => {
          if (action !== 'increment') return;
          const parts = dateStr.split('-');
          if (parts.length !== 3) return;
          const [d, m, y] = parts.map(s => parseInt(s, 10));
          if (isNaN(d) || isNaN(m) || isNaN(y)) return;
          const ms = new Date(y, m - 1, d, 12).getTime();
          if (ms >= lastEnded) habitCompletions += 1;
        });
      });
    });
  }
  return { journalSince, moodSince, accepted, pending, newGoals, habitCompletions, lastEnded };
}

// Last mood entry timestamp (ms). iOS writes mood entries with creationDate
// as Unix seconds (fractional); web mirrors. Promote to ms if needed.
function latestMoodTs(mood) {
  if (!mood) return null;
  let best = 0;
  Object.values(mood).forEach(m => {
    const ts = Number((m && (m.creationDate || m.ts)) || 0);
    const ms = ts > 1e12 ? ts : ts * 1000;
    if (ms > best) best = ms;
  });
  return best || null;
}
function latestJournalTs(journal) {
  if (!journal) return null;
  let best = 0;
  Object.values(journal).forEach(j => {
    const ts = Number((j && (j.creationDate || j.modificationDate)) || 0);
    const ms = ts > 1e12 ? ts : ts * 1000;
    if (ms > best) best = ms;
  });
  return best || null;
}

// Counts of items the SIGNED-IN coach sent to this client. waitingCount =
// pushed-but-unresolved (or pending, never pushed). acceptedRecent = accepted
// in last 7 days. Both gated to fromCoachId === coachUid.
function summarizeOutbox(outbox, coachUid) {
  if (!outbox || !coachUid) return { waiting: 0, acceptedRecent: 0 };
  const weekAgo = Date.now() - 7 * 86_400_000;
  let waiting = 0, acceptedRecent = 0;
  Object.entries(outbox).forEach(([id, it]) => {
    if (id === '_meta' || !it || it.fromCoachId !== coachUid) return;
    if (it.acceptedAt) {
      if (Number(it.acceptedAt) >= weekAgo) acceptedRecent += 1;
      return;
    }
    if (it.skippedAt) return;
    waiting += 1;
  });
  return { waiting, acceptedRecent };
}

// Compact mini-pulse line for dashboard cards. Renders top 2-3 most-significant
// signals as colored chips inline (✓ N · + N · ◇ N). Hidden when no signals.
function CardMiniPulse({ pulse, c, t }) {
  if (!pulse || !pulse.lastEnded) return null;
  const chips = [];
  if (pulse.pending > 0)        chips.push({ key: 'p', text: t('dash.card_pulse_pending', { n: pulse.pending }), tone: c.flame });
  if (pulse.accepted > 0)       chips.push({ key: 'a', text: t('dash.card_pulse_accepted', { n: pulse.accepted }), tone: c.accent });
  if (pulse.habitCompletions > 0) chips.push({ key: 'h', text: t('dash.card_pulse_habits', { n: pulse.habitCompletions }), tone: c.accent });
  if (pulse.newGoals > 0)       chips.push({ key: 'g', text: t('dash.card_pulse_new_goal', { n: pulse.newGoals }), tone: c.accent });
  if (pulse.journalSince > 0)   chips.push({ key: 'j', text: t('dash.card_pulse_journal', { n: pulse.journalSince }), tone: c.textSecondary });
  if (pulse.moodSince > 0)      chips.push({ key: 'm', text: t('dash.card_pulse_mood', { n: pulse.moodSince }), tone: c.textSecondary });
  if (chips.length === 0) return null;
  // Cap at 3 most-prioritized signals to keep the card compact.
  const shown = chips.slice(0, 3);
  return (
    <div style={{
      display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 2,
      paddingTop: 6, borderTop: `1px dashed ${c.borderSubtle}`,
    }}>
      {shown.map(ch => (
        <span key={ch.key} style={{
          fontFamily: 'inherit', fontSize: 11, fontWeight: 700,
          letterSpacing: '0.02em', color: ch.tone,
          whiteSpace: 'nowrap',
        }}>
          {ch.text}
        </span>
      ))}
    </div>
  );
}

function OutboxBadge({ waiting, accepted }) {
  const { c, fonts, t } = useLW();
  // Waiting takes priority — that's the "needs the client's eyes" signal.
  // Fall back to accepted only when nothing's pending.
  const showWaiting = waiting > 0;
  const label = showWaiting
    ? t('dash.card_outbox_pending', { n: waiting })
    : t('dash.card_outbox_accepted', { n: accepted });
  const tone = showWaiting ? c.flame : c.accent;
  const bg = showWaiting ? hexToRgba(c.flame, 0.12) : hexToRgba(c.accent, 0.10);
  return (
    <div style={{
      display: 'inline-flex', alignItems: 'center', gap: 5,
      padding: '4px 9px', borderRadius: 999,
      background: bg,
      fontFamily: fonts.body, fontSize: 10, fontWeight: 700,
      letterSpacing: '0.06em', color: tone,
      whiteSpace: 'nowrap',
    }}>
      <span style={{ width: 5, height: 5, borderRadius: '50%', background: tone }} />
      {label}
    </div>
  );
}

function RealRosterGrid({ clients, enrichments }) {
  return (
    <div style={{ display: 'grid', gap: 14, gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))' }}>
      {clients.map(cl => (
        <RealClientCard key={cl.id} client={cl} enrichment={enrichments[cl.userUid] || {}} />
      ))}
    </div>
  );
}

// First-run nudge — when coach hasn't filled in displayName / avatar yet,
// show a small pill linking to Settings → Account. Hides once both are set.
function ProfileNudge() {
  const { c, fonts, t, brutMode } = useLW();
  const isBrut = !!brutMode;
  const [missing, setMissing] = useState(null); // 'name' | 'avatar' | 'both' | null
  useEffect(() => {
    if (!window.LWAuth || !window.LWFB) return;
    const off = window.LWAuth.onAuthChanged(async (u) => {
      if (!u) { setMissing(null); return; }
      try {
        const snap = await window.LWFB.db.ref(`/coaches/${u.uid}`).get();
        const v = snap.exists() ? snap.val() : {};
        const hasName = !!(v.displayName && String(v.displayName).trim());
        const hasAvatar = !!(v.avatarUrl && String(v.avatarUrl).trim());
        if (!hasName && !hasAvatar) setMissing('both');
        else if (!hasName) setMissing('name');
        else if (!hasAvatar) setMissing('avatar');
        else setMissing(null);
      } catch { setMissing(null); }
    });
    return off;
  }, []);
  if (!missing) return null;
  const label = missing === 'name'   ? t('dash.profile_nudge_name')
              : missing === 'avatar' ? t('dash.profile_nudge_avatar')
              : t('dash.profile_nudge_both');
  const ink = c.ink || c.textPrimary;
  return (
    <a href="Settings.html?tab=account"
       title={t('dash.profile_nudge_hint')}
       style={{
         display: 'inline-flex', alignItems: 'center', gap: 10,
         padding: isBrut ? '8px 14px' : '8px 14px',
         borderRadius: isBrut ? 2 : 999,
         background: isBrut ? c.bg : hexToRgba(c.accent, 0.10),
         border: isBrut ? `1.5px solid ${ink}` : `1px solid ${hexToRgba(c.accent, 0.32)}`,
         boxShadow: isBrut ? `2px 2px 0 0 ${ink}` : 'none',
         fontFamily: fonts.body,
         fontSize: isBrut ? 11 : 13,
         fontWeight: isBrut ? 800 : 600,
         letterSpacing: isBrut ? '0.08em' : 0,
         textTransform: isBrut ? 'uppercase' : 'none',
         color: isBrut ? ink : c.accent,
         textDecoration: 'none',
         marginBottom: 24,
       }}>
      <span style={{ fontSize: isBrut ? 12 : 14, fontWeight: 800 }}>{isBrut ? '※' : '✦'}</span>
      {label} →
    </a>
  );
}

// ── Today's sessions rail ──
function TodaySessionsRail({ sessions, clients }) {
  const { c, fonts, t, lang, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  const byUid = Object.fromEntries(clients.map(cl => [cl.userUid, cl]));
  const loc = lang === 'ru' ? 'ru-RU' : undefined;
  return (
    <div style={{ marginBottom: 28 }}>
      <div style={{
        fontFamily: fonts.body, fontSize: 11, fontWeight: 800, letterSpacing: '0.18em',
        textTransform: 'uppercase', color: isBrut ? ink : c.textTertiary, marginBottom: 12,
      }}>{t('dash.todays_sessions')}</div>
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: isBrut ? 12 : 10 }}>
        {sessions.map(s => {
          const cli = byUid[s.userUid];
          if (!cli) return null;
          const startMs = Number(s.startedAt);
          const time = new Date(startMs).toLocaleTimeString(loc, { hour: '2-digit', minute: '2-digit' });
          return (
            <a key={s.id}
               href={`Client.html?id=${encodeURIComponent(cli.userUid)}&view=prep`}
               style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 12,
                        padding: '12px 16px',
                        borderRadius: isBrut ? 2 : 12,
                        background: isBrut ? c.bg : hexToRgba(c.accent, 0.08),
                        border: isBrut ? `1.5px solid ${ink}` : `1px solid ${hexToRgba(c.accent, 0.35)}`,
                        boxShadow: isBrut ? `3px 3px 0 0 ${ink}` : 'none',
                        color: c.textPrimary, cursor: 'pointer' }}>
              <Avatar initials={cli.initials} name={cli.name} hue={cli.avatarHue} size={32} />
              <div>
                <div style={{ fontFamily: fonts.body, fontSize: 14, fontWeight: 800, color: c.textPrimary, letterSpacing: isBrut ? '0.02em' : 0 }}>
                  {time} · <span style={{ fontWeight: isBrut ? 700 : 500 }}>{cli.name}</span>
                </div>
                <div style={{
                  fontFamily: fonts.body, fontSize: isBrut ? 10 : 12,
                  color: isBrut ? ink : c.accent,
                  fontWeight: 800,
                  letterSpacing: isBrut ? '0.08em' : 0,
                  textTransform: isBrut ? 'uppercase' : 'none',
                }}>
                  {t('dash.session_open_prep')} →
                </div>
              </div>
            </a>
          );
        })}
      </div>
    </div>
  );
}

// ── Attention queue ──
function AttentionQueue({ entries }) {
  const { c, fonts, t } = useLW();
  return (
    <div style={{ marginBottom: 32 }}>
      <SectionHead eyebrow={null} title={t('dash.attention_eyebrow')} accent={null} style={{ marginBottom: 12 }} />
      <div style={{ display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))' }}>
        {entries.map(({ client, signals }) => (
          <AttentionCard key={client.id} client={client} signals={signals} />
        ))}
      </div>
    </div>
  );
}

function AttentionCard({ client, signals }) {
  const { c, fonts, t, lang, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  const top = signals.slice().sort((a, b) => b.severity - a.severity)[0];
  const tone = top.severity >= 2 ? c.flame : c.accent;
  const brutAccent = top.severity >= 2 ? c.flame : ink;
  const bg = top.severity >= 2 ? hexToRgba(c.flame, 0.08) : hexToRgba(c.accent, 0.06);
  const border = top.severity >= 2 ? hexToRgba(c.flame, 0.30) : hexToRgba(c.accent, 0.30);
  return (
    <a href={`Client.html?id=${encodeURIComponent(client.userUid)}`}
       style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 12,
                padding: '14px 16px',
                borderRadius: isBrut ? 2 : 14,
                background: isBrut ? c.bg : bg,
                border: isBrut ? `1.5px solid ${brutAccent}` : `1px solid ${border}`,
                boxShadow: isBrut ? `3px 3px 0 0 ${brutAccent}` : 'none',
                color: c.textPrimary, cursor: 'pointer',
                transition: 'transform 120ms ease, box-shadow 120ms ease' }}
       onMouseEnter={(e) => {
         if (isBrut) {
           e.currentTarget.style.transform = 'translate(-1px, -1px)';
           e.currentTarget.style.boxShadow = `5px 5px 0 0 ${brutAccent}`;
         } else {
           e.currentTarget.style.transform = 'translateY(-1px)';
           e.currentTarget.style.boxShadow = '0 6px 20px rgba(0,0,0,0.15)';
         }
       }}
       onMouseLeave={(e) => {
         if (isBrut) {
           e.currentTarget.style.transform = 'none';
           e.currentTarget.style.boxShadow = `3px 3px 0 0 ${brutAccent}`;
         } else {
           e.currentTarget.style.transform = 'none';
           e.currentTarget.style.boxShadow = 'none';
         }
       }}>
      <Avatar initials={client.initials} name={client.name} hue={client.avatarHue} size={40} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{
          fontFamily: fonts.body,
          fontSize: isBrut ? 15 : 17,
          fontWeight: isBrut ? 800 : 600,
          color: c.textPrimary,
          letterSpacing: isBrut ? '0.02em' : '-0.005em',
          textTransform: isBrut ? 'uppercase' : 'none',
          overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
        }}>
          {client.name}
        </div>
        <div style={{ fontFamily: fonts.body, fontSize: 12, color: tone, fontWeight: 700, marginTop: 2 }}>
          {signals.map(s => signalLabel(t, s, lang)).join(' · ')}
        </div>
      </div>
      <div style={{
        fontFamily: fonts.body, fontSize: isBrut ? 10 : 12, fontWeight: 800,
        color: isBrut ? brutAccent : tone,
        letterSpacing: isBrut ? '0.10em' : '0.04em',
        textTransform: isBrut ? 'uppercase' : 'none',
      }}>
        {t('dash.attention_view')} →
      </div>
    </a>
  );
}

function signalLabel(t, sig, lang) {
  if (sig.kind === 'outbox') {
    return t('dash.attention_outbox_stale', { n: sig.n, days: 2 });
  }
  if (sig.kind === 'quiet') {
    return t('dash.attention_quiet', { n: sig.n });
  }
  if (sig.kind === 'mood_drop') {
    return t('dash.attention_mood_drop', { n: sig.n });
  }
  if (sig.kind === 'fresh_wheel') {
    return t('dash.attention_fresh_wheel', { when: sig.when || '' });
  }
  return '';
}

function RealClientCard({ client, enrichment }) {
  const { c: cBase, fonts: fontsBase, t, lang } = useLW();
  const isBrut = !!(window.__brutMode && window.__brutC);
  const c = isBrut ? window.__brutC : cBase;
  const fonts = isBrut ? (window.__brutFonts || fontsBase) : fontsBase;
  const scores = window.sphereScoresFromAny
    ? window.sphereScoresFromAny(enrichment.spheres, enrichment.areas)
    : null;
  // Mini wheel labels: prefer the iOS user's real area.title so coaches see
  // each client's actual wheel labels (V3 schema, customizations) rather
  // than a canonical V4.2 map that misrepresents legacy positions.
  const titles = window.areaTitlesFromAny
    ? window.areaTitlesFromAny(enrichment.spheres, enrichment.areas)
    : null;
  const moodSeries = enrichment.mood ? buildMoodWeek(enrichment.mood) : null;
  const moodValid = moodSeries ? moodSeries.filter(v => v != null) : [];
  const moodNow = moodValid.length ? moodValid[moodValid.length - 1] : null;
  const moodEarly = moodValid.length ? moodValid[0] : null;
  const moodDelta = (moodNow != null && moodEarly != null) ? moodNow - moodEarly : 0;
  const moodGlyph = moodDelta > 0.4 ? '↑' : moodDelta < -0.4 ? '↓' : '·';
  const moodTone = moodDelta > 0.4 ? c.accent : moodDelta < -0.4 ? c.flame : c.textTertiary;

  const wheelAvg = scores
    ? (scores.filter(s => typeof s === 'number').reduce((s, v) => s + v, 0) /
        Math.max(1, scores.filter(s => typeof s === 'number').length))
    : null;
  const tags = client.tags && client.tags.length ? client.tags.slice(0, 2).join(' · ') : null;
  // Recent activity line: most-recent of journal/mood timestamp.
  const lastAct = enrichment.lastActivity || null;
  const lastKind = (() => {
    const j = enrichment.journalLast || 0, m = enrichment.moodLast || 0;
    if (j > m) return 'journal';
    if (m > 0) return 'mood';
    return null;
  })();
  const connectedAt = client.connectedAt ? Number(client.connectedAt) : null;
  const connectedDays = connectedAt ? Math.floor((Date.now() - connectedAt) / 86_400_000) : null;
  const activityLine = (() => {
    if (lastAct && lastKind) {
      const ago = relTimeShort(t, lastAct);
      return t(lastKind === 'journal' ? 'dash.activity_journal' : 'dash.activity_mood', { ago });
    }
    if (connectedDays != null && connectedDays < 1) return t('dash.activity_brand_new');
    if (connectedDays != null && connectedDays >= 7) return t('dash.activity_quiet', { n: connectedDays });
    return t('dash.mood_no_signal');
  })();

  return (
    <a href={`Client.html?id=${encodeURIComponent(client.userUid)}`}
       style={{ textDecoration: 'none', display: 'block', color: 'inherit', cursor: 'pointer' }}>
      <div style={{
        background: c.card,
        border: isBrut ? `1.5px solid ${c.ink}` : `1px solid ${c.borderDefault}`,
        borderRadius: isBrut ? 2 : 14,
        boxShadow: isBrut ? `2px 2px 0 0 ${c.ink}` : 'none',
        overflow: 'hidden',
        transition: isBrut
          ? 'transform 140ms ease, box-shadow 140ms ease'
          : 'transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease',
      }}
      onMouseEnter={(e) => {
        if (isBrut) {
          e.currentTarget.style.transform = 'translate(-2px, -2px)';
          e.currentTarget.style.boxShadow = `4px 4px 0 0 ${c.ink}`;
        } else {
          e.currentTarget.style.transform = 'translateY(-2px)';
          e.currentTarget.style.boxShadow = '0 14px 36px rgba(0,0,0,0.22)';
          e.currentTarget.style.borderColor = hexToRgba(c.accent, 0.5);
        }
      }}
      onMouseLeave={(e) => {
        if (isBrut) {
          e.currentTarget.style.transform = 'none';
          e.currentTarget.style.boxShadow = `2px 2px 0 0 ${c.ink}`;
        } else {
          e.currentTarget.style.transform = 'none';
          e.currentTarget.style.boxShadow = 'none';
          e.currentTarget.style.borderColor = c.borderDefault;
        }
      }}
      >
        {/* Top row: avatar + name + outbox badge */}
        <div style={{
          padding: '14px 16px 12px',
          display: 'flex', alignItems: 'center', gap: 12,
        }}>
          <Avatar initials={client.initials} name={client.name} hue={client.avatarHue} size={40} status="live" />
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
              <div style={{ fontFamily: fonts.display, fontSize: 17, fontWeight: 600, color: c.textPrimary, letterSpacing: '-0.005em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
                {client.name}
              </div>
              {client.isSelfPreview && (
                <span style={{
                  flexShrink: 0,
                  fontFamily: fonts.body, fontSize: 9, fontWeight: 800,
                  letterSpacing: '0.14em', textTransform: 'uppercase',
                  color: isBrut ? c.bg : c.textTertiary,
                  background: isBrut ? (c.ink || c.textPrimary) : hexToRgba(c.textTertiary, 0.16),
                  border: isBrut ? `1.5px solid ${c.ink || c.textPrimary}` : `1px solid ${hexToRgba(c.textTertiary, 0.30)}`,
                  borderRadius: isBrut ? 1 : 999,
                  padding: '2px 6px',
                }}>{lang === 'ru' ? 'ТЫ' : 'YOU'}</span>
              )}
            </div>
            <div style={{ fontFamily: fonts.body, fontSize: 11, color: c.textTertiary, marginTop: 2, fontWeight: 500 }}>
              {activityLine}
            </div>
          </div>
          {enrichment.outbox && (enrichment.outbox.waiting > 0 || enrichment.outbox.acceptedRecent > 0) && (
            <OutboxBadge waiting={enrichment.outbox.waiting} accepted={enrichment.outbox.acceptedRecent} />
          )}
        </div>

        {/* Body row: wheel + right column */}
        <div style={{ padding: '4px 16px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
          <div style={{ flexShrink: 0 }}>
            {scores ? (
              <WheelChart size={88} scores={scores} showLabels={false} showAxes={false} labels={titles} />
            ) : (
              <div style={{
                width: 88, height: 88, borderRadius: '50%',
                border: `1px dashed ${c.borderDefault}`,
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                fontFamily: fonts.body, fontSize: 9, color: c.textTertiary, letterSpacing: '0.08em', textTransform: 'uppercase',
              }}>no wheel</div>
            )}
          </div>
          <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
            {/* Wheel avg + focus tags */}
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
              {wheelAvg != null && (
                <span style={{ fontFamily: fonts.display, fontSize: 28, fontWeight: 600, color: c.textPrimary, lineHeight: 1, letterSpacing: '-0.02em' }}>
                  {wheelAvg.toFixed(1)}
                </span>
              )}
              <span style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: c.textTertiary }}>
                {t('dash.card_avg')}
              </span>
            </div>
            {tags && (
              <div style={{
                fontFamily: fonts.body, fontSize: 12, color: c.textSecondary, lineHeight: 1.3,
                textTransform: 'capitalize',
              }}>{tags}</div>
            )}
            {/* Mood sparkline */}
            <MoodSparkline series={moodSeries} c={c} t={t} />
            {/* Mini-pulse: counts since last session — only renders when there's
                signal. Padding above to separate from sparkline. */}
            <CardMiniPulse pulse={enrichment.pulse} c={c} t={t} />
          </div>
        </div>
      </div>
    </a>
  );
}

// Inline 7-day mood sparkline. Each non-null point is a small dot; line connects
// the points. Tone shifts: rising → accent; falling sharply → flame; flat → tertiary.
function MoodSparkline({ series, c, t }) {
  const valid = (series || []).filter(v => v != null);
  if (valid.length === 0) {
    return (
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <span style={{
          fontFamily: 'inherit', fontSize: 10, color: c.textTertiary, fontStyle: 'italic',
        }}>{t('dash.mood_no_signal')}</span>
      </div>
    );
  }
  // Single point: show value without a sparkline.
  if (valid.length === 1) {
    return (
      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        <span style={{
          fontFamily: 'inherit', fontSize: 10, fontWeight: 700, letterSpacing: '0.14em',
          textTransform: 'uppercase', color: c.textTertiary,
        }}>{t('dash.card_mood')}</span>
        <span style={{ fontFamily: 'inherit', fontSize: 16, fontWeight: 700, color: c.textSecondary, fontVariantNumeric: 'tabular-nums' }}>
          {Math.round(valid[0] * 10) / 10}
        </span>
      </div>
    );
  }
  const last = valid[valid.length - 1];
  const first = valid[0];
  const delta = last - first;
  const tone = delta >= 0.4 ? c.accent : delta <= -1.2 ? c.flame : c.textSecondary;
  // Map series to 90×22 svg
  const w = 130, h = 22;
  const xs = (series || []).map((_, i) => (i / Math.max(1, (series.length - 1))) * w);
  // Normalize to 0-10 mood domain
  const norm = (v) => h - ((v / 10) * h);
  const points = (series || []).map((v, i) => v == null ? null : `${xs[i]},${norm(v)}`).filter(Boolean).join(' ');
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
      <span style={{
        fontFamily: 'inherit', fontSize: 10, fontWeight: 700, letterSpacing: '0.14em',
        textTransform: 'uppercase', color: c.textTertiary,
      }}>{t('dash.card_mood')}</span>
      <span style={{ fontFamily: 'inherit', fontSize: 16, fontWeight: 700, color: tone, fontVariantNumeric: 'tabular-nums' }}>
        {Math.round(last * 10) / 10}
      </span>
      <svg width={w} height={h} style={{ flexShrink: 0 }}>
        <polyline
          points={points}
          fill="none"
          stroke={tone}
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
        {/* Last point dot */}
        {valid.length > 0 && (() => {
          const lastIdx = (series || []).map((v, i) => v != null ? i : -1).filter(i => i >= 0).pop();
          if (lastIdx == null) return null;
          return <circle cx={xs[lastIdx]} cy={norm(series[lastIdx])} r="2.5" fill={tone} />;
        })()}
      </svg>
    </div>
  );
}

// Concise relative-time formatter for activity lines on cards: "today",
// "yesterday", "Nd ago", or "Nh ago" within last 24h.
function relTimeShort(t, ms) {
  const now = Date.now();
  const diff = now - ms;
  if (diff < 60_000) return t('dash.rel_today');
  if (diff < 3_600_000) {
    const m = Math.round(diff / 60_000);
    return m + 'm';
  }
  if (diff < 86_400_000) {
    const h = Math.round(diff / 3_600_000);
    return h + 'h';
  }
  const today0 = new Date(); today0.setHours(0, 0, 0, 0);
  const then0 = new Date(ms); then0.setHours(0, 0, 0, 0);
  const days = Math.round((today0.getTime() - then0.getTime()) / 86_400_000);
  if (days === 0) return t('dash.rel_today');
  if (days === 1) return t('dash.rel_yesterday');
  return t('dash.rel_days_ago', { n: days });
}

// Pull a 7-day mood series from /mood map. Inline copy of moodWeekFromMap so
// dashboard.jsx doesn't import client.jsx helpers.
function buildMoodWeek(mood) {
  if (!mood) return null;
  const VALS = { terrible_mood:2, bad_mood:4, neutral_mood:6, good_mood:8, great_mood:10, awful:2, bad:4, neutral:6, good:8, great:10 };
  const today0 = new Date(); today0.setHours(0,0,0,0);
  const dayMs = 86400000;
  const out = new Array(7).fill(null);
  const buckets = new Array(7).fill(null).map(() => ({ sum:0, n:0 }));
  Object.values(mood).forEach(e => {
    if (!e) return;
    const v = (typeof e.mood === 'string' && VALS[e.mood] != null) ? VALS[e.mood]
            : (typeof e.value === 'number' ? e.value : null);
    if (v == null) return;
    const t = Number(e.creationDate || e.createdAt || e.ts || 0);
    if (!t) return;
    const ms = t > 1e12 ? t : t * 1000;
    const d = new Date(ms); d.setHours(0,0,0,0);
    const off = Math.round((today0 - d) / dayMs);
    if (off < 0 || off >= 7) return;
    const slot = 6 - off;
    buckets[slot].sum += v; buckets[slot].n += 1;
  });
  for (let i = 0; i < 7; i++) if (buckets[i].n > 0) out[i] = buckets[i].sum / buckets[i].n;
  return out;
}

// Legacy demo Dashboard, kept reachable for design-partner walkthroughs while
// the real per-client reads are wired up. Triggered if URL has ?demo=1.
function DemoDashboard() {
  const { c, fonts } = useLW();
  const { isMobile, isTablet } = useViewport();
  const stackBody = isMobile || isTablet;
  const clients = useMemo(() => LWDATA.CLIENTS.map(deriveClient), []);
  const clientById = useMemo(() => Object.fromEntries(clients.map(cl => [cl.id, cl])), [clients]);
  const sessions = useMemo(() => LWDATA.SESSIONS.map(s => deriveSession(s, clientById)), [clientById]);

  const today0 = new Date(LW_NOW); today0.setHours(0,0,0,0);
  const tomorrow0 = new Date(today0); tomorrow0.setDate(tomorrow0.getDate() + 1);
  const todaySessions = sessions.filter(s => s.start >= today0 && s.start < tomorrow0);
  const todayClientIds = new Set(todaySessions.map(s => s.clientId));
  const attentionClients = clients.filter(cl => cl.status === 'attention');
  const otherClients = clients
    .filter(cl => !todayClientIds.has(cl.id))
    .sort((a,b) => {
      const order = { attention: 0, new: 1, active: 2, stable: 3 };
      return (order[a.status] ?? 9) - (order[b.status] ?? 9);
    });
  const upcomingThisWeek = sessions.filter(s => s.status === 'upcoming');

  return (
    <PageShell active="dashboard">
      <DashHero name={LWDATA.COACH.name} todayCount={todaySessions.length} attentionCount={attentionClients.length} />

      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 14, marginBottom: 32 }}>
        <KPI label="Active clients"
             value={clients.filter(cl => cl.status !== 'paused').length}
             sub={`${clients.length} total · ${clients.filter(cl => cl.status === 'new').length} just joined`} />
        <KPI label="This week"
             value={upcomingThisWeek.length}
             sub={`sessions scheduled · ${todaySessions.length} today`}
             tone="accent" />
        <KPI label="Need a check-in"
             value={attentionClients.length}
             sub={attentionClients.length ? attentionClients.map(cl => cl.name.split(' ')[0]).slice(0,3).join(', ') : 'all caught up'}
             tone={attentionClients.length ? 'flame' : null} />
        <KPI label="Wheels updated"
             value={`${clients.filter(cl => cl.scores).length}/${clients.length}`}
             sub="have a current wheel" />
      </div>

      <SectionHead
        eyebrow="On the calendar"
        title="Today's"
        accent="sessions"
        right={<a href="Calendar.html" style={{ fontFamily: fonts.body, fontSize: 13, color: c.textSecondary, textDecoration: 'none' }}>
          See full calendar →
        </a>}
      />
      <TodaySessions sessions={sessions} />

      <div style={{ height: 32 }} />
      <SectionHead eyebrow="Next seven days" title="Week" accent="ahead" />
      <WeekStrip sessions={sessions} />

      <div style={{ height: 40 }} />
      <div style={{ display: 'grid', gridTemplateColumns: stackBody ? '1fr' : 'minmax(0, 1fr) 340px', gap: 28 }}>
        <div>
          <SectionHead eyebrow="Everyone else" title="Your" accent="roster"
            right={<Chip tone="subtle">{otherClients.length} clients</Chip>} />
          <RosterGrid clients={otherClients} />
        </div>
        <div>
          <SectionHead eyebrow="Between sessions" title="Pings"
            accent="& celebrations"
            right={<Chip tone="accent">{LWDATA.PINGS_SUGGESTED.length} suggested</Chip>}
            style={{ marginBottom: 8 }} />
          <PingsRail suggested={LWDATA.PINGS_SUGGESTED} recent={LWDATA.PINGS_RECENT} clientById={clientById} />

          <div style={{ height: 28 }} />
          <SectionHead eyebrow="Activity stream" title="Recent"
            accent="activity"
            style={{ marginBottom: 8 }} />
          <ActivityFeed events={LWDATA.FEED} clientById={clientById} />
        </div>
      </div>
    </PageShell>
  );
}

window.LWDashboard = Dashboard;
// Expose so feed.jsx can reuse the roster + enrichment plumbing without
// duplicating the RTDB subscription logic.
window.useRealRoster = useRealRoster;
window.useEnrichedRoster = useEnrichedRoster;
window.useTodaySessions = useTodaySessions;
window.pulseSinceLastSession = pulseSinceLastSession;
