// ─────────────────────────────────────────────────────────────
// LifeWheel Coach — Client detail page
// Reads ?id=<clientId> from URL; falls back to first attention client.
// ─────────────────────────────────────────────────────────────

const LW_NOW = new Date(2026, 4, 5, 16, 12, 0);

// Per-client extra data — journals, sessions, habits — keyed by client id.
const CLIENT_EXTRAS = (window.LW_CLIENT_EXTRAS = {
  maya: {
    journals: [
      { ts: 'today 14:14', shared: false, sphere: 'joy', mood: 2,
        text: "I keep refreshing email instead of writing this. Work is a fire I keep walking back into. I don't know if I'm scared to step back or if I just don't know how. Slept 3 hours." },
      { ts: 'sun 21:02', shared: true, sphere: 'health', mood: 3,
        text: "Said I'd take Saturday off. Worked Saturday. Promised myself Sunday morning. Worked Sunday morning. The boundary I set in our last session is just a sentence I keep re-reading." },
      { ts: 'fri 07:30', shared: true, sphere: 'career', mood: 4,
        text: "The launch landed. Three people emailed thanks. I felt nothing. I think that's the part I want to talk about." },
    ],
    habits: [
      { name: 'Morning pages', target: 7, done: 1, streak: 0 },
      { name: 'Walk 20m',      target: 5, done: 1, streak: 0 },
      { name: 'Lights off 11pm', target: 7, done: 0, streak: 0 },
    ],
    goals: [
      {
        id: 'g_maya_1', title: 'Reclaim evenings', sphere: 'health',
        dueDays: 12, createdSession: 'apr 22',
        tasks: [
          { id: 't1', title: 'Set "no email after 7pm" auto-reply', done: true },
          { id: 't2', title: 'Move work chat off phone home screen', done: true },
          { id: 't3', title: 'Track which evenings I held the line', done: false },
          { id: 't4', title: 'Bring 1 evening that worked + 1 that didn\'t to next session', done: false },
        ],
      },
      {
        id: 'g_maya_2', title: 'Find what "rested" feels like again', sphere: 'joy',
        dueDays: 26, createdSession: 'apr 8',
        tasks: [
          { id: 't1', title: 'Notice 1 moment per day that\'s purely mine', done: false },
          { id: 't2', title: 'Try the "Sunday afternoon nothing" experiment once', done: false },
        ],
      },
    ],
    todos: [
      { id: 'td1', title: 'Read the burnout chapter', sphere: null, dueDays: 4, done: false },
      { id: 'td2', title: 'Forward partner the boundary plan', sphere: 'love', dueDays: 0, done: true },
      { id: 'td3', title: 'Book a massage for Saturday', sphere: 'health', dueDays: 3, done: false },
    ],
    sessionLog: [
      { date: 'apr 22', summary: 'Set boundary: no email after 7pm.', followups: ['Track adherence', 'How did partner receive it'] },
      { date: 'apr 8',  summary: 'Mapped the work-overload pattern back to a parental script.', followups: ['Notice the script when it activates'] },
      { date: 'mar 25', summary: 'Intake. Joy & Health were the two she flagged unprompted.', followups: [] },
    ],
    pingsLog: [
      { ts: 'sun 08:00', text: "Quietly here. No reply needed." },
      { ts: 'apr 26 09:14', text: "Saw the launch land — proud of you, even if the feeling is delayed." },
    ],
    who5: [
      { date: 'jan 14', score: 64 },
      { date: 'feb 11', score: 56 },
      { date: 'mar 11', score: 48 },
      { date: 'apr 8',  score: 36 },
      { date: 'apr 22', score: 32 },
      { date: 'today',  score: 28 },
    ],
  },
  alex: {
    journals: [
      { ts: 'today 06:15', shared: true, sphere: 'growth', mood: 9,
        text: "23 days. The trick wasn't motivation — it was making the bar laughably low. 5 minutes. 5 push-ups. The threshold is the thing." },
      { ts: 'mon 19:40', shared: true, sphere: 'joy', mood: 8,
        text: "Started sketching what 6 weeks off would look like. Not a vacation. A pause to listen for what's next. Scary how much I want it." },
    ],
    habits: [
      { name: 'Move 5m',         target: 7, done: 7, streak: 23 },
      { name: 'Read 10pp',       target: 7, done: 6, streak: 23 },
      { name: 'No phone in bed', target: 7, done: 7, streak: 23 },
    ],
    goals: [
      {
        id: 'g_alex_1', title: 'Decide on the sabbatical — yes or no, with a date', sphere: 'growth',
        dueDays: 18, createdSession: 'apr 7',
        tasks: [
          { id: 't1', title: 'Write the one-sentence "why now"', done: true },
          { id: 't2', title: 'Talk to two people who’ve done it', done: true },
          { id: 't3', title: 'Draft conversation script for manager', done: false },
          { id: 't4', title: 'Pick a target start month', done: false },
        ],
      },
    ],
    todos: [
      { id: 'td1', title: 'Bring sabbatical proposal draft tonight', sphere: 'career', dueDays: 0, done: false },
      { id: 'td2', title: 'List 3 things you’d miss about work', sphere: 'career', dueDays: 7, done: false },
    ],
    sessionLog: [
      { date: 'apr 21', summary: 'Designed the 5-minute threshold for the third habit.', followups: ['Run 21 days, then revisit the bar'] },
      { date: 'apr 7',  summary: 'Talked through fear of "wasting" sabbatical time.', followups: ['Define what "well spent" means to him'] },
    ],
    pingsLog: [
      { ts: 'mon 18:04', text: "Before tomorrow: one sentence on what you'd want a sabbatical to give you." },
    ],
    who5: [
      { date: 'jan 20', score: 52 },
      { date: 'feb 17', score: 60 },
      { date: 'mar 17', score: 68 },
      { date: 'apr 7',  score: 76 },
      { date: 'apr 21', score: 80 },
      { date: 'today',  score: 84 },
    ],
  },
  priya: {
    journals: [
      { ts: 'today 14:14', shared: true, sphere: 'joy', mood: 3,
        text: "Rough morning. Slept 4 hours, the meeting went sideways, and I cried in a bathroom stall like I'm 19. I don't want to talk about it tonight. I just wanted you to know." },
      { ts: 'wed 22:11', shared: false, sphere: 'love', mood: 4,
        text: "Mom called. Asked when. I said when what. We both knew." },
    ],
    habits: [
      { name: 'Morning pages',  target: 7, done: 4, streak: 6 },
      { name: 'Walk 20m',       target: 5, done: 1, streak: 6 },
    ],
    goals: [
      {
        id: 'g_priya_1', title: 'Build language for family conversations', sphere: 'love',
        dueDays: 21, createdSession: 'apr 20',
        tasks: [
          { id: 't1', title: 'Write 3 phrases I want to keep ready', done: true },
          { id: 't2', title: 'Try the “this is mine to choose” phrase once', done: false },
          { id: 't3', title: 'Note what came up after', done: false },
        ],
      },
    ],
    todos: [
      { id: 'td1', title: 'Reschedule call with mom (after Wednesday)', sphere: 'love', dueDays: 2, done: false },
    ],
    sessionLog: [
      { date: 'apr 20', summary: 'Family pressure as a recurring trigger; she wants language for it.', followups: ['Try the "this is mine to choose" phrase'] },
    ],
    pingsLog: [
      { ts: 'today 14:22', text: "Saw your note. Not asking you to unpack it — just here." },
    ],
    who5: [
      { date: 'feb 6',  score: 44 },
      { date: 'mar 6',  score: 40 },
      { date: 'apr 3',  score: 38 },
      { date: 'apr 20', score: 44 },
      { date: 'today',  score: 48 },
    ],
  },
  nora: {
    journals: [
      { ts: 'today 09:34', shared: true, sphere: 'career', mood: 8,
        text: "Said yes to the offer. Sat in the car after for 20 minutes. The fear came right after the relief. I think they're a package deal now." },
    ],
    habits: [
      { name: 'Daily reflection', target: 7, done: 6, streak: 31 },
      { name: 'Strength 3x/wk',   target: 3, done: 3, streak: 31 },
    ],
    goals: [
      {
        id: 'g_nora_1', title: 'Land the first 30 days of the new role', sphere: 'career',
        dueDays: 30, createdSession: 'apr 22',
        tasks: [
          { id: 't1', title: 'Resignation letter — sent', done: true },
          { id: 't2', title: 'Map the first 5 people I want 1:1s with', done: false },
          { id: 't3', title: 'Pick the one thing I won’t bring from old job', done: false },
        ],
      },
    ],
    todos: [
      { id: 'td1', title: 'Send week-1 wheel screenshot before next session', sphere: null, dueDays: 5, done: false },
      { id: 'td2', title: 'Tell two close friends about the move', sphere: 'people', dueDays: 7, done: true },
    ],
    sessionLog: [
      { date: 'apr 22', summary: 'Pressure-tested the offer logistics.', followups: ['Bring week-one wheel for contrast'] },
    ],
    pingsLog: [
      { ts: 'sun 09:10', text: "Three months in. The version of you from week one would not believe this wheel." },
    ],
    who5: [
      { date: 'feb 4',  score: 36 },
      { date: 'feb 25', score: 48 },
      { date: 'mar 18', score: 60 },
      { date: 'apr 8',  score: 68 },
      { date: 'apr 22', score: 72 },
      { date: 'today',  score: 76 },
    ],
  },
  james: {
    journals: [
      { ts: 'sun 19:30', shared: false, sphere: 'growth', mood: 5,
        text: "Streak broke at 11. Not going to dramatize it. Tired." },
    ],
    habits: [
      { name: 'Boundaries log',  target: 5, done: 3, streak: 0 },
      { name: 'Walk 20m',        target: 5, done: 4, streak: 0 },
    ],
    goals: [
      {
        id: 'g_james_1', title: 'Hold one boundary at work this week', sphere: 'career',
        dueDays: 5, createdSession: 'apr 21',
        tasks: [
          { id: 't1', title: 'Pick one repeating ask to push back on', done: true },
          { id: 't2', title: 'Use the script once', done: false },
          { id: 't3', title: 'Write down what happened after', done: false },
        ],
      },
    ],
    todos: [],
    sessionLog: [
      { date: 'apr 21', summary: 'Boundaries with team — concrete script for the 9pm Slack ask.', followups: ['Try the script once this week'] },
    ],
    pingsLog: [],
    who5: [
      { date: 'feb 12', score: 48 },
      { date: 'mar 10', score: 52 },
      { date: 'apr 7',  score: 56 },
      { date: 'apr 21', score: 60 },
      { date: 'today',  score: 64 },
    ],
  },
  sam: {
    journals: [],
    habits: [],
    goals: [],
    todos: [],
    sessionLog: [{ date: 'mon (intake)', summary: 'Onboarding call booked. First wheel today.', followups: [] }],
    pingsLog: [],
  },
});

// Build a synthetic 28-day habit completion grid from each client's habit weekly stats.
function deriveHabitGrid(client) {
  const extras = CLIENT_EXTRAS[client.id] || {};
  const habits = extras.habits || [];
  if (!habits.length) return Array.from({length: 28}, () => null);
  const weeklyAvg = habits.reduce((s, h) => s + (h.target ? h.done / h.target : 0), 0) / habits.length;
  // bias the recent week to weeklyAvg, older weeks centered higher
  const grid = [];
  for (let w = 0; w < 4; w++) {
    for (let d = 0; d < 7; d++) {
      const base = w === 3 ? weeklyAvg : Math.max(0, Math.min(1, weeklyAvg + (3 - w) * 0.08));
      const jitter = (Math.sin(client.id.charCodeAt(0) * 13 + w * 7 + d) + 1) / 2 * 0.4;
      grid.push(Math.max(0, Math.min(1, base * 0.6 + jitter * 0.4)));
    }
  }
  return grid;
}

// Build per-habit 28-day series. Returns array of habits with `days: number[28]`
// where each value is 0 or 1 (skipped/done). Missing days = 0.
function deriveHabitSeries(client) {
  // Real-mode: filter goals to habits, build per-habit 28-day completion grid
  // from the user's TodoExecutionLog. Demo-mode: keep the deterministic-jitter
  // fixture so the design walkthrough still works.
  const real = client._real;
  if (real) return deriveRealHabitSeries(real);

  const extras = CLIENT_EXTRAS[client.id] || {};
  const habits = extras.habits || [];
  return habits.map((h, hi) => {
    const ratio = h.target ? h.done / h.target : 0;
    const days = [];
    const streak = Math.max(0, h.streak || 0);
    for (let i = 0; i < 28; i++) {
      const daysAgo = 27 - i;
      if (daysAgo < streak) { days.push(1); continue; }
      const seed = client.id.charCodeAt(0) * 17 + hi * 31 + i * 7;
      const r = (Math.sin(seed) + 1) / 2;
      const week = Math.floor(daysAgo / 7);
      const bias = ratio + week * 0.05;
      days.push(r < bias ? 1 : 0);
    }
    return { ...h, days };
  });
}

// Build areaKey → { pos, title, colorHex } lookup for the user's active wheel.
// Goals reference areas via `areaKey`; this lets the Goals section show the
// user's actual area title (e.g. "Health and Sport") and the wheel's chosen
// color rather than the canonical sphere defaults.
function buildAreaByKey(real) {
  const areas = real && real.areasMap;
  if (!areas || typeof areas !== 'object') return {};
  const list = Object.entries(areas).map(([id, a]) => ({ id, ...a }));
  const posMap = normalizeAreaPositions(list);
  const out = {};
  list.forEach(a => {
    const pos = posMap.get(a);
    if (pos == null) return;
    const colorId = a.color != null ? parseInt(String(a.color), 10) : null;
    const colorHex = (typeof lwColorByIosId === 'function') ? lwColorByIosId(colorId, 'dark') : null;
    out[a.id] = { pos, title: a.title || null, colorHex };
  });
  return out;
}

// iOS habit detection mirrors LWGoal.isHabit: `repeatTime != nil && != -1`.
// RTDB stores repeatTime as a stringified int.
function isIosHabit(g) {
  if (!g) return false;
  const rt = g.repeatTime;
  if (rt == null) return false;
  const n = typeof rt === 'number' ? rt : parseInt(String(rt), 10);
  if (isNaN(n)) return false;
  return n !== -1;
}

// Real habit grid from /goals (filtered to habits) + /TodoExecutionLog.
// Each habit gets:
//   - name from goal title
//   - 28-day boolean grid (today rightmost)
//   - this-week done / target
//   - current streak (consecutive days ending today)
function deriveRealHabitSeries(real) {
  const goals = real.goals || [];
  const allHabits = goals.filter(isIosHabit);
  // Build the area lookup once — each habit references an area via `areaKey`,
  // and the row paints in that area's iOS-chosen color so the habit grid
  // mirrors the wheel palette instead of all-accent.
  const areaByKey = buildAreaByKey(real);
  // Privacy model: shared habits surface with full title + cadence; unshared
  // habits surface as anonymized rows ("Habit · private") with the same
  // 28-day grid + week count, dimmed in the UI. Cadence-without-name is the
  // industry standard for shared-but-private fitness data (Apple Health,
  // Whoop) — coach gets an engagement signal, client keeps the topic itself
  // walled off.
  if (allHabits.length === 0) return [];
  const habits = [
    ...allHabits.filter(isCoachShared),
    ...allHabits.filter(g => !isCoachShared(g)).map(g => ({ ...g, _private: true })),
  ];

  // executionLog parsed flat list (already provided by client._real). But we
  // need PER-GOAL completions, not flat. Re-walk the raw log if available.
  const rawLog = real.executionLog;
  const completionsByGoal = parseExecutionLogByGoal(rawLog);

  const today0 = new Date(); today0.setHours(0, 0, 0, 0);
  const dayMs = 86400000;
  const weekStart0 = today0.getTime() - 6 * dayMs;

  return habits.map(h => {
    const dates = completionsByGoal[h.id] || [];
    const dayHits = new Set();
    dates.forEach(ms => {
      const d = new Date(ms); d.setHours(0, 0, 0, 0);
      dayHits.add(d.getTime());
    });

    const days = [];
    for (let i = 0; i < 28; i++) {
      const daysAgo = 27 - i;
      const dms = today0.getTime() - daysAgo * dayMs;
      days.push(dayHits.has(dms) ? 1 : 0);
    }

    let streak = 0;
    for (let d = today0.getTime(); ; d -= dayMs) {
      if (dayHits.has(d)) streak += 1;
      else break;
    }

    const target = Math.max(1, parseInt(String(h.habitCountPerDay || 1), 10) * 7);
    const done = dates.filter(ms => ms >= weekStart0).length;

    const areaInfo = h.areaKey ? areaByKey[h.areaKey] : null;
    return {
      id: h.id,
      name: h.title || h.name || '—',
      target,
      done,
      streak,
      days,
      isPrivate: !!h._private,
      colorHex: areaInfo ? areaInfo.colorHex : null,
    };
  });
}

// Walks /TodoExecutionLog and returns `{ goalKey: [ms, ms, ...] }` so we can
// attribute completions to specific habits. Mirrors parseExecutionLog but
// keeps the goal grouping instead of flattening.
// iOS writes /users/{uid}/TodoExecutionLog/{goalKey}/executionDates as a flat
// JSON array of Unix timestamps in seconds (Swift `Date` → Firebase double).
// The legacy "increment/decrement dict per day" shape is not what iOS produces;
// historically the web parser assumed it and silently returned [] for every
// real user, which is why the habit-grid 28-day surface read as empty.
function parseExecutionLogByGoal(executionLog) {
  if (!executionLog) return {};
  const out = {};
  Object.entries(executionLog).forEach(([goalKey, goalRecord]) => {
    out[goalKey] = collectExecutionTimestampsMs(goalRecord && goalRecord.executionDates);
  });
  return out;
}

// Collect a flat list of completion timestamps in ms from whatever the iOS
// app wrote at /users/{uid}/TodoExecutionLog/{goalKey}/executionDates.
// Handles three shapes:
//   1. flat array of unix-seconds (current iOS schema)
//   2. flat array of unix-ms (defensive)
//   3. legacy nested dict { autoId: { 'd-m-yyyy': 'increment' } } from
//      pre-2024 builds (kept for safety; no-op when empty).
function collectExecutionTimestampsMs(dates) {
  if (!dates) return [];
  if (Array.isArray(dates)) {
    return dates
      .map(v => (typeof v === 'number' ? v : parseFloat(v)))
      .filter(n => !isNaN(n) && n > 0)
      .map(n => (n > 1e12 ? n : n * 1000));
  }
  if (typeof dates === 'object') {
    const out = [];
    // Could be array-as-dict (Firebase serializes sparse arrays as objects with
    // numeric keys), or the legacy nested-dict shape.
    Object.values(dates).forEach(v => {
      if (typeof v === 'number' || (typeof v === 'string' && !isNaN(parseFloat(v)))) {
        const n = parseFloat(v);
        if (!isNaN(n) && n > 0) out.push(n > 1e12 ? n : n * 1000);
        return;
      }
      // Legacy dict-of-dict shape — preserve handling so old accounts don't
      // regress.
      if (v && typeof v === 'object') {
        Object.entries(v).forEach(([dateStr, action]) => {
          if (action !== 'increment') return;
          const parts = String(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;
          out.push(new Date(y, m - 1, d, 12, 0, 0).getTime());
        });
      }
    });
    return out;
  }
  return [];
}

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 getClient() {
  const params = new URLSearchParams(window.location.search);
  const id = params.get('id');
  return LWDATA.byId(id) || LWDATA.CLIENTS.find(c => c.status === 'attention') || LWDATA.CLIENTS[0];
}

// Hook: subscribe to RTDB for the client whose userUid is in ?id=.
// Returns { status: 'loading' | 'unauthorized' | 'demo' | 'ready', client }.
//   demo:   no ?id= → fall back to LWDATA for design-partner walkthroughs.
//   unauthorized: ?id= present but the coach has no active link to this user.
//   ready:  client object built from real RTDB reads.
function useRealClient(routeParams) {
  // Prefer SPA-supplied params, fall back to legacy `?id=` for direct loads.
  const userUid = (routeParams && routeParams.id)
    || (window.LWRouter ? (window.LWRouter.parseRoute().params || {}).id : null)
    || new URLSearchParams(window.location.search).get('id');
  const isDemo = !userUid || (LWDATA.byId(userUid) != null);
  const [state, setState] = useState(() => isDemo
    ? { status: 'demo', client: getClient() }
    : { status: 'loading', client: null });

  useEffect(() => {
    if (isDemo) return;
    if (!window.LWFB || !window.LWAuth) return;
    let cleanups = [];
    let coachUid = null;
    // Per-section state we collect as listeners fire
    const acc = {
      roster: null, link: null,
      spheres: null, areas: null,
      goals: null, mood: null, journal: null,
      sessions: null, outbox: null, executionLog: null, pings: null,
      wheels: null, activeWheelId: null, who5: null,
    };

    function recompute() {
      // Need link + spheres source before we render anything meaningful.
      if (acc.link == null) return; // still waiting on link snap
      if (acc.link === false) {
        setState({ status: 'unauthorized', client: null });
        return;
      }
      const display = acc.roster || {};
      const link = acc.link;
      // Determine which wheel the user is currently on. v0 shows only that
      // wheel's data — multi-wheel switcher comes later. nil activeWheelId
      // means default (first by sortOrder).
      const wheelCtx = resolveActiveWheel(acc.wheels, acc.activeWheelId);
      const spheresOnActive = filterMapToActiveWheel(acc.spheres, wheelCtx);
      const areasOnActive = filterMapToActiveWheel(acc.areas, wheelCtx);
      const sphereScores = sphereScoresFromAny(spheresOnActive, areasOnActive);
      const areaTitles = areaTitlesFromAny(spheresOnActive, areasOnActive);
      const areaColorIds = areaColorIdsFromAny(spheresOnActive, areasOnActive);
      const areaIconIds = areaIconIdsFromAny(spheresOnActive, areasOnActive);
      const habitCompletions = parseExecutionLog(acc.executionLog);
      const pingsList = pingsFromMap(acc.pings, coachUid);
      const moodSeries = moodWeekFromMap(acc.mood);
      const moodMonth  = moodMonthFromMap(acc.mood);
      const goalsList = goalsFromMap(acc.goals, wheelCtx);
      const who5List = who5FromMap(acc.who5);
      const journalList = journalFromMap(acc.journal);
      const sessionsList = sessionsFromMap(acc.sessions, coachUid, userUid);
      const outboxList = outboxFromMap(acc.outbox, coachUid);

      const fallbackName = display.displayName || 'Client';
      const initials = display.initials || (window.LWAuth ? window.LWAuth.initialsOf(fallbackName) : fallbackName.slice(0,2).toUpperCase());
      const tagsFromLow = sphereScores
        ? LWDATA.SPHERE_KEYS
            .map((k, i) => ({ k, v: sphereScores[i] }))
            .filter(x => x.v != null && x.v <= 4)
            .map(x => x.k)
            .slice(0, 2)
        : [];

      setState({
        status: 'ready',
        client: {
          id: userUid,
          userUid,
          name: fallbackName,
          initials,
          avatarHue: display.avatarHue ?? hueFromUid(userUid),
          joinedDays: display.connectedAt ? Math.max(0, Math.floor((Date.now() - Number(display.connectedAt)) / 86400000)) : 0,
          commPref: { channel: display.channel || 'direct', handle: display.channelHandle || '', url: '' },
          lastActiveHrs: 24,
          status: 'active',
          attentionReason: null,
          scores: sphereScores,
          scoresPrev: sphereScores ? [...sphereScores] : null,
          moodWeek: moodSeries,
          moodMonth,
          habitsThisWeek: { done: 0, total: goalsList.length },
          streak: 0,
          streakBest: 0,
          sessionTopics: [],
          lastNote: '',
          sharesJournal: !!(link.scopes && link.scopes.journal),
          journalShared: journalList.length,
          tags: tagsFromLow,
          // For the self-preview banner — surface the actual coach this user
          // is connected to, so we can label "What Vlad sees" rather than a
          // generic "what your coach sees".
          linkCoachName: link.coachName || '',
          linkCoachId:   link.coachId || '',
          // Used as a fallback anchor in SinceLastSession when there's no
          // ended session yet (fresh coach, or self-preview where the
          // coachUid===userUid path has no /sessions records).
          linkClaimedAt: link.claimedAt ? Number(link.claimedAt) : null,
          // Carry the real underlying lists for sections that want them.
          _real: {
            coachUid,
            goals: goalsList,
            who5: who5List,
            areasMap: areasOnActive,
            mood: acc.mood || {},
            journal: journalList,
            sessions: sessionsList,
            outbox: outboxList,
            habitCompletions,
            executionLog: acc.executionLog,
            pings: pingsList,
            areaTitles,
            areaColorIds,
            areaIconIds,
            link,
            scopes: link.scopes || {},
            setDisplayName: async (name) => {
              if (!coachUid) return;
              await window.LWFB.db.ref(`/coach_clients/${coachUid}/${userUid}/displayName`).set(name);
            },
          },
        },
      });
    }

    const unsubAuth = window.LWAuth.onAuthChanged((u) => {
      // Reset listeners on auth change
      cleanups.forEach(fn => { try { fn(); } catch {} });
      cleanups = [];
      if (!u) return;
      coachUid = u.uid;

      // Roster entry — display data the coach wrote at claim time
      const rosterRef = window.LWFB.db.ref(`/coach_clients/${coachUid}/${userUid}`);
      rosterRef.on('value', (snap) => { acc.roster = snap.exists() ? snap.val() : null; recompute(); });
      cleanups.push(() => rosterRef.off());

      // Coach link — gates everything below; absence/inactive = unauthorized.
      // Self-preview path: when the signed-in coach is also the userUid, they
      // are looking at their own data and the link.coachId may belong to a
      // different coach (the actual coach). We allow this case so coaches can
      // preview what their own coach sees of them. Scope filtering still
      // honors link.scopes (the user's own privacy settings), so the preview
      // matches the actual coach's view byte-for-byte.
      const isSelfPreview = userUid === coachUid;
      const linkRef = window.LWFB.db.ref(`/users/${userUid}/coach_link`);
      linkRef.on('value', (snap) => {
        if (!snap.exists()) { acc.link = false; recompute(); return; }
        const link = snap.val();
        if (link.status !== 'active') { acc.link = false; recompute(); return; }
        if (!isSelfPreview && link.coachId !== coachUid) { acc.link = false; recompute(); return; }
        acc.link = link;
        attachScopeListeners(link.scopes || {});
        recompute();
      }, () => { acc.link = false; recompute(); });
      cleanups.push(() => linkRef.off());

      // Subscribe to the per-scope user data once we know the scope set.
      function attachScopeListeners(scopes) {
        if (scopes.wheel) {
          const sphR = window.LWFB.db.ref(`/users/${userUid}/spheres`);
          sphR.on('value', (s) => { acc.spheres = s.exists() ? s.val() : null; recompute(); }, () => {});
          cleanups.push(() => sphR.off());
          const areaR = window.LWFB.db.ref(`/users/${userUid}/areas`);
          areaR.on('value', (s) => { acc.areas = s.exists() ? s.val() : null; recompute(); }, () => {});
          cleanups.push(() => areaR.off());
          // Multi-wheel users have one or more `/users/{uid}/wheels/{wheelId}`
          // docs; `/users/{uid}/activeWheelId` points at the one they're
          // viewing right now. Coach v0 mirrors that selection — show only
          // the active wheel's spheres/areas/goals.
          const wheelsR = window.LWFB.db.ref(`/users/${userUid}/wheels`);
          wheelsR.on('value', (s) => { acc.wheels = s.exists() ? s.val() : null; recompute(); }, () => {});
          cleanups.push(() => wheelsR.off());
          const activeR = window.LWFB.db.ref(`/users/${userUid}/activeWheelId`);
          activeR.on('value', (s) => { acc.activeWheelId = s.exists() ? s.val() : null; recompute(); }, () => {});
          cleanups.push(() => activeR.off());
        }
        if (scopes.habits || scopes.tasks) {
          const gR = window.LWFB.db.ref(`/users/${userUid}/goals`);
          gR.on('value', (s) => { acc.goals = s.exists() ? s.val() : null; recompute(); }, () => {});
          cleanups.push(() => gR.off());
        }
        if (scopes.habits) {
          // Habit completion log — /TodoExecutionLog/{goalKey}/executionDates/{autoId}/{dd-m-yyyy}: "increment".
          const exR = window.LWFB.db.ref(`/users/${userUid}/TodoExecutionLog`);
          exR.on('value', (s) => { acc.executionLog = s.exists() ? s.val() : null; recompute(); }, () => {});
          cleanups.push(() => exR.off());
        }
        if (scopes.mood) {
          const mR = window.LWFB.db.ref(`/users/${userUid}/mood`);
          mR.on('value', (s) => { acc.mood = s.exists() ? s.val() : null; recompute(); }, () => {});
          cleanups.push(() => mR.off());
        }
        if (scopes.journal) {
          const jR = window.LWFB.db.ref(`/users/${userUid}/journal`);
          jR.on('value', (s) => { acc.journal = s.exists() ? s.val() : null; recompute(); }, () => {});
          cleanups.push(() => jR.off());
        }
        if (scopes.who5) {
          const wR = window.LWFB.db.ref(`/users/${userUid}/who5`);
          wR.on('value', (s) => { acc.who5 = s.exists() ? s.val() : null; recompute(); }, () => {});
          cleanups.push(() => wR.off());
        }
      }

      // Sessions list — query by coachId (rule-allowed), then filter to this client client-side.
      const sessQ = window.LWFB.db.ref('/sessions').orderByChild('coachId').equalTo(coachUid);
      sessQ.on('value', (s) => { acc.sessions = s.exists() ? s.val() : null; recompute(); }, () => {});
      cleanups.push(() => sessQ.off());

      // Outbox — coach can read /coach_outbox/{userUid} via coach_link rule.
      // Filter client-side to fromCoachId === coachUid (we only show items WE sent).
      const outboxRef = window.LWFB.db.ref(`/coach_outbox/${userUid}`);
      outboxRef.on('value', (s) => { acc.outbox = s.exists() ? s.val() : null; recompute(); }, () => {});
      cleanups.push(() => outboxRef.off());

      // Pings the coach has sent to this client. Same per-user rule shape as outbox.
      const pingsRef = window.LWFB.db.ref(`/coach_pings/${userUid}`);
      pingsRef.on('value', (s) => { acc.pings = s.exists() ? s.val() : null; recompute(); }, () => {});
      cleanups.push(() => pingsRef.off());
    });

    return () => {
      try { unsubAuth(); } catch {}
      cleanups.forEach(fn => { try { fn(); } catch {} });
    };
  }, [userUid, isDemo]);

  return state;
}

// Build the 8-tuple [pos0..pos7] from whichever shape the user has.
//   spheres: flat object { health: 4, ... }   (web-seeded shape)
//   areas:   iOS native — keyed map of LWArea objects with string-formatted
//            `value` and `sortOrder` (1..8). Sphere identity is positional —
//            iOS templates write areas in canonical order. Old (v3) templates
//            placed Things at sortOrder 7 and Family/Friends at sortOrder 8;
//            v4.2 places People at 7 and Contribution at 8. We trust the order
//            and let the v4.2 labels override on the wheel chart — coaches
//            looking at legacy clients will see slight label mismatch but the
//            scores are right.
function sphereScoresFromAny(spheres, areas) {
  const order = LWDATA.SPHERE_KEYS;
  if (spheres && typeof spheres === 'object') {
    if (Array.isArray(spheres) && spheres.length === 8) return spheres.map(n => Number(n) || 0);
    return order.map(k => {
      const v = spheres[k];
      return v == null ? null : Number(v);
    });
  }
  if (areas) {
    const list = Array.isArray(areas) ? areas : Object.values(areas);
    // First try the keyed shape: {sphere/initialCategory: 'health', value: 4}
    const byKey = {};
    list.forEach(a => {
      if (!a) return;
      const key = (a.initialCategory || a.sphere || a.key || a.id || '').toString().toLowerCase();
      const v = a.value != null ? a.value : (a.score != null ? a.score : null);
      if (key && v != null && order.indexOf(key) >= 0) byKey[key] = parseFloat(v);
    });
    if (Object.keys(byKey).length >= 4) {
      return order.map(k => byKey[k] != null ? byKey[k] : null);
    }
    // Fallback: positional via sortOrder (iOS LWArea legacy shape).
    // Indexing convention varies across wheels — normalize via shared helper.
    const valued = list.filter(a => a && a.value != null);
    const posMap = normalizeAreaPositions(valued);
    if (posMap.size === 0) return null;
    const out = Array(8).fill(null);
    valued.forEach(a => {
      const pos = posMap.get(a);
      if (pos == null) return;
      const v = parseFloat(a.value);
      if (isNaN(v)) return;
      out[pos] = Math.max(0, Math.min(10, v));
    });
    return out;
  }
  return null;
}

// iOS MoodType enum → 1–10 numeric score for the timeline. Mirrors the
// emotional ranks (terrible … great) so the chart shows a meaningful slope.
const MOOD_VALUES = {
  terrible_mood: 2, bad_mood: 4, neutral_mood: 6, good_mood: 8, great_mood: 10,
  // Older/alternate keys some users may have:
  awful: 2, bad: 4, neutral: 6, good: 8, great: 10,
};

// Convert a mood entry to a numeric value 1..10. Returns null if unknown.
function moodEntryValue(entry) {
  if (!entry) return null;
  if (typeof entry.mood === 'string' && MOOD_VALUES[entry.mood] != null) return MOOD_VALUES[entry.mood];
  if (typeof entry.value === 'number') return entry.value;
  if (typeof entry.score === 'number') return entry.score;
  if (typeof entry.value === 'string' && entry.value) {
    const n = parseFloat(entry.value); return isFinite(n) ? n : null;
  }
  return null;
}

// Convert a mood entry's timestamp (creationDate seconds OR ms variant) to ms.
function moodEntryMs(entry) {
  const t = entry.creationDate ?? entry.createdAt ?? entry.ts ?? entry.date ?? 0;
  const n = Number(t);
  if (!n) return 0;
  // Heuristic: > 1e12 is ms, else seconds.
  return n > 1e12 ? n : n * 1000;
}

// Bucket mood entries into per-day averages over the last `days` days.
// Returns an array of length `days` (oldest → newest), nulls for missing days.
function moodDailySeries(mood, days) {
  const out = new Array(days).fill(null);
  if (!mood) return out;
  const today0 = new Date(); today0.setHours(0, 0, 0, 0);
  const dayMs = 24 * 60 * 60 * 1000;
  const buckets = new Array(days).fill(null).map(() => ({ sum: 0, n: 0 }));
  Object.values(mood).forEach((entry) => {
    const v = moodEntryValue(entry);
    const t = moodEntryMs(entry);
    if (v == null || !t) return;
    const d = new Date(t); d.setHours(0, 0, 0, 0);
    const offset = Math.round((today0 - d) / dayMs); // 0 = today, 1 = yesterday, ...
    if (offset < 0 || offset >= days) return;
    const slot = days - 1 - offset;
    buckets[slot].sum += v;
    buckets[slot].n += 1;
  });
  for (let i = 0; i < days; i++) {
    if (buckets[i].n > 0) out[i] = buckets[i].sum / buckets[i].n;
  }
  return out;
}
function moodWeekFromMap(mood)  { return moodDailySeries(mood, 7); }
function moodMonthFromMap(mood) { return moodDailySeries(mood, 30); }
// Filters a keyed RTDB map (spheres, areas, ...) down to entries that belong
// to the user's currently-active wheel. Both spheres and areas have a
// `wheelId` per entry. Items missing wheelId are legacy and live on the
// default wheel — kept only when the active wheel IS the default.
//
// Fallback: if the filter zeros out a non-empty map, return the original.
// Real-world data is messy — items can have wheelIds that don't map cleanly
// to the wheels collection (e.g. wheel deleted, mid-migration). Better to
// show something than to hide everything.
function filterMapToActiveWheel(map, wheelCtx) {
  if (!map || typeof map !== 'object') return map;
  if (!wheelCtx || !wheelCtx.activeId) return map; // legacy single-wheel users
  const out = {};
  Object.entries(map).forEach(([id, item]) => {
    if (!item || typeof item !== 'object') return;
    if (belongsToActiveWheel(item.wheelId, wheelCtx)) out[id] = item;
  });
  if (Object.keys(out).length === 0 && Object.keys(map).length > 0) {
    return map; // safety fallback — filter pruned everything, show the lot
  }
  return out;
}

// Returns the wheel-context the coach sees: v0 PINS to the user's default
// (Personal) wheel regardless of which wheel they're currently viewing on
// iOS. Multi-wheel switching is a v2 feature; for now the coach always sees
// the user's Personal wheel — that's what aligns with their coaching work.
//
// `wheels` is /users/{uid}/wheels — keyed map of wheel docs with sortOrder.
// `_activeIdIgnored` is /users/{uid}/activeWheelId; we still subscribe to it
// so the param is here for shape parity, but we don't use it for filtering.
// Legacy single-wheel users have no wheels node — return a sentinel that
// short-circuits filtering so all items pass through.
function resolveActiveWheel(wheels, _activeIdIgnored) {
  if (!wheels || typeof wheels !== 'object') {
    return { activeId: null, isDefault: true, defaultId: null };
  }
  const list = Object.entries(wheels).map(([id, w]) => ({ id, sortOrder: Number((w && w.sortOrder) || 0) }));
  list.sort((a, b) => a.sortOrder - b.sortOrder);
  const defaultId = list.length ? list[0].id : null;
  // Pin to default. isDefault=true so legacy items (wheelId=nil) also match.
  return { activeId: defaultId, isDefault: true, defaultId };
}

// True when an item with `wheelId` belongs to the active wheel:
//   - explicit match → ✓
//   - missing/null wheelId on default wheel → ✓ (legacy items live on default)
function belongsToActiveWheel(itemWheelId, wheelCtx) {
  if (!wheelCtx || !wheelCtx.activeId) return true; // legacy single-wheel users
  if (itemWheelId && itemWheelId === wheelCtx.activeId) return true;
  if (!itemWheelId && wheelCtx.isDefault) return true;
  return false;
}

// Goals filter for the coach view:
//   - paused goals are work the user has explicitly stepped back from — hide
//   - completed goals (completionTime set) are history — hide
//   - off-active-wheel goals belong to a wheel the coach hasn't been added to in v0 — hide
function goalsFromMap(goals, wheelCtx) {
  if (!goals) return [];
  return Object.entries(goals)
    .map(([id, g]) => ({ id, ...g }))
    .filter(g => !g.isPaused)
    .filter(g => !g.completionTime || String(g.completionTime).trim() === '')
    .filter(g => belongsToActiveWheel(g.wheelId, wheelCtx));
}
function journalFromMap(journal) {
  if (!journal) return [];
  return Object.entries(journal).map(([id, j]) => ({ id, ...j }));
}

function formatWho5Date(ms, lang) {
  if (!ms) return '';
  try {
    return new Date(Number(ms)).toLocaleDateString(lang === 'ru' ? 'ru-RU' : undefined, { month: 'short', day: 'numeric' });
  } catch { return ''; }
}

// WHO-5 measurements: /users/{uid}/who5/measurements/{autoId} with
// `score` (0-100), `items[5]`, `source`, `timestamp`. Returned as a flat
// array sorted oldest→newest so the panel can grab last/prev easily.
function who5FromMap(who5) {
  const measurements = who5 && who5.measurements;
  if (!measurements) return [];
  return Object.entries(measurements)
    .map(([id, m]) => ({
      id,
      score: typeof m?.score === 'number' ? m.score : Number(m?.score) || 0,
      items: Array.isArray(m?.items) ? m.items : [],
      ts: typeof m?.timestamp === 'number' ? m.timestamp * 1000
         : Number(m?.timestamp) ? Number(m.timestamp) * 1000 : 0,
      source: m?.source || null,
    }))
    .filter(m => m.ts > 0 && !isNaN(m.score))
    .sort((a, b) => a.ts - b.ts);
}

// Pings: short messages this coach sent to the client. Newest-first.
function pingsFromMap(pings, coachUid) {
  if (!pings) return [];
  return Object.entries(pings)
    .map(([id, p]) => ({ id, ...p }))
    .filter(p => p && p.fromCoachId === coachUid && p.text)
    .sort((a, b) => Number(b.ts || 0) - Number(a.ts || 0));
}

// Outbox: only items I (this coach) sent. Returns newest-first.
function outboxFromMap(outbox, coachUid) {
  if (!outbox) return [];
  return Object.entries(outbox)
    .filter(([id]) => id !== '_meta')
    .map(([id, item]) => ({ id, ...item }))
    .filter(it => it && it.fromCoachId === coachUid)
    .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
}

// Sessions: filter to only those where coachId matches (defense-in-depth on top of rules).
function sessionsFromMap(sessions, coachUid, userUid) {
  if (!sessions) return [];
  return Object.entries(sessions)
    .map(([id, s]) => ({ id, ...s }))
    .filter(s => s.coachId === coachUid && s.userUid === userUid)
    .sort((a, b) => Number(b.startedAt || 0) - Number(a.startedAt || 0));
}

// Walk the iOS execution log and emit a flat list of completion timestamps (ms).
// Schema (current iOS): /users/{uid}/TodoExecutionLog/{goalKey}/executionDates
// is a JSON array of unix-seconds. See `collectExecutionTimestampsMs` for the
// shape detection — this thin wrapper just flattens across all goals.
function parseExecutionLog(executionLog) {
  if (!executionLog) return [];
  const out = [];
  Object.values(executionLog).forEach(goalRecord => {
    const list = collectExecutionTimestampsMs(goalRecord && goalRecord.executionDates);
    list.forEach(ms => out.push(ms));
  });
  return out;
}

// Pull per-area iOS color ids in canonical [pos0..pos7] order from iOS `areas`.
// iOS stores color as a stringified int; web ColorHelper maps it to hex.
// Returns null when areas missing or don't yield 8 entries.
function areaColorIdsFromAny(_spheres, areas) {
  if (!areas) return null;
  const list = Array.isArray(areas) ? areas : Object.values(areas);
  const filtered = list.filter(a => a && a.color != null);
  const posMap = normalizeAreaPositions(filtered);
  if (posMap.size === 0) return null;
  const out = Array(8).fill(null);
  filtered.forEach(a => {
    const pos = posMap.get(a);
    if (pos == null) return;
    const c = parseInt(String(a.color), 10);
    if (!isNaN(c)) out[pos] = c;
  });
  return out.some(v => v != null) ? out : null;
}

// Same shape but for iconIdentifier ("5" → 5) so the wheel can render the icon
// the user actually picked instead of the canonical sphere fallback.
function areaIconIdsFromAny(_spheres, areas) {
  if (!areas) return null;
  const list = Array.isArray(areas) ? areas : Object.values(areas);
  const filtered = list.filter(a => a && a.iconIdentifier != null);
  const posMap = normalizeAreaPositions(filtered);
  if (posMap.size === 0) return null;
  const out = Array(8).fill(null);
  filtered.forEach(a => {
    const pos = posMap.get(a);
    if (pos == null) return;
    const i = parseInt(String(a.iconIdentifier), 10);
    if (!isNaN(i)) out[pos] = i;
  });
  return out.some(v => v != null) ? out : null;
}

// Pull localized titles in canonical [pos0..pos7] order from iOS `areas`.
// Returns null if data is unavailable or doesn't yield 8 titles.
// iOS area sortOrder convention is inconsistent: legacy users (pre-WheelManager)
// have 0-indexed sortOrders (0..7); newer wheels created by WheelManager use
// 1-indexed (1..8). Detect which convention each list uses by min sortOrder
// and normalize to a 0..7 position.
function normalizeAreaPositions(list) {
  const raws = list
    .filter(a => a && a.sortOrder != null)
    .map(a => parseInt(a.sortOrder, 10))
    .filter(n => !isNaN(n));
  if (raws.length === 0) return new Map();
  const min = Math.min(...raws);
  const offset = (min === 0) ? 0 : 1; // 0-indexed list keeps 0; 1-indexed subtracts 1
  const map = new Map();
  list.forEach(a => {
    if (!a || a.sortOrder == null) return;
    const n = parseInt(a.sortOrder, 10);
    if (isNaN(n)) return;
    const pos = n - offset;
    if (pos >= 0 && pos < 8) map.set(a, pos);
  });
  return map;
}

function areaTitlesFromAny(spheres, areas) {
  if (!areas) return null;
  const list = Array.isArray(areas) ? areas : Object.values(areas);
  const titled = list.filter(a => a && a.title);
  const posMap = normalizeAreaPositions(titled);
  const out = Array(8).fill(null);
  titled.forEach(a => {
    const pos = posMap.get(a);
    if (pos != null) out[pos] = String(a.title);
  });
  // If we got fewer than 8 titles, leave gaps as null — render layer falls
  // back to default sphere names per-position via `titles[i] || SPHERE_NAMES[i]`.
  return out.some(t => t) ? out : null;
}

// Stable, well-distributed hue from a user uid so each connected client gets
// a distinct avatar color even when the coach hasn't customised one.
function hueFromUid(uid) {
  if (!uid) return 140;
  let h = 0;
  for (let i = 0; i < uid.length; i++) h = (h * 31 + uid.charCodeAt(i)) >>> 0;
  return h % 360;
}

function getInitialPrepView() {
  try {
    const params = new URLSearchParams(window.location.search);
    return params.get('view') === 'prep';
  } catch { return false; }
}

// ── Hero ──
function ClientHero({ client, onOpenPrep, onOpenBook, onOpenPing }) {
  const { c, fonts, t, lang } = useLW();
  const { isMobile } = useViewport();
  // Status pill is honest: only render when there's a real signal (attention,
  // brand-new, or coach explicitly tagged the client). Don't show "active" /
  // "в порядке" by default — that's noise.
  const stateMeta = (() => {
    if (client.status === 'attention') return { label: t('client.status_attention'), tone: 'flame'  };
    if (client.status === 'new')       return { label: t('client.status_new'),       tone: 'accent' };
    if (client.status === 'stable')    return { label: t('client.status_stable'),    tone: 'subtle' };
    return null;
  })();

  const canRename = !!(client._real && client._real.setDisplayName);
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(client.name);
  useEffect(() => { setDraft(client.name); }, [client.name]);
  const commit = async () => {
    const v = (draft || '').trim();
    if (!v || v === client.name) { setEditing(false); return; }
    try { await client._real.setDisplayName(v); } catch (e) { console.warn('[rename]', e); }
    setEditing(false);
  };

  return (
    <div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'flex-start' : 'center', gap: isMobile ? 16 : 20, marginBottom: 28 }}>
      <Avatar name={client.name} hue={client.avatarHue} size={isMobile ? 56 : 72}
              status={client.status === 'attention' ? 'attn' : client.status === 'new' ? 'live' : null} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap', marginBottom: 4 }}>
          {editing ? (
            <input
              autoFocus
              value={draft}
              onChange={(e) => setDraft(e.target.value)}
              onBlur={commit}
              onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') { setDraft(client.name); setEditing(false); } }}
              style={{
                margin: 0, fontFamily: fonts.display, fontWeight: 500,
                fontSize: 'clamp(28px, 4vw, 40px)', letterSpacing: '-0.02em',
                color: c.textPrimary, lineHeight: 1.05,
                background: c.bgSubtle, border: `1px solid ${c.borderDefault}`,
                borderRadius: 10, padding: '4px 10px', outline: 'none', minWidth: 220,
              }}
            />
          ) : (
            <h1
              onClick={() => canRename && setEditing(true)}
              title={canRename ? t('client.click_to_rename') : ''}
              style={{
                margin: 0, fontFamily: fonts.display, fontWeight: 500,
                fontSize: 'clamp(28px, 4vw, 40px)', letterSpacing: '-0.02em',
                color: c.textPrimary, lineHeight: 1.05,
                cursor: canRename ? 'text' : 'default',
                borderRadius: 8, padding: canRename ? '4px 10px' : 0, margin: canRename ? '-4px -10px' : 0,
                transition: 'background 150ms ease',
              }}
              onMouseEnter={(e) => { if (canRename) e.currentTarget.style.background = c.bgSubtle; }}
              onMouseLeave={(e) => { if (canRename) e.currentTarget.style.background = 'transparent'; }}
            >
              {client.name}
            </h1>
          )}
          {stateMeta && <Chip tone={stateMeta.tone}>{stateMeta.label}</Chip>}
        </div>
        <div style={{ fontFamily: fonts.body, fontSize: 14, color: c.textSecondary, marginBottom: 4 }}>
          {(() => {
            // Build a clean meta line — skip empty placeholders. Order:
            // focus tags → days connected → cadence (only if known).
            const parts = [];
            if (client.tags && client.tags.length) {
              parts.push(client.tags.map(tag => tag[0].toUpperCase() + tag.slice(1)).join(' · '));
            }
            if (client.joinedDays != null && client.joinedDays >= 1) {
              parts.push(t('client.days_in', { n: client.joinedDays }));
            } else if (client.joinedDays === 0) {
              parts.push(t('client.status_new'));
            }
            // Cadence is currently always 'biweekly' as a placeholder — drop until
            // a real cadence is wired so we don't lie to the coach.
            return parts.join(' · ');
          })()}
        </div>
        {client.attentionReason && (
          <div style={{ fontFamily: fonts.body, fontStyle: 'italic', fontSize: 13, color: c.flame, marginTop: 6 }}>
            ⚑ {client.attentionReason}
          </div>
        )}
      </div>
      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
        {client.userUid && (
          <a href={`Session.html?clientId=${encodeURIComponent(client.userUid)}`} style={{ textDecoration: 'none' }}>
            <Button variant="primary" size="md" icon={<span style={{ fontSize: 14 }}>●</span>}>{t('client.start_session')}</Button>
          </a>
        )}
        <Button variant="ghost" size="md" icon={<span style={{ fontSize: 14 }}>✦</span>} onClick={onOpenPrep}>
          {t('client.prep_brief')}
        </Button>
        <Button variant={client.userUid ? 'ghost' : 'primary'} size="md" icon={<span style={{ fontSize: 14 }}>✉</span>}
          onClick={client.userUid ? onOpenPing : undefined}
          disabled={!client.userUid}>{t('client.send_ping')}</Button>
        <Button variant="ghost" size="md" icon={<span style={{ fontSize: 14 }}>📅</span>} onClick={onOpenBook}>{t('client.book_session')}</Button>
      </div>
    </div>
  );
}

// ── Wheel + deltas section ──
// What's happened since last session.endedAt: journal entries, mood logs (+ trend),
// pending outbox items, sphere movements. Compact at-a-glance pulse so the coach
// doesn't have to scroll through the whole detail to know if anything happened.
function SinceLastSession({ client }) {
  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 real = client._real;
  if (!real) return null;
  const lastSession = (real.sessions || []).find(s => s.endedAt);

  // Anchor for "what changed since X". Prefer the most recent ended session;
  // fall back to the coach_link claim timestamp (when the coach gained
  // visibility), then to "7 days ago" so the panel always renders something
  // useful instead of silently disappearing on the self-preview path or for
  // brand-new coach relationships with no sessions yet.
  let anchorMs;
  let anchorKind; // 'session' | 'claim' | 'recent7'
  if (lastSession && lastSession.endedAt) {
    anchorMs = Number(lastSession.endedAt);
    anchorKind = 'session';
  } else if (real.linkClaimedAt) {
    const v = Number(real.linkClaimedAt);
    anchorMs = v > 1e12 ? v : v * 1000;
    anchorKind = 'claim';
  } else {
    anchorMs = Date.now() - 7 * 86_400_000;
    anchorKind = 'recent7';
  }
  const lastEnded = anchorMs;
  const lastEndedRel = sinceRel(t, lastEnded);
  const daysSince = Math.floor((Date.now() - lastEnded) / 86_400_000);

  // 1. Journal entries since — shared with coach only. Counting unshared
  // entries would leak existence; show shared count + private chip if needed.
  const journalSince = (real.journal || []).filter(j => {
    if (!isCoachShared(j)) return false;
    const ts = Number((j && (j.creationDate || j.modificationDate)) || 0);
    const ms = ts > 1e12 ? ts : ts * 1000;
    return ms >= lastEnded;
  }).sort((a, b) => {
    const ams = Number(a.creationDate || 0); const bms = Number(b.creationDate || 0);
    return (bms > 1e12 ? bms : bms * 1000) - (ams > 1e12 ? ams : ams * 1000);
  });
  const recentJournal = journalSince[0];
  const recentJournalAgo = recentJournal && (() => {
    const ts = Number(recentJournal.creationDate || 0);
    return sinceRel(t, ts > 1e12 ? ts : ts * 1000);
  })();

  // 2. Mood logs since + delta vs prior 7d
  const moodMap = real.mood || {};
  const moodSince = [];
  let moodPrior7 = []; // entries in the 7 days BEFORE lastEnded — used as baseline.
  Object.values(moodMap).forEach(m => {
    if (!m) return;
    const valMap = { 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 v = (typeof m.mood === 'string' && valMap[m.mood] != null) ? valMap[m.mood]
            : (typeof m.value === 'number' ? m.value : null);
    if (v == null) return;
    const ts = Number(m.creationDate || m.ts || 0);
    const ms = ts > 1e12 ? ts : ts * 1000;
    if (!ms) return;
    if (ms >= lastEnded) moodSince.push(v);
    else if (ms >= lastEnded - 7 * 86_400_000) moodPrior7.push(v);
  });
  const moodAvg = moodSince.length ? (moodSince.reduce((s, v) => s + v, 0) / moodSince.length) : null;
  const moodPriorAvg = moodPrior7.length ? (moodPrior7.reduce((s, v) => s + v, 0) / moodPrior7.length) : null;
  const moodDelta = (moodAvg != null && moodPriorAvg != null) ? (moodAvg - moodPriorAvg) : null;

  // 3. Wheel changes since session.scoresAtEnd. Only available with a real
  // ended session — the claim/recent7 fallbacks have no baseline to diff
  // against, so the wheel-changes tile is just hidden in those cases.
  const wheelChanges = (() => {
    if (anchorKind !== 'session' || !lastSession) return 0;
    if (!Array.isArray(lastSession.scoresAtEnd) || !Array.isArray(client.scores)) return 0;
    let n = 0;
    for (let i = 0; i < 8; i++) {
      const a = Number(lastSession.scoresAtEnd[i]);
      const b = Number(client.scores[i]);
      if (!isNaN(a) && !isNaN(b) && Math.abs(b - a) >= 0.5) n += 1;
    }
    return n;
  })();

  // 4. Pending outbox items waiting on client (already filtered to this coach in _real.outbox).
  // Keep the items themselves (not just count) so the tile can preview the title.
  const pendingOutboxItems = (real.outbox || []).filter(it => !it.acceptedAt && !it.skippedAt);
  const pendingOutbox = pendingOutboxItems.length;

  // 5. Outbox items the client accepted SINCE the last session — positive signal,
  //    "they actually engaged with what you proposed". Keep the items so the
  //    tile can list titles.
  const acceptedItems = (real.outbox || [])
    .filter(it => it.acceptedAt && Number(it.acceptedAt) >= lastEnded)
    .sort((a, b) => Number(b.acceptedAt) - Number(a.acceptedAt));
  const acceptedSince = acceptedItems.length;

  // 6. Goals/habits/todos the client created since the last session. Split by
  //    whether the client shared each item with the coach (coachShare flag).
  //    Shared → show title. Private → only count.
  const newGoalItems = (real.goals || []).filter(g => {
    const ct = parseFloat((g && g.creationTime) || 0);
    if (!ct) return false;
    const ms = ct > 1e12 ? ct : ct * 1000;
    return ms >= lastEnded;
  }).sort((a, b) => parseFloat(b.creationTime || 0) - parseFloat(a.creationTime || 0));
  const newGoalsShared = newGoalItems.filter(g => isCoachShared(g));
  const newGoalsPrivate = newGoalItems.length - newGoalsShared.length;
  const newGoalsSince = newGoalItems.length;

  // 7. Habit completions since last session (from TodoExecutionLog timestamps).
  const habitCompletionsSince = (real.habitCompletions || []).filter(ts => ts >= lastEnded).length;

  // 8. Subtasks + simple todos the client checked off since the anchor.
  // iOS marks completion via `isAchieved: true` + `completionTime` (seconds).
  // Subtasks live at goal.tasks[key]; simple todos are top-level goals with
  // `isSimpleTodo: true`. Privacy: only items whose parent goal is shared
  // get their TITLE surfaced — others contribute to the count anonymously.
  const completedTaskItems = [];
  (real.goals || []).forEach(g => {
    const parentShared = isCoachShared(g);
    // Top-level simple todos
    if (g.isSimpleTodo && g.isAchieved) {
      const ct = parseFloat(g.completionTime || 0);
      if (ct) {
        const ms = ct > 1e12 ? ct : ct * 1000;
        if (ms >= lastEnded) {
          completedTaskItems.push({
            ms,
            title: parentShared ? (g.title || g.name || '') : '',
            shared: parentShared,
            kind: 'todo',
          });
        }
      }
    }
    // Nested subtasks under a goal
    if (g.tasks && typeof g.tasks === 'object') {
      Object.values(g.tasks).forEach(st => {
        if (!st || !st.isAchieved) return;
        const ct = parseFloat(st.completionTime || 0);
        if (!ct) return;
        const ms = ct > 1e12 ? ct : ct * 1000;
        if (ms < lastEnded) return;
        completedTaskItems.push({
          ms,
          title: parentShared ? (st.name || st.text || '') : '',
          shared: parentShared,
          kind: 'subtask',
        });
      });
    }
  });
  completedTaskItems.sort((a, b) => b.ms - a.ms);
  const completedTasksSince = completedTaskItems.length;
  const completedSharedItems = completedTaskItems.filter(x => x.shared && x.title);
  const completedPrivateCount = completedTasksSince - completedSharedItems.length;

  const hasAnyActivity =
    journalSince.length > 0 || moodSince.length > 0 || wheelChanges > 0 ||
    pendingOutbox > 0 || acceptedSince > 0 || newGoalsSince > 0 ||
    habitCompletionsSince > 0 || completedTasksSince > 0;

  // Long-quiet variant when nothing happened in 7+ days
  const isLongQuiet = !hasAnyActivity && daysSince >= 7;

  return (
    <div style={{
      marginBottom: 28,
      padding: '18px 22px',
      borderRadius: isBrut ? 2 : 16,
      background: hasAnyActivity ? c.card : c.bgSubtle,
      border: isBrut
        ? `1.5px solid ${c.ink}`
        : `1px solid ${hasAnyActivity ? c.borderDefault : c.borderSubtle}`,
      boxShadow: isBrut ? `4px 4px 0 0 ${c.ink}` : 'none',
    }}>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginBottom: hasAnyActivity ? 14 : 0 }}>
        <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary }}>
          {t(anchorKind === 'session' ? 'client.since_eyebrow'
             : anchorKind === 'claim' ? 'client.since_eyebrow_claim'
             : 'client.since_eyebrow_recent')}
        </div>
        <div style={{ fontFamily: fonts.body, fontSize: 12, color: c.textSecondary }}>
          {lastEndedRel}
        </div>
      </div>

      {!hasAnyActivity ? (
        <div style={{ fontFamily: fonts.body, fontSize: 14, color: c.textTertiary, fontStyle: 'italic', lineHeight: 1.5 }}>
          {isLongQuiet ? t('client.since_quiet_long', { n: daysSince }) : t('client.since_quiet')}
        </div>
      ) : (
        <div style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))',
          gap: 14,
        }}>
          {journalSince.length > 0 && (
            <PulseTile
              c={c} fonts={fonts}
              icon="✎"
              primary={pluralLabel(t, lang, 'client.since_journal', journalSince.length)}
              secondary={recentJournalAgo ? t('client.since_journal_recent', { ago: recentJournalAgo }) : null}
              tone="accent"
            />
          )}
          {moodSince.length > 0 && (
            <PulseTile
              c={c} fonts={fonts}
              icon="◐"
              primary={pluralLabel(t, lang, 'client.since_mood', moodSince.length)}
              secondary={moodDelta != null
                ? (moodDelta >= 0.4 ? t('client.since_mood_up', { n: moodDelta.toFixed(1) })
                  : moodDelta <= -0.4 ? t('client.since_mood_down', { n: Math.abs(moodDelta).toFixed(1) })
                  : t('client.since_mood_avg', { n: moodAvg.toFixed(1) }))
                : (moodAvg != null ? t('client.since_mood_avg', { n: moodAvg.toFixed(1) }) : null)}
              tone={moodDelta != null && moodDelta <= -0.4 ? 'flame' : 'accent'}
            />
          )}
          {wheelChanges > 0 && (
            <PulseTile
              c={c} fonts={fonts}
              icon="◯"
              primary={t('client.since_wheel_changed', { n: wheelChanges })}
              secondary={null}
              tone="accent"
            />
          )}
          {acceptedSince > 0 && (
            <PulseTile
              c={c} fonts={fonts}
              icon="✓"
              primary={pluralLabel(t, lang, 'client.since_accepted', acceptedSince)}
              secondary={renderItemTitles(acceptedItems, c, t, 'title-or-text')}
              tone="accent"
              onClick={() => {
                const target = document.querySelector('[data-section="outbox"]');
                if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
              }}
            />
          )}
          {newGoalsSince > 0 && (
            <PulseTile
              c={c} fonts={fonts}
              icon="+"
              primary={pluralLabel(t, lang, 'client.since_new_goal', newGoalsSince)}
              secondary={renderNewGoalsSecondary(newGoalsShared, newGoalsPrivate, c, t)}
              tone="accent"
              onClick={() => {
                const target = document.querySelector('[data-section="goals"]');
                if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
              }}
            />
          )}
          {habitCompletionsSince > 0 && (
            <PulseTile
              c={c} fonts={fonts}
              icon="◉"
              primary={pluralLabel(t, lang, 'client.since_habits_done', habitCompletionsSince)}
              secondary={null}
              tone="accent"
            />
          )}
          {completedTasksSince > 0 && (
            <PulseTile
              c={c} fonts={fonts}
              icon="✓"
              primary={pluralLabel(t, lang, 'client.since_tasks_done', completedTasksSince)}
              secondary={completedSharedItems.length > 0 ? (
                <>
                  {completedSharedItems.slice(0, 2).map((it, i) => (
                    <span key={i} style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                      · {it.title}
                    </span>
                  ))}
                  {(completedSharedItems.length > 2 || completedPrivateCount > 0) && (
                    <span style={{ display: 'block', opacity: 0.7 }}>
                      + {lang === 'ru' ? 'ещё' : 'more'} {completedTasksSince - Math.min(2, completedSharedItems.length)}
                    </span>
                  )}
                </>
              ) : (completedPrivateCount > 0 ? (
                <span style={{ display: 'block', fontStyle: 'italic', opacity: 0.7 }}>
                  {lang === 'ru' ? 'без названий (приватно)' : 'titles private'}
                </span>
              ) : null)}
              tone="accent"
              onClick={() => {
                const target = document.querySelector('[data-section="goals"]');
                if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
              }}
            />
          )}
          {pendingOutbox > 0 && (() => {
            const top = pendingOutboxItems[0];
            const topTitle = (top && (top.title || top.text)) || '';
            const more = pendingOutbox - 1;
            // Tile becomes clickable — coach can scroll to the full outbox feed
            // below to see all pending items + their accept/skip state.
            return (
              <PulseTile
                c={c} fonts={fonts}
                icon="◇"
                primary={pluralLabel(t, lang, 'client.since_outbox_count', pendingOutbox)}
                secondary={topTitle ? (
                  <>
                    <span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                      "{topTitle}"
                    </span>
                    {more > 0 && (
                      <span style={{ display: 'block', marginTop: 2, color: c.textTertiary, fontSize: 11 }}>
                        {t('client.since_outbox_more', { n: more })}
                      </span>
                    )}
                    <span style={{ display: 'block', marginTop: 4, fontSize: 11, fontWeight: 700, letterSpacing: '0.04em', color: c.flame }}>
                      {t('client.since_outbox_pending')} →
                    </span>
                  </>
                ) : null}
                tone="flame"
                onClick={() => {
                  const target = document.querySelector('[data-section="outbox"]');
                  if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
                }}
              />
            );
          })()}
        </div>
      )}

      {/* Day-of-week activity chart — always rendered so the coach has a
          pattern context regardless of whether anything happened since the
          last session. Hides itself if there's not enough data to read. */}
      <PulseDowChart client={client} />
    </div>
  );
}

// 7-bar day-of-week activity chart over the last 4 weeks. Aggregates all
// observable activity (journal entries, mood logs, new goals/habits/todos
// the client added, and accepted-outbox items) by ISO weekday. Lets the
// coach see when the client tends to engage — useful question fuel for
// a session ("Thursdays look quiet — what's going on?").
function PulseDowChart({ client }) {
  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 real = client._real;
  if (!real) return null;
  // Last 7 days, day-by-day. Bar 0 = 6 days ago, Bar 6 = today.
  const DAYS = 7;
  const now = new Date();
  const startOfDayMs = (d) => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x.getTime(); };
  const todayStart = startOfDayMs(now);
  const dayStarts = []; // ms-at-midnight for each of the 7 days, oldest first
  for (let k = DAYS - 1; k >= 0; k--) dayStarts.push(todayStart - k * 86_400_000);
  const buckets = new Array(DAYS).fill(0);
  const tally = (rawTs) => {
    const ts = Number(rawTs || 0);
    if (!ts) return;
    const ms = ts > 1e12 ? ts : ts * 1000;
    const dayStart = startOfDayMs(new Date(ms));
    const idx = dayStarts.indexOf(dayStart);
    if (idx >= 0) buckets[idx] += 1;
  };
  Object.values(real.journal || {}).forEach(j => j && tally(j.creationDate || j.modificationDate));
  Object.values(real.mood || {}).forEach(m => m && tally(m.creationDate || m.ts));
  (real.goals || []).forEach(g => tally(parseFloat(g && g.creationTime || 0)));
  (real.outbox || []).forEach(it => it && it.acceptedAt && tally(it.acceptedAt));
  (real.habitCompletions || []).forEach(ts => tally(ts));

  const total = buckets.reduce((s, v) => s + v, 0);
  if (total < 1) {
    return (
      <div style={{
        marginTop: 14, padding: '14px 16px',
        borderRadius: isBrut ? 2 : 10,
        background: c.bgSubtle,
        border: isBrut ? `1.5px solid ${c.ink}` : `1px dashed ${c.borderSubtle}`,
        fontFamily: isBrut ? fonts.mono : fonts.body,
        fontSize: 13, color: c.textTertiary, fontStyle: 'italic',
      }}>{lang === 'ru' ? 'За последние 7 дней пока тихо.' : 'Quiet for the last 7 days.'}</div>
    );
  }

  const max = Math.max(1, ...buckets);
  // Short day labels for the last 7 days. "Today", "Yest" specifically labeled.
  const labelOf = (dayStart, idx) => {
    const offset = (todayStart - dayStart) / 86_400_000;
    if (offset === 0) return lang === 'ru' ? 'Сег' : 'Today';
    if (offset === 1) return lang === 'ru' ? 'Вчр' : 'Yest';
    const d = new Date(dayStart);
    const ru = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
    const en = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
    return (lang === 'ru' ? ru : en)[d.getDay()];
  };
  const dateLabel = (dayStart) => {
    const d = new Date(dayStart);
    return `${d.getDate()}`;
  };

  return (
    <div style={{ marginTop: 16 }}>
      <div style={{
        display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
        gap: 12, marginBottom: 10,
      }}>
        <div style={{
          fontFamily: isBrut ? fonts.mono : fonts.body,
          fontSize: 11, fontWeight: isBrut ? 600 : 700,
          letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary,
        }}>
          {isBrut ? `—— ${lang === 'ru' ? 'АКТИВНОСТЬ · 7 ДНЕЙ' : 'ACTIVITY · 7 DAYS'}` : (lang === 'ru' ? 'Активность · 7 дней' : 'Activity · 7 days')}
        </div>
        <div style={{
          fontFamily: isBrut ? fonts.mono : fonts.body,
          fontSize: 11, color: c.textTertiary, fontWeight: 600, letterSpacing: '0.06em',
        }}>
          {lang === 'ru' ? `Всего: ${total}` : `Total: ${total}`}
        </div>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: `repeat(${DAYS}, 1fr)`, gap: 8, alignItems: 'end', height: 132 }}>
        {buckets.map((v, i) => {
          const h = max > 0 ? Math.max(2, Math.round((v / max) * 80)) : 2;
          const isToday = i === DAYS - 1;
          return (
            <div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', gap: 6, height: '100%' }}>
              <div style={{
                fontFamily: isBrut ? fonts.mono : fonts.app,
                fontSize: 11, fontWeight: 700,
                color: v > 0 ? (isToday ? c.accentInk || c.accent : c.textSecondary) : c.textTertiary,
                fontVariantNumeric: 'tabular-nums',
              }}>{v > 0 ? v : ''}</div>
              <div style={{
                width: '100%',
                maxWidth: 44,
                height: h,
                borderRadius: isBrut ? 0 : 5,
                background: v > 0
                  ? (isToday ? c.accent : (isBrut ? c.accentMuted : hexToRgba(c.accent, 0.45)))
                  : (isBrut ? 'transparent' : c.borderSubtle),
                border: isBrut ? `1.5px solid ${c.ink}` : 'none',
              }} />
              <div style={{
                fontFamily: isBrut ? fonts.mono : fonts.body,
                fontSize: 10, fontWeight: 600, letterSpacing: '0.04em',
                color: isToday ? (c.accentInk || c.accent) : c.textTertiary,
                textTransform: 'uppercase', textAlign: 'center', lineHeight: 1.2,
              }}>
                <div>{labelOf(dayStarts[i], i)}</div>
                <div style={{ opacity: 0.6, fontSize: 9, marginTop: 1 }}>{dateLabel(dayStarts[i])}</div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

function PulseTile({ c, fonts, icon, primary, secondary, tone, onClick }) {
  const isBrut = !!(window.__brutMode && window.__brutC);
  const cBrut = isBrut ? window.__brutC : c;
  const fBrut = isBrut ? (window.__brutFonts || fonts) : fonts;
  const accent = tone === 'flame' ? cBrut.flame : cBrut.accent;
  const clickable = !!onClick;
  return (
    <div
      onClick={onClick}
      style={{
        padding: '12px 14px',
        borderRadius: isBrut ? 2 : 10,
        background: cBrut.card,
        border: isBrut
          ? `1.5px solid ${cBrut.ink}`
          : `1px solid ${hexToRgba(accent, 0.22)}`,
        boxShadow: isBrut ? `2px 2px 0 0 ${cBrut.ink}` : 'none',
        display: 'flex', alignItems: 'flex-start', gap: 10,
        cursor: clickable ? 'pointer' : 'default',
        transition: isBrut
          ? 'transform 120ms cubic-bezier(.2,.7,.2,1), box-shadow 120ms ease'
          : 'transform 120ms ease, border-color 120ms ease',
      }}
      onMouseEnter={clickable ? (e) => {
        if (isBrut) {
          e.currentTarget.style.transform = 'translate(-2px, -2px)';
          e.currentTarget.style.boxShadow = `4px 4px 0 0 ${cBrut.ink}`;
        } else {
          e.currentTarget.style.transform = 'translateY(-1px)';
          e.currentTarget.style.borderColor = hexToRgba(accent, 0.5);
        }
      } : undefined}
      onMouseLeave={clickable ? (e) => {
        if (isBrut) {
          e.currentTarget.style.transform = 'none';
          e.currentTarget.style.boxShadow = `2px 2px 0 0 ${cBrut.ink}`;
        } else {
          e.currentTarget.style.transform = 'none';
          e.currentTarget.style.borderColor = hexToRgba(accent, 0.22);
        }
      } : undefined}
    >
      <div style={{
        flexShrink: 0, width: 26, height: 26,
        borderRadius: isBrut ? 2 : '50%',
        background: accent,
        color: cBrut.textOnAccent,
        border: isBrut ? `1.5px solid ${cBrut.ink}` : 'none',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontSize: 13, fontWeight: 700,
      }}>{icon}</div>
      <div style={{ minWidth: 0, flex: 1 }}>
        <div style={{
          fontFamily: isBrut ? fBrut.mono : fBrut.body,
          fontSize: 13, fontWeight: 700, color: cBrut.textPrimary, lineHeight: 1.35,
          textTransform: isBrut ? 'uppercase' : 'none',
          letterSpacing: isBrut ? '0.02em' : 'normal',
        }}>
          {primary}
        </div>
        {secondary && (
          <div style={{
            fontFamily: isBrut ? fBrut.mono : fBrut.body,
            fontSize: 11, color: cBrut.textTertiary, marginTop: 2,
            letterSpacing: isBrut ? '0.04em' : 'normal',
          }}>
            {secondary}
          </div>
        )}
      </div>
    </div>
  );
}

// Render up to 2 item titles + "+ N more" line below. Used by accepted-outbox
// and new-goals tiles so the coach sees WHAT, not just how many.
function renderItemTitles(items, c, t, mode) {
  const top = items.slice(0, 2);
  const rest = items.length - top.length;
  const titleOf = (it) => {
    if (mode === 'title-or-text') return it.title || it.text || '';
    return it.title || '';
  };
  return (
    <>
      {top.map((it, i) => {
        const title = titleOf(it) || '—';
        return (
          <span key={i} style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
            {mode === 'title-or-text' ? `"${title}"` : `· ${title}`}
          </span>
        );
      })}
      {rest > 0 && (
        <span style={{ display: 'block', marginTop: 2, color: c.textTertiary, fontSize: 11 }}>
          {t('client.since_titles_more', { n: rest })}
        </span>
      )}
    </>
  );
}

function renderNewGoalsSecondary(sharedGoals, privateCount, c, t) {
  // Helper imported via window context — pluralLabel is module-scope.
  const lang = (window.LWLang && window.LWLang.lang()) || 'en';
  return (
    <>
      {sharedGoals.length > 0 ? (
        renderItemTitles(sharedGoals, c, t, 'title')
      ) : null}
      {privateCount > 0 && (
        <span style={{
          display: 'inline-block', marginTop: sharedGoals.length > 0 ? 6 : 0,
          padding: '2px 8px', borderRadius: 999,
          background: c.bgSubtle, border: `1px solid ${c.borderSubtle}`,
          fontSize: 11, color: c.textTertiary, fontStyle: 'italic',
        }}>
          {pluralLabel(t, lang, 'client.since_new_goal_private', privateCount)}
        </span>
      )}
    </>
  );
}

// Whether a Goal record is shared with the coach.
// iOS writes coachShare as the string "shared" (or "private" / "off" for off).
// Older code paths may write Bool true / "true" / 1 — treat all truthy variants
// as shared, undefined / "private" / "off" / "false" / 0 as private.
// Default unset = private (privacy-safe).
function isCoachShared(goal) {
  if (!goal) return false;
  const v = goal.coachShare;
  if (v === true) return true;
  if (v === false || v == null) return false;
  if (typeof v === 'string') {
    const s = v.toLowerCase().trim();
    return s === 'shared' || s === 'true' || s === '1' || s === 'yes' || s === 'on';
  }
  if (typeof v === 'number') return v === 1;
  return false;
}

// "today" / "yesterday" / "{n} days ago" — shared by Pulse + helpers.
function sinceRel(t, ms) {
  if (!ms) return '';
  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('client.since_today');
  if (days === 1) return t('client.since_yesterday');
  return t('client.since_days_ago', { n: days });
}

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 });
}

function WheelSection({ client }) {
  const { c, fonts, mode, t, lang } = useLW();
  const { isMobile } = useViewport();
  // Real-mode: use the latest ended session's date as proxy for "when this
  // wheel was last looked at together". Demo: fall back to a -14d hint.
  const real = client._real;
  const lastSession = real ? (real.sessions || []).find(s => s.endedAt) : null;
  const lastWheelLabel = (() => {
    if (lastSession && lastSession.endedAt) {
      return new Date(Number(lastSession.endedAt))
        .toLocaleDateString(lang === 'ru' ? 'ru-RU' : undefined, { month: 'short', day: 'numeric' });
    }
    if (real) return null; // real client + no session yet → hide the date pill
    return new Date(LW_NOW.getTime() - 14 * 86400000)
      .toLocaleDateString(lang === 'ru' ? 'ru-RU' : undefined, { month: 'short', day: 'numeric' });
  })();

  if (!client.scores) {
    return (
      <Card padding={32} style={{ textAlign: 'center' }}>
        <div style={{ fontFamily: fonts.display, fontStyle: 'italic', fontSize: 22, color: c.textSecondary, marginBottom: 6 }}>
          {t('client.no_wheel')}
        </div>
        <div style={{ fontFamily: fonts.body, fontSize: 14, color: c.textTertiary }}>
          {t('client.no_wheel_body', { name: (client.name || '').split(' ')[0] })}
        </div>
      </Card>
    );
  }

  return (
    <Card padding={isMobile ? 18 : 24}>
      <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1.2fr) minmax(0, 1fr)', gap: isMobile ? 28 : 32, alignItems: 'center' }}>
        <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
          <WheelChart
            size={isMobile ? 300 : 360}
            scores={client.scores}
            prevScores={client.scoresPrev}
            labels={client._real && client._real.areaTitles}
            colorIds={client._real && client._real.areaColorIds}
            iconIds={client._real && client._real.areaIconIds}
          />
          {lastWheelLabel && (
            <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary }}>
              {t('client.taken_date', { date: lastWheelLabel })}
            </div>
          )}
        </div>
        <div style={{ minWidth: 0 }}>
          {(() => {
            // Faithful to the iOS user's actual area.title — V3-schema users
            // and people who customized their wheel get their REAL labels,
            // not a canonical V4.2 mapping that misrepresents the position
            // (V3 pos 5 = "Brightness" not "Joy", pos 7 = "Things" not
            // "People", pos 8 = "Family/Friends" not "Contribution").
            // Coach-locale canonical names are the *fallback* when iOS hasn't
            // written a title — empty slot per position.
            const iosTitles = (client._real && client._real.areaTitles) || [];
            const fallback = (window.LWLang && window.LWLang.sphereNames)
              ? window.LWLang.sphereNames(lang)
              : LWDATA.SPHERE_NAMES;
            const titles = Array.from({ length: 8 }, (_, i) => iosTitles[i] || fallback[i]);
            const scores = client.scores || [];
            const prev = client.scoresPrev || scores;
            const anyMovement = scores.some((s, i) => Number(s) !== Number(prev[i]));
            return (
              <>
                <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary, marginBottom: 14 }}>
                  {anyMovement ? t('client.deltas_eyebrow') : t('client.snapshot_eyebrow')}
                </div>
                <SphereDeltas
                  scores={scores}
                  prev={prev}
                  height={120}
                  labels={client._real && client._real.areaTitles}
                  colorIds={client._real && client._real.areaColorIds}
                  iconIds={client._real && client._real.areaIconIds}
                />
                <div style={{ height: 18 }} />
                <div style={{ fontFamily: fonts.body, fontSize: 13, color: c.textSecondary, lineHeight: 1.55, fontStyle: 'italic' }}>
                  {(() => {
                    if (anyMovement) {
                      const deltas = scores.map((s, i) => ({ name: titles[i] || LWDATA.SPHERE_NAMES[i], d: Number(s) - Number(prev[i]) }));
                      const drops = deltas.filter(x => x.d < 0).sort((a, b) => a.d - b.d);
                      const gains = deltas.filter(x => x.d > 0).sort((a, b) => b.d - a.d);
                      const parts = [];
                      if (drops.length) parts.push(<span key="d">{drops[0].name} {drops[0].d.toFixed(1)}{drops[1] ? `, ${drops[1].name} ${drops[1].d.toFixed(1)}` : ''}.</span>);
                      if (gains.length) parts.push(<span key="g" style={{ display: 'block', marginTop: 4 }}>{gains[0].name} +{gains[0].d.toFixed(1)}{gains[1] ? `, ${gains[1].name} +${gains[1].d.toFixed(1)}` : ''}.</span>);
                      return parts;
                    }
                    // Steady — surface highest and lowest as a coaching cue.
                    const ranked = scores.map((s, i) => ({ name: titles[i] || LWDATA.SPHERE_NAMES[i], v: Number(s) }))
                      .filter(x => !isNaN(x.v))
                      .sort((a, b) => b.v - a.v);
                    if (ranked.length === 0) return null;
                    const high = ranked[0];
                    const low = ranked[ranked.length - 1];
                    if (!low || high.name === low.name) return null;
                    return t('client.snapshot_summary', { name: high.name, low: low.name, lowVal: low.v.toFixed(1) });
                  })()}
                </div>
              </>
            );
          })()}
        </div>
      </div>
    </Card>
  );
}

// ── Sphere chip (mini, for tasks/goals) ──
function SphereChip({ sphere, label, colorHex }) {
  const { c, fonts, brutMode, sphereNames, t } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  if (!sphere && !label) {
    return (
      <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontFamily: fonts.body, fontSize: 11, color: c.textTertiary }}>
        <span style={{
          width: 8, height: 8,
          borderRadius: isBrut ? 0 : '50%',
          border: isBrut ? `1.5px solid ${c.textTertiary}` : `1px dashed ${c.borderDefault}`,
        }} />
        {t('client.no_sphere')}
      </span>
    );
  }
  const idx = LWDATA.SPHERE_KEYS.indexOf(sphere);
  const color = colorHex || (idx >= 0 ? c.spheres[sphere] : c.textTertiary);
  const localizedNames = (sphereNames && sphereNames.length === 8) ? sphereNames : LWDATA.SPHERE_NAMES;
  const text = label || (idx >= 0 ? localizedNames[idx] : null);
  if (!text) return null;
  if (isBrut) {
    // Mono ink text + a small square swatch — keeps contrast on cream paper
    // (sphere colors at full saturation drop below WCAG against #F2EBD8).
    return (
      <span style={{
        display: 'inline-flex', alignItems: 'center', gap: 6,
        fontFamily: fonts.body, fontSize: 10, fontWeight: 800,
        letterSpacing: '0.10em', textTransform: 'uppercase',
        color: ink,
      }}>
        <span style={{
          width: 9, height: 9, flexShrink: 0,
          background: color,
          border: `1.5px solid ${ink}`,
        }} />
        {text}
      </span>
    );
  }
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontFamily: fonts.body, fontSize: 11, fontWeight: 600, color }}>
      <SphereIcon sphere={sphere} size={12} color={color} strokeWidth={1.8} />
      {text}
    </span>
  );
}

// ── Checkbox ──
function CheckBox({ checked, onChange, size = 16 }) {
  const { c, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  return (
    <button
      type="button"
      onClick={onChange}
      style={{
        width: size, height: size, flexShrink: 0,
        border: isBrut
          ? `1.5px solid ${ink}`
          : `1.5px solid ${checked ? c.accent : c.borderDefault}`,
        background: isBrut
          ? (checked ? ink : c.bg)
          : (checked ? c.accent : 'transparent'),
        borderRadius: isBrut ? 0 : 4,
        cursor: 'pointer', padding: 0,
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        transition: isBrut ? 'background 0.10s ease' : 'background 0.12s ease, border-color 0.12s ease',
      }}>
      {checked && (
        <svg width={size * 0.6} height={size * 0.6} viewBox="0 0 12 12" fill="none">
          <path d="M2 6.5 L5 9 L10 3.5"
                stroke={isBrut ? c.bg : c.textOnAccent}
                strokeWidth={isBrut ? '2.4' : '2'}
                strokeLinecap={isBrut ? 'square' : 'round'}
                strokeLinejoin={isBrut ? 'miter' : 'round'} />
        </svg>
      )}
    </button>
  );
}

// ── Days-until pill ──
function DueChip({ days }) {
  const { c, fonts } = useLW();
  if (days == null || isNaN(days)) return null;
  let label, tone;
  if (days < 0) { label = `${Math.abs(days)}d overdue`; tone = 'flame'; }
  else if (days === 0) { label = 'today'; tone = 'flame'; }
  else if (days === 1) { label = 'tomorrow'; tone = 'subtle'; }
  else { label = `in ${days}d`; tone = 'subtle'; }
  return <Chip tone={tone} style={{ fontSize: 11, padding: '2px 8px' }}>{label}</Chip>;
}

// ── Goals & tasks section ──
function GoalsTasks({ client }) {
  const { c, fonts, t, lang } = useLW();
  const { isMobile } = useViewport();
  // Per-goal inline subtask editor: which goal is currently editing + the
  // draft text. Only one goal at a time can be in editing mode (matches the
  // single-row reveal under the goal). Saving writes the new task into the
  // user's RTDB goal directly; the iOS-side observer picks it up so the
  // client sees it on next foreground.
  const [subtaskEditorGoalId, setSubtaskEditorGoalId] = useState(null);
  const [subtaskDraft, setSubtaskDraft] = useState('');
  const [subtaskBusy, setSubtaskBusy] = useState(false);
  const [subtaskError, setSubtaskError] = useState(null);

  async function saveSubtask(goalId, raw) {
    const text = (raw || '').trim();
    if (!text) return;
    if (!client.userUid) {
      setSubtaskError('client missing userUid');
      return;
    }
    setSubtaskBusy(true); setSubtaskError(null);
    try {
      const ref = window.LWFB.db
        .ref(`/users/${client.userUid}/goals/${goalId}/tasks`)
        .push();
      const now = Date.now() / 1000;
      await ref.set({
        name: text,
        creationTime: String(now.toFixed(6)),
        lastUpdateTime: String(now.toFixed(6)),
        sortOrder: String(now.toFixed(6)),
        habitCountPerDay: '1',
        isAchieved: 0,
      });
      setSubtaskDraft('');
      setSubtaskEditorGoalId(null);
    } catch (err) {
      setSubtaskError(err && err.message ? err.message : String(err));
    } finally {
      setSubtaskBusy(false);
    }
  }
  const extras = CLIENT_EXTRAS[client.id] || {};
  // Real mode: derive goals/todos from /users/{uid}/goals (iOS LWGoal shape).
  // Privacy contract: respect per-item `coachShare`. Default unset = private.
  // Coach only sees items the client explicitly shared. Private count is
  // surfaced as a chip so the coach knows there's content they can't see.
  const realGoalsRaw = client._real ? (client._real.goals || null) : null;
  const realGoalsAll = realGoalsRaw || [];
  const realGoalsShared = realGoalsAll.filter(g => isCoachShared(g));
  const privateGoalCount = realGoalsAll.length - realGoalsShared.length;
  const realGoals = realGoalsRaw ? realGoalsShared : null;
  // Build areaKey → {title, sphereIdx, color} lookup so each goal/task can
  // surface the user's own area title (e.g. "Health and Sport") and the right
  // sphere color/icon, instead of falling back to the canonical "growth".
  const areaByKey = useMemo(() => buildAreaByKey(client._real), [client._real && client._real.areaTitles, client._real && client._real.areaColorIds]);
  const sphereForArea = (g) => {
    const key = g.areaKey || g.categoryKey || null;
    const a = key && areaByKey[key];
    if (a) return { sphere: LWDATA.SPHERE_KEYS[a.pos] || 'growth', label: a.title || null, colorHex: a.colorHex || null };
    // Demo-mode goals carry an explicit `g.sphere`; iOS goals never do, so
    // when the user simply skipped the sphere picker (areaKey === ""), don't
    // force "growth" — return null so the chip can render "no sphere"
    // instead of mislabeling the item.
    if (g.sphere) return { sphere: String(g.sphere).toLowerCase(), label: null, colorHex: null };
    return { sphere: null, label: null, colorHex: null };
  };
  // iOS distinguishes "goal-with-subtasks" from "single-checkbox simple todo"
  // via the `isSimpleTodo` flag (see GoalFirebaseMapper.swift:34). Simple
  // todos belong in the right-hand "ЗАДАЧИ · БЕЗ ЦЕЛИ" column, not the
  // left-hand goals list. Without this split, single-line todos render as
  // dummy "0/0" goals — what the user reported.
  const isSimpleTodoGoal = (g) => g && (g.isSimpleTodo === true || g.isSimpleTodo === 'true' || g.goalType === 'task');
  const goals = realGoals
    ? realGoals
        .filter(g => g && (g.name || g.title))
        .filter(g => !isIosHabit(g)) // habits live in the Habits section
        .filter(g => !isSimpleTodoGoal(g)) // todos render in the standalone column
        .map(g => {
          const meta = sphereForArea(g);
          return {
            id: g.id,
            title: g.name || g.title || 'Untitled goal',
            sphere: meta.sphere,
            sphereLabel: meta.label,
            sphereColorHex: meta.colorHex,
            dueDays: null,
            createdSession: g.creationTime ? new Date(Number(g.creationTime) * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '—',
            // iOS writes subtasks at /goals/{id}/tasks as a dict keyed by
            // taskKey ("task1", "task2", ...) where each task carries
            // `name` + `isAchieved` (boolean) + `completionTime`. The legacy
            // web shape `g.subtasks` (array of {text,done}) is also handled
            // for forward-compat with future writers.
            tasks: (() => {
              if (g.tasks && typeof g.tasks === 'object') {
                return Object.entries(g.tasks)
                  .map(([key, st]) => {
                    const text = st.name || st.text || st.title || '';
                    return {
                      id: key,
                      // Render reads `t.title`; demo data uses `title`. iOS
                      // writes the user-typed string under `name`. Surface
                      // both so the renderer doesn't have to care.
                      text,
                      title: text,
                      done: !!(st.isAchieved || st.done || st.completed),
                      createdAt: st.creationTime ? Number(st.creationTime) * 1000 : 0,
                      completedAt: st.completionTime ? Number(st.completionTime) * 1000 : null,
                    };
                  })
                  .filter(st => st.text)
                  .sort((a, b) => a.createdAt - b.createdAt);
              }
              if (Array.isArray(g.subtasks)) {
                return g.subtasks.map(st => ({
                  id: st.id || Math.random().toString(36).slice(2),
                  text: st.text || '',
                  title: st.text || st.title || '',
                  done: !!st.done,
                }));
              }
              return [];
            })(),
          };
        })
    : (extras.goals || []);
  const todos = realGoals
    ? realGoals
        .filter(g => g && (g.name || g.title))
        .filter(g => !isIosHabit(g))
        .filter(g => isSimpleTodoGoal(g))
        .map(g => {
          // iOS marks completion via `isAchieved` (boolean) or, for
          // quantitative todos, when current >= target.
          const qCur = parseFloat(g.quantitativeCurrentValue);
          const qTgt = parseFloat(g.quantitativeGoalValue);
          const quantDone = (!isNaN(qCur) && !isNaN(qTgt) && qTgt > 0 && qCur >= qTgt);
          const meta = sphereForArea(g) || {};
          const title = g.name || g.title || 'Task';
          return {
            id: g.id,
            text: title,
            title,
            done: !!(g.isAchieved || g.completed || quantDone),
            sphere: meta.sphere || 'growth',
            sphereLabel: meta.label || null,
            sphereColorHex: meta.colorHex || null,
            dueDays: null,
            createdSession: g.creationTime
              ? new Date(Number(g.creationTime) * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
              : null,
          };
        })
    : (extras.todos || []);
  // Pending outbox items the coach sent but the client hasn't accepted yet —
  // surface them inline in the matching list so the coach has visible
  // feedback that the send went through and is awaiting acceptance.
  const pendingOutbox = (client._real && Array.isArray(client._real.outbox))
    ? client._real.outbox.filter(o => !o.acceptedAt && !o.skippedAt)
    : [];
  const pendingGoals = pendingOutbox.filter(o => o.kind === 'goal');
  const pendingTodos = pendingOutbox.filter(o => o.kind === 'todo');
  const [expandedId, setExpandedId] = useState(goals[0] ? goals[0].id : null);
  const [taskState, setTaskState] = useState(() => {
    const map = {};
    goals.forEach(g => g.tasks.forEach(t => { map[`${g.id}/${t.id}`] = t.done; }));
    todos.forEach(t => { map[`todo/${t.id}`] = t.done; });
    return map;
  });
  const toggle = (key) => setTaskState(s => ({ ...s, [key]: !s[key] }));

  return (
    <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1.4fr) minmax(0, 1fr)', gap: 16 }}>
      {/* Goals column */}
      <Card padding={20}>
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 14, gap: 12 }}>
          <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary }}>
            {t('client.tab_goals')}
          </div>
          <span style={{ fontFamily: fonts.body, fontSize: 12, color: c.textTertiary }}>
            {goals.length === 0 ? t('client.goals_none') : t('client.goals_active', { n: goals.length })}
          </span>
        </div>

        {privateGoalCount > 0 && (() => {
          const isBrut = !!(window.__brutMode && window.__brutC);
          const ink = c.ink || c.textPrimary;
          return (
            <div title={t('client.private_hint')} style={{
              display: 'inline-flex', alignItems: 'center', gap: 8,
              padding: '4px 10px',
              borderRadius: isBrut ? 1 : 999,
              background: isBrut ? 'transparent' : c.bgSubtle,
              border: isBrut ? `1.5px solid ${ink}` : `1px dashed ${c.borderDefault}`,
              fontFamily: fonts.body, fontSize: 11, fontWeight: 800,
              letterSpacing: isBrut ? '0.06em' : 0,
              textTransform: isBrut ? 'uppercase' : 'none',
              color: isBrut ? ink : c.textTertiary,
              marginBottom: 12,
            }}>
              <span style={{
                width: 6, height: 6,
                borderRadius: isBrut ? 0 : '50%',
                background: isBrut ? ink : c.textTertiary,
              }} />
              {pluralLabel(t, lang, 'client.private', privateGoalCount)}
            </div>
          );
        })()}

        {goals.length === 0 && pendingGoals.length === 0 && (
          <div style={{ fontFamily: fonts.body, fontStyle: 'italic', fontSize: 13, color: c.textTertiary, padding: '10px 0 16px' }}>
            {t('client.no_goals_yet')}
          </div>
        )}

        {pendingGoals.length > 0 && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
            {pendingGoals.map(o => (
              <PendingOutboxRow key={o.id} item={o} />
            ))}
          </div>
        )}

        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {goals.map((g, gi) => {
            const sphereIdx = LWDATA.SPHERE_KEYS.indexOf(g.sphere);
            // Real-mode prefers the iOS area's chosen color (mapped via
            // ColorHelper); demo-mode falls back to the canonical sphere palette.
            const sphereColor = g.sphereColorHex
              || (sphereIdx >= 0 ? c.spheres[g.sphere] : c.textTertiary);
            // taskState is the local-toggle override seeded once on mount,
            // so it's empty on the first render after data loads. Fall back
            // to each task's server-side `done` flag (mapped from iOS
            // `isAchieved`) when the local state hasn't been touched, so the
            // progress count reflects reality on first paint.
            const isDone = (g, t) => {
              const k = `${g.id}/${t.id}`;
              return Object.prototype.hasOwnProperty.call(taskState, k)
                ? !!taskState[k]
                : !!t.done;
            };
            const doneCount = g.tasks.filter(t => isDone(g, t)).length;
            const totalCount = g.tasks.length;
            const pct = totalCount ? doneCount / totalCount : 0;
            const expanded = expandedId === g.id;
            const isBrut = !!(window.__brutMode && window.__brutC);
            const ink = c.ink || c.textPrimary;
            return (
              <div key={g.id} style={{
                border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
                borderRadius: isBrut ? 2 : 10,
                background: isBrut
                  ? 'transparent'
                  : (expanded ? hexToRgba(sphereColor, 0.04) : 'transparent'),
                boxShadow: isBrut ? `2px 2px 0 0 ${ink}` : 'none',
                transition: 'background 0.15s ease',
              }}>
                <button
                  type="button"
                  onClick={() => setExpandedId(expanded ? null : g.id)}
                  style={{
                    width: '100%', textAlign: 'left', padding: '14px 14px',
                    background: 'transparent', border: 'none', cursor: 'pointer',
                    fontFamily: 'inherit', color: 'inherit',
                    display: 'flex', alignItems: 'center', gap: 14,
                  }}>
                  <div style={{
                    width: 4, alignSelf: 'stretch', borderRadius: 2,
                    background: sphereColor,
                  }} />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 6 }}>
                      <div style={{
                        fontFamily: isBrut ? fonts.body : fonts.display,
                        fontSize: isBrut ? 14 : 16,
                        fontWeight: isBrut ? 800 : 600,
                        color: c.textPrimary,
                        lineHeight: 1.25,
                        letterSpacing: isBrut ? '0.01em' : 0,
                        textTransform: isBrut ? 'uppercase' : 'none',
                      }}>
                        {g.title}
                      </div>
                      <DueChip days={g.dueDays} />
                      {client.userUid && (
                        <span onClick={(e) => e.stopPropagation()}>
                          <LikeButton
                            userUid={client.userUid}
                            itemKey={`goal:${g.id}`}
                            meta={{ kind: 'goal', goalId: g.id, itemTitle: g.title || '' }}
                          />
                        </span>
                      )}
                    </div>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
                      <SphereChip sphere={g.sphere} label={g.sphereLabel} colorHex={g.sphereColorHex} />
                      <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 140 }}>
                        <div style={{
                          flex: 1,
                          height: isBrut ? 8 : 5,
                          borderRadius: isBrut ? 0 : 3,
                          background: isBrut ? c.bg : c.borderSubtle,
                          border: isBrut ? `1.5px solid ${ink}` : 'none',
                          overflow: 'hidden',
                        }}>
                          <div style={{
                            width: `${pct * 100}%`, height: '100%',
                            background: isBrut ? ink : sphereColor,
                            transition: 'width 0.2s ease',
                          }} />
                        </div>
                        <div style={{
                          fontFamily: isBrut ? fonts.body : fonts.app,
                          fontSize: 12, fontWeight: 800, color: c.textSecondary,
                          letterSpacing: isBrut ? '0.04em' : 0,
                        }}>
                          {doneCount}/{totalCount}
                        </div>
                      </div>
                      <div style={{
                        fontFamily: fonts.body,
                        fontSize: isBrut ? 10 : 11,
                        color: c.textTertiary,
                        fontStyle: isBrut ? 'normal' : 'italic',
                        letterSpacing: isBrut ? '0.10em' : 0,
                        textTransform: isBrut ? 'uppercase' : 'none',
                        fontWeight: isBrut ? 700 : 400,
                      }}>
                        {isBrut ? `↳ ${g.createdSession}` : `set ${g.createdSession}`}
                      </div>
                    </div>
                  </div>
                  <div style={{ color: c.textTertiary, fontSize: 12, transform: expanded ? 'rotate(90deg)' : 'rotate(0)', transition: 'transform 0.15s ease' }}>
                    ▸
                  </div>
                </button>

                {expanded && (
                  <div style={{ padding: '0 14px 14px 32px', display: 'flex', flexDirection: 'column', gap: 10 }}>
                    {g.tasks.map(t => {
                      const k = `${g.id}/${t.id}`;
                      const done = isDone(g, t);
                      const completedDate = t.completedAt
                        ? new Date(t.completedAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : undefined, { month: 'short', day: 'numeric' })
                        : null;
                      return (
                        <div key={t.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
                          <label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', flex: 1, minWidth: 0 }}>
                            <CheckBox checked={done} onChange={(e) => { e.preventDefault(); toggle(k); }} />
                            <span style={{ flex: 1, minWidth: 0 }}>
                              <span style={{
                                display: 'block',
                                fontFamily: fonts.body,
                                fontSize: 13,
                                fontWeight: isBrut ? 600 : 400,
                                color: done ? c.textTertiary : c.textPrimary,
                                textDecoration: done ? 'line-through' : 'none',
                                lineHeight: 1.45,
                              }}>{t.title}</span>
                              {done && completedDate && (
                                <span style={{
                                  display: 'block',
                                  marginTop: 2,
                                  fontFamily: fonts.body,
                                  fontSize: 10, fontWeight: 700,
                                  letterSpacing: isBrut ? '0.10em' : 0,
                                  textTransform: isBrut ? 'uppercase' : 'none',
                                  color: c.textTertiary,
                                }}>{isBrut ? `✓ ${completedDate}` : `done ${completedDate}`}</span>
                              )}
                            </span>
                          </label>
                          {done && client.userUid && (
                            <LikeButton
                              userUid={client.userUid}
                              itemKey={`task:${g.id}:${t.id}`}
                              meta={{ kind: 'task_completion', goalId: g.id, taskId: t.id, itemTitle: t.title || '', goalTitle: g.title || '' }}
                            />
                          )}
                        </div>
                      );
                    })}
                    {subtaskEditorGoalId === g.id ? (
                      <div style={{
                        display: 'flex', flexDirection: 'column', gap: 8,
                        marginTop: 4, padding: isBrut ? 10 : 8,
                        background: isBrut ? c.bg : c.bgSubtle,
                        border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
                        borderRadius: isBrut ? 2 : 8,
                      }}>
                        <input autoFocus value={subtaskDraft}
                          onChange={(e) => setSubtaskDraft(e.target.value)}
                          onKeyDown={(e) => {
                            if (e.key === 'Enter') { e.preventDefault(); saveSubtask(g.id, subtaskDraft); }
                            else if (e.key === 'Escape') { e.preventDefault(); setSubtaskEditorGoalId(null); setSubtaskDraft(''); setSubtaskError(null); }
                          }}
                          placeholder={t('client.add_subtask_placeholder')}
                          disabled={subtaskBusy}
                          style={{
                            padding: '8px 10px',
                            borderRadius: isBrut ? 2 : 6,
                            background: isBrut ? c.bg : c.card,
                            border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
                            color: c.textPrimary, fontFamily: fonts.body, fontSize: 13, outline: 'none',
                            boxSizing: 'border-box',
                          }} />
                        {subtaskError && (
                          <div style={{ fontFamily: fonts.body, fontSize: 11, color: c.flame }}>{subtaskError}</div>
                        )}
                        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
                          <button type="button" disabled={subtaskBusy}
                            onClick={() => { setSubtaskEditorGoalId(null); setSubtaskDraft(''); setSubtaskError(null); }}
                            style={{
                              padding: isBrut ? '6px 10px' : '5px 8px',
                              borderRadius: isBrut ? 2 : 6,
                              background: 'transparent',
                              border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
                              color: c.textSecondary, fontFamily: fonts.body,
                              fontSize: isBrut ? 10 : 12, fontWeight: 800,
                              letterSpacing: isBrut ? '0.06em' : 0, textTransform: isBrut ? 'uppercase' : 'none',
                              cursor: subtaskBusy ? 'default' : 'pointer', opacity: subtaskBusy ? 0.5 : 1,
                            }}>{t('client.add_cancel')}</button>
                          <button type="button" disabled={subtaskBusy}
                            onClick={() => saveSubtask(g.id, subtaskDraft)}
                            style={{
                              padding: isBrut ? '6px 12px' : '5px 10px',
                              borderRadius: isBrut ? 2 : 6,
                              background: isBrut ? c.bg : c.accent,
                              border: isBrut ? `1.5px solid ${ink}` : 'none',
                              boxShadow: isBrut ? `2px 2px 0 0 ${ink}` : 'none',
                              color: isBrut ? ink : c.textOnAccent,
                              fontFamily: fonts.body, fontSize: isBrut ? 10 : 12, fontWeight: 800,
                              letterSpacing: isBrut ? '0.06em' : 0, textTransform: isBrut ? 'uppercase' : 'none',
                              cursor: subtaskBusy ? 'default' : 'pointer', opacity: subtaskBusy ? 0.5 : 1,
                            }}>{subtaskBusy ? t('client.add_sending') : (t('client.add_subtask_add') || 'Add')}</button>
                        </div>
                      </div>
                    ) : (
                      <button type="button" onClick={() => {
                        setSubtaskEditorGoalId(g.id);
                        setSubtaskDraft('');
                        setSubtaskError(null);
                      }} style={{
                        marginTop: 4, alignSelf: 'flex-start',
                        padding: isBrut ? '7px 12px' : '6px 10px',
                        borderRadius: isBrut ? 2 : 7,
                        background: 'transparent',
                        border: isBrut ? `1.5px dashed ${ink}` : `1px dashed ${c.borderDefault}`,
                        color: isBrut ? ink : c.textSecondary,
                        cursor: 'pointer',
                        fontFamily: fonts.body,
                        fontSize: isBrut ? 11 : 12,
                        fontWeight: isBrut ? 800 : 500,
                        letterSpacing: isBrut ? '0.06em' : 0,
                        textTransform: isBrut ? 'uppercase' : 'none',
                      }}>+ {(window.LWLang && window.LWLang.t('client.add_subtask_btn')) || 'Add subtask'}</button>
                    )}
                  </div>
                )}
              </div>
            );
          })}
        </div>

      </Card>

      {/* Standalone tasks column */}
      <Card padding={20}>
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 14, gap: 12 }}>
          <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary }}>
            {t('client.tab_tasks')}
          </div>
          <span style={{ fontFamily: fonts.body, fontSize: 12, color: c.textTertiary }}>
            {todos.length === 0
              ? t('client.tasks_none')
              : `${todos.filter(td => {
                  const k = 'todo/' + td.id;
                  return Object.prototype.hasOwnProperty.call(taskState, k)
                    ? !taskState[k]
                    : !td.done;
                }).length} ${t('client.tasks_open')}`}
          </span>
        </div>

        {pendingTodos.length > 0 && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
            {pendingTodos.map(o => (
              <PendingOutboxRow key={o.id} item={o} />
            ))}
          </div>
        )}

        {todos.length === 0 && pendingTodos.length === 0 ? (
          <div style={{ fontFamily: fonts.body, fontStyle: 'italic', fontSize: 13, color: c.textTertiary, padding: '10px 0 16px' }}>
            {t('client.no_tasks')}
          </div>
        ) : todos.length === 0 ? null : (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
            {todos.map((t, i) => {
              const k = `todo/${t.id}`;
              const done = Object.prototype.hasOwnProperty.call(taskState, k)
                ? !!taskState[k]
                : !!t.done;
              return (
                <label key={t.id} style={{
                  display: 'flex', alignItems: 'center', gap: 10,
                  padding: '10px 0',
                  borderBottom: i < todos.length - 1 ? `1px solid ${c.borderSubtle}` : 'none',
                  cursor: 'pointer',
                }}>
                  <CheckBox checked={done} onChange={(e) => { e.preventDefault(); toggle(k); }} />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{
                      fontFamily: fonts.body, fontSize: 14, color: done ? c.textTertiary : c.textPrimary,
                      textDecoration: done ? 'line-through' : 'none',
                      lineHeight: 1.35,
                    }}>{t.title}</div>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
                      <SphereChip sphere={t.sphere} />
                      {!done && <DueChip days={t.dueDays} />}
                    </div>
                  </div>
                </label>
              );
            })}
          </div>
        )}

      </Card>
      {/* Unified add-anything button — shows a 4-kind picker (Goal / Habit /
          Journal / Todo) that writes to /coach_outbox/{userUid}/{itemId}. iOS
          surfaces them in CoachOutboxReview for the client to accept. */}
      {client.userUid && (
        <div style={{ gridColumn: '1 / -1', marginTop: 4 }}>
          <AddItemForCoach client={client} />
        </div>
      )}
    </div>
  );
}

// Inline pending row for items the coach sent via the outbox that the client
// hasn't accepted yet. Dashed border + flame eyebrow signals "in flight" so
// it reads as separate from the client's own active goals/tasks.
function PendingOutboxRow({ item }) {
  const { c, fonts, t } = useLW();
  const subCount = Array.isArray(item.subtasks) ? item.subtasks.length : 0;
  return (
    <div style={{
      padding: '12px 14px', borderRadius: 10,
      background: hexToRgba(c.flame, 0.06),
      border: `1px dashed ${hexToRgba(c.flame, 0.40)}`,
      display: 'flex', alignItems: 'flex-start', gap: 12,
    }}>
      <span style={{
        flexShrink: 0, fontSize: 9, fontWeight: 700, letterSpacing: '0.12em',
        textTransform: 'uppercase', color: c.flame,
        padding: '3px 8px', borderRadius: 999,
        border: `1px solid ${hexToRgba(c.flame, 0.40)}`,
      }}>{t('client.pending_label')}</span>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontFamily: fonts.body, fontSize: 14, fontWeight: 600, color: c.textPrimary, lineHeight: 1.3 }}>
          {item.title || item.text || t('client.add_kind_journal')}
        </div>
        {subCount > 0 && (
          <div style={{ fontFamily: fonts.body, fontSize: 12, color: c.textTertiary, marginTop: 3 }}>
            {t('client.pending_subtasks', { n: subCount })}
          </div>
        )}
        {item.text && item.title && (
          <div style={{ fontFamily: fonts.body, fontSize: 12, color: c.textTertiary, marginTop: 4, lineHeight: 1.45,
                        overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box',
                        WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
            {item.text}
          </div>
        )}
      </div>
    </div>
  );
}

function AddItemForCoach({ client }) {
  const { c, fonts, t, lang, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  const [open, setOpen] = useS(false);
  return (
    <>
      <button type="button" onClick={() => setOpen(true)} style={{
        width: '100%',
        padding: '14px 18px',
        borderRadius: isBrut ? 2 : 12,
        background: isBrut ? c.bg : hexToRgba(c.accent, 0.10),
        border: isBrut
          ? `1.5px dashed ${ink}`
          : `1px dashed ${hexToRgba(c.accent, 0.40)}`,
        boxShadow: isBrut ? `3px 3px 0 0 ${ink}` : 'none',
        color: isBrut ? ink : c.accent,
        cursor: 'pointer',
        fontFamily: fonts.body,
        fontSize: isBrut ? 12 : 14,
        fontWeight: isBrut ? 800 : 600,
        letterSpacing: isBrut ? '0.08em' : '0.01em',
        textTransform: isBrut ? 'uppercase' : 'none',
        display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
        transition: 'background 120ms ease, box-shadow 120ms ease, transform 120ms ease',
      }}
        onMouseEnter={(e) => {
          if (isBrut) {
            e.currentTarget.style.transform = 'translate(-1px, -1px)';
            e.currentTarget.style.boxShadow = `5px 5px 0 0 ${ink}`;
          } else {
            e.currentTarget.style.background = hexToRgba(c.accent, 0.14);
          }
        }}
        onMouseLeave={(e) => {
          if (isBrut) {
            e.currentTarget.style.transform = 'none';
            e.currentTarget.style.boxShadow = `3px 3px 0 0 ${ink}`;
          } else {
            e.currentTarget.style.background = hexToRgba(c.accent, 0.10);
          }
        }}>
        <span style={{ fontSize: isBrut ? 14 : 17, fontWeight: 800 }}>+</span>
        {t('client.add_for_client_cta', { name: (client.name || '').split(' ')[0] })}
      </button>
      {open && <AddItemSheet client={client} onClose={() => setOpen(false)} />}
    </>
  );
}

const ADD_KINDS = [
  { kind: 'goal',    labelKey: 'client.add_kind_goal',    descKey: 'client.add_kind_goal_desc',    glyph: '◎' },
  { kind: 'habit',   labelKey: 'client.add_kind_habit',   descKey: 'client.add_kind_habit_desc',   glyph: '↻' },
  { kind: 'journal', labelKey: 'client.add_kind_journal', descKey: 'client.add_kind_journal_desc', glyph: '✎' },
  { kind: 'todo',    labelKey: 'client.add_kind_todo',    descKey: 'client.add_kind_todo_desc',    glyph: '✓' },
];

function AddItemSheet({ client, onClose }) {
  const { c, fonts, t, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  const { isMobile } = useViewport();
  const [kind, setKind] = useS(null); // null = picker; set = form
  const [title, setTitle] = useS('');
  const [body, setBody] = useS('');
  // Subtasks only meaningful for goals — kept as a flat string list,
  // serialized to JSON before write so the iOS-side CoachOutboxReview can
  // ingest them on accept.
  const [subtasks, setSubtasks] = useS([]);
  const [subtaskDraft, setSubtaskDraft] = useS('');
  const subtaskInputRef = React.useRef(null);
  const [submitting, setSubmitting] = useS(false);
  const [error, setError] = useS(null);

  const addSubtask = () => {
    const v = subtaskDraft.trim();
    if (!v) return;
    setSubtasks(s => [...s, v]);
    setSubtaskDraft('');
  };
  const removeSubtask = (i) => setSubtasks(s => s.filter((_, j) => j !== i));

  // Esc closes.
  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);

  const submit = async () => {
    if (submitting) return;
    const trimmedTitle = title.trim();
    const trimmedBody = body.trim();
    if (!trimmedTitle && !trimmedBody) {
      setError(t('client.add_error_empty'));
      return;
    }
    setSubmitting(true); setError(null);
    try {
      const coach = window.LWAuth.currentCoach && window.LWAuth.currentCoach();
      const coachId = coach && coach.uid;
      const coachName = (coach && coach.profile && coach.profile.displayName) || (coach && coach.email) || '';
      const ref = window.LWFB.db.ref(`/coach_outbox/${client.userUid}`).push();
      const payload = {
        kind,
        title: trimmedTitle || null,
        text: trimmedBody || null,
        fromCoachId: coachId,
        fromCoachName: coachName,
        createdAt: Date.now(),
      };
      if (kind === 'goal' && subtasks.length > 0) {
        payload.subtasks = subtasks.map(s => ({ text: s, done: false }));
      }
      await ref.set(payload);
      onClose();
    } catch (err) {
      setError(err && err.message ? err.message : String(err));
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <div onClick={submitting ? undefined : onClose} style={{
      position: 'fixed', inset: 0, zIndex: 100,
      background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      padding: 20,
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        background: isBrut ? c.bg : c.card,
        border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderDefault}`,
        borderRadius: isBrut ? 2 : 14,
        width: '100%', maxWidth: kind ? 520 : 560, padding: 28,
        boxShadow: isBrut ? `8px 8px 0 0 ${ink}` : '0 20px 60px rgba(0,0,0,0.4)',
      }}>
        {!kind ? (
          <>
            <div style={{
              display: 'inline-block',
              fontFamily: fonts.body, fontSize: 10, fontWeight: 800,
              letterSpacing: '0.18em', textTransform: 'uppercase',
              color: isBrut ? c.bg : c.textTertiary,
              background: isBrut ? ink : 'transparent',
              border: isBrut ? `1.5px solid ${ink}` : 'none',
              padding: isBrut ? '3px 8px' : 0,
              marginBottom: 12,
            }}>{isBrut ? `[§ ${t('client.add_kind_eyebrow')}]` : t('client.add_kind_eyebrow')}</div>
            <h3 style={{
              margin: 0, marginBottom: 8,
              fontFamily: isBrut ? fonts.body : fonts.display,
              fontSize: isBrut ? 22 : 24,
              fontWeight: isBrut ? 800 : 600,
              color: c.textPrimary,
              textTransform: isBrut ? 'uppercase' : 'none',
              letterSpacing: isBrut ? '-0.01em' : 0,
              lineHeight: 1.2,
            }}>
              {t('client.add_kind_title', { name: (client.name || '').split(' ')[0] })}
            </h3>
            <p style={{
              margin: 0, marginBottom: 22,
              fontFamily: fonts.body, fontSize: 13,
              color: c.textSecondary, lineHeight: 1.5,
            }}>
              {t('client.add_kind_sub')}
            </p>
            <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
              {ADD_KINDS.map(k => (
                <button key={k.kind} onClick={() => setKind(k.kind)} style={{
                  display: 'flex', alignItems: 'flex-start', gap: 12,
                  padding: '14px 14px',
                  borderRadius: isBrut ? 2 : 11,
                  background: isBrut ? 'transparent' : c.bgSubtle,
                  border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
                  boxShadow: isBrut ? `2px 2px 0 0 ${ink}` : 'none',
                  color: c.textPrimary, cursor: 'pointer',
                  textAlign: 'left',
                  transition: isBrut
                    ? 'transform 120ms ease, box-shadow 120ms ease'
                    : 'border-color 120ms ease, background 120ms ease',
                }}
                  onMouseEnter={(e) => {
                    if (isBrut) {
                      e.currentTarget.style.transform = 'translate(-1px, -1px)';
                      e.currentTarget.style.boxShadow = `4px 4px 0 0 ${ink}`;
                    } else {
                      e.currentTarget.style.borderColor = c.borderStrong;
                      e.currentTarget.style.background = c.cardHover;
                    }
                  }}
                  onMouseLeave={(e) => {
                    if (isBrut) {
                      e.currentTarget.style.transform = 'none';
                      e.currentTarget.style.boxShadow = `2px 2px 0 0 ${ink}`;
                    } else {
                      e.currentTarget.style.borderColor = c.borderSubtle;
                      e.currentTarget.style.background = c.bgSubtle;
                    }
                  }}>
                  <span style={{
                    flexShrink: 0, width: 38, height: 38,
                    borderRadius: isBrut ? 2 : 10,
                    background: isBrut ? c.bg : hexToRgba(c.accent, 0.14),
                    border: isBrut ? `1.5px solid ${ink}` : 'none',
                    color: isBrut ? ink : c.accent,
                    display: 'flex', alignItems: 'center', justifyContent: 'center',
                    fontFamily: isBrut ? fonts.body : fonts.display,
                    fontSize: isBrut ? 18 : 18,
                    fontWeight: 800,
                  }}>{k.glyph}</span>
                  <span style={{ flex: 1, minWidth: 0 }}>
                    <span style={{
                      display: 'block', fontFamily: fonts.body,
                      fontSize: isBrut ? 13 : 14,
                      fontWeight: 800,
                      letterSpacing: isBrut ? '0.02em' : 0,
                      textTransform: isBrut ? 'uppercase' : 'none',
                      marginBottom: 3,
                    }}>{t(k.labelKey)}</span>
                    <span style={{
                      display: 'block', fontFamily: fonts.body,
                      fontSize: 12, color: c.textSecondary, lineHeight: 1.4,
                    }}>{t(k.descKey)}</span>
                  </span>
                </button>
              ))}
            </div>
            <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 22 }}>
              <Button variant="ghost" size="md" onClick={onClose}>{t('client.add_cancel')}</Button>
            </div>
          </>
        ) : (
          <>
            <div style={{
              display: 'inline-block',
              fontFamily: fonts.body, fontSize: 10, fontWeight: 800,
              letterSpacing: '0.18em', textTransform: 'uppercase',
              color: isBrut ? c.bg : c.accent,
              background: isBrut ? ink : 'transparent',
              border: isBrut ? `1.5px solid ${ink}` : 'none',
              padding: isBrut ? '3px 8px' : 0,
              marginBottom: 10,
            }}>{isBrut ? `[§ ${t(`client.add_kind_${kind}`)}]` : t(`client.add_kind_${kind}`)}</div>
            <h3 style={{
              margin: 0, marginBottom: 18,
              fontFamily: isBrut ? fonts.body : fonts.display,
              fontSize: isBrut ? 20 : 22,
              fontWeight: isBrut ? 800 : 600,
              color: c.textPrimary,
              textTransform: isBrut ? 'uppercase' : 'none',
              letterSpacing: isBrut ? '-0.005em' : 0,
              lineHeight: 1.2,
            }}>
              {t(`client.add_form_title_${kind}`, { name: (client.name || '').split(' ')[0] })}
            </h3>
            <label style={{ display: 'block', marginBottom: 14 }}>
              <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.10em', textTransform: 'uppercase', color: c.textTertiary, marginBottom: 6 }}>
                {t('client.add_field_title')}
              </div>
              <input value={title} onChange={(e) => setTitle(e.target.value)} autoFocus
                placeholder={t(`client.add_placeholder_title_${kind}`)}
                style={{
                  width: '100%', padding: '11px 14px',
                  borderRadius: isBrut ? 2 : 10,
                  background: isBrut ? c.bg : c.bgSubtle,
                  border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
                  color: c.textPrimary, fontFamily: fonts.body, fontSize: 15, outline: 'none',
                  boxSizing: 'border-box',
                }} />
            </label>
            {(kind === 'journal' || kind === 'goal') && (
              <label style={{ display: 'block', marginBottom: 14 }}>
                <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.10em', textTransform: 'uppercase', color: c.textTertiary, marginBottom: 6 }}>
                  {kind === 'journal' ? t('client.add_field_body') : t('client.add_field_notes')}
                </div>
                <textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4}
                  placeholder={t(`client.add_placeholder_body_${kind}`)}
                  style={{
                    width: '100%', padding: 12,
                    borderRadius: isBrut ? 2 : 10,
                    background: isBrut ? c.bg : c.bgSubtle,
                    border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
                    color: c.textPrimary, fontFamily: fonts.body, fontSize: 14, lineHeight: 1.5,
                    outline: 'none', resize: 'vertical', minHeight: 80,
                    boxSizing: 'border-box',
                  }} />
              </label>
            )}
            {kind === 'goal' && (
              <div style={{ marginBottom: 14 }}>
                <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.10em', textTransform: 'uppercase', color: c.textTertiary, marginBottom: 6 }}>
                  {t('client.add_field_subtasks')}
                </div>
                {subtasks.length > 0 && (
                  <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
                    {subtasks.map((s, i) => (
                      <div key={i} style={{
                        display: 'flex', alignItems: 'center', gap: 10,
                        padding: '8px 10px',
                        borderRadius: isBrut ? 2 : 9,
                        background: isBrut ? c.bg : c.bgSubtle,
                        border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
                      }}>
                        <span style={{
                          flexShrink: 0,
                          fontFamily: isBrut ? fonts.body : fonts.app,
                          fontSize: 11, fontWeight: 800,
                          color: isBrut ? ink : c.textTertiary,
                          letterSpacing: isBrut ? '0.04em' : 0,
                          width: 18,
                        }}>{i + 1}.</span>
                        <span style={{ flex: 1, fontFamily: fonts.body, fontSize: 14, color: c.textPrimary }}>{s}</span>
                        <button type="button" onClick={() => removeSubtask(i)} aria-label={t('client.add_subtask_remove')} style={{
                          background: 'transparent', border: 'none',
                          color: isBrut ? ink : c.textTertiary,
                          cursor: 'pointer', fontSize: 16, fontWeight: 800, padding: 0,
                        }}>×</button>
                      </div>
                    ))}
                  </div>
                )}
                <div style={{ display: 'flex', gap: 8 }}>
                  <input ref={subtaskInputRef} value={subtaskDraft} onChange={(e) => setSubtaskDraft(e.target.value)}
                    onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSubtask(); } }}
                    placeholder={t('client.add_subtask_placeholder')}
                    style={{
                      flex: 1, padding: '9px 12px',
                      borderRadius: isBrut ? 2 : 9,
                      background: isBrut ? c.bg : c.bgSubtle,
                      border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
                      color: c.textPrimary, fontFamily: fonts.body, fontSize: 14, outline: 'none',
                      boxSizing: 'border-box',
                    }} />
                  {/* No `disabled` — empty taps focus the input instead of feeling
                      dead. Keeps press feedback (shadow + transform) consistent. */}
                  <button type="button" onClick={() => {
                    if (subtaskDraft.trim()) {
                      addSubtask();
                    } else if (subtaskInputRef.current) {
                      subtaskInputRef.current.focus();
                    }
                  }} style={{
                    padding: '9px 14px',
                    borderRadius: isBrut ? 2 : 9,
                    border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderDefault}`,
                    background: isBrut ? c.bg : 'transparent',
                    color: isBrut ? ink : c.textSecondary,
                    boxShadow: isBrut ? `2px 2px 0 0 ${ink}` : 'none',
                    fontFamily: fonts.body,
                    fontSize: isBrut ? 11 : 13,
                    fontWeight: 800,
                    letterSpacing: isBrut ? '0.06em' : 0,
                    textTransform: isBrut ? 'uppercase' : 'none',
                    cursor: 'pointer',
                  }}>+ {t('client.add_subtask_add')}</button>
                </div>
              </div>
            )}
            <div style={{ fontFamily: fonts.body, fontSize: 12, color: c.textTertiary, fontStyle: 'italic', lineHeight: 1.5, marginBottom: 16 }}>
              {t('client.add_hint')}
            </div>
            {error && (
              <div style={{
                marginBottom: 14, padding: '8px 12px', borderRadius: 8,
                background: hexToRgba(c.flame, 0.10), border: `1px solid ${hexToRgba(c.flame, 0.3)}`,
                color: c.flame, fontFamily: fonts.body, fontSize: 13,
              }}>{error}</div>
            )}
            <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
              <Button variant="ghost" size="md" onClick={() => setKind(null)} disabled={submitting}>
                ← {t('client.add_back')}
              </Button>
              <div style={{ display: 'flex', gap: 8 }}>
                <Button variant="ghost" size="md" onClick={onClose} disabled={submitting}>{t('client.add_cancel')}</Button>
                <Button variant="primary" size="md" onClick={submit} disabled={submitting || (!title.trim() && !body.trim())}>
                  {submitting ? t('client.add_sending') : t('client.add_send')}
                </Button>
              </div>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

// ── Per-habit grid (one row per habit, columns = days) ──
function HabitDayCell({ d, i, h, isToday, isBrut, ink, fillColor, todayOutline, c, userUid }) {
  // Composite key: habit-day:{goalId}:{YYYY-MM-DD}. The 28-cell grid is
  // ordered oldest → today, so the date for slot i is today - (27 - i) days.
  const today0 = useMemo(() => { const d = new Date(); d.setHours(0,0,0,0); return d; }, []);
  const dt = new Date(today0.getTime() - (27 - i) * 86400000);
  const iso = `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;
  const itemKey = `habit-day:${h.id}:${iso}`;
  const canLike = !!(userUid && d && !h.isPrivate);
  const { liked, toggle } = useCoachLike(canLike ? userUid : null, canLike ? itemKey : null);
  const cellStyle = {
    aspectRatio: '1 / 1',
    borderRadius: isBrut ? 0 : 3,
    background: d ? fillColor : 'transparent',
    border: isBrut
      ? `1.5px solid ${ink}`
      : (d ? 'none' : `1px solid ${c.borderSubtle}`),
    outline: isToday
      ? (isBrut ? `2px solid ${ink}` : `1.5px solid ${todayOutline}`)
      : 'none',
    outlineOffset: isToday ? (isBrut ? '2px' : '1px') : 0,
    opacity: isBrut ? 1 : (d ? (i >= 21 ? 1 : 0.7) : 1),
    position: 'relative',
    cursor: canLike ? 'pointer' : 'default',
    padding: 0,
  };
  const overlay = liked ? (
    <span style={{
      position: 'absolute', inset: 0,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      fontSize: 11, lineHeight: 1,
      color: c.flame || '#E85D5D',
      pointerEvents: 'none',
      textShadow: isBrut ? `0 0 0 ${ink}` : '0 1px 1px rgba(0,0,0,0.35)',
    }}>♥</span>
  ) : null;
  if (canLike) {
    return (
      <button type="button" title={liked ? '♥' : ''} onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggle({
        kind: 'habit_day', goalId: h.id, date: iso, itemTitle: h.name || '',
      }); }} style={cellStyle}>{overlay}</button>
    );
  }
  return <div style={cellStyle}>{overlay}</div>;
}

function HabitGrid({ habits, userUid }) {
  const { c, fonts, t, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  if (!habits.length) {
    return (
      <div style={{ fontFamily: fonts.body, fontStyle: 'italic', fontSize: 13, color: c.textTertiary, padding: '14px 0' }}>
        {t('client.no_habits')}
      </div>
    );
  }
  const dayLabel = (i) => {
    // 27 = today; show "today" / "−7d" / "−14d" / "−21d" only
    const daysAgo = 27 - i;
    if (daysAgo === 0) return 'today';
    if (daysAgo === 7) return '−1w';
    if (daysAgo === 14) return '−2w';
    if (daysAgo === 21) return '−3w';
    return '';
  };
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
      {/* axis ruler */}
      <div style={{ display: 'grid', gridTemplateColumns: '180px repeat(28, minmax(0, 1fr)) 56px', columnGap: 3, alignItems: 'end' }}>
        <div />
        {Array.from({length: 28}, (_, i) => (
          <div key={i} style={{
            fontFamily: fonts.body, fontSize: 9, fontWeight: 700, letterSpacing: '0.10em',
            textTransform: 'uppercase', color: c.textTertiary, textAlign: 'center',
            opacity: dayLabel(i) ? 1 : 0,
          }}>{dayLabel(i)}</div>
        ))}
        <div />
      </div>
      {habits.map((h, hi) => (
        <div key={hi} style={{
          display: 'grid', gridTemplateColumns: '180px repeat(28, minmax(0, 1fr)) 56px',
          columnGap: 3, alignItems: 'center',
          opacity: h.isPrivate ? 0.6 : 1,
        }}>
          <div style={{ minWidth: 0, paddingRight: 14 }}>
            <div style={{
              fontFamily: fonts.body, fontSize: 14, fontWeight: 500,
              color: h.isPrivate ? c.textTertiary : c.textPrimary,
              lineHeight: 1.25,
              fontStyle: h.isPrivate ? 'italic' : 'normal',
              display: 'flex', alignItems: 'center', gap: 8,
            }}>
              {h.isPrivate ? t('client.habit_private_row') : h.name}
              {h.isPrivate && (
                <span title={t('client.private_hint')} style={{
                  fontSize: 9, fontWeight: 700, letterSpacing: '0.10em', textTransform: 'uppercase',
                  color: c.textTertiary, padding: '1px 6px', borderRadius: 999,
                  border: `1px dashed ${c.borderDefault}`,
                }}>{t('client.private_label')}</span>
              )}
            </div>
            <div style={{ fontFamily: fonts.body, fontSize: 11, color: c.textTertiary, marginTop: 2 }}>
              {h.done}/{h.target} {t('client.habits_this_week')}
              {h.streak > 0 && !h.isPrivate && (
                <span style={{ color: c.flame, fontWeight: 700, marginLeft: 6 }}>· {h.streak}{t('client.habits_streak_suffix')}</span>
              )}
            </div>
          </div>
          {h.days.map((d, i) => {
            const isToday = i === 27;
            // Per-habit color comes from the iOS area's chosen palette;
            // private habits stay neutral so the row reads as a different
            // class from the colored shared rows.
            const fillColor = h.isPrivate
              ? hexToRgba(c.textTertiary, 0.55)
              : (h.colorHex || c.accent);
            const todayOutline = h.isPrivate
              ? c.textTertiary
              : (h.colorHex || c.accent);
            return (
              <HabitDayCell
                key={i} d={d} i={i} h={h} isToday={isToday}
                isBrut={isBrut} ink={ink} c={c}
                fillColor={fillColor} todayOutline={todayOutline}
                userUid={userUid}
              />
            );
          })}
          <div style={{
            textAlign: 'right',
            fontFamily: isBrut ? '"IBM Plex Mono", monospace' : fonts.app,
            fontSize: 13, fontWeight: 700,
            color: c.textPrimary, paddingLeft: 8,
            letterSpacing: isBrut ? '0.04em' : 'normal',
          }}>
            {h.days.reduce((s,v) => s+v, 0)}<span style={{ color: c.textTertiary, fontWeight: 600 }}>/28</span>
          </div>
        </div>
      ))}
    </div>
  );
}

// ── Mood + habits panel ──
function MoodHabitsPanel({ client }) {
  const { c, fonts, t, lang, brutMode } = useLW();
  const { isMobile } = useViewport();
  const isBrutLocal = !!brutMode;
  const inkLocal = c.ink || c.textPrimary;
  // Re-derive whenever the underlying goals or execution log change — not
  // just on client.id, since real-mode data streams in via RTDB after mount.
  const realGoalsLen = (client._real && client._real.goals) ? client._real.goals.length : 0;
  const realLogKey = client._real ? Object.keys(client._real.executionLog || {}).length : 0;
  const habitSeries = useMemo(
    () => deriveHabitSeries(client),
    [client.id, realGoalsLen, realLogKey]
  );
  // Count habits the client kept private — surface as a chip so the coach
  // knows there's content they aren't seeing without revealing what.
  const privateHabitCount = (() => {
    const real = client._real;
    if (!real || !Array.isArray(real.goals)) return 0;
    const allHabits = real.goals.filter(isIosHabit);
    return allHabits.length - allHabits.filter(g => isCoachShared(g)).length;
  })();
  const moodValid = client.moodMonth.filter(v => v != null);
  const moodAvg = moodValid.length ? (moodValid.reduce((s,v)=>s+v,0) / moodValid.length).toFixed(1) : '—';
  const moodNow = moodValid.length ? moodValid[moodValid.length - 1] : null;
  const moodEarly = moodValid.length ? moodValid[0] : null;
  const moodTrend = moodNow != null && moodEarly != null ? moodNow - moodEarly : null;

  const extras = CLIENT_EXTRAS[client.id] || {};
  // Real-mode pulls from RTDB measurements; demo falls back to fixture data.
  const real = client._real;
  const who5Raw = real ? (real.who5 || []) : (extras.who5 || []);
  const who5 = who5Raw.map(m => ({
    score: m.score,
    items: m.items || null,
    // Real entries have `ts` (ms); fixture entries have `date` strings.
    ts: m.ts || null,
    date: m.date || (m.ts ? formatWho5Date(m.ts, lang) : null),
  }));
  const who5LatestItems = (() => {
    for (let i = who5.length - 1; i >= 0; i--) {
      const it = who5[i].items;
      if (Array.isArray(it) && it.length === 5) return it;
    }
    return null;
  })();
  const who5Now = who5.length ? who5[who5.length - 1].score : null;
  const who5Prev = who5.length > 1 ? who5[who5.length - 2].score : null;
  const who5Delta = (who5Now != null && who5Prev != null) ? who5Now - who5Prev : null;
  // WHO-5 thresholds: <50 = poor wellbeing, <28 = depression screen
  const who5Band = who5Now == null ? null
    : who5Now < 28 ? { label: t('client.who5_band_screen'),  color: c.flame }
    : who5Now < 50 ? { label: t('client.who5_band_poor'),    color: c.flame }
    : who5Now < 75 ? { label: t('client.who5_band_moderate'), color: c.textSecondary }
    :                { label: t('client.who5_band_good'),    color: c.accent };

  return (
    <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 14 }}>
      <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1.4fr) minmax(0, 1fr)', gap: 14, alignItems: 'start' }}>
        <Card padding={20}>
          <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 16, gap: 12 }}>
            <div>
              <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary, marginBottom: 6 }}>
                {t('client.mood_eyebrow')}
              </div>
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 14 }}>
                <div style={{ fontFamily: brutMode ? '"IBM Plex Mono", monospace' : fonts.display, fontSize: 36, fontWeight: brutMode ? 700 : 600, color: c.textPrimary, letterSpacing: '-0.02em', lineHeight: 1 }}>
                  {moodNow ?? '—'}
                </div>
                <div style={{ fontFamily: fonts.body, fontSize: 13, color: c.textTertiary }}>
                  {t('client.mood_today_avg', { avg: moodAvg })}
                  {moodTrend != null && (
                    <span style={{ marginLeft: 8, color: moodTrend > 0 ? c.accent : moodTrend < 0 ? c.flame : c.textTertiary, fontWeight: 600 }}>
                      {moodTrend > 0 ? '↑' : moodTrend < 0 ? '↓' : '·'} {t('client.mood_over_month', { n: Math.abs(moodTrend).toFixed(1) })}
                    </span>
                  )}
                </div>
              </div>
            </div>
          </div>
          <MoodTimeline values={client.moodMonth} width={640} height={150} />
        </Card>

        {/* WHO-5 wellbeing index */}
        <Card padding={20}>
          <div style={{ marginBottom: 14 }}>
            <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary, marginBottom: 6 }}>
              {t('client.who5_eyebrow')}
            </div>
            {who5Now != null ? (
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
                <div style={{ fontFamily: brutMode ? '"IBM Plex Mono", monospace' : fonts.display, fontSize: 36, fontWeight: brutMode ? 700 : 600, color: c.textPrimary, letterSpacing: '-0.02em', lineHeight: 1 }}>
                  {who5Now}
                  <span style={{ fontSize: 16, color: c.textTertiary, fontWeight: 500 }}>/100</span>
                </div>
                {who5Delta != null && (
                  <span style={{ fontFamily: fonts.body, fontSize: 12, fontWeight: 700,
                    color: who5Delta > 0 ? c.accent : who5Delta < 0 ? c.flame : c.textTertiary }}>
                    {who5Delta > 0 ? '↑' : who5Delta < 0 ? '↓' : '·'} {Math.abs(who5Delta)} pts
                  </span>
                )}
              </div>
            ) : (
              <div style={{ fontFamily: fonts.body, fontSize: 13, color: c.textTertiary, fontStyle: 'italic' }}>
                {t('client.who5_not_taken')}
              </div>
            )}
            {who5Band && (
              <div style={{ marginTop: 6, fontFamily: fonts.body, fontSize: 12, fontWeight: 600, color: who5Band.color }}>
                {who5Band.label}
              </div>
            )}
          </div>

          {/* History bars */}
          {who5.length > 0 && (
            <div>
              <div style={{
                display: 'flex', alignItems: 'flex-end', gap: 6, height: 80,
                paddingBottom: 4,
                borderBottom: isBrutLocal ? `1.5px solid ${inkLocal}` : `1px solid ${c.borderSubtle}`,
              }}>
                {who5.map((p, i) => {
                  const isLast = i === who5.length - 1;
                  const h = Math.max(4, (p.score / 100) * 76);
                  const tone = p.score < 28 ? c.flame
                             : p.score < 50 ? hexToRgba(c.flame, 0.6)
                             : p.score < 75 ? c.textSecondary
                             : c.accent;
                  return (
                    <div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
                      <div style={{
                        fontFamily: isBrutLocal ? '"IBM Plex Mono", monospace' : fonts.app,
                        fontSize: 10, fontWeight: 700,
                        color: isLast ? (isBrutLocal ? inkLocal : c.textPrimary) : (isBrutLocal ? hexToRgba(inkLocal, 0.5) : c.textTertiary),
                        letterSpacing: isBrutLocal ? '0.04em' : 0,
                      }}>
                        {p.score}
                      </div>
                      <div style={{
                        width: '100%', maxWidth: 28, height: h,
                        background: tone,
                        opacity: isBrutLocal ? 1 : (isLast ? 1 : 0.7),
                        borderRadius: isBrutLocal ? 0 : '3px 3px 0 0',
                        border: isBrutLocal ? `1.5px solid ${inkLocal}` : 'none',
                        borderBottom: isBrutLocal ? 'none' : undefined,
                      }} />
                    </div>
                  );
                })}
              </div>
              <div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
                {who5.map((p, i) => (
                  <div key={i} style={{ flex: 1, fontFamily: fonts.body, fontSize: 9, fontWeight: 700, letterSpacing: '0.10em', textTransform: 'uppercase', color: c.textTertiary, textAlign: 'center' }}>
                    {p.date}
                  </div>
                ))}
              </div>
              {/* Per-item answers for the latest measurement */}
              {who5LatestItems && (
                <div style={{ marginTop: 14 }}>
                  <div style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: c.textTertiary, marginBottom: 8 }}>
                    {t('client.who5_items_eyebrow')}
                  </div>
                  <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                    {who5LatestItems.map((v, i) => {
                      const n = Number(v) || 0;
                      const labelKey = `client.who5_q${i + 1}`;
                      return (
                        <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                          <div style={{
                            flexShrink: 0, width: 24, fontFamily: fonts.app, fontSize: 12, fontWeight: 700,
                            color: n <= 1 ? c.flame : n >= 4 ? c.accent : c.textSecondary,
                            textAlign: 'right',
                          }}>{n}</div>
                          <div style={{ flex: 1, fontFamily: fonts.body, fontSize: 12, color: c.textSecondary, lineHeight: 1.4 }}>
                            {t(labelKey)}
                          </div>
                          <div style={{ flexShrink: 0, display: 'flex', gap: 2 }}>
                            {[0, 1, 2, 3, 4, 5].map(j => (
                              <span key={j} style={{
                                width: isBrutLocal ? 8 : 6,
                                height: isBrutLocal ? 8 : 6,
                                borderRadius: isBrutLocal ? 0 : '50%',
                                background: j === n
                                  ? (n <= 1 ? c.flame : n >= 4 ? c.accent : (isBrutLocal ? inkLocal : c.textSecondary))
                                  : (isBrutLocal ? c.bg : hexToRgba(c.textTertiary, 0.20)),
                                border: isBrutLocal ? `1.5px solid ${inkLocal}` : 'none',
                              }} />
                            ))}
                          </div>
                        </div>
                      );
                    })}
                  </div>
                </div>
              )}

              {/* Threshold reference */}
              <div style={{
                marginTop: 12, padding: isBrutLocal ? '8px 12px' : '8px 10px',
                borderRadius: isBrutLocal ? 2 : 7,
                background: isBrutLocal ? c.bg : hexToRgba(c.flame, 0.06),
                border: isBrutLocal ? `1.5px solid ${inkLocal}` : 'none',
                fontFamily: fonts.body, fontSize: 11,
                color: isBrutLocal ? inkLocal : c.textSecondary,
                lineHeight: 1.5,
                fontWeight: isBrutLocal ? 600 : 400,
              }}>
                {t('client.who5_threshold_hint')}
              </div>
            </div>
          )}
        </Card>
      </div>

      <Card padding={20}>
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 14, gap: 12 }}>
          <div style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: c.textTertiary }}>
            {t('client.habits_eyebrow')}
          </div>
          {client.streakBest > 0 && (
            <div style={{ fontFamily: fonts.body, fontSize: 12, color: c.textTertiary }}>
              Best streak: <strong style={{ color: c.textSecondary, fontWeight: 600 }}>{client.streakBest} days</strong>
            </div>
          )}
        </div>
        {/* Private rows render inline in the grid (anonymized + dimmed),
            so the redundant top-level count chip is dropped. */}
        <HabitGrid habits={habitSeries} userUid={client.userUid} />
      </Card>
    </div>
  );
}

// ── Journal feed ──
function JournalFeed({ client }) {
  const { c, fonts, t, lang } = useLW();
  const extras = CLIENT_EXTRAS[client.id] || {};
  // Real mode: pull from /users/{uid}/journal subscription, filter by per-entry
  // coachShare. iOS may not yet stamp coachShare on every entry — unset
  // defaults to private (privacy-safe).
  const real = client._real;
  let journals;
  let privateCount = 0;
  if (real) {
    const allReal = (real.journal || []);
    const sharedReal = allReal.filter(j => isCoachShared(j));
    privateCount = allReal.length - sharedReal.length;
    // Map iOS journal schema → web render shape (ts/text/sphere/mood) and sort newest-first.
    journals = sharedReal
      .map(j => {
        const ts = Number(j.creationDate || j.modificationDate || 0);
        const ms = ts > 1e12 ? ts : ts * 1000;
        return {
          id: j.id,
          ts: ms ? new Date(ms).toLocaleDateString(lang === 'ru' ? 'ru-RU' : undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—',
          tsMs: ms,
          text: j.body || j.text || '',
          title: j.title || '',
          sphere: j.sphere || j.wheelId || null,
          mood: typeof j.moodEmoji === 'string' ? j.moodEmoji : (j.mood != null ? j.mood : null),
          shared: true,
        };
      })
      .filter(j => j.text)
      .sort((a, b) => b.tsMs - a.tsMs);
  } else {
    journals = extras.journals || [];
  }

  if (journals.length === 0) {
    return (
      <Card padding={28} style={{ textAlign: 'center' }}>
        <div style={{ fontFamily: fonts.display, fontStyle: 'italic', fontSize: 18, color: c.textSecondary, marginBottom: 4 }}>
          {t('client.journal_empty')}
        </div>
        <div style={{ fontFamily: fonts.body, fontSize: 13, color: c.textTertiary, marginBottom: privateCount > 0 ? 12 : 0 }}>
          {client.sharesJournal === false
            ? t('client.journal_private', { name: (client.name || '').split(' ')[0] })
            : t('client.journal_none_shared', { name: (client.name || '').split(' ')[0] })}
        </div>
        {privateCount > 0 && (
          <div title={t('client.private_hint')} style={{
            display: 'inline-flex', alignItems: 'center', gap: 6,
            padding: '4px 10px', borderRadius: 999,
            background: c.bgSubtle, border: `1px dashed ${c.borderDefault}`,
            fontFamily: fonts.body, fontSize: 11, fontWeight: 600, color: c.textTertiary,
          }}>
            <span style={{ width: 5, height: 5, borderRadius: '50%', background: c.textTertiary }} />
            {pluralLabel(t, lang, 'client.private', privateCount)}
          </div>
        )}
      </Card>
    );
  }
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      {privateCount > 0 && (
        <div title={t('client.private_hint')} style={{
          display: 'inline-flex', alignItems: 'center', gap: 6,
          padding: '4px 10px', borderRadius: 999,
          background: c.bgSubtle, border: `1px dashed ${c.borderDefault}`,
          fontFamily: fonts.body, fontSize: 11, fontWeight: 600, color: c.textTertiary,
          alignSelf: 'flex-start', marginBottom: 4,
        }}>
          <span style={{ width: 5, height: 5, borderRadius: '50%', background: c.textTertiary }} />
          {pluralLabel(t, lang, 'client.private', privateCount)}
        </div>
      )}
      {journals.map((j, i) => {
        const sphereIdx = LWDATA.SPHERE_KEYS.indexOf(j.sphere);
        const sphereColor = sphereIdx >= 0 ? c.spheres[j.sphere] : c.textTertiary;
        const localizedSphereNames = (window.LWLang && window.LWLang.sphereNames) ? window.LWLang.sphereNames(lang) : LWDATA.SPHERE_NAMES;
        const sphereName = sphereIdx >= 0 ? localizedSphereNames[sphereIdx] : '';
        return (
          <Card key={i} padding={18}>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 10, flexWrap: 'wrap' }}>
              <span style={{ fontFamily: fonts.body, fontSize: 11, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: c.textTertiary }}>
                {j.ts}
              </span>
              {sphereName && (
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6,
                              fontFamily: fonts.body, fontSize: 11, fontWeight: 600, color: sphereColor }}>
                  <span style={{ width: 8, height: 8, borderRadius: '50%', background: sphereColor }} />
                  {sphereName}
                </span>
              )}
              {j.mood != null && (
                <span style={{ fontFamily: fonts.app, fontSize: 11, fontWeight: 700, color: c.textSecondary }}>
                  mood {j.mood}/10
                </span>
              )}
              <span style={{ flex: 1 }} />
              {!j.shared && (
                <Chip tone="subtle" style={{ fontSize: 10 }} title="Private to client. Lifeworks does not show coaches the contents of unshared entries.">
                  🔒 private — you can't see this
                </Chip>
              )}
              {j.shared && (
                <Chip tone="accent" style={{ fontSize: 10 }}>shared with you</Chip>
              )}
              {j.shared && j.id && client.userUid && (
                <LikeButton
                  userUid={client.userUid}
                  itemKey={`journal:${j.id}`}
                  meta={{ kind: 'journal', journalId: j.id, itemTitle: (j.title || (j.text || '').slice(0, 60)) || '' }}
                />
              )}
            </div>
            {j.shared ? (
              <div style={{
                fontFamily: fonts.display, fontSize: 16, lineHeight: 1.6, color: c.textPrimary,
                fontStyle: 'italic', textWrap: 'pretty',
                paddingLeft: 14, borderLeft: `2px solid ${hexToRgba(sphereColor, 0.45)}`,
              }}>
                “{j.text}”
              </div>
            ) : (
              <div style={{
                paddingLeft: 14, borderLeft: `2px dashed ${c.borderDefault}`,
                fontFamily: fonts.body, fontSize: 13, color: c.textTertiary, fontStyle: 'italic',
                lineHeight: 1.5,
              }}>
                Private entry — {j.text.split(/\s+/).length} words. Only {(client.name || '').split(' ')[0]} can read this.
                {j.mood != null && <> Their self-reported mood was <strong style={{ color: c.textSecondary, fontStyle: 'normal' }}>{j.mood}/10</strong>.</>}
              </div>
            )}
            {j.shared && (
              <div style={{ marginTop: 12, display: 'flex', gap: 6 }}>
                <Button size="sm" variant="quiet" icon={<span aria-hidden style={{ fontSize: 12 }}>✦</span>}>Save to prep brief</Button>
                <Button size="sm" variant="quiet" icon={<span aria-hidden style={{ fontSize: 12 }}>↗</span>}>Send a ping</Button>
              </div>
            )}
          </Card>
        );
      })}
    </div>
  );
}

// ── Session log ──
function SessionLog({ client }) {
  const { c, fonts, t } = useLW();
  // Real-mode: read from /sessions filter we already loaded into client._real.sessions.
  // Demo-mode: fall back to CLIENT_EXTRAS for design walkthroughs.
  const real = client._real && client._real.sessions;
  if (real) {
    if (real.length === 0) {
      return (
        <div style={{ fontFamily: fonts.body, fontStyle: 'italic', fontSize: 13, color: c.textTertiary, padding: '14px 0' }}>
          {t('client.no_sessions')}
        </div>
      );
    }
    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
        {real.map((s, i) => {
          const started = s.startedAt ? new Date(Number(s.startedAt)) : null;
          const ended = s.endedAt ? new Date(Number(s.endedAt)) : null;
          const durMin = started && ended ? Math.max(1, Math.round((ended - started) / 60000)) : null;
          const dateStr = started ? started.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '—';
          const items = (s.agenda || []).filter(a => a && a.text);
          const summary = items.length
            ? items.slice(0, 3).map(a => (a.done ? '✓ ' : '○ ') + a.text).join(' · ')
            : (s.endedAt ? 'Session ended.' : 'Session in progress…');
          return (
            <a key={s.id} href={`Session.html?clientId=${encodeURIComponent(client.userUid)}&sessionId=${encodeURIComponent(s.id)}`}
               style={{
                 display: 'block', textDecoration: 'none', color: 'inherit',
                 padding: '14px 0', borderBottom: i < real.length - 1 ? `1px solid ${c.borderSubtle}` : 'none',
               }}>
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 6 }}>
                <span style={{ fontFamily: fonts.app, fontSize: 12, fontWeight: 700, letterSpacing: '0.04em', color: c.textPrimary, whiteSpace: 'nowrap' }}>
                  {dateStr}{durMin != null ? ` · ${durMin} min` : ''}{!s.endedAt && started ? ' · live' : ''}
                </span>
                <span style={{ flex: 1, height: 1, background: c.borderSubtle }} />
              </div>
              <div style={{ fontFamily: fonts.body, fontSize: 14, color: c.textSecondary, lineHeight: 1.5 }}>
                {summary}
              </div>
            </a>
          );
        })}
      </div>
    );
  }
  // Demo
  const extras = CLIENT_EXTRAS[client.id] || {};
  const log = extras.sessionLog || [];
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
      {log.map((s, i) => (
        <div key={i} style={{
          padding: '14px 0',
          borderBottom: i < log.length - 1 ? `1px solid ${c.borderSubtle}` : 'none',
        }}>
          <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 6 }}>
            <span style={{ fontFamily: fonts.app, fontSize: 12, fontWeight: 700, letterSpacing: '0.04em', color: c.textPrimary, textTransform: 'capitalize', whiteSpace: 'nowrap' }}>
              {s.date}
            </span>
            <span style={{ flex: 1, height: 1, background: c.borderSubtle }} />
          </div>
          <div style={{ fontFamily: fonts.body, fontSize: 14, color: c.textSecondary, lineHeight: 1.5 }}>
            {s.summary}
          </div>
          {s.followups.length > 0 && (
            <div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
              {s.followups.map((f, k) => <Chip key={k} tone="accent">↳ {f}</Chip>)}
            </div>
          )}
        </div>
      ))}
      {log.length === 0 && (
        <div style={{ fontFamily: fonts.body, fontStyle: 'italic', fontSize: 13, color: c.textTertiary, padding: '14px 0' }}>
          No sessions yet.
        </div>
      )}
    </div>
  );
}

// ── Pings sent log ──
// Coach handoff status — what the coach sent + what the client did with it.
function OutboxFeed({ client }) {
  const { c, fonts, t, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  const items = (client._real && client._real.outbox) || [];
  if (items.length === 0) {
    return (
      <div style={{
        fontFamily: fonts.body,
        fontStyle: isBrut ? 'normal' : 'italic',
        fontSize: 13,
        color: isBrut ? ink : c.textTertiary,
        padding: isBrut ? '14px 12px' : '14px 0',
        border: isBrut ? `1.5px dashed ${ink}` : 'none',
        lineHeight: 1.5,
      }}>
        {t('client.outbox_empty')}
      </div>
    );
  }
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
      {items.slice(0, 8).map((it, i) => {
        const status = outboxStatus(it);
        const tone = outboxStatusTone(status, c);
        const kindLabel = outboxKindLabel(it.kind, t);
        const titleOrBody = it.title || it.text || '';
        const ts = outboxRelTime(it, t);
        return (
          <div key={it.id || i} style={{
            padding: isBrut ? '12px 14px' : '10px 12px',
            borderRadius: isBrut ? 2 : 10,
            background: isBrut ? c.bg : c.bgSubtle,
            border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
            boxShadow: isBrut ? `2px 2px 0 0 ${ink}` : 'none',
            display: 'flex', flexDirection: 'column', gap: 4,
          }}>
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
              <div style={{
                fontFamily: fonts.body, fontSize: 10, fontWeight: isBrut ? 800 : 700,
                letterSpacing: isBrut ? '0.10em' : '0.14em', textTransform: 'uppercase',
                color: isBrut ? ink : c.accent,
                background: isBrut ? c.accent : 'transparent',
                padding: isBrut ? '2px 8px' : 0,
                border: isBrut ? `1.5px solid ${ink}` : 'none',
                borderRadius: isBrut ? 2 : 0,
              }}>
                {kindLabel}
              </div>
              <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                <span style={{
                  width: isBrut ? 8 : 6, height: isBrut ? 8 : 6,
                  borderRadius: isBrut ? 0 : '50%',
                  background: tone.dot,
                  border: isBrut ? `1.5px solid ${ink}` : 'none',
                }} />
                <span style={{
                  fontFamily: fonts.body, fontSize: 10, fontWeight: isBrut ? 800 : 700,
                  letterSpacing: isBrut ? '0.08em' : '0.10em', textTransform: 'uppercase',
                  color: isBrut ? ink : tone.text,
                }}>
                  {t(`client.outbox_status_${status}`)}
                </span>
              </div>
            </div>
            <div style={{
              fontFamily: fonts.body, fontSize: 13,
              fontWeight: isBrut ? 600 : 400,
              color: c.textPrimary, lineHeight: 1.4,
              overflow: 'hidden', textOverflow: 'ellipsis',
              display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
            }}>
              {titleOrBody}
            </div>
            <div style={{
              fontFamily: fonts.body, fontSize: 11,
              fontWeight: isBrut ? 700 : 400,
              letterSpacing: isBrut ? '0.06em' : 'normal',
              textTransform: isBrut ? 'uppercase' : 'none',
              color: c.textTertiary,
            }}>
              {ts}
            </div>
          </div>
        );
      })}
    </div>
  );
}

function outboxStatus(it) {
  if (it.acceptedAt) return 'accepted';
  if (it.skippedAt)  return 'skipped';
  if (it.pushedAt)   return 'pushed';
  return 'pending';
}
function outboxStatusTone(status, c) {
  switch (status) {
    case 'accepted': return { dot: c.accent, text: c.accent };
    case 'skipped':  return { dot: c.textTertiary, text: c.textTertiary };
    case 'pushed':   return { dot: c.flame, text: c.flame };
    default:         return { dot: c.textTertiary, text: c.textTertiary };
  }
}
function outboxKindLabel(kind, t) {
  if (kind === 'goal')  return t('claim.scope_tasks');
  if (kind === 'habit') return t('claim.scope_habits');
  if (kind === 'todo')  return t('claim.scope_tasks');
  return t('claim.scope_journal');
}
function outboxRelTime(it, t) {
  const ts = Number(it.acceptedAt || it.skippedAt || it.pushedAt || it.createdAt || 0);
  if (!ts) return '';
  const diff = Date.now() - ts;
  if (diff < 60_000) return t('client.outbox_just_now');
  if (diff < 3_600_000) return t('client.outbox_min_ago', { n: Math.round(diff / 60_000) });
  if (diff < 86_400_000) return t('client.outbox_hr_ago', { n: Math.round(diff / 3_600_000) });
  return t('client.outbox_day_ago', { n: Math.round(diff / 86_400_000) });
}

// ── Coach likes / hearts ─────────────────────────────────────────────────
// Lightweight "I noticed this" reaction from coach to client. Stored at
// /coach_likes/{userUid}/{itemKey}/{coachId} and rendered as a heart on
// both sides. Per-coach per-item — many coaches per item is theoretical
// (multi-coach v2+) but the schema permits it.
function useCoachLike(userUid, itemKey) {
  const [liked, setLiked] = useState(false);
  const [count, setCount] = useState(0);
  const coach = window.LWAuth && window.LWAuth.currentCoach && window.LWAuth.currentCoach();
  const coachUid = coach && coach.uid;
  useEffect(() => {
    if (!userUid || !itemKey || !coachUid) return;
    const ref = window.LWFB.db.ref(`/coach_likes/${userUid}/${itemKey}`);
    const handler = (snap) => {
      const v = snap.val() || {};
      setCount(Object.keys(v).length);
      setLiked(!!v[coachUid]);
    };
    ref.on('value', handler, () => {});
    return () => ref.off('value', handler);
  }, [userUid, itemKey, coachUid]);
  const toggle = async (meta) => {
    if (!userUid || !itemKey || !coachUid) return;
    const ref = window.LWFB.db.ref(`/coach_likes/${userUid}/${itemKey}/${coachUid}`);
    try {
      if (liked) {
        await ref.remove();
      } else {
        const coachName = (coach && coach.profile && coach.profile.displayName) || (coach && coach.email) || '';
        // Sanitize meta — RTDB rejects undefined values entirely on .set().
        const cleanMeta = {};
        if (meta && typeof meta === 'object') {
          Object.keys(meta).forEach(k => {
            const v = meta[k];
            if (v !== undefined && v !== null) cleanMeta[k] = v;
          });
        }
        await ref.set({
          likedAt: Date.now(),
          coachName: coachName || '',
          ...cleanMeta,
        });
      }
    } catch (err) {
      console.error('[coach-like] write failed', { itemKey, userUid, coachUid, err });
      // Surface the error visibly so the user can see what's blocking the write.
      try { alert('Like failed: ' + (err && err.message ? err.message : String(err))); } catch {}
    }
  };
  return { liked, count, toggle };
}

function LikeButton({ userUid, itemKey, meta, size = 16, label = null }) {
  const { c, fonts, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  const { liked, count, toggle } = useCoachLike(userUid, itemKey);
  if (!userUid || !itemKey) return null;
  return (
    <button type="button"
      onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggle(meta); }}
      title={liked ? 'Снять ♥' : 'Поставить ♥'}
      aria-label={liked ? 'Unlike' : 'Like'}
      style={{
        display: 'inline-flex', alignItems: 'center', gap: 4,
        padding: isBrut ? '2px 6px' : '2px 5px',
        background: 'transparent',
        border: isBrut
          ? `1.5px solid ${liked ? (c.flame || ink) : ink}`
          : `1px solid ${liked ? hexToRgba(c.flame, 0.5) : c.borderSubtle}`,
        borderRadius: isBrut ? 2 : 999,
        cursor: 'pointer',
        color: liked ? (c.flame || ink) : c.textTertiary,
        fontFamily: fonts.body, fontSize: 11,
        fontWeight: liked ? 800 : 600,
        letterSpacing: isBrut ? '0.04em' : 0,
        transition: 'transform 80ms ease, color 120ms ease',
      }}
      onMouseDown={(e) => { e.currentTarget.style.transform = 'scale(0.92)'; }}
      onMouseUp={(e) => { e.currentTarget.style.transform = 'none'; }}
      onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }}
    >
      <span style={{ fontSize: size, lineHeight: 1 }}>{liked ? '♥' : '♡'}</span>
      {label != null && <span>{label}</span>}
      {count > 1 && <span style={{ marginLeft: 2 }}>{count}</span>}
    </button>
  );
}

// ── Weekly plan helpers ──────────────────────────────────────────────────
// Week boundary: Monday → Sunday (ISO 8601). All week keys are the Monday
// date as "YYYY-MM-DD" derived from the coach's local time.
function weekStartFromDate(d) {
  const dt = new Date(d.getFullYear(), d.getMonth(), d.getDate());
  const dow = dt.getDay(); // 0 Sun … 6 Sat
  const diff = dow === 0 ? -6 : 1 - dow; // shift to Mon
  dt.setDate(dt.getDate() + diff);
  return dt;
}
function weekStartISO(d) {
  const ws = weekStartFromDate(d);
  const y = ws.getFullYear();
  const m = String(ws.getMonth() + 1).padStart(2, '0');
  const day = String(ws.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}
function weekRangeLabel(weekStartDate, lang) {
  const end = new Date(weekStartDate);
  end.setDate(end.getDate() + 6);
  const fmt = (d) => d.toLocaleDateString(lang === 'ru' ? 'ru-RU' : undefined, { day: 'numeric', month: 'short' });
  return `${fmt(weekStartDate).toLowerCase()} — ${fmt(end).toLowerCase()}`;
}
function weekStartFromISO(iso) {
  const [y, m, d] = String(iso).split('-').map(Number);
  return new Date(y, (m || 1) - 1, d || 1);
}
function relWhen(ms, t, lang) {
  if (!ms) return '';
  const now = Date.now();
  const diff = now - Number(ms);
  if (diff < 60_000) return t('client.outbox_just_now');
  if (diff < 3_600_000) return t('client.outbox_min_ago', { n: Math.round(diff / 60_000) });
  if (diff < 86_400_000) return t('client.outbox_hr_ago', { n: Math.round(diff / 3_600_000) });
  return t('client.outbox_day_ago', { n: Math.round(diff / 86_400_000) });
}

// ── Weekly plan hero ─────────────────────────────────────────────────────
// Shared coach + client surface. Stored at
// /coach_weekly/{coachId}/{userUid}/{weekStartISO}.
// Both sides can write focus/checkin/pinned per the rule grant; past weeks
// are read-only at the UI layer (rule layer not enforced for v0).
function WeeklyPlan({ client }) {
  const { c, fonts, t, lang, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  const accent = c.accent || ink;

  const coach = window.LWAuth && window.LWAuth.currentCoach && window.LWAuth.currentCoach();
  const coachUid = coach && coach.uid;
  const userUid = client && client.userUid;
  const firstName = (client && client.name) ? String(client.name).split(' ')[0] : '';

  // Active week being viewed — defaults to current. Past weeks viewable
  // via the prev/next arrows; they render read-only.
  const todayISO = useMemo(() => weekStartISO(new Date()), []);
  const [viewingISO, setViewingISO] = useState(todayISO);
  const isCurrent = viewingISO === todayISO;
  const isPast = viewingISO < todayISO;
  const isFuture = viewingISO > todayISO;
  const readOnly = isPast;

  // Plan doc + previous week's plan (for carry-over)
  const [doc, setDoc] = useState(null);
  const [prevDoc, setPrevDoc] = useState(null);
  const [hydrated, setHydrated] = useState(false);
  const [status, setStatus] = useState('idle'); // idle|saving|saved|error
  const [pickerOpen, setPickerOpen] = useState(false);

  // Local drafts so the textareas stay smooth while RTDB writes settle.
  const [focusDraft, setFocusDraft] = useState('');
  const [checkinDraft, setCheckinDraft] = useState('');
  const focusTimer = React.useRef(null);
  const checkinTimer = React.useRef(null);
  const lastFocusRef = React.useRef('');
  const lastCheckinRef = React.useRef('');

  // Subscribe to the active week.
  useEffect(() => {
    if (!coachUid || !userUid) return;
    const ref = window.LWFB.db.ref(`/coach_weekly/${coachUid}/${userUid}/${viewingISO}`);
    const handler = (snap) => {
      const v = snap.val();
      setDoc(v || null);
      const ft = (v && v.focus && v.focus.text) || '';
      const ct = (v && v.checkin && v.checkin.text) || '';
      setFocusDraft(ft);
      setCheckinDraft(ct);
      lastFocusRef.current = ft;
      lastCheckinRef.current = ct;
      setHydrated(true);
    };
    ref.on('value', handler, () => setHydrated(true));
    return () => ref.off('value', handler);
  }, [coachUid, userUid, viewingISO]);

  // Pull previous week's plan for the carry-over prompt (only on the
  // current week and only when there are unfinished pinned items).
  useEffect(() => {
    if (!coachUid || !userUid || !isCurrent) { setPrevDoc(null); return; }
    const prevDate = weekStartFromISO(viewingISO);
    prevDate.setDate(prevDate.getDate() - 7);
    const prevISO = weekStartISO(prevDate);
    window.LWFB.db.ref(`/coach_weekly/${coachUid}/${userUid}/${prevISO}`).get()
      .then(s => setPrevDoc(s.val()))
      .catch(() => setPrevDoc(null));
  }, [coachUid, userUid, viewingISO, isCurrent]);

  const flashSaved = () => {
    setStatus('saved');
    setTimeout(() => setStatus(s => s === 'saved' ? 'idle' : s), 1400);
  };

  const writeField = async (field, value) => {
    if (!coachUid || !userUid || readOnly) return;
    setStatus('saving');
    try {
      await window.LWFB.db
        .ref(`/coach_weekly/${coachUid}/${userUid}/${viewingISO}/${field}`)
        .set({
          text: value,
          updatedAt: Date.now(),
          by: 'coach',
        });
      // Ensure the week-doc has weekStart stamped (idempotent).
      window.LWFB.db
        .ref(`/coach_weekly/${coachUid}/${userUid}/${viewingISO}/weekStart`)
        .set(viewingISO).catch(() => {});
      if (field === 'focus') lastFocusRef.current = value;
      if (field === 'checkin') lastCheckinRef.current = value;
      flashSaved();
    } catch (err) {
      console.warn('[weekly-plan] save failed', err);
      setStatus('error');
    }
  };

  const onFocusChange = (e) => {
    const v = e.target.value;
    setFocusDraft(v);
    if (focusTimer.current) clearTimeout(focusTimer.current);
    focusTimer.current = setTimeout(() => {
      if (v !== lastFocusRef.current) writeField('focus', v);
    }, 800);
  };
  const onFocusBlur = () => {
    if (focusTimer.current) { clearTimeout(focusTimer.current); focusTimer.current = null; }
    if (focusDraft !== lastFocusRef.current) writeField('focus', focusDraft);
  };
  const onCheckinChange = (e) => {
    const v = e.target.value;
    setCheckinDraft(v);
    if (checkinTimer.current) clearTimeout(checkinTimer.current);
    checkinTimer.current = setTimeout(() => {
      if (v !== lastCheckinRef.current) writeField('checkin', v);
    }, 800);
  };
  const onCheckinBlur = () => {
    if (checkinTimer.current) { clearTimeout(checkinTimer.current); checkinTimer.current = null; }
    if (checkinDraft !== lastCheckinRef.current) writeField('checkin', checkinDraft);
  };

  // Pinned items resolution — look up each pinned key against the client's
  // shared goals/todos/habits. Items the client has since unshared simply
  // don't render (they're filtered out of client._real.goals upstream).
  const pinnedRaw = (doc && doc.pinned) || {};
  const realGoalsRaw = client._real ? (client._real.goals || []) : [];
  const allItems = realGoalsRaw.filter(g => g && (g.title || g.name) && isCoachShared(g));
  const itemsById = useMemo(() => {
    const m = new Map();
    allItems.forEach(g => m.set(g.id, g));
    return m;
  }, [allItems]);
  const pinnedKeys = Object.keys(pinnedRaw).sort((a, b) => (pinnedRaw[a].pinnedAt || 0) - (pinnedRaw[b].pinnedAt || 0));
  const pinnedItems = pinnedKeys
    .map(k => ({ key: k, item: itemsById.get(k), meta: pinnedRaw[k] }))
    .filter(x => x.item);

  const togglePin = async (g) => {
    if (!coachUid || !userUid || readOnly) return;
    const path = `/coach_weekly/${coachUid}/${userUid}/${viewingISO}/pinned/${g.id}`;
    if (pinnedRaw[g.id]) {
      await window.LWFB.db.ref(path).remove();
    } else {
      const kind = isIosHabit(g) ? 'habit' : (g.isSimpleTodo === true || g.isSimpleTodo === 'true' ? 'todo' : 'goal');
      await window.LWFB.db.ref(path).set({
        kind, pinnedAt: Date.now(), by: 'coach',
      });
      window.LWFB.db
        .ref(`/coach_weekly/${coachUid}/${userUid}/${viewingISO}/weekStart`)
        .set(viewingISO).catch(() => {});
    }
    flashSaved();
  };

  // Carry-over: previous week's pinned items that aren't done. v0
  // heuristic — for goals, "not done" = at least one subtask incomplete;
  // for habits/todos, just "still exists in current shared roster".
  const prevPinnedKeys = (prevDoc && prevDoc.pinned) ? Object.keys(prevDoc.pinned) : [];
  const carryCandidates = prevPinnedKeys.filter(k => !pinnedRaw[k] && itemsById.has(k));
  const showCarryBanner = isCurrent && carryCandidates.length > 0;

  const carryOver = async () => {
    if (!showCarryBanner) return;
    const updates = {};
    carryCandidates.forEach(k => {
      const it = itemsById.get(k);
      if (!it) return;
      const kind = isIosHabit(it) ? 'habit' : (it.isSimpleTodo === true || it.isSimpleTodo === 'true' ? 'todo' : 'goal');
      updates[`/coach_weekly/${coachUid}/${userUid}/${viewingISO}/pinned/${k}`] = {
        kind, pinnedAt: Date.now(), by: 'coach',
      };
    });
    updates[`/coach_weekly/${coachUid}/${userUid}/${viewingISO}/weekStart`] = viewingISO;
    await window.LWFB.db.ref().update(updates);
    flashSaved();
  };
  const skipCarry = async () => {
    // Mark as dismissed so the banner doesn't return on re-render.
    await window.LWFB.db
      .ref(`/coach_weekly/${coachUid}/${userUid}/${viewingISO}/carryDismissed`)
      .set(Date.now());
  };
  const carryDismissed = !!(doc && doc.carryDismissed);

  // Week nav
  const goPrev = () => { const d = weekStartFromISO(viewingISO); d.setDate(d.getDate() - 7); setViewingISO(weekStartISO(d)); };
  const goNext = () => { const d = weekStartFromISO(viewingISO); d.setDate(d.getDate() + 7); setViewingISO(weekStartISO(d)); };
  const rangeLabel = weekRangeLabel(weekStartFromISO(viewingISO), lang);
  const stateLabel = isCurrent ? t('week.now') : isPast ? t('week.archived') : t('week.future');

  // Bylines
  const focusByline = doc && doc.focus && doc.focus.updatedAt
    ? (doc.focus.by === 'client'
        ? t('week.byline_client', { name: firstName, when: relWhen(doc.focus.updatedAt, t, lang) })
        : t('week.byline_coach', { when: relWhen(doc.focus.updatedAt, t, lang) }))
    : null;
  const checkinByline = doc && doc.checkin && doc.checkin.updatedAt
    ? (doc.checkin.by === 'coach'
        ? t('week.byline_coach', { when: relWhen(doc.checkin.updatedAt, t, lang) })
        : t('week.byline_client', { name: firstName, when: relWhen(doc.checkin.updatedAt, t, lang) }))
    : null;

  return (
    <div style={{
      padding: isBrut ? 18 : 20,
      background: isBrut ? c.bg : c.card,
      border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
      boxShadow: isBrut ? `3px 3px 0 0 ${ink}` : 'none',
      borderRadius: isBrut ? 2 : 14,
      display: 'flex', flexDirection: 'column', gap: 18,
    }}>
      {/* Header — week range + nav */}
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
        <div>
          <div style={{
            fontFamily: fonts.body, fontSize: 11, fontWeight: 800,
            letterSpacing: '0.16em', textTransform: 'uppercase',
            color: c.textTertiary,
          }}>
            {rangeLabel}
          </div>
          <div style={{
            fontFamily: isBrut ? '"IBM Plex Mono", monospace' : fonts.display,
            fontSize: 22, fontWeight: isBrut ? 800 : 600,
            letterSpacing: isBrut ? '0.04em' : '-0.01em',
            textTransform: isBrut ? 'uppercase' : 'none',
            color: c.textPrimary, marginTop: 2,
            display: 'flex', alignItems: 'baseline', gap: 10,
          }}>
            <span>{t('week.section_eyebrow', { name: firstName })}</span>
            <span style={{
              fontSize: 10, fontWeight: 800, letterSpacing: '0.08em',
              color: isCurrent ? accent : c.textTertiary,
              border: `1.5px solid ${isCurrent ? accent : c.textTertiary}`,
              padding: '1px 6px', textTransform: 'uppercase',
            }}>
              {stateLabel}
            </span>
          </div>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <button onClick={goPrev} style={weekNavBtn(c, isBrut, ink)}>◂ {t('week.prev')}</button>
          {!isCurrent && (
            <button onClick={() => setViewingISO(todayISO)} style={weekNavBtn(c, isBrut, ink)}>{t('week.now')}</button>
          )}
          <button onClick={goNext} style={weekNavBtn(c, isBrut, ink)}>{t('week.next')} ▸</button>
          {status !== 'idle' && (
            <span style={{
              marginLeft: 8,
              fontFamily: fonts.body, fontSize: 10, fontWeight: 800,
              letterSpacing: '0.08em', textTransform: 'uppercase',
              color: status === 'error' ? c.flame : status === 'saved' ? accent : c.textTertiary,
            }}>
              {status === 'saving' ? t('week.saving') : status === 'saved' ? t('week.saved') : t('week.error')}
            </span>
          )}
        </div>
      </div>

      {readOnly && (
        <div style={{
          padding: '8px 12px',
          background: isBrut ? c.bg : hexToRgba(c.textTertiary, 0.08),
          border: isBrut ? `1.5px dashed ${ink}` : `1px dashed ${c.borderDefault}`,
          borderRadius: isBrut ? 2 : 8,
          fontFamily: fonts.body, fontSize: 12,
          color: isBrut ? ink : c.textSecondary,
          fontWeight: isBrut ? 700 : 500,
        }}>
          {t('week.read_only_hint')}
        </div>
      )}

      {/* Focus */}
      <WeekBlock
        label={t('week.focus_label')}
        byline={focusByline}
        emptyByline={t('week.focus_empty')}
        c={c} fonts={fonts} isBrut={isBrut} ink={ink}
      >
        <textarea
          value={focusDraft}
          onChange={onFocusChange}
          onBlur={onFocusBlur}
          disabled={readOnly || !hydrated}
          rows={3}
          placeholder={t('week.focus_placeholder', { name: firstName })}
          style={textareaStyle(c, fonts, isBrut, ink)}
        />
      </WeekBlock>

      {/* Check-in */}
      <WeekBlock
        label={t('week.checkin_label', { name: firstName })}
        byline={checkinByline}
        emptyByline={t('week.checkin_empty', { name: firstName })}
        c={c} fonts={fonts} isBrut={isBrut} ink={ink}
      >
        <textarea
          value={checkinDraft}
          onChange={onCheckinChange}
          onBlur={onCheckinBlur}
          disabled={readOnly || !hydrated}
          rows={3}
          placeholder={t('week.checkin_placeholder', { name: firstName })}
          style={textareaStyle(c, fonts, isBrut, ink)}
        />
      </WeekBlock>

      {/* Pinned items */}
      <WeekBlock
        label={t('week.pinned_label')}
        byline={pinnedItems.length ? t('week.pinned_count', { n: pinnedItems.length }) : null}
        emptyByline={null}
        action={!readOnly && (
          <button onClick={() => setPickerOpen(true)} style={weekNavBtn(c, isBrut, ink)}>
            + {t('week.pinned_pick')}
          </button>
        )}
        c={c} fonts={fonts} isBrut={isBrut} ink={ink}
      >
        {showCarryBanner && !carryDismissed && (
          <div style={{
            display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
            padding: isBrut ? '10px 12px' : '10px 14px',
            background: isBrut ? c.bg : hexToRgba(accent, 0.08),
            border: isBrut ? `1.5px dashed ${ink}` : `1px dashed ${hexToRgba(accent, 0.4)}`,
            borderRadius: isBrut ? 2 : 8,
            marginBottom: 10, flexWrap: 'wrap',
          }}>
            <div style={{
              fontFamily: fonts.body, fontSize: 12,
              color: isBrut ? ink : c.textSecondary,
              fontWeight: isBrut ? 700 : 500,
            }}>
              {t('week.carry_over', { n: carryCandidates.length })}
            </div>
            <div style={{ display: 'flex', gap: 8 }}>
              <button onClick={skipCarry} style={weekNavBtn(c, isBrut, ink)}>{t('week.carry_over_skip')}</button>
              <button onClick={carryOver} style={weekNavBtn(c, isBrut, ink, true)}>{t('week.carry_over_btn')}</button>
            </div>
          </div>
        )}
        {pinnedItems.length === 0 ? (
          <div style={{
            padding: isBrut ? '12px 14px' : '12px 0',
            border: isBrut ? `1.5px dashed ${ink}` : 'none',
            fontFamily: fonts.body, fontSize: 12,
            color: isBrut ? ink : c.textTertiary,
            fontStyle: isBrut ? 'normal' : 'italic',
            fontWeight: isBrut ? 600 : 400,
          }}>
            {t('week.pinned_empty', { name: firstName })}
          </div>
        ) : (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
            {pinnedItems.map(({ key, item, meta }) => (
              <PinnedRow key={key} item={item} meta={meta}
                onUnpin={readOnly ? null : () => togglePin(item)}
                c={c} fonts={fonts} isBrut={isBrut} ink={ink} t={t} />
            ))}
          </div>
        )}
      </WeekBlock>

      {pickerOpen && (
        <PinPickerSheet
          client={client}
          allItems={allItems}
          pinned={pinnedRaw}
          onToggle={togglePin}
          onClose={() => setPickerOpen(false)}
        />
      )}
    </div>
  );
}

// Common-styled subblock with label / byline / optional action.
function WeekBlock({ label, byline, emptyByline, action, children, c, fonts, isBrut, ink }) {
  return (
    <div>
      <div style={{
        display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
        gap: 8, marginBottom: 6,
      }}>
        <div style={{
          fontFamily: fonts.body, fontSize: 11, fontWeight: 800,
          letterSpacing: isBrut ? '0.10em' : '0.14em',
          textTransform: 'uppercase',
          color: isBrut ? ink : c.textTertiary,
          background: isBrut ? c.accent : 'transparent',
          padding: isBrut ? '2px 8px' : 0,
          border: isBrut ? `1.5px solid ${ink}` : 'none',
          borderRadius: isBrut ? 2 : 0,
        }}>
          {label}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          {(byline || emptyByline) && (
            <div style={{
              fontFamily: fonts.body, fontSize: 10, fontWeight: 700,
              letterSpacing: '0.10em', textTransform: 'uppercase',
              color: c.textTertiary,
            }}>
              {byline || emptyByline}
            </div>
          )}
          {action}
        </div>
      </div>
      {children}
    </div>
  );
}

function PinnedRow({ item, meta, onUnpin, c, fonts, isBrut, ink, t }) {
  // Goal-progress mirror: tasks done / total. Habits: 0/0 (no live week
  // completion math here yet — defer to v1 to keep the v0 surface simple).
  const tasks = (item.tasks && typeof item.tasks === 'object') ? Object.values(item.tasks) : [];
  const done = tasks.filter(x => x && (x.isAchieved || x.done || x.completed)).length;
  const total = tasks.length;
  const isHabit = isIosHabit(item);
  const isTodo = item.isSimpleTodo === true || item.isSimpleTodo === 'true';
  const glyph = isHabit ? '↻' : isTodo ? '✓' : '◎';
  const title = item.title || item.name || '';
  return (
    <div style={{
      display: 'flex', alignItems: 'center', gap: 10,
      padding: isBrut ? '8px 12px' : '8px 0',
      background: isBrut ? c.bg : 'transparent',
      border: isBrut ? `1.5px solid ${ink}` : 'none',
      borderBottom: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
    }}>
      <span style={{
        fontFamily: fonts.body, fontSize: 11, fontWeight: 800,
        color: c.textTertiary, width: 14, textAlign: 'center',
      }}>{glyph}</span>
      <span style={{
        flex: 1, minWidth: 0,
        fontFamily: fonts.body, fontSize: 13,
        fontWeight: isBrut ? 600 : 500,
        color: c.textPrimary, lineHeight: 1.35,
        overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
      }}>{title}</span>
      {total > 0 && (
        <span style={{
          fontFamily: isBrut ? '"IBM Plex Mono", monospace' : fonts.app,
          fontSize: 11, fontWeight: 700,
          color: c.textSecondary, fontVariantNumeric: 'tabular-nums',
        }}>{done}/{total}</span>
      )}
      {onUnpin && (
        <button onClick={onUnpin} title={t('week.pinned_unpin')} style={{
          background: 'transparent', border: 'none', cursor: 'pointer',
          color: c.textTertiary, fontSize: 14, fontWeight: 800, padding: '0 4px',
        }}>×</button>
      )}
    </div>
  );
}

function PinPickerSheet({ client, allItems, pinned, onToggle, onClose }) {
  const { c, fonts, t, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  // Group by kind — goals (with subtasks), habits, todos.
  const byKind = useMemo(() => {
    const goals = [], habits = [], todos = [];
    allItems.forEach(g => {
      if (isIosHabit(g)) habits.push(g);
      else if (g.isSimpleTodo === true || g.isSimpleTodo === 'true') todos.push(g);
      else goals.push(g);
    });
    return { goals, habits, todos };
  }, [allItems]);
  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);
  const Section = ({ label, items }) => (
    items.length === 0 ? null : (
      <div style={{ marginBottom: 14 }}>
        <div style={{
          fontFamily: fonts.body, fontSize: 10, fontWeight: 800,
          letterSpacing: '0.14em', textTransform: 'uppercase',
          color: c.textTertiary, marginBottom: 8,
        }}>{label}</div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
          {items.map(g => {
            const isPinned = !!pinned[g.id];
            const title = g.title || g.name || '';
            return (
              <button key={g.id} onClick={() => onToggle(g)} style={{
                display: 'flex', alignItems: 'center', gap: 10,
                padding: '8px 12px',
                background: isPinned ? (isBrut ? c.accent : hexToRgba(c.accent, 0.10)) : (isBrut ? c.bg : c.bgSubtle),
                border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
                borderRadius: isBrut ? 2 : 8,
                color: c.textPrimary, fontFamily: fonts.body, fontSize: 13,
                cursor: 'pointer', textAlign: 'left',
              }}>
                <span style={{
                  width: 14, height: 14, flexShrink: 0,
                  border: `1.5px solid ${ink}`,
                  background: isPinned ? ink : (isBrut ? c.bg : 'transparent'),
                  borderRadius: isBrut ? 0 : 3,
                }} />
                <span style={{ flex: 1, lineHeight: 1.35 }}>{title}</span>
              </button>
            );
          })}
        </div>
      </div>
    )
  );
  const empty = allItems.length === 0;
  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, zIndex: 100,
      background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      padding: 20,
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        background: isBrut ? c.bg : c.card,
        border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderDefault}`,
        borderRadius: isBrut ? 2 : 14,
        boxShadow: isBrut ? `8px 8px 0 0 ${ink}` : '0 20px 60px rgba(0,0,0,0.4)',
        width: '100%', maxWidth: 520, padding: 22,
        maxHeight: '80vh', overflow: 'auto',
      }}>
        <div style={{
          fontFamily: isBrut ? '"IBM Plex Mono", monospace' : fonts.display,
          fontSize: 18, fontWeight: isBrut ? 800 : 600,
          letterSpacing: isBrut ? '0.06em' : '-0.01em',
          textTransform: isBrut ? 'uppercase' : 'none',
          color: c.textPrimary, marginBottom: 6,
        }}>{t('week.pick_title')}</div>
        <div style={{
          fontFamily: fonts.body, fontSize: 12, color: c.textTertiary,
          marginBottom: 14, fontStyle: isBrut ? 'normal' : 'italic',
        }}>{t('week.pick_subtitle')}</div>

        {empty ? (
          <div style={{
            padding: '14px 12px',
            border: isBrut ? `1.5px dashed ${ink}` : 'none',
            fontFamily: fonts.body, fontSize: 13, color: c.textTertiary,
          }}>{t('week.pick_empty')}</div>
        ) : (
          <>
            <Section label={t('week.pick_section_goals')}  items={byKind.goals} />
            <Section label={t('week.pick_section_habits')} items={byKind.habits} />
            <Section label={t('week.pick_section_todos')}  items={byKind.todos} />
          </>
        )}

        <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
          <button onClick={onClose} style={{
            padding: isBrut ? '8px 14px' : '8px 14px',
            borderRadius: isBrut ? 2 : 8,
            background: isBrut ? c.bg : c.accent,
            border: isBrut ? `1.5px solid ${ink}` : 'none',
            boxShadow: isBrut ? `2px 2px 0 0 ${ink}` : 'none',
            color: isBrut ? ink : c.textOnAccent,
            fontFamily: fonts.body, fontSize: isBrut ? 11 : 13,
            fontWeight: 800,
            letterSpacing: isBrut ? '0.08em' : 0,
            textTransform: isBrut ? 'uppercase' : 'none',
            cursor: 'pointer',
          }}>{t('week.pick_done')}</button>
        </div>
      </div>
    </div>
  );
}

function weekNavBtn(c, isBrut, ink, primary = false) {
  return {
    padding: isBrut ? '5px 10px' : '4px 10px',
    borderRadius: isBrut ? 2 : 6,
    background: primary ? (isBrut ? c.bg : c.accent) : 'transparent',
    border: isBrut ? `1.5px solid ${ink}` : `1px solid ${primary ? hexToRgba(c.accent, 0.4) : c.borderDefault}`,
    boxShadow: isBrut && primary ? `2px 2px 0 0 ${ink}` : 'none',
    color: primary ? (isBrut ? ink : c.textOnAccent) : (isBrut ? ink : c.textSecondary),
    fontFamily: '"IBM Plex Mono", monospace',
    fontSize: 10,
    fontWeight: 800,
    letterSpacing: '0.08em',
    textTransform: 'uppercase',
    cursor: 'pointer',
  };
}

function textareaStyle(c, fonts, isBrut, ink) {
  return {
    width: '100%',
    padding: isBrut ? 12 : 12,
    borderRadius: isBrut ? 2 : 8,
    background: isBrut ? c.bg : c.bgSubtle,
    border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
    color: c.textPrimary,
    fontFamily: fonts.body, fontSize: 13, lineHeight: 1.55,
    outline: 'none', resize: 'vertical', minHeight: 64,
    boxSizing: 'border-box',
  };
}

// Coach private notes per client. Stored at /coach_private/{coachId}/clients/{userUid}/notes.
// Existing rule grants the entire /coach_private/{coachId}/* subtree to the
// coach only — client literally cannot read it because rules don't permit it.
function CoachNotes({ client }) {
  const { c, fonts, t, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  const [text, setText] = useState('');
  const [hydrated, setHydrated] = useState(false);
  const [status, setStatus] = useState('idle'); // idle|saving|saved|error
  const dirtyRef = React.useRef(false);
  const saveTimer = React.useRef(null);
  const lastSavedRef = React.useRef('');

  const coach = window.LWAuth && window.LWAuth.currentCoach && window.LWAuth.currentCoach();
  const coachUid = coach && coach.uid;
  const userUid = client && client.userUid;

  // Hydrate once per (coachUid, userUid) pair.
  useEffect(() => {
    if (!coachUid || !userUid) return;
    const ref = window.LWFB.db.ref(`/coach_private/${coachUid}/clients/${userUid}/notes`);
    ref.get().then(snap => {
      const v = snap.val();
      const initial = (v && typeof v === 'object' && typeof v.text === 'string') ? v.text : (typeof v === 'string' ? v : '');
      setText(initial);
      lastSavedRef.current = initial;
      setHydrated(true);
    }).catch(() => setHydrated(true));
  }, [coachUid, userUid]);

  const flushSave = React.useCallback(async (value) => {
    if (!coachUid || !userUid) return;
    if (value === lastSavedRef.current) return;
    setStatus('saving');
    try {
      await window.LWFB.db.ref(`/coach_private/${coachUid}/clients/${userUid}/notes`).set({
        text: value,
        updatedAt: Date.now(),
      });
      lastSavedRef.current = value;
      dirtyRef.current = false;
      setStatus('saved');
      // Fade the "saved" pill back to idle after 1.6s.
      setTimeout(() => setStatus(s => s === 'saved' ? 'idle' : s), 1600);
    } catch (err) {
      console.warn('[coach-notes] save failed', err);
      setStatus('error');
    }
  }, [coachUid, userUid]);

  const onChange = (e) => {
    const next = e.target.value;
    setText(next);
    dirtyRef.current = true;
    if (saveTimer.current) clearTimeout(saveTimer.current);
    saveTimer.current = setTimeout(() => flushSave(next), 800);
  };
  const onBlur = () => {
    if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
    if (dirtyRef.current) flushSave(text);
  };

  // Status pill copy
  const statusLabel = status === 'saving' ? t('client.notes_saving')
                    : status === 'saved'  ? t('client.notes_saved')
                    : status === 'error'  ? t('client.notes_error')
                    : '';
  const statusColor = status === 'error' ? c.flame
                    : status === 'saved' ? c.accent
                    :                       c.textTertiary;

  return (
    <div style={{
      padding: isBrut ? 14 : 16,
      background: isBrut ? c.bg : c.card,
      border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
      boxShadow: isBrut ? `2px 2px 0 0 ${ink}` : 'none',
      borderRadius: isBrut ? 2 : 12,
      display: 'flex', flexDirection: 'column', gap: 8,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
        <div style={{
          fontFamily: fonts.body, fontSize: 10, fontWeight: 800,
          letterSpacing: isBrut ? '0.10em' : '0.14em', textTransform: 'uppercase',
          color: isBrut ? ink : c.textTertiary,
          background: isBrut ? c.accent : 'transparent',
          padding: isBrut ? '2px 8px' : 0,
          border: isBrut ? `1.5px solid ${ink}` : 'none',
          borderRadius: isBrut ? 2 : 0,
        }}>
          {t('client.notes_private_label')}
        </div>
        {statusLabel && (
          <div style={{
            fontFamily: fonts.body, fontSize: 10, fontWeight: 700,
            letterSpacing: '0.08em', textTransform: 'uppercase',
            color: statusColor,
          }}>
            {statusLabel}
          </div>
        )}
      </div>
      <textarea
        value={text}
        onChange={onChange}
        onBlur={onBlur}
        disabled={!hydrated || !coachUid || !userUid}
        rows={6}
        placeholder={t('client.notes_placeholder')}
        style={{
          width: '100%',
          padding: isBrut ? 10 : 12,
          borderRadius: isBrut ? 2 : 8,
          background: isBrut ? c.bg : c.bgSubtle,
          border: isBrut ? `1.5px solid ${ink}` : `1px solid ${c.borderSubtle}`,
          color: c.textPrimary, fontFamily: fonts.body, fontSize: 13, lineHeight: 1.55,
          outline: 'none', resize: 'vertical', minHeight: 120,
          boxSizing: 'border-box',
        }}
      />
      <div style={{
        fontFamily: fonts.body, fontSize: 11,
        color: isBrut ? hexToRgba(ink, 0.6) : c.textTertiary,
        fontStyle: isBrut ? 'normal' : 'italic',
        fontWeight: isBrut ? 600 : 400,
        letterSpacing: isBrut ? '0.02em' : 0,
      }}>
        {t('client.notes_hint')}
      </div>
    </div>
  );
}

function PingsLog({ client, onCompose }) {
  const { c, fonts, t, brutMode } = useLW();
  const isBrut = !!brutMode;
  const ink = c.ink || c.textPrimary;
  // Real-mode pings come from /coach_pings/{userUid} subscription. Demo-mode
  // falls back to the static CLIENT_EXTRAS for the design walkthrough.
  const real = client._real;
  const realPings = real ? (real.pings || []) : null;
  const extras = CLIENT_EXTRAS[client.id] || {};
  const pings = realPings
    ? realPings.map(p => ({
        ts: relPingTime(p.ts, t),
        text: p.text,
      }))
    : (extras.pingsLog || []);
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: isBrut ? 8 : 0 }}>
      {pings.map((p, i) => (
        <div key={i} style={{
          padding: isBrut ? '12px 14px' : '12px 0',
          background: isBrut ? c.bg : 'transparent',
          border: isBrut ? `1.5px solid ${ink}` : 'none',
          boxShadow: isBrut ? `2px 2px 0 0 ${ink}` : 'none',
          borderBottom: isBrut
            ? `1.5px solid ${ink}`
            : (i < pings.length - 1 ? `1px solid ${c.borderSubtle}` : 'none'),
        }}>
          <div style={{
            fontFamily: fonts.body, fontSize: 11,
            fontWeight: isBrut ? 800 : 700,
            letterSpacing: isBrut ? '0.10em' : '0.14em',
            textTransform: 'uppercase',
            color: isBrut ? ink : c.textTertiary,
            marginBottom: 5,
          }}>
            {p.ts}
          </div>
          <div style={{
            fontFamily: fonts.body,
            fontStyle: isBrut ? 'normal' : 'italic',
            fontSize: 13, lineHeight: 1.5,
            color: isBrut ? c.textPrimary : c.textSecondary,
            fontWeight: isBrut ? 600 : 400,
            paddingLeft: 10,
            borderLeft: isBrut
              ? `3px solid ${c.accent || ink}`
              : `2px solid ${hexToRgba(c.accent, 0.4)}`,
          }}>
            {isBrut ? p.text : `“${p.text}”`}
          </div>
        </div>
      ))}
      {pings.length === 0 && (
        <div style={{
          fontFamily: fonts.body,
          fontStyle: isBrut ? 'normal' : 'italic',
          fontSize: 13,
          color: isBrut ? ink : c.textTertiary,
          padding: isBrut ? '14px 12px' : '12px 0',
          border: isBrut ? `1.5px dashed ${ink}` : 'none',
        }}>
          {real ? t('client.ping_empty') : t('client.no_pings')}
        </div>
      )}
      <button onClick={onCompose} style={{
        marginTop: 10,
        padding: isBrut ? '11px 14px' : '10px 14px',
        borderRadius: isBrut ? 2 : 9,
        background: 'transparent',
        border: isBrut ? `1.5px dashed ${ink}` : `1px dashed ${c.borderDefault}`,
        color: isBrut ? ink : c.textSecondary,
        cursor: 'pointer',
        fontFamily: fonts.body,
        fontSize: isBrut ? 11 : 13,
        fontWeight: isBrut ? 800 : 400,
        letterSpacing: isBrut ? '0.08em' : 0,
        textTransform: isBrut ? 'uppercase' : 'none',
        textAlign: 'left',
      }}>
        + {t('client.compose_ping')}
      </button>
    </div>
  );
}

// Relative-time formatter for ping rows. Shorter than the outbox version —
// the ping list is dense, no "X min ago" / "X h ago" — just "today" / "вчера" / "5 May".
// Kept distinct from dashboard.jsx's `relTimeShort(t, ms)` — both files declare
// top-level functions that collide on the window scope when babel-standalone
// concatenates them.
function relPingTime(ms, t) {
  if (!ms) return '';
  const today0 = new Date(); today0.setHours(0, 0, 0, 0);
  const then0 = new Date(Number(ms)); then0.setHours(0, 0, 0, 0);
  const days = Math.round((today0.getTime() - then0.getTime()) / 86_400_000);
  if (days <= 0) return t('client.outbox_just_now');
  if (days === 1) return t('client.outbox_day_ago', { n: 1 });
  return t('client.outbox_day_ago', { n: days });
}

// Compose modal — short text → /coach_pings/{userUid}/{pingId} write.
// CF picks it up on create and pushes via OneSignal.
function ComposePingSheet({ client, open, onClose }) {
  const { c, fonts, t } = useLW();
  const [text, setText] = useState('');
  const [state, setState] = useState('idle'); // idle|sending|sent|error
  useEffect(() => {
    if (open) { setText(''); setState('idle'); }
  }, [open]);
  if (!open) return null;
  const submit = async () => {
    const trimmed = text.trim();
    if (!trimmed || state === 'sending') return;
    setState('sending');
    try {
      const coach = window.LWAuth.currentCoach && window.LWAuth.currentCoach();
      const coachName = (coach && coach.profile && coach.profile.displayName) || (coach && coach.email) || '';
      const ref = window.LWFB.db.ref(`/coach_pings/${client.userUid}`).push();
      await ref.set({
        text: trimmed,
        fromCoachId: coach.uid,
        fromCoachName: coachName,
        ts: Date.now(),
      });
      setState('sent');
      setTimeout(onClose, 700);
    } catch (e) {
      console.warn('[ping] send failed', e);
      setState('error');
    }
  };
  return (
    <div onClick={state === 'sending' ? undefined : onClose} style={{
      position: 'fixed', inset: 0, zIndex: 100,
      background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(6px)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      padding: 20,
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        background: c.card, border: `1px solid ${c.borderDefault}`,
        borderRadius: 14,
        width: '100%', maxWidth: 480, padding: 22,
        boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
      }}>
        <h3 style={{ margin: 0, fontFamily: fonts.display, fontSize: 22, fontWeight: 600, color: c.textPrimary }}>
          {t('client.ping_compose_title')}
        </h3>
        <div style={{ marginTop: 6, fontFamily: fonts.body, fontSize: 13, color: c.textTertiary }}>
          {t('client.ping_compose_hint')}
        </div>
        <textarea
          value={text}
          onChange={(e) => setText(e.target.value)}
          autoFocus
          rows={4}
          maxLength={400}
          placeholder={t('client.ping_compose_placeholder')}
          disabled={state === 'sending'}
          style={{
            width: '100%', marginTop: 14,
            padding: 12,
            background: c.bgSubtle, border: `1px solid ${c.borderSubtle}`, borderRadius: 10,
            color: c.textPrimary, fontFamily: fonts.body, fontSize: 15, lineHeight: 1.5,
            resize: 'vertical', minHeight: 80, outline: 'none',
            boxSizing: 'border-box',
          }}
        />
        {state === 'error' && (
          <div style={{
            marginTop: 10, padding: '8px 12px', borderRadius: 8,
            background: hexToRgba(c.flame, 0.10), border: `1px solid ${hexToRgba(c.flame, 0.3)}`,
            color: c.flame, fontFamily: fonts.body, fontSize: 13,
          }}>{t('client.ping_err')}</div>
        )}
        <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 14 }}>
          <Button variant="ghost" size="md" onClick={onClose} disabled={state === 'sending'}>
            {t('client.ping_cancel')}
          </Button>
          <Button variant="primary" size="md" onClick={submit} disabled={state === 'sending' || !text.trim()}>
            {state === 'sending' ? t('client.ping_sending')
             : state === 'sent' ? t('client.ping_sent')
             : t('client.ping_send')}
          </Button>
        </div>
      </div>
    </div>
  );
}

// ── Page ──
function ClientDetail({ routeParams }) {
  const { c: cBase, fonts: fontsBase, t } = useLW();
  // Brutalism theme override (cream/dark) — same as Dashboard
  const [brutMode, setBrutMode] = window.LWBrutal.useBrutMode();
  const c = window.LWBrutal.makeC(cBase, brutMode);
  const fonts = { ...fontsBase, ...window.LWBrutal.fonts };
  window.__brutMode = brutMode; window.__brutSetMode = setBrutMode;
  window.__brutC = c; window.__brutFonts = fonts;
  const { isMobile, isTablet } = useViewport();
  const stackBody = isMobile || isTablet;
  const realState = useRealClient(routeParams);
  const [prepOpen, setPrepOpen] = useState(() => getInitialPrepView());
  const [bookOpen, setBookOpen] = useState(false);
  const [pingOpen, setPingOpen] = useState(false);
  const PrepBriefComp = window.PrepBrief;
  const NewSessionSheetComp = window.NewSessionSheet;

  if (realState.status === 'loading') return <ClientLoadingShell />;
  if (realState.status === 'unauthorized') return <ClientUnauthorizedShell />;
  const client = realState.client;
  const isReal = realState.status === 'ready';
  // Self-preview: routeParams.id === auth.uid means the coach is viewing
  // themselves to see what their coach sees of them.
  const authedUid = (window.LWAuth && window.LWAuth.currentCoach && window.LWAuth.currentCoach()) ? window.LWAuth.currentCoach().uid : null;
  const isSelfPreview = client && authedUid && client.userUid === authedUid;
  const previewCoachName = (realState && realState.client && realState.client.linkCoachName) || (client && client.coachName) || '';
  const isRu = (window.LWLang ? window.LWLang.lang() : 'en') === 'ru';
  const ink = c.ink || c.textPrimary;

  return (
    <PageShell active="dashboard">
      {prepOpen && PrepBriefComp && (
        <PrepBriefComp client={client} onClose={() => {
          setPrepOpen(false);
          try {
            const url = new URL(window.location.href);
            url.searchParams.delete('view');
            window.history.replaceState({}, '', url.toString());
          } catch {}
        }} />
      )}
      {/* breadcrumb */}
      <div style={{ marginBottom: 18, fontFamily: fonts.body, fontSize: 13, color: c.textTertiary }}>
        <a href="Dashboard.html" style={{ color: c.textSecondary, textDecoration: 'none' }}>{t('client.breadcrumb_today')}</a>
        <span style={{ margin: '0 8px' }}>›</span>
        <span>{client.name}</span>
      </div>
      {isSelfPreview && (
        <div style={{
          marginBottom: 18,
          padding: '14px 16px',
          background: 'transparent',
          border: `1.5px solid ${ink}`,
          borderRadius: 2,
          boxShadow: `3px 3px 0 0 ${ink}`,
          display: 'flex', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap',
        }}>
          <span style={{
            flexShrink: 0,
            fontFamily: fonts.body, fontSize: 10, fontWeight: 800,
            letterSpacing: '0.18em', textTransform: 'uppercase',
            color: c.bg, background: ink,
            padding: '4px 8px',
          }}>{isRu ? '※ ПРЕВЬЮ' : '※ PREVIEW'}</span>
          <div style={{ flex: 1, minWidth: 200 }}>
            <div style={{
              fontFamily: fonts.body, fontSize: 13, fontWeight: 700,
              color: c.textPrimary, letterSpacing: '0.02em',
              textTransform: 'uppercase', marginBottom: 4,
            }}>
              {previewCoachName
                ? (isRu ? `Так тебя видит ${previewCoachName}` : `What ${previewCoachName} sees`)
                : (isRu ? 'Так тебя видит твой коуч' : 'What your coach sees')}
            </div>
            <div style={{
              fontFamily: fonts.body, fontSize: 12, color: c.textSecondary, lineHeight: 1.5,
            }}>
              {isRu
                ? 'Эта страница рендерится по тем же фильтрам приватности, по которым её видит твой коуч. Ты можешь поменять, что отдаёшь, в настройках приватности приложения.'
                : 'Rendered with the exact privacy filters your coach sees. Adjust what they see from privacy settings in the app.'}
            </div>
          </div>
        </div>
      )}

      <ClientHero
        client={client}
        onOpenPrep={() => setPrepOpen(true)}
        onOpenBook={() => setBookOpen(true)}
        onOpenPing={() => setPingOpen(true)}
      />
      {pingOpen && client.userUid && (
        <ComposePingSheet
          client={client}
          open={pingOpen}
          onClose={() => setPingOpen(false)}
        />
      )}
      {bookOpen && NewSessionSheetComp && (
        <NewSessionSheetComp
          open={bookOpen}
          onClose={() => setBookOpen(false)}
          prefill={(() => {
            // Default to tomorrow at 10:00 local. Coach can pick another slot
            // by jumping to the Calendar; this sheet lets them confirm in one tap.
            const d = new Date();
            d.setDate(d.getDate() + 1);
            d.setHours(10, 0, 0, 0);
            return d;
          })()}
          roster={client.userUid ? { [client.userUid]: { displayName: client.name, avatarHue: client.avatarHue } } : null}
        />
      )}

      {/* Weekly plan hero — shared coach + client surface. Sits at the top
          so coach lands on "what are we doing this week" before the deep
          data. Both sides write; past weeks are read-only. */}
      <div data-section="week" style={{ scrollMarginTop: 24, marginTop: 24 }}>
        <SectionHead eyebrow={t('week.section_eyebrow', { name: (client.name || '').split(' ')[0] })} title={t('week.section_title')} accent={t('week.section_accent')}
          style={{ marginBottom: 8 }} />
        <WeeklyPlan client={client} />
      </div>

      <div style={{ height: 28 }} />
      <SinceLastSession client={client} />

      {/* Private coach notes — pinned high so the coach can jot/scan strategy
          before scrolling into the data. Client never sees this (RTDB rule
          restricts /coach_private/{coachId}/* to the coach only). */}
      <div data-section="notes" style={{ scrollMarginTop: 24, marginTop: 24 }}>
        <SectionHead eyebrow={t('client.section_notes_eyebrow')} title={t('client.section_notes')} accent={t('client.section_notes_accent')}
          style={{ marginBottom: 8 }} />
        <CoachNotes client={client} />
      </div>

      <div style={{ height: 32 }} />
      <SectionHead eyebrow={t('client.section_wheel_eyebrow')} title={t('client.section_wheel')} accent={t('client.section_wheel_accent')} />
      <WheelSection client={client} />

      <div style={{ height: 32 }} />
      <div data-section="goals" style={{ scrollMarginTop: 24 }}>
        <SectionHead eyebrow={t('client.section_goals_eyebrow')} title={t('client.section_goals')} accent={t('client.section_goals_accent')} />
        <GoalsTasks client={client} />
      </div>

      <div style={{ height: 32 }} />
      <SectionHead eyebrow={t('client.section_mood_eyebrow')} title={t('client.section_mood')} accent={t('client.section_mood_accent')} />
      <MoodHabitsPanel client={client} />

      <div style={{ height: 40 }} />
      <div style={{ display: 'grid', gridTemplateColumns: stackBody ? '1fr' : 'minmax(0, 1fr) 360px', gap: 28 }}>
        <div>
          <SectionHead eyebrow={t('client.section_journal_eyebrow')} title={t('client.section_journal')} accent={t('client.section_journal_accent')} />
          <JournalFeed client={client} />
        </div>
        <div>
          <div data-section="outbox" style={{ scrollMarginTop: 24 }}>
            <SectionHead eyebrow={t('client.section_outbox_eyebrow')} title={t('client.section_outbox')} accent={t('client.section_outbox_accent')}
              style={{ marginBottom: 8 }} />
            <OutboxFeed client={client} />
          </div>

          <div style={{ height: 28 }} />
          <SectionHead eyebrow={t('client.section_history_eyebrow')} title={t('client.section_history')} accent={t('client.section_history_accent')}
            style={{ marginBottom: 8 }} />
          <SessionLog client={client} />

          <div style={{ height: 28 }} />
          <SectionHead eyebrow={t('client.section_pings_eyebrow')} title={t('client.section_pings')} accent={t('client.section_pings_accent')}
            style={{ marginBottom: 8 }} />
          <PingsLog client={client} onCompose={() => setPingOpen(true)} />
        </div>
      </div>
    </PageShell>
  );
}

function ClientLoadingShell() {
  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;
  return (
    <PageShell active="dashboard">
      <div style={{
        marginBottom: 18,
        fontFamily: isBrut ? fonts.mono : fonts.body,
        fontSize: 11, color: c.textTertiary,
        letterSpacing: isBrut ? '0.10em' : 'normal',
        textTransform: isBrut ? 'uppercase' : 'none',
      }}>
        <a href="#/dashboard" style={{ color: c.textSecondary, textDecoration: 'none' }}>{t('client.breadcrumb_today')}</a>
        <span style={{ margin: '0 8px' }}>›</span>
        <span>…</span>
      </div>
      <div style={{
        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 ? `—— ${t('client.loading')} ——` : t('client.loading')}
      </div>
    </PageShell>
  );
}

function ClientUnauthorizedShell() {
  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;
  return (
    <PageShell active="dashboard">
      <div style={{
        marginBottom: 18,
        fontFamily: isBrut ? fonts.mono : fonts.body,
        fontSize: 11, color: c.textTertiary,
        letterSpacing: isBrut ? '0.10em' : 'normal',
        textTransform: isBrut ? 'uppercase' : 'none',
      }}>
        <a href="#/dashboard" style={{ color: c.textSecondary, textDecoration: 'none' }}>{t('client.breadcrumb_today')}</a>
        <span style={{ margin: '0 8px' }}>›</span>
        <span>—</span>
      </div>
      <div style={{
        padding: 36,
        borderRadius: isBrut ? 2 : 16,
        background: c.card,
        border: isBrut ? `1.5px solid ${c.ink}` : `1px solid ${c.borderSubtle}`,
        boxShadow: isBrut ? `6px 6px 0 0 ${c.ink}` : 'none',
      }}>
        <h2 style={{
          fontFamily: isBrut ? fonts.mono : fonts.display,
          fontSize: isBrut ? 22 : 24,
          fontWeight: isBrut ? 700 : 600,
          lineHeight: 1.15, letterSpacing: '-0.01em',
          color: c.textPrimary, margin: 0, marginBottom: 8,
          textTransform: isBrut ? 'uppercase' : 'none',
        }}>{t('client.unauthorized_title')}</h2>
        <p style={{
          fontFamily: isBrut ? fonts.mono : fonts.display,
          fontStyle: isBrut ? 'italic' : 'italic',
          fontSize: isBrut ? 13 : 15,
          color: c.textSecondary, margin: 0, marginBottom: 22,
          lineHeight: 1.55,
          ...(isBrut ? { borderLeft: `2px solid ${c.ink}`, paddingLeft: 14 } : {}),
        }}>
          {t('client.unauthorized_body')}
        </p>
        <a href="#/clients" className={isBrut ? 'br-cta' : ''} style={isBrut ? {
          display: 'inline-block', padding: '11px 18px', fontSize: 12,
          textDecoration: 'none',
        } : {
          display: 'inline-block', padding: '10px 18px', borderRadius: 10,
          background: c.accent, color: c.textOnAccent, textDecoration: 'none',
          fontFamily: fonts.app, fontSize: 14, fontWeight: 700, letterSpacing: '0.01em',
        }}>
          {isBrut ? `[ ← ${(t('client.back_to_dashboard') || 'Back to dashboard').toUpperCase()} ]` : t('client.back_to_dashboard')}
        </a>
      </div>
    </PageShell>
  );
}

window.LWClientDetail = ClientDetail;
window.sphereScoresFromAny = sphereScoresFromAny;
window.areaTitlesFromAny = areaTitlesFromAny;
// Exposed for feed.jsx (cross-client activity timeline) to reuse the same
// helpers without duplicating logic.
window.LikeButton = LikeButton;
window.isCoachShared = isCoachShared;
window.isIosHabit = isIosHabit;
