'use strict';
// ═══════════════════════════════════════════════
// js/dashboard.js -- dashboard: stats, activity feed, weekly log, profiles
// ═══════════════════════════════════════════════

function ciBadgeClass(ci) { return ''; }

// Remote device's current active timer state (received via broadcastActivityToDevices)
let _remoteActivity = null;

// ── Timer device selection ──────────────────────────────────────────────────
function _getTimerDeviceSel() {
  try { return JSON.parse(localStorage.getItem('frj_timer_device_sel') || '{}'); } catch(e) { return {}; }
}
function _setTimerDeviceSel(type, device) {
  const sel = _getTimerDeviceSel();
  const key = type === 'tugging' ? 'tug' : 'ret';
  sel[key] = (sel[key] === device) ? null : device; // click again to deselect
  localStorage.setItem('frj_timer_device_sel', JSON.stringify(sel));
}
function selectTimerDevice(type, device) {
  _setTimerDeviceSel(type, device);
  _renderDashTimer();
  // If a timer of this type is active, re-broadcast with updated device to buddies + own devices
  const active = typeof getActiveTimer === 'function' ? getActiveTimer() : null;
  if (active && !active.stopped && active.type === type) {
    const _dsel = _getTimerDeviceSel();
    const _dev = _dsel[type === 'tugging' ? 'tug' : 'ret'] || null;
    if (typeof p2pBroadcastBuddyTimerStatus === 'function') {
      const _todayMs = typeof getTodayTimerTotals === 'function' ? getTodayTimerTotals() : { tugMs: 0, retMs: 0 };
      p2pBroadcastBuddyTimerStatus({ type, startedAt: active.startedAt, id: active.id, device: _dev, todayTugMs: _todayMs.tugMs, todayRetMs: _todayMs.retMs });
    }
    if (typeof broadcastActivityToDevices === 'function') broadcastActivityToDevices({ ...active, device: _dev });
  }
}

function _renderDashTimerRemote(activity) {
  _remoteActivity = activity;
  _renderDashTimer();
  if (activity) _timerRemoteFlash();
}

function _timerRemoteFlash() {
  const h = document.querySelector('#dash-timer .timer-hero');
  if (!h) return;
  h.classList.remove('live-update');
  void h.offsetWidth; // force reflow so animation restarts
  h.classList.add('live-update');
}

let _renderDashRaf = 0;
let _renderProfileRaf   = 0;
let _renderProfileTimer = null;

function renderDash() {
  cancelAnimationFrame(_renderDashRaf);
  _renderDashRaf = requestAnimationFrame(_doRenderDash);
}

function _doRenderDash() {
  _renderDashRaf = 0;
  // Eagerly cache local device ID for stale-timer detection
  if (!_localDeviceId && typeof getDeviceId === 'function') {
    getDeviceId().then(id => { _localDeviceId = id; }).catch(() => {});
  }
  // Don't rebuild timer section if user is actively editing -- form would be destroyed
  if (!_editingSessionId && !_addingSession) _renderDashTimer();
  _renderDashWelcome();
  _renderBackupNudge();
  _renderDashQuickLog();
  _renderDashDailyTracker();
  _renderDashProfile();
  const af = document.getElementById('dash-activity-feed');
  if (af) af.innerHTML = '';
}

// ── Daily Tracker (collapsible, collapsed by default, below Weekly Tugging Log) ─
function _renderDashDailyTracker() {
  const el = document.getElementById('dash-daily-tracker');
  if (!el) return;
  try {
    const _dtSessions = typeof getTimerSessions === 'function' ? getTimerSessions() : [];
    const _dtByDay = {};
    const _dtToday = _localTodayStr();
    _dtSessions.filter(s => s.endedAt && s.durationMs > 0).forEach(s => {
      const startDate  = new Date(s.startedAt);
      const startDayStr = _localDateStr(s.startedAt);
      const endDayStr   = _localDateStr(s.endedAt);
      const _addSeg = (dayStr, startMin, endMin) => {
        if (dayStr === _dtToday) return;
        if (!_dtByDay[dayStr]) _dtByDay[dayStr] = { date: dayStr, sessions: [] };
        _dtByDay[dayStr].sessions.push({ type: s.type, startMin: Math.max(0, startMin), endMin: Math.min(1440, endMin) });
      };
      const startMidnight = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()).getTime();
      const startMin = (s.startedAt - startMidnight) / 60000;
      if (startDayStr === endDayStr) {
        _addSeg(startDayStr, startMin, startMin + s.durationMs / 60000);
      } else {
        _addSeg(startDayStr, startMin, 1440);
        const endDate = new Date(s.endedAt);
        const endMidnight = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime();
        _addSeg(endDayStr, 0, (s.endedAt - endMidnight) / 60000);
      }
    });
    const _dtDays = Object.values(_dtByDay)
      .sort((a, b) => b.date.localeCompare(a.date))
      .slice(0, 21);
    if (!_dtDays.length) { el.innerHTML = ''; return; }
    const _dtSvg = _dtDays[0]?.sessions ? _activityTimelineSvg(_dtDays) : _activityBarSvg(_dtDays);
    // Persist open/closed state across re-renders
    const _dtOpen = localStorage.getItem('frj_panel_dailytracker') === '1';
    el.innerHTML = `<details class="panel"${_dtOpen ? ' open' : ''} id="dash-daily-tracker-details">
      <summary class="ph" style="cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;outline:none">
        <h2 style="margin:0;flex:1">Daily tracker</h2>
        <span style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${_dtOpen ? 'collapse' : 'expand'}</span>
      </summary>
      <div class="pb" style="padding:10px 14px">${_dtSvg}</div>
    </details>`;
    // Persist state on toggle
    const det = el.querySelector('#dash-daily-tracker-details');
    if (det) {
      det.addEventListener('toggle', () => {
        localStorage.setItem('frj_panel_dailytracker', det.open ? '1' : '0');
        const lbl = det.querySelector('summary span');
        if (lbl) lbl.textContent = det.open ? 'collapse' : 'expand';
      });
    }
  } catch(e) { console.warn('[dash] daily tracker render failed:', e); el.innerHTML = ''; }
}

// ── Tugging / Retaining Timer ────────────────────────────────────────────────

let _timerInterval = null;
let _buddyTimerInterval = null;
let _localDeviceId = null; // cached for render (async getDeviceId fills this)

function _fmtElapsed(ms) {
  const s = Math.floor(ms / 1000);
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const sec = s % 60;
  if (h > 0) return `${h}h ${String(m).padStart(2,'0')}m ${String(sec).padStart(2,'0')}s`;
  return `${m}m ${String(sec).padStart(2,'0')}s`;
}

function _fmtDuration(ms) {
  const s = Math.floor(ms / 1000);
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  if (h > 0) return `${h}h ${m}m`;
  return `${m}m`;
}

function _fmtTime(ts) {
  const d = new Date(ts);
  return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

function _fmtDateShort(ts) {
  const d = new Date(ts);
  const today = new Date().toDateString();
  if (d.toDateString() === today) return 'Today';
  const yesterday = new Date(Date.now() - 86400000).toDateString();
  if (d.toDateString() === yesterday) return 'Yesterday';
  return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}

function _startTimerTick() {
  clearInterval(_timerInterval);
  _timerInterval = setInterval(() => {
    const active = getActiveTimer();
    const display = active || _remoteActivity;
    if (!display || !display.startedAt) { clearInterval(_timerInterval); _timerInterval = null; return; }
    const el = document.getElementById('timer-elapsed');
    if (el) el.textContent = _fmtElapsed(Date.now() - display.startedAt);
  }, 1000);
}

// Re-renders the profile section every 60s while any buddy has an active timer,
// so the live "Today: Xm tugged" elapsed time ticks forward on their profile row.
function _syncBuddyProfileTick() {
  const hasActiveBuddy = typeof getBuddyTimerStatus === 'function'
    && (typeof getContacts === 'function' ? getContacts() : []).some(c => {
      const s = getBuddyTimerStatus(c.pubKeyB64);
      return s && s.type && s.startedAt;
    });
  if (hasActiveBuddy) {
    if (!_buddyTimerInterval) {
      _buddyTimerInterval = setInterval(() => {
        const stillActive = typeof getBuddyTimerStatus === 'function'
          && (typeof getContacts === 'function' ? getContacts() : []).some(c => {
            const s = getBuddyTimerStatus(c.pubKeyB64);
            return s && s.type && s.startedAt;
          });
        if (!stillActive) { clearInterval(_buddyTimerInterval); _buddyTimerInterval = null; return; }
        _renderDashProfile();
      }, 60000);
    }
  } else {
    if (_buddyTimerInterval) { clearInterval(_buddyTimerInterval); _buddyTimerInterval = null; }
  }
}

async function startTimer(type) {
  // Resolve device ID before any state check so isOtherDevice is accurate
  if (!_localDeviceId && typeof getDeviceId === 'function') {
    try { _localDeviceId = await getDeviceId(); } catch(e) {}
  }

  const active = getActiveTimer();
  if (active && !active.stopped) {
    const ageMs = Date.now() - active.startedAt;
    if (ageMs > 12 * 3600000) {
      // Stale timer -- user tapping Start means "discard and begin fresh"
      // (Save path is still available via the amber panel's "Save & Stop here" button)
      const staleStop = { id: active.id, stopped: true, changedAt: Date.now() };
      setActiveTimer(staleStop);
      // Broadcast so paired device doesn't resurrect this stale timer on next sync
      if (typeof broadcastActivityToDevices === 'function') broadcastActivityToDevices(staleStop, null);
    } else if (active.type === type) {
      // Same type, recent -- normal toggle-off
      stopTimer();
      return;
    } else {
      // Different type -- silent stop
      stopTimer(true);
    }
  }

  const now = Date.now();
  const timer = { id: uuid(), type, startedAt: now, changedAt: now };
  // Tag with device ID if available (for cross-device display)
  if (typeof getDeviceId === 'function') {
    try { timer.deviceId = await getDeviceId(); _localDeviceId = timer.deviceId; } catch(e) {}
  }
  setActiveTimer(timer);
  _startTimerTick();
  _renderDashTimer();
  _updateBuddyTimerBadge(null); // update own timer badge without full panel rewrite
  // Broadcast activity to own paired devices (real-time)
  if (typeof broadcastActivityToDevices === 'function') broadcastActivityToDevices(timer);
  // Broadcast live status to buddies (ephemeral) -- include today's accumulated hours + device
  if (typeof p2pBroadcastBuddyTimerStatus === 'function') {
    const _todayMs = getTodayTimerTotals();
    const _dsel = _getTimerDeviceSel();
    const _device = _dsel[type === 'tugging' ? 'tug' : 'ret'] || null;
    p2pBroadcastBuddyTimerStatus({ type, startedAt: now, id: timer.id, device: _device, todayTugMs: _todayMs.tugMs, todayRetMs: _todayMs.retMs });
  }
  if (typeof p2pPushBuddySnapshot === 'function') p2pPushBuddySnapshot();
}

function stopTimer(silent) {
  const active = getActiveTimer();
  if (!active || active.stopped) return;
  const now = Date.now();
  const durationMs = now - active.startedAt;
  // Skip saving if a session with this ID already exists (stopped on another device)
  const sessions = getTimerSessions();
  const alreadySaved = active.id && sessions.some(s => s.id === active.id);
  let savedSession = null;
  // Only save sessions longer than 5 seconds and not already saved
  if (durationMs > 5000 && !alreadySaved) {
    savedSession = {
      id: active.id,
      type: active.type,
      startedAt: active.startedAt,
      endedAt: now,
      durationMs,
      note: ''
    };
    if (active.deviceId) savedSession.deviceId = active.deviceId;
    const _dsel = _getTimerDeviceSel();
    const _dkey = active.type === 'tugging' ? 'tug' : 'ret';
    if (_dsel[_dkey]) savedSession.device = _dsel[_dkey];
    sessions.push(savedSession);
    saveTimerSessions(sessions);
    if (savedSession.type === 'tugging' && typeof checkPeakMitosisToast === 'function') {
      checkPeakMitosisToast(sessions);
    }
  }
  // Write a stop marker with changedAt for LWW sync resolution
  setActiveTimer({ id: active.id, stopped: true, changedAt: now });
  clearInterval(_timerInterval);
  _timerInterval = null;
  if (!silent) {
    _renderDashTimer();
    _updateBuddyTimerBadge(null); // clear own timer badge without full panel rewrite
    if (durationMs > 5000 && !alreadySaved) {
      toast(`${active.type === 'tugging' ? 'Tugging' : 'Retaining'} session: ${_fmtDuration(durationMs)}`);
    }
  }
  // Broadcast stop to own paired devices (real-time) -- includes session data
  // so the other device can save it immediately without waiting for batch sync
  if (typeof broadcastActivityToDevices === 'function') {
    broadcastActivityToDevices({ id: active.id, stopped: true, changedAt: now }, savedSession);
  }
  // Broadcast null status to buddies (timer stopped) -- include today's totals so they see final count
  if (typeof p2pBroadcastBuddyTimerStatus === 'function') {
    const _todayMs = getTodayTimerTotals();
    p2pBroadcastBuddyTimerStatus(_todayMs.tugMs || _todayMs.retMs
      ? { type: null, todayTugMs: _todayMs.tugMs, todayRetMs: _todayMs.retMs }
      : null);
  }
  if (typeof p2pPushBuddySnapshot === 'function') p2pPushBuddySnapshot();
  // Auto-aggregate completed weeks after saving a session
  if (durationMs > 5000 && !alreadySaved) _autoAggregateWeeks();
  // Sync timer session data to paired devices (batch)
  if (durationMs > 5000 && !alreadySaved && typeof syncAfterSave === 'function') syncAfterSave();
}

// Clear an adopted timer without saving -- the other device already stopped it,
// session will arrive via batch sync. Uses the real id (fixes empty-id ghost bug).
function _dismissAdoptedTimer() {
  const active = getActiveTimer();
  if (!active) return;
  setActiveTimer({ id: active.id, stopped: true, changedAt: Date.now() });
  clearInterval(_timerInterval);
  _timerInterval = null;
  _renderDashTimer();
  // No broadcast -- other device already stopped, we're just clearing our display
}

// Discard a stale timer (>12h) without saving -- user explicitly says forget it.
function _discardStaleTimer() {
  const active = getActiveTimer();
  if (!active) return;
  const now = Date.now();
  setActiveTimer({ id: active.id, stopped: true, changedAt: now });
  clearInterval(_timerInterval);
  _timerInterval = null;
  _renderDashTimer();
  if (typeof broadcastActivityToDevices === 'function') {
    broadcastActivityToDevices({ id: active.id, stopped: true, changedAt: now }, null);
  }
}

function _deleteTimerSession(id) {
  confirm2('Delete session', 'Delete this timer session?', () => {
    saveTimerSessions(getTimerSessions().filter(s => s.id !== id));
    // Record tombstone so sync doesn't resurrect deleted session
    if (typeof recordSessionDeletion === 'function') recordSessionDeletion(id);
    if (typeof syncAfterSave === 'function') syncAfterSave();
    _renderDashTimer();
    _renderDashProfile();
  });
}

// Inline editing / adding state
let _editingSessionId = null;
let _addingSession = false;

function _showAddSessionForm() {
  _addingSession = true;
  _editingSessionId = null;
  _renderDashTimer();
}

function _cancelAddSession() {
  _addingSession = false;
  _renderDashTimer();
}

function _saveNewSession() {
  const typeEl = document.getElementById('new-session-type');
  const startEl = document.getElementById('new-session-start');
  const endEl = document.getElementById('new-session-end');
  const noteEl = document.getElementById('new-session-note');
  if (!typeEl || !startEl || !endEl) return;
  const type = typeEl.value;
  const startMs = new Date(startEl.value).getTime();
  const endMs = new Date(endEl.value).getTime();
  if (isNaN(startMs) || isNaN(endMs) || endMs <= startMs) {
    toast('End must be after start', true);
    return;
  }
  const durationMs = endMs - startMs;
  const session = {
    id: uuid(),
    type,
    startedAt: startMs,
    endedAt: endMs,
    durationMs,
    note: noteEl?.value || ''
  };
  const sessions = getTimerSessions();
  sessions.push(session);
  saveTimerSessions(sessions);
  if (typeof syncAfterSave === 'function') syncAfterSave();
  _addingSession = false;
  _autoAggregateWeeks();
  _renderDashTimer();
  _renderDashProfile();
  toast('Session added');
}

function _editTimerSession(id) {
  _editingSessionId = id;
  _renderDashTimer();
}

function _cancelEditSession() {
  _editingSessionId = null;
  _renderDashTimer();
}

function _saveEditSession(id) {
  const sessions = getTimerSessions();
  const idx = sessions.findIndex(s => s.id === id);
  if (idx < 0) return;
  const startInput = document.getElementById('edit-session-start');
  const endInput = document.getElementById('edit-session-end');
  const noteInput = document.getElementById('edit-session-note');
  if (startInput) {
    const startMs = new Date(startInput.value).getTime();
    if (!isNaN(startMs)) sessions[idx].startedAt = startMs;
  }
  if (endInput) {
    const endMs = new Date(endInput.value).getTime();
    if (!isNaN(endMs)) sessions[idx].endedAt = endMs;
  }
  if (noteInput) {
    sessions[idx].note = noteInput.value;
    sessions[idx].noteUpdatedAt = Date.now();
  }
  // Validate end > start
  if (sessions[idx].endedAt && sessions[idx].startedAt && sessions[idx].endedAt < sessions[idx].startedAt) {
    if (typeof toast === 'function') toast('End time must be after start time', true);
    return;
  }
  // Recalculate duration
  if (sessions[idx].endedAt && sessions[idx].startedAt) {
    sessions[idx].durationMs = sessions[idx].endedAt - sessions[idx].startedAt;
  }
  saveTimerSessions(sessions);
  if (typeof syncAfterSave === 'function') syncAfterSave();
  _editingSessionId = null;
  _renderDashTimer();
  _renderDashProfile();
}

// ── CSV Export/Import for Timer Sessions ──────────────────────────────────────

function _csvEscape(str) {
  if (str == null) return '""';
  const s = String(str);
  return '"' + s.replace(/"/g, '""') + '"';
}

function _fmtCsvDate(ts) {
  const d = new Date(ts);
  const pad = n => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

function _exportSessionsCsv() {
  const sessions = getTimerSessions().sort((a, b) => a.startedAt - b.startedAt);
  if (!sessions.length) { toast('No sessions to export', true); return; }
  const header = 'type,startedAt,endedAt,durationMinutes,device,note,id';
  const rows = sessions.map(s => [
    _csvEscape(s.type),
    _csvEscape(_fmtCsvDate(s.startedAt)),
    _csvEscape(_fmtCsvDate(s.endedAt)),
    _csvEscape(Math.round((s.durationMs || 0) / 60000)),
    _csvEscape(s.device || ''),
    _csvEscape(s.note || ''),
    _csvEscape(s.id || '')
  ].join(','));
  const csv = header + '\n' + rows.join('\n') + '\n';
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  const dateStr = new Date().toISOString().slice(0, 10);
  a.href = url;
  a.download = `forestorers-sessions-${dateStr}.csv`;
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 5000);
  toast(`Exported ${sessions.length} session${sessions.length !== 1 ? 's' : ''}`);
}

// Strict date parser: only accepts YYYY-MM-DD HH:MM or YYYY-MM-DD HH:MM:SS
// Returns ms timestamp in LOCAL time (not UTC) -- consistent across all browsers.
function _parseSessionDate(str) {
  if (typeof str !== 'string') return NaN;
  const m = str.trim().match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?$/);
  if (!m) return NaN;
  const [, yr, mo, dy, hr, mn, sc] = m;
  const d = new Date(+yr, +mo - 1, +dy, +hr, +mn, +(sc || 0));
  // Validate the date didn't roll over (e.g. Feb 30 → Mar 2)
  if (d.getFullYear() !== +yr || d.getMonth() !== +mo - 1 || d.getDate() !== +dy) return NaN;
  return d.getTime();
}

// RFC 4180 CSV row parser -- handles quoted fields, escaped quotes, commas inside quotes
function _parseCsvRow(line) {
  const fields = [];
  let i = 0, len = line.length;
  while (i <= len) {
    if (i === len) { fields.push(''); break; }
    if (line[i] === '"') {
      let val = '', j = i + 1;
      while (j < len) {
        if (line[j] === '"') {
          if (j + 1 < len && line[j + 1] === '"') { val += '"'; j += 2; }
          else { j++; break; }
        } else { val += line[j]; j++; }
      }
      fields.push(val);
      i = j + 1; // skip comma
    } else {
      const next = line.indexOf(',', i);
      if (next === -1) { fields.push(line.slice(i)); break; }
      fields.push(line.slice(i, next));
      i = next + 1;
    }
  }
  return fields;
}

function _importSessionsCsv() {
  const input = document.createElement('input');
  input.type = 'file';
  input.accept = '.csv,text/csv';
  input.onchange = async () => {
    const file = input.files?.[0];
    if (!file) return;
    // File size cap: 1 MB
    if (file.size > 1024 * 1024) { toast('File too large (max 1 MB)', true); return; }
    const text = await file.text();
    const lines = text.split(/\r?\n/).filter(l => l.trim());
    if (lines.length < 2) { toast('CSV file is empty or has no data rows', true); return; }

    // Parse header -- find column indices (flexible column order)
    const header = _parseCsvRow(lines[0]).map(h => h.trim().toLowerCase());
    const col = {
      type:     header.indexOf('type'),
      start:    header.indexOf('startedat') !== -1 ? header.indexOf('startedat') : header.indexOf('started'),
      end:      header.indexOf('endedat')   !== -1 ? header.indexOf('endedat')   : header.indexOf('ended'),
      duration: header.indexOf('durationminutes') !== -1 ? header.indexOf('durationminutes') : header.indexOf('duration'),
      device:   header.indexOf('device'),
      note:     header.indexOf('note')
    };
    if (col.type < 0 || col.start < 0 || col.end < 0) {
      toast('CSV must have columns: type, startedAt, endedAt', true);
      return;
    }

    const imported = [];
    const errors = [];
    const MAX_IMPORT = 2000;
    for (let r = 1; r < lines.length && imported.length < MAX_IMPORT; r++) {
      const fields = _parseCsvRow(lines[r]);
      const rowNum = r + 1;

      // Type
      const rawType = (fields[col.type] || '').trim().toLowerCase();
      if (rawType !== 'tugging' && rawType !== 'retaining') {
        errors.push(`Row ${rowNum}: invalid type "${(fields[col.type] || '').slice(0, 20)}"`);
        continue;
      }

      // Dates -- strict parse
      const startMs = _parseSessionDate(fields[col.start]);
      const endMs   = _parseSessionDate(fields[col.end]);
      if (isNaN(startMs)) { errors.push(`Row ${rowNum}: invalid start date`); continue; }
      if (isNaN(endMs))   { errors.push(`Row ${rowNum}: invalid end date`);   continue; }
      if (endMs <= startMs) { errors.push(`Row ${rowNum}: end must be after start`); continue; }

      const durationMs = endMs - startMs;
      if (durationMs < 5000) { errors.push(`Row ${rowNum}: session too short (< 5s)`); continue; }
      if (durationMs > 24 * 3600 * 1000) { errors.push(`Row ${rowNum}: session longer than 24h`); continue; }

      // Optional fields -- sanitise
      let device = col.device >= 0 ? (fields[col.device] || '').trim().slice(0, 40) : '';
      let note   = col.note >= 0   ? (fields[col.note]   || '').trim().slice(0, 200) : '';
      // Strip anything that looks like HTML tags
      device = device.replace(/<[^>]*>/g, '');
      note   = note.replace(/<[^>]*>/g, '');

      imported.push({
        id: uuid(),
        type: rawType,
        startedAt: startMs,
        endedAt: endMs,
        durationMs,
        device: device || undefined,
        note: note || '',
        noteUpdatedAt: note ? Date.now() : undefined
      });
    }

    if (!imported.length) {
      toast(`No valid sessions found. ${errors.length ? errors.slice(0, 3).join('; ') : ''}`, true);
      return;
    }

    // Date range summary
    const sorted = imported.sort((a, b) => a.startedAt - b.startedAt);
    const firstDate = new Date(sorted[0].startedAt).toLocaleDateString();
    const lastDate  = new Date(sorted[sorted.length - 1].startedAt).toLocaleDateString();
    const truncated = lines.length - 1 > MAX_IMPORT;

    let msg = `Import ${imported.length} session${imported.length !== 1 ? 's' : ''} (${firstDate} -- ${lastDate})?`;
    if (errors.length) msg += `\n${errors.length} row${errors.length !== 1 ? 's' : ''} skipped.`;
    if (truncated) msg += `\nFile truncated at ${MAX_IMPORT} rows.`;

    if (typeof appConfirm === 'function') {
      appConfirm(msg, () => _applyImport(imported));
    } else if (confirm(msg)) {
      _applyImport(imported);
    }
  };
  input.click();
}

function _applyImport(imported) {
  const sessions = getTimerSessions();
  sessions.push(...imported);
  saveTimerSessions(sessions);
  _autoAggregateWeeks();
  if (typeof syncAfterSave === 'function') syncAfterSave();
  _renderDashTimer();
  _renderDashProfile();
  toast(`Imported ${imported.length} session${imported.length !== 1 ? 's' : ''}`);
}

function _toLocalDatetimeStr(ts) {
  const d = new Date(ts);
  const pad = n => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

function computeWeekSummaryFromSessions(weekEndDate) {
  const end = new Date(weekEndDate + 'T23:59:59').getTime();
  const start = end - 7 * 86400000;
  const sessions = getTimerSessions().filter(s => s.startedAt >= start && s.startedAt <= end && s.endedAt);
  let tugMs = 0, retMs = 0;
  const tugDays = new Set(), retDays = new Set();
  const notes = [];
  sessions.forEach(s => {
    const day = new Date(s.startedAt).toISOString().slice(0, 10);
    if (s.type === 'tugging') { tugMs += s.durationMs; tugDays.add(day); }
    else { retMs += s.durationMs; retDays.add(day); }
    if (s.note) notes.push(s.note);
  });
  const totalHrsTugged = +(tugMs / 3600000).toFixed(2);
  const totalHrsRetained = +(retMs / 3600000).toFixed(2);
  return {
    // New per-active-day fields
    daysTugged: tugDays.size,
    daysRetained: retDays.size,
    totalHrsTugged,
    totalHrsRetained,
    hrsPerDayTugged: tugDays.size ? +(totalHrsTugged / tugDays.size).toFixed(1) : null,
    hrsPerDayRetained: retDays.size ? +(totalHrsRetained / retDays.size).toFixed(1) : null,
    sessionNotes: notes,
    // Legacy ÷7 fields (backward compat)
    avgHrsTugged: sessions.length ? +(tugMs / (7 * 3600000)).toFixed(1) : null,
    avgHrsRetained: sessions.length ? +(retMs / (7 * 3600000)).toFixed(1) : null,
    hasSessions: sessions.length > 0
  };
}

// ── Auto-aggregate completed weeks into weekly stats ────────────────────────
// Groups timer sessions by ISO calendar week (Mon-Sun). For each completed
// week (not the current in-progress week), creates or updates a weekly stat
// with source:'timer'. Manual stats (no source or source!=='timer') are never
// overwritten -- manual takes precedence.

function _autoAggregateWeeks() {
  const sessions = getTimerSessions().filter(s => s.endedAt);
  if (!sessions.length) return;

  // Group sessions by ISO week start (Monday)
  const weeks = {};
  for (const s of sessions) {
    if (!s.startedAt) continue;
    const d = new Date(s.startedAt);
    if (isNaN(d.getTime())) continue;
    const day = d.getDay(); // 0=Sun .. 6=Sat
    const mon = new Date(d);
    mon.setDate(d.getDate() - ((day + 6) % 7)); // roll back to Monday
    const key = mon.toISOString().slice(0, 10);
    if (!weeks[key]) weeks[key] = [];
    weeks[key].push(s);
  }

  // Current week's Monday -- skip this week (still in progress)
  const now = new Date();
  const today = now.getDay();
  const curMon = new Date(now);
  curMon.setDate(now.getDate() - ((today + 6) % 7));
  const curWeekKey = curMon.toISOString().slice(0, 10);

  const weeklyStats = getWeeklyStats();
  let changed = false;

  for (const [weekMonday, weekSessions] of Object.entries(weeks)) {
    if (weekMonday >= curWeekKey) continue; // skip current incomplete week

    // Check existing stat for this week
    const existingIdx = weeklyStats.findIndex(s => s.date === weekMonday);
    if (existingIdx >= 0) {
      const existing = weeklyStats[existingIdx];
      // Manual stat -- never overwrite
      if (existing.source !== 'timer') continue;
    }

    // Aggregate sessions for this week
    let tugMs = 0, retMs = 0;
    const tugDays = new Set(), retDays = new Set();
    const sessionNotes = [];
    for (const s of weekSessions) {
      const day = new Date(s.startedAt).toISOString().slice(0, 10);
      if (s.type === 'tugging') { tugMs += (s.durationMs || 0); tugDays.add(day); }
      else { retMs += (s.durationMs || 0); retDays.add(day); }
      if (s.note) sessionNotes.push(s.note);
    }

    const totalHrsTugged = +(tugMs / 3600000).toFixed(2);
    const totalHrsRetained = +(retMs / 3600000).toFixed(2);

    const stat = {
      id: existingIdx >= 0 ? weeklyStats[existingIdx].id : uuid(),
      date: weekMonday,
      source: 'timer',
      // New enriched fields
      daysTugged: tugDays.size,
      daysRetained: retDays.size,
      totalHrsTugged,
      totalHrsRetained,
      hrsPerDayTugged: tugDays.size ? +(totalHrsTugged / tugDays.size).toFixed(1) : null,
      hrsPerDayRetained: retDays.size ? +(totalHrsRetained / retDays.size).toFixed(1) : null,
      sessionNotes,
      notes: existingIdx >= 0 ? weeklyStats[existingIdx].notes : '',
      // Legacy fields (backward compat)
      pctTugged: Math.round(tugDays.size / 7 * 100),
      pctRetained: Math.round(retDays.size / 7 * 100),
      avgHrsTugged: tugDays.size ? +(tugMs / (7 * 3600000)).toFixed(1) : null,
      avgHrsRetained: retDays.size ? +(retMs / (7 * 3600000)).toFixed(1) : null,
    };

    if (existingIdx >= 0) {
      weeklyStats[existingIdx] = stat;
    } else {
      weeklyStats.push(stat);
    }
    changed = true;
  }

  if (changed) saveWeeklyStats(weeklyStats);
}

// ── Migrate old weekly stats to enriched format ─────────────────────────────
// Old stats have pctTugged/avgHrsTugged but lack daysTugged/totalHrsTugged/
// hrsPerDayTugged. Backfill from raw timer sessions when possible, otherwise
// derive approximate values from the legacy ÷7 fields. Runs once on init.
function _migrateWeeklyStats() {
  const weeklyStats = getWeeklyStats();
  if (!weeklyStats.length) return;
  let changed = false;
  const sessions = typeof getTimerSessions === 'function' ? getTimerSessions() : [];

  for (let i = 0; i < weeklyStats.length; i++) {
    const s = weeklyStats[i];
    if (s.daysTugged != null && s.totalHrsTugged != null && s.hrsPerDayTugged !== undefined) continue; // already migrated

    // For timer-sourced stats, try to re-aggregate from raw sessions
    if (s.source === 'timer' && sessions.length) {
      const weekStart = new Date(s.date + 'T00:00:00').getTime();
      const weekEnd = weekStart + 7 * 86400000;
      const weekSessions = sessions.filter(se => se.startedAt >= weekStart && se.startedAt < weekEnd && se.endedAt);
      if (weekSessions.length) {
        let tugMs = 0, retMs = 0;
        const tugDays = new Set(), retDays = new Set();
        const sessionNotes = [];
        for (const se of weekSessions) {
          const day = new Date(se.startedAt).toISOString().slice(0, 10);
          if (se.type === 'tugging') { tugMs += (se.durationMs || 0); tugDays.add(day); }
          else { retMs += (se.durationMs || 0); retDays.add(day); }
          if (se.note) sessionNotes.push(se.note);
        }
        const totalHrsTugged = +(tugMs / 3600000).toFixed(2);
        const totalHrsRetained = +(retMs / 3600000).toFixed(2);
        s.daysTugged = tugDays.size;
        s.daysRetained = retDays.size;
        s.totalHrsTugged = totalHrsTugged;
        s.totalHrsRetained = totalHrsRetained;
        s.hrsPerDayTugged = tugDays.size ? +(totalHrsTugged / tugDays.size).toFixed(1) : null;
        s.hrsPerDayRetained = retDays.size ? +(totalHrsRetained / retDays.size).toFixed(1) : null;
        if (!s.sessionNotes) s.sessionNotes = sessionNotes;
        changed = true;
        continue;
      }
    }

    // Fallback: derive from legacy fields
    if (s.pctTugged != null && s.daysTugged == null) {
      s.daysTugged = Math.round(s.pctTugged * 7 / 100);
      changed = true;
    }
    if (s.pctRetained != null && s.daysRetained == null) {
      s.daysRetained = Math.round(s.pctRetained * 7 / 100);
      changed = true;
    }
    if (s.avgHrsTugged != null && s.totalHrsTugged == null) {
      s.totalHrsTugged = +(s.avgHrsTugged * 7).toFixed(2);
      changed = true;
    }
    if (s.avgHrsRetained != null && s.totalHrsRetained == null) {
      s.totalHrsRetained = +(s.avgHrsRetained * 7).toFixed(2);
      changed = true;
    }
    if (s.totalHrsTugged != null && s.daysTugged && s.hrsPerDayTugged == null) {
      s.hrsPerDayTugged = +(s.totalHrsTugged / s.daysTugged).toFixed(1);
      changed = true;
    }
    if (s.totalHrsRetained != null && s.daysRetained && s.hrsPerDayRetained == null) {
      s.hrsPerDayRetained = +(s.totalHrsRetained / s.daysRetained).toFixed(1);
      changed = true;
    }
  }

  if (changed) {
    saveWeeklyStats(weeklyStats);
    console.log('[dashboard] migrated weekly stats to enriched format');
  }
}

function _renderDashTimer() {
  const el = document.getElementById('dash-timer');
  if (!el) return;

  // Sessions render into the dash-logs cluster when present (falls back inline)
  const sessEl = document.getElementById('dash-sessions');
  // Preserve sessions dropdown open state across re-renders
  const sessionsWasOpen = (sessEl || el).querySelector('details.timer-sessions')?.open ?? false;

  let active = getActiveTimer(); // null for stop-markers (stopped:true)

  // Auto-clear: session already arrived via sync -- this device's active timer is stale.
  // Preserve the ORIGINAL changedAt so this render-triggered stop can't win LWW
  // against a legitimate new start on the other device.
  if (active && active.id) {
    if (getTimerSessions().some(s => s.id === active.id)) {
      setActiveTimer({ id: active.id, stopped: true, changedAt: active.changedAt || 0 });
      clearInterval(_timerInterval);
      _timerInterval = null;
      active = null;
    }
  }

  // Ensure local device ID is cached (async, fire-and-forget on first call)
  if (!_localDeviceId && typeof getDeviceId === 'function') {
    getDeviceId().then(id => { _localDeviceId = id; if (active) _renderDashTimer(); }).catch(() => {});
  }

  // Determine timer state: 'own' | 'adopted' | 'stale' | 'idle'
  const STALE_MS = 12 * 3600000;
  const isOtherDevice = !!(active && active.deviceId && _localDeviceId && active.deviceId !== _localDeviceId);
  const ageMs = active ? (Date.now() - active.startedAt) : 0;
  // Any timer >12h is stale regardless of which device started it
  const timerState = !active ? 'idle' : ageMs > STALE_MS ? 'stale' : isOtherDevice ? 'adopted' : 'own';

  // Remote display fills in when idle (e.g. real-time broadcast not yet adopted by LWW)
  const displayTimer = active || (!active ? _remoteActivity : null);
  const tugActive = displayTimer && displayTimer.type === 'tugging';
  const retActive = displayTimer && displayTimer.type === 'retaining';

  // Resume/stop tick
  if (displayTimer && !_timerInterval) _startTimerTick();
  if (!displayTimer && _timerInterval) { clearInterval(_timerInterval); _timerInterval = null; }

  const elapsed = displayTimer ? _fmtElapsed(Date.now() - displayTimer.startedAt) : '';

  // Button extra classes
  const tugExtraClass = tugActive ? ` active${timerState === 'adopted' ? ' adopted' : timerState === 'stale' ? ' stale' : ''}` : '';
  const retExtraClass = retActive ? ` active${timerState === 'adopted' ? ' adopted' : timerState === 'stale' ? ' stale' : ''}` : '';

  // Sub-text inside each button
  const tugSub = tugActive
    ? `<span id="timer-elapsed">${elapsed}</span>${isOtherDevice ? ' <span style="font-size:12px">📱</span>' : ''}`
    : (timerState !== 'idle' && !tugActive ? '<span style="opacity:.6">start here</span>' : 'tap to start');
  const retSub = retActive
    ? `<span id="timer-elapsed">${elapsed}</span>${isOtherDevice ? ' <span style="font-size:12px">📱</span>' : ''}`
    : (timerState !== 'idle' && !retActive ? '<span style="opacity:.6">start here</span>' : 'tap to start');

  // Info bar rendered below the hero buttons
  let infoBar = '';
  if (timerState === 'adopted') {
    infoBar = `<div class="timer-sync-bar adopted">
      Running on your other device · tap to stop here
      <button class="btn-link" data-action="dismissAdoptedTimer" style="margin-left:10px;font-size:11px;color:var(--muted2);cursor:pointer;background:none;border:none;padding:0;text-decoration:underline;opacity:.7">Already stopped? Clear</button>
    </div>`;
  } else if (timerState === 'stale') {
    const staleDevice = isOtherDevice ? 'from your other device · still open there' : 'left open from a previous session';
    infoBar = `<div class="timer-sync-bar stale">
      <div style="font-weight:600;margin-bottom:3px">⚠ Old session still open</div>
      <div style="color:var(--muted);font-size:11px;margin-bottom:8px">Started ${_fmtElapsed(ageMs)} ago · ${staleDevice}</div>
      <div style="display:flex;gap:8px;justify-content:center">
        <button class="btn btn-primary btn-sm" data-action="stopTimer">Save &amp; Stop here</button>
        <button class="btn btn-ghost btn-sm" data-action="discardStaleTimer">Discard</button>
      </div>
    </div>`;
  }

  // Show all sessions sorted newest-first
  const allSessions = getTimerSessions().sort((a, b) => b.startedAt - a.startedAt);
  const sessions = allSessions.slice(0, _showAllSessions ? allSessions.length : 10);
  const hasMore = !_showAllSessions && allSessions.length > 10;

  const _sessionInputStyle = 'font-family:var(--mono);font-size:12px;padding:5px 8px;border-radius:4px;border:1px solid var(--border2);background:var(--surface2);color:var(--text)';

  // "Add session" form
  let addFormHtml = '';
  if (_addingSession) {
    const now = _toLocalDatetimeStr(Date.now());
    const oneHrAgo = _toLocalDatetimeStr(Date.now() - 3600000);
    addFormHtml = `<div class="timer-session-row editing" style="border-bottom:1px solid var(--border2);padding-bottom:8px;margin-bottom:6px">
      <div class="timer-session-edit-fields" style="width:100%">
        <div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
          <label style="font-family:var(--mono);font-size:11px;color:var(--muted2)">Type</label>
          <select id="new-session-type" style="${_sessionInputStyle}">
            <option value="tugging">Tugging</option>
            <option value="retaining">Retaining</option>
          </select>
        </div>
        <div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:4px">
          <label style="font-family:var(--mono);font-size:11px;color:var(--muted2)">Start</label>
          <input type="datetime-local" id="new-session-start" value="${oneHrAgo}" style="${_sessionInputStyle}">
          <label style="font-family:var(--mono);font-size:11px;color:var(--muted2)">End</label>
          <input type="datetime-local" id="new-session-end" value="${now}" style="${_sessionInputStyle}">
        </div>
        <input type="text" id="new-session-note" placeholder="Note (optional)" style="${_sessionInputStyle};width:100%;box-sizing:border-box;margin-top:4px">
        <div style="display:flex;gap:4px;margin-top:4px">
          <button class="btn btn-primary btn-sm" data-action="saveNewSession">Save</button>
          <button class="btn btn-ghost btn-sm" data-action="cancelAddSession">Cancel</button>
        </div>
      </div>
    </div>`;
  }

  let sessionRows = '';
  if (sessions.length) {
    sessionRows = sessions.map(s => {
      const isEditing = _editingSessionId === s.id;
      const icon = s.type === 'tugging' ? 'T' : 'R';
      if (isEditing) {
        return `<div class="timer-session-row editing">
          <span class="timer-session-icon">${icon}</span>
          <div class="timer-session-edit-fields">
            <div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
              <label style="font-family:var(--mono);font-size:11px;color:var(--muted2)">Start</label>
              <input type="datetime-local" id="edit-session-start" value="${_toLocalDatetimeStr(s.startedAt)}" style="${_sessionInputStyle}">
              <label style="font-family:var(--mono);font-size:11px;color:var(--muted2)">End</label>
              <input type="datetime-local" id="edit-session-end" value="${_toLocalDatetimeStr(s.endedAt)}" style="${_sessionInputStyle}">
            </div>
            <input type="text" id="edit-session-note" value="${esc(s.note || '')}" placeholder="Note (optional)" style="${_sessionInputStyle};width:100%;box-sizing:border-box;margin-top:4px">
            <div style="display:flex;gap:4px;margin-top:4px">
              <button class="btn btn-primary btn-sm" data-action="saveEditSession" data-a0="${s.id}">Save</button>
              <button class="btn btn-ghost btn-sm" data-action="cancelEditSession">Cancel</button>
            </div>
          </div>
        </div>`;
      }
      const _sKots = s.id && typeof getSessionKotCount === 'function' ? getSessionKotCount(s.id) : 0;
      return `<div class="timer-session-row">
        <span class="timer-session-icon">${icon}</span>
        <span class="timer-session-date">${_fmtDateShort(s.startedAt)} ${_fmtTime(s.startedAt)}</span>
        <span class="timer-session-dur">${_fmtDuration(s.durationMs)}</span>
        ${_sKots > 0 ? `<span title="${_sKots} KOT${_sKots > 1 ? 's' : ''}" style="font-size:13px;line-height:1;opacity:.85">✊${_sKots > 1 ? `<span style="font-family:var(--mono);font-size:10px;vertical-align:middle;margin-left:1px">${_sKots}</span>` : ''}</span>` : ''}
        ${s.device ? `<span class="timer-session-note" style="opacity:.7">${esc(s.device)}</span>` : ''}
        ${s.note ? `<span class="timer-session-note">${esc(s.note)}</span>` : ''}
        <span class="timer-session-actions">
          <button class="btn-icon" data-action="editTimerSession" data-a0="${s.id}" title="Edit">✎</button>
          <button class="btn-icon" data-action="deleteTimerSession" data-a0="${s.id}" title="Delete">×</button>
        </span>
      </div>`;
    }).join('');
  }

  // Device chip rows -- only devices the user has used in journal entries
  const _entries = typeof getEntries === 'function' ? getEntries() : [];
  const _tugDevs = [...new Set(_entries.flatMap(e => e.tuggingDevices || []))];
  const _retDevs = [...new Set(_entries.flatMap(e => e.retainingDevices || []))];
  const _dsel = _getTimerDeviceSel();
  const _selTug = _dsel.tug || null;
  const _selRet = _dsel.ret || null;
  const _deviceChips = (_tugDevs.length || _retDevs.length) ? `<div class="timer-device-row">
    <div class="timer-device-col">${_tugDevs.map(d => `<span class="tdev-chip${_selTug === d ? ' tug-sel' : ''}" data-action="selectTimerDevice" data-a0="tugging" data-a1="${esc(d)}">${esc(d)}</span>`).join('')}</div>
    <div class="timer-device-col">${_retDevs.map(d => `<span class="tdev-chip${_selRet === d ? ' ret-sel' : ''}" data-action="selectTimerDevice" data-a0="retaining" data-a1="${esc(d)}">${esc(d)}</span>`).join('')}</div>
  </div>` : '';

  const activeLabel = active ? (active.type === 'tugging' ? 'Tugging' : 'Retaining') : null;
  const headerExtra = activeLabel
    ? ` <span style="color:#86efac;font-weight:600;margin-left:4px">· ${activeLabel}</span>`
    : '';

  // Buddies currently tugging/retaining -- show avatars + inline KOT button above the buttons
  const _buddyTimerRow = (() => {
    if (typeof getAllBuddyTimerStates !== 'function') return '';
    const states = getAllBuddyTimerStates();
    if (!states.size) return '';
    const contacts = typeof getContacts === 'function' ? getContacts() : [];
    const tugging = [], retaining = [];
    for (const [pk, s] of states) {
      if (!s?.type) continue;
      const c = contacts.find(x => x.pubKeyB64 === pk);
      if (!c) continue;
      const avatar = c.journal?.avatar || '🌿';
      const name = c.journal?.displayName || c.nickname || 'Buddy';
      const alreadyKot = s.id && typeof hasGivenSessionKot === 'function' && hasGivenSessionKot(s.id);
      const kotBtn = s.id
        ? `<button class="btn btn-sm" style="padding:0 5px;font-size:13px;line-height:1.4;min-width:0;height:22px" data-action="sendSessionKot" data-a0="${esc(pk)}" data-a1="${esc(s.id)}" ${alreadyKot ? 'disabled' : ''} title="${alreadyKot ? 'KOT sent' : (s.type === 'retaining' ? 'Keep on Retaining!' : 'Keep on Tugging!')}">✊${alreadyKot ? '✓' : ''}</button>`
        : '';
      const chip = `<span style="display:inline-flex;align-items:center;gap:3px"><span title="${esc(name)}${s.device ? ' · ' + esc(s.device) : ''}" style="font-size:16px;cursor:pointer" data-action="openBuddyProfile" data-a0="${esc(pk)}">${esc(avatar)}</span>${kotBtn}</span>`;
      if (s.type === 'tugging') tugging.push(chip);
      else retaining.push(chip);
    }
    if (!tugging.length && !retaining.length) return '';
    const parts = [];
    if (tugging.length) parts.push(`<span style="display:inline-flex;align-items:center;gap:5px;flex-wrap:wrap"><span style="font-family:var(--mono);font-size:10px;color:var(--muted2)">Tugging</span> ${tugging.join(' ')}</span>`);
    if (retaining.length) parts.push(`<span style="display:inline-flex;align-items:center;gap:5px;flex-wrap:wrap"><span style="font-family:var(--mono);font-size:10px;color:var(--muted2)">Retaining</span> ${retaining.join(' ')}</span>`);
    return `<div style="display:flex;justify-content:center;gap:14px;margin-bottom:8px;flex-wrap:wrap;align-items:center">${parts.join('')}</div>`;
  })();

  el.innerHTML = `
    ${_buddyTimerRow}
    <div class="timer-hero">
      <div class="timer-hero-btn tug${tugExtraClass}" data-action="startTimer" data-a0="tugging">
        <div class="timer-hero-label">Tugging</div>
        <div class="timer-hero-sub">${tugSub}</div>
      </div>
      <div class="timer-hero-btn ret${retExtraClass}" data-action="startTimer" data-a0="retaining">
        <div class="timer-hero-label">Retaining</div>
        <div class="timer-hero-sub">${retSub}</div>
      </div>
    </div>
    ${_deviceChips}
    ${infoBar}`;

  const sessionsHtml = (allSessions.length || _addingSession) ? `<div class="panel">
      <details class="timer-sessions" style="margin:0">
        <summary class="timer-sessions-hdr" style="padding:10px 14px">Sessions (${allSessions.length})</summary>
        <div class="timer-sessions-list" style="padding:0 14px 10px">
          ${addFormHtml}${sessionRows}
          ${hasMore ? `<div style="text-align:center;margin-top:6px"><button class="btn btn-ghost btn-sm" data-action="showAllSessions">Show all (${allSessions.length})</button></div>` : ''}
          ${!_addingSession ? `<div style="text-align:center;margin-top:6px;display:flex;gap:4px;justify-content:center;flex-wrap:wrap">
            <button class="btn btn-ghost btn-sm" data-action="showAddSessionForm">+ Add session</button>
            <button class="btn btn-ghost btn-sm" data-action="exportSessionsCsv">Export CSV</button>
            <button class="btn btn-ghost btn-sm" data-action="importSessionsCsv">Import CSV</button>
          </div>` : ''}
        </div>
      </details>
    </div>` : `<div style="text-align:center;margin:8px 0">
      <button class="btn btn-ghost btn-sm" data-action="showAddSessionForm">+ Add a session</button>
    </div>`;

  if (sessEl) sessEl.innerHTML = sessionsHtml;
  else el.insertAdjacentHTML('beforeend', sessionsHtml);

  if (sessionsWasOpen || _addingSession) {
    const det = (sessEl || el).querySelector('details.timer-sessions');
    if (det) det.open = true;
  }
}

// ── Gallery Post (dashboard quick-add) ────────────────────────────────────────

function _renderGalleryPostPanel() {
  const galleries = typeof getGalleries === 'function' ? getGalleries() : [];
  if (!galleries.length) return ''; // no galleries yet -- nothing to post to

  return `<div class="panel" style="margin-bottom:1rem">
    <div class="pb" style="padding:10px 14px">
      <div id="dash-gal-preview" style="display:none;margin-bottom:8px;border-radius:7px;overflow:hidden;background:var(--surface2);border:1px solid var(--border)">
        <img id="dash-gal-img" style="width:100%;max-height:200px;object-fit:contain;display:block">
      </div>
      <button id="dash-gal-pick" class="btn btn-ghost" style="width:100%;padding:14px;border:2px dashed var(--border2);border-radius:7px;font-size:12px;color:var(--muted2);margin-bottom:8px">
        📷 Choose a photo
      </button>
      <input type="text" id="dash-gal-title" class="input" placeholder="Title..." maxlength="120" style="margin-bottom:4px;font-size:12px">
      <textarea id="dash-gal-note" class="input" placeholder="Note..." maxlength="500" rows="2" style="margin-bottom:6px;resize:vertical;font-family:var(--body);font-size:12px"></textarea>
      <div style="display:flex;gap:6px;align-items:center">
        <select id="dash-gal-gallery" class="input" style="flex:1;font-size:11px">
          ${(() => { const last = localStorage.getItem('frj_last_gallery') || ''; return galleries.map(g => `<option value="${esc(g.id)}" ${g.id === last ? 'selected' : ''}>${esc(g.title)}</option>`).join(''); })()}
        </select>
        <button id="dash-gal-post" class="btn btn-primary btn-sm" disabled>Post</button>
      </div>
    </div>
  </div>`;
}

function toggleDashGalPost() {
  const wrap = document.getElementById('dash-gal-post-wrap');
  if (!wrap) return;
  if (wrap.style.display !== 'none' && wrap.innerHTML) {
    wrap.style.display = 'none';
    wrap.innerHTML = '';
    return;
  }
  const html = _renderGalleryPostPanel();
  if (!html) { toast('Create a gallery first', true); return; }
  wrap.innerHTML = html;
  wrap.style.display = '';
  _wireGalleryPostPanel(wrap);
}

function _wireGalleryPostPanel(root) {
  const pickBtn  = root.querySelector('#dash-gal-pick');
  const preview  = root.querySelector('#dash-gal-preview');
  const imgEl    = root.querySelector('#dash-gal-img');
  const postBtn  = root.querySelector('#dash-gal-post');
  const titleEl  = root.querySelector('#dash-gal-title');
  const noteEl   = root.querySelector('#dash-gal-note');
  const galSel   = root.querySelector('#dash-gal-gallery');
  const suggestEl = root.querySelector('#dash-gal-suggestions');
  if (!pickBtn || !postBtn) return;

  let _file = null;

  function _refreshChips() {
    if (!suggestEl || !galSel) return;
    const gal = (typeof getGalleries === 'function' ? getGalleries() : []).find(g => g.id === galSel.value);
    const existing = new Set((gal?.items || []).map(i => (i.title || '').toLowerCase()));
    suggestEl.innerHTML = _GALLERY_POSITIONS.map(p => {
      const done = existing.has(p.toLowerCase());
      return `<button class="btn btn-sm dash-gal-suggest" style="font-size:11px;padding:3px 8px;${done
        ? 'background:rgba(34,197,94,.15);border-color:rgba(34,197,94,.3);color:var(--green)'
        : ''}" data-title="${esc(p)}" ${done ? 'title="Already in gallery"' : ''}>${done ? '✓ ' : ''}${esc(p)}</button>`;
    }).join('');
    // Re-wire chip clicks
    suggestEl.querySelectorAll('.dash-gal-suggest').forEach(btn => {
      btn.addEventListener('click', () => {
        titleEl.value = btn.dataset.title || '';
        pickBtn.click();
      });
    });
  }

  // Initial chip wiring + refresh on gallery change
  _refreshChips();
  if (galSel) galSel.addEventListener('change', _refreshChips);

  pickBtn.addEventListener('click', () => {
    const input = document.createElement('input');
    input.type = 'file'; input.accept = 'image/*'; input.style.display = 'none';
    document.body.appendChild(input);
    input.onchange = () => {
      document.body.removeChild(input);
      const file = input.files[0];
      if (!file) return;
      if (file.size > 10 * 1024 * 1024) { toast('Image too large (max 10 MB)', true); return; }
      _file = file;
      imgEl.src = URL.createObjectURL(file);
      preview.style.display = '';
      pickBtn.textContent = '📷 Change photo';
      postBtn.disabled = false;
    };
    input.click();
  });

  postBtn.addEventListener('click', async () => {
    if (!_file) return;
    postBtn.disabled = true;
    postBtn.textContent = 'Posting...';
    const galleryId = galSel.value;
    // Check-In flow
    if (galleryId === '__checkin__') {
      try {
        if (typeof postCheckIn === 'function') await postCheckIn(_file);
        _file = null;
        _renderDashTimer();
        if (typeof renderActivityPage === 'function') renderActivityPage();
      } catch(e) { toast('Check-in failed', true); }
      postBtn.disabled = false;
      postBtn.textContent = 'Post';
      return;
    }
    try {
      const key = await storeBlob(_file);
      const galleries = getGalleries();
      const gi = galleries.findIndex(g => g.id === galleryId);
      if (gi < 0) { toast('Gallery not found', true); return; }
      const now = new Date().toISOString();
      const item = {
        id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
        imgKey: key,
        title: titleEl.value.trim().slice(0, 120),
        note: noteEl ? noteEl.value.trim().slice(0, 500) : '',
        addedAt: now,
        updatedAt: now,
      };
      galleries[gi].items.push(item);
      galleries[gi].updatedAt = now;
      saveGalleries(galleries);
      try { localStorage.setItem('frj_last_gallery', galleryId); } catch(e) {}
      const blob = await idbGet(key);
      if (blob && typeof imgCache !== 'undefined') {
        if (imgCache[key]) URL.revokeObjectURL(imgCache[key]);
        imgCache[key] = URL.createObjectURL(blob);
      }
      if (typeof p2pRelayGalleryManifest === 'function') p2pRelayGalleryManifest(galleries[gi]);
      if (typeof p2pRelayGalleryPhoto === 'function') p2pRelayGalleryPhoto(galleries[gi], item.id, key);
      if (typeof syncAfterSave === 'function') syncAfterSave();
      toast('Posted!');
      root.style.display = 'none';
      root.innerHTML = '';
      _renderDashTimer();
      if (typeof renderActivityPage === 'function') renderActivityPage();
    } catch(e) {
      toast('Failed to post', true);
      postBtn.disabled = false;
      postBtn.textContent = 'Post';
    }
  });
}

let _showAllSessions = false;

// ── Onboarding Welcome Panel ───────────────────────────────────────────────────

function _renderDashWelcome() {
  const el = document.getElementById('dash-welcome');
  if (!el) return;

  const profile = (typeof getProfile === 'function') ? getProfile() : {};
  const ob = profile.onboarding || {};

  // All 4 done → hide
  if (ob.profile && ob.journal && ob.encrypt && ob.backup) {
    el.innerHTML = '';
    return;
  }

  const actions = [
    {
      key: 'encrypt',
      icon: '🔒',
      title: 'Encrypt your data',
      sub: 'Password-protect everything on device',
      action: () => {
        _dismissOnboarding('encrypt');
        if (typeof showMigrationModal === 'function') showMigrationModal();
      }
    },
    {
      key: 'profile',
      icon: '🌱',
      title: 'Start your profile',
      sub: 'Set your CI, start date, and avatar',
      action: () => {
        _dismissOnboarding('profile');
        openProfile();
      }
    },
    {
      key: 'journal',
      icon: '📸',
      title: 'Post your first update',
      sub: 'Log a CI, photos, or measurements',
      action: () => {
        _dismissOnboarding('journal');
        tab('journal');
        if (typeof newEntry === 'function') newEntry();
      }
    },
    {
      key: 'backup',
      icon: '💾',
      title: 'Back your data up',
      sub: 'Download a backup file',
      action: () => {
        _dismissOnboarding('backup');
        if (typeof exportBackup === 'function') exportBackup();
      }
    }
  ];

  // Treat encrypt as done if encryption is already active (schema v2 live)
  const isEncrypted = (typeof _frjBlobSchemaV2 !== 'undefined' && _frjBlobSchemaV2) ||
                      (typeof getSessionKey === 'function' && !!getSessionKey());

  const remaining = actions.filter(a => {
    if (a.key === 'encrypt' && isEncrypted) return false;
    return !ob[a.key];
  });
  if (!remaining.length) { el.innerHTML = ''; return; }

  el.innerHTML = `<div class="panel" style="margin-bottom:1.5rem">
    <div class="ph"><h2 style="margin:0">Welcome to Forestorers!</h2></div>
    <div class="pb" style="padding:14px 16px 18px">
      <p style="font-size:12px;color:var(--muted);margin:0 0 12px;line-height:1.5">Tap a card to get started, or &times; to dismiss it.</p>
      <div class="welcome-actions">
        ${remaining.map(a => `<div class="welcome-action-wrap">
          <button class="welcome-action-btn" data-action="onboardingClick" data-a0="${a.key}">
            <span class="wa-icon">${a.icon}</span>
            <span class="wa-title">${a.title}</span>
            <span class="wa-sub">${a.sub}</span>
          </button>
          <button class="wa-dismiss" data-action="dismissOnboarding" data-a0="${a.key}" title="Dismiss">&times;</button>
        </div>`).join('')}
      </div>
    </div>
  </div>`;
}

// Store onboarding action handlers so onclick strings can call them
const _onboardingActions = {};

function _dismissOnboarding(key) {
  const profile = (typeof getProfile === 'function') ? getProfile() : {};
  profile.onboarding = profile.onboarding || {};
  profile.onboarding[key] = true;
  if (typeof saveProfile === 'function') saveProfile(profile);
  _renderDashWelcome();
}

function _onboardingClick(key) {
  const actions = {
    profile: () => { _dismissOnboarding('profile'); openProfile(); },
    journal: () => { _dismissOnboarding('journal'); tab('journal'); if(typeof newEntry==='function') newEntry(); },
    encrypt: () => { _dismissOnboarding('encrypt'); if(typeof showMigrationModal==='function') showMigrationModal(); },
    backup:  () => { _dismissOnboarding('backup');  if(typeof exportBackup==='function') exportBackup(); }
  };
  if (actions[key]) actions[key]();
}

function _renderBackupNudge() {
  const el = document.getElementById('dash-backup-nudge');
  if (!el) return;

  const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent) || (/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
  if (!isIOS) { el.innerHTML = ''; return; }

  const isStandalone = window.navigator.standalone === true;
  if (isStandalone) { el.innerHTML = ''; return; }

  // Check if user dismissed recently (30-day snooze)
  const DISMISS_KEY = 'frj_ios_nudge_dismissed';
  const dismissed = parseInt(localStorage.getItem(DISMISS_KEY) || '0', 10);
  const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
  if (dismissed && Date.now() - dismissed < THIRTY_DAYS) { el.innerHTML = ''; return; }

  el.innerHTML = `
    <div class="ios-persist-nudge" style="background:var(--surface2,#1e2330);border:1px solid var(--warn,#e6a817);border-radius:10px;padding:14px 16px;margin-bottom:16px;display:flex;gap:12px;align-items:flex-start">
      <span style="font-size:1.4rem;flex-shrink:0">⚠️</span>
      <div style="flex:1;min-width:0">
        <div style="font-weight:600;font-size:.92rem;margin-bottom:4px;color:var(--warn,#e6a817)">Your data may be deleted by iOS</div>
        <div style="font-size:.82rem;color:var(--muted,#9aa5b4);line-height:1.45">
          Safari deletes app data after 7 days of inactivity. To keep your journal safe:
          tap <strong style="color:var(--text,#e4e8ef)">Share → Add to Home Screen</strong>, then open the app from there.
          Home Screen apps are exempt from this rule.
        </div>
      </div>
      <button onclick="localStorage.setItem('frj_ios_nudge_dismissed',Date.now());this.closest('.ios-persist-nudge').remove()" style="background:none;border:none;color:var(--muted,#9aa5b4);font-size:1.1rem;cursor:pointer;padding:0;flex-shrink:0" title="Dismiss for 30 days">✕</button>
    </div>`;
}

// ── Stats Row ──────────────────────────────────────────────────────────────────

function _renderDashStats() {
  const el = document.getElementById('dash-stats-grid');
  if (!el) return;

  const entries = getEntries().sort((a, b) => b.date.localeCompare(a.date));
  const weeklyStats = getWeeklyStats();
  const profile = (typeof getProfile === 'function') ? getProfile() : {};

  const latestEntry = entries[0] || null;
  const currentCI = latestEntry?.ci ?? null;

  // CI change since first entry (or starting CI from profile)
  const firstEntry = entries.length ? entries[entries.length - 1] : null;
  const startCI = profile.startingCI != null ? profile.startingCI
    : (firstEntry?.ci ?? null);
  const ciChange = (currentCI != null && startCI != null) ? (currentCI - startCI) : null;

  // 30-day per-active-day averages from weekly stats
  const now = new Date();
  const thirtyDaysAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10);
  const recent30 = weeklyStats.filter(s => s.date >= thirtyDaysAgo);
  const _r30Tug = recent30.reduce((acc, s) => {
    acc.hrs += (s.totalHrsTugged ?? (s.avgHrsTugged != null ? s.avgHrsTugged * 7 : 0));
    acc.days += (s.daysTugged ?? (s.pctTugged != null ? Math.round(s.pctTugged * 7 / 100) : 0));
    return acc;
  }, { hrs: 0, days: 0 });
  const _r30Ret = recent30.reduce((acc, s) => {
    acc.hrs += (s.totalHrsRetained ?? (s.avgHrsRetained != null ? s.avgHrsRetained * 7 : 0));
    acc.days += (s.daysRetained ?? (s.pctRetained != null ? Math.round(s.pctRetained * 7 / 100) : 0));
    return acc;
  }, { hrs: 0, days: 0 });
  const avg30Tug = _r30Tug.days ? (_r30Tug.hrs / _r30Tug.days).toFixed(1) : null;
  const avg30Ret = _r30Ret.days ? (_r30Ret.hrs / _r30Ret.days).toFixed(1) : null;

  // Time since started
  let timeRestoring = null;
  if (profile.startDate) {
    const ms = Date.now() - new Date(profile.startDate + 'T00:00:00').getTime();
    const days = Math.floor(ms / 86400000);
    if (days < 30) timeRestoring = days + 'd';
    else if (days < 365) timeRestoring = Math.floor(days / 30) + 'mo';
    else timeRestoring = (days / 365).toFixed(1) + 'yr';
  }

  function statCard(label, value, sub, extraClass, valueClass) {
    return `<div class="stat-card${extraClass ? ' ' + extraClass : ''}">
      <div class="label">${label}</div>
      <div class="value${valueClass ? ' ' + valueClass : ''}">${value}</div>
      ${sub ? `<div class="sub">${sub}</div>` : ''}
    </div>`;
  }

  const ciVal = currentCI != null ? `CI-${currentCI}` : '-';
  const ciSub = currentCI != null ? CI_LABELS[currentCI] || '' : 'No entries yet';

  const ciChangeStr = ciChange != null
    ? (ciChange >= 0 ? '+' + ciChange : String(ciChange)) + ' since start'
    : '-';

  const cards = [
    statCard('Current CI', ciVal, ciSub, 'border-accent', 'accent'),
    statCard('Progress', ciChangeStr, ciChange != null ? `started CI-${startCI}` : 'set starting CI in profile', ciChange > 0 ? 'border-green' : '', ciChange > 0 ? 'green' : ''),
    statCard('Tug Hrs/day', avg30Tug != null ? avg30Tug + 'h' : '-', '30d per active day', ''),
    statCard('Ret Hrs/day', avg30Ret != null ? avg30Ret + 'h' : '-', '30d per active day', ''),
    statCard('Total Entries', String(entries.length), entries.length ? 'since ' + (firstEntry?.date || '-') : 'start journaling', ''),
    statCard('Time Restoring', timeRestoring || '-', profile.startDate ? 'since ' + profile.startDate : 'set start date in profile', 'border-amber'),
  ];

  el.innerHTML = `<div class="stats-grid">${cards.join('')}</div>`;
}

// ── Activity Feed ──────────────────────────────────────────────────────────────

function _renderDashFeed() {
  const el = document.getElementById('dash-activity-feed');
  if (!el) return;

  const profile = (typeof getProfile === 'function') ? getProfile() : {};
  const myName = profile.displayName || 'Me';

  // Build combined feed items: own entries
  const myEntries = getEntries()
    .sort((a, b) => b.date.localeCompare(a.date))
    .slice(0, 8)
    .map(e => ({ source: 'own', entry: e, date: e.date, author: myName }));

  // Buddy entries
  const contacts = (typeof getContacts === 'function') ? getContacts() : [];
  const buddyItems = [];
  contacts.forEach(c => {
    const j = (typeof getPeerJournal === 'function') ? getPeerJournal(c.pubKeyB64) : null;
    if (!j) return;
    (j.entries || []).slice().sort((a, b) => b.date.localeCompare(a.date)).slice(0, 4).forEach(e => {
      buddyItems.push({ source: 'buddy', entry: e, date: e.date, author: c.nickname || 'Buddy', pubKey: c.pubKeyB64 });
    });
  });

  // Merge, sort by date, take top 8
  const allItems = [...myEntries, ...buddyItems]
    .sort((a, b) => b.date.localeCompare(a.date))
    .slice(0, 8);

  if (!allItems.length) {
    el.innerHTML = `<div class="panel" style="margin-bottom:1.5rem">
      <div class="ph"><h2>Recent Activity</h2></div>
      <div class="empty"><div class="icon">◳</div><p>No entries yet - create your first progress update</p></div>
    </div>`;
    return;
  }

  const rows = allItems.map(item => {
    const e = item.entry;
    const isBuddy = item.source === 'buddy';

    let thumbSrc = null;
    if (isBuddy) {
      thumbSrc = imgURL(`c_${item.pubKey.slice(0, 16)}_${e.id}_front`);
    } else {
      thumbSrc = imgURL(e.imgFront);
    }

    const thumb = thumbSrc
      ? `<img class="feed-thumb thumb-hover" src="${thumbSrc}" alt="">`
      : `<div class="feed-no-thumb">no photo</div>`;

    const chips = [];
    if (e.ci != null) chips.push(`<span class="ci-badge ${ciBadgeClass(e.ci)}">CI-${e.ci}</span>`);
    if (e.pctTugged != null) chips.push(`<span class="feed-chip">${e.pctTugged}% tug</span>`);
    if (e.pctRetained != null) chips.push(`<span class="feed-chip">${e.pctRetained}% ret</span>`);
    if (e.avgHrsTugged != null) chips.push(`<span class="feed-chip">${e.avgHrsTugged}h/d tug</span>`);
    const noteExcerpt = e.notes
      ? `<div class="feed-note">${esc(e.notes.slice(0, 120))}${e.notes.length > 120 ? '…' : ''}</div>`
      : '';

    return `<div class="feed-item" data-action="feedClick" ${isBuddy ? `data-pub="${esc(item.pubKey)}"` : `data-id="${esc(e.id)}"`}>
      ${thumb}
      <div class="feed-meta">
        <div class="feed-date">${esc(e.date)} &middot; ${timeAgo(e.date)}</div>
        <div class="feed-author">${esc(item.author)}</div>
        <div class="feed-chips">${chips.join('')}</div>
        ${noteExcerpt}
      </div>
    </div>`;
  }).join('');

  el.innerHTML = `<div class="panel" style="margin-bottom:1.5rem">
    <div class="ph">
      <h2>Recent Activity</h2>
      <button class="btn btn-primary btn-sm" style="margin-left:auto" data-action="tabNewEntry">+ New Entry</button>
    </div>
    <div>${rows}</div>
  </div>`;
}

// ── Quick Week Log ─────────────────────────────────────────────────────────────

function _renderDashQuickLog() {
  const ql = document.getElementById('dash-quick-log');
  if (!ql) return;

  const today = new Date().toISOString().slice(0, 10);
  const weeklyStats = getWeeklyStats().sort((a, b) => b.date.localeCompare(a.date));
  const recentWeeks = weeklyStats.slice(0, 8);

  const lastStat = weeklyStats[0] || null;
  const lastStatTs = lastStat ? new Date(lastStat.date + 'T00:00:00').getTime() : 0;
  // Also check timer sessions -- if user tugged recently, no need to nag
  const sessions = typeof getTimerSessions === 'function' ? getTimerSessions() : [];
  const lastTugSession = sessions.find(s => s.type === 'tugging');
  const lastTugTs = lastTugSession ? (lastTugSession.endedAt || lastTugSession.startedAt || 0) : 0;
  const lastTugData = Math.max(lastStatTs, lastTugTs);
  const overdue = !lastTugData || (Date.now() - lastTugData) >= 7 * 86400000;
  const overdueBadge = overdue
    ? `<span style="background:var(--green);color:#fff;font-family:var(--mono);font-size:11px;padding:2px 8px;border-radius:10px;font-weight:600;margin-left:8px">(update)</span>`
    : '';

  // Pre-fill from timer sessions (per-active-day values)
  const timerSummary = computeWeekSummaryFromSessions(today);
  const prefillTugD = timerSummary.hasSessions ? timerSummary.daysTugged : (lastStat?.daysTugged ?? (lastStat?.pctTugged != null ? Math.round(lastStat.pctTugged * 7 / 100) : null));
  const prefillRetD = timerSummary.hasSessions ? timerSummary.daysRetained : (lastStat?.daysRetained ?? (lastStat?.pctRetained != null ? Math.round(lastStat.pctRetained * 7 / 100) : null));
  const prefillTugH = timerSummary.hasSessions ? timerSummary.hrsPerDayTugged : (lastStat?.hrsPerDayTugged ?? lastStat?.avgHrsTugged ?? null);
  const prefillRetH = timerSummary.hasSessions ? timerSummary.hrsPerDayRetained : (lastStat?.hrsPerDayRetained ?? lastStat?.avgHrsRetained ?? null);
  const timerNote = timerSummary.hasSessions
    ? `<div style="font-family:var(--mono);font-size:11px;color:var(--green);margin-bottom:8px">From timer: ${timerSummary.daysTugged}d, ${timerSummary.totalHrsTugged != null ? timerSummary.totalHrsTugged.toFixed(1) + 'h total' : ''}${prefillTugH != null ? ' (' + prefillTugH + 'h/day)' : ''} tug${timerSummary.daysRetained ? ' · ' + timerSummary.daysRetained + 'd, ' + (timerSummary.totalHrsRetained != null ? timerSummary.totalHrsRetained.toFixed(1) + 'h total' : '') + (prefillRetH != null ? ' (' + prefillRetH + 'h/day)' : '') + ' ret' : ''}</div>`
    : '';

  ql.innerHTML = `
    <details class="panel">
      <summary class="ph" style="cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;outline:none">
        <h2 style="margin:0;flex:1">Weekly Tugging Log${overdueBadge}</h2>
        <span style="font-family:var(--mono);font-size:11px;color:var(--muted2)">expand</span>
      </summary>
      <div class="pb">
        <p style="font-size:12px;color:var(--muted);margin:0 0 12px 0;line-height:1.5">Track weekly tugging and retention habits. Overrides incomplete timer data for the week. </p>
        ${timerNote}
        <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;margin-bottom:10px">
          <div class="fg" style="margin:0">
            <label>Week ending</label>
            <input type="date" id="ql-date" value="${today}">
          </div>
          <div class="fg" style="margin:0">
            <label>Days Tugged (0–7)</label>
            <input type="number" id="ql-tug" min="0" max="7" step="1" inputmode="numeric" placeholder="0–7"${prefillTugD != null ? ` value="${prefillTugD}"` : ''}>
          </div>
          <div class="fg" style="margin:0">
            <label>Days Retained (0–7)</label>
            <input type="number" id="ql-ret" min="0" max="7" step="1" inputmode="numeric" placeholder="0–7"${prefillRetD != null ? ` value="${prefillRetD}"` : ''}>
          </div>
          <div class="fg" style="margin:0">
            <label>Hrs/day tugged</label>
            <input type="number" id="ql-hrt" min="0" max="24" step="0.5" inputmode="decimal" placeholder="e.g. 4"${prefillTugH != null ? ` value="${prefillTugH}"` : ''}>
          </div>
          <div class="fg" style="margin:0">
            <label>Hrs/day retained</label>
            <input type="number" id="ql-hrr" min="0" max="24" step="0.5" inputmode="decimal" placeholder="e.g. 8"${prefillRetH != null ? ` value="${prefillRetH}"` : ''}>
          </div>
        </div>
        <div class="fg" style="margin:0 0 10px 0">
          <label>Note (optional)</label>
          <input type="text" id="ql-note" placeholder="Brief note..." class="input" style="font-size:12px">
        </div>
        <button class="btn btn-primary btn-sm" data-action="saveQuickLog">Log Week</button>

        ${recentWeeks.length ? `
        <div style="margin-top:14px;border-top:1px solid var(--border);padding-top:10px">
          <div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:8px;text-transform:uppercase;letter-spacing:.05em">Recent weeks</div>
          <div style="overflow-x:auto">
            <table style="width:100%;border-collapse:collapse;font-family:var(--mono);font-size:11px">
              <thead>
                <tr style="border-bottom:1px solid var(--border2)">
                  <th style="text-align:left;padding:4px 8px 4px 0;color:var(--muted2);font-weight:500">Date</th>
                  <th style="text-align:right;padding:4px 6px;color:var(--muted2);font-weight:500">Tug</th>
                  <th style="text-align:right;padding:4px 6px;color:var(--muted2);font-weight:500">Ret</th>
                  <th style="text-align:left;padding:4px 6px;color:var(--muted2);font-weight:500">Note</th>
                  <th style="width:24px"></th>
                </tr>
              </thead>
              <tbody>
                ${recentWeeks.map(s => {
                  const tD = s.daysTugged ?? (s.pctTugged != null ? Math.round(s.pctTugged * 7 / 100) : null);
                  const rD = s.daysRetained ?? (s.pctRetained != null ? Math.round(s.pctRetained * 7 / 100) : null);
                  const tH = s.hrsPerDayTugged ?? s.avgHrsTugged;
                  const rH = s.hrsPerDayRetained ?? s.avgHrsRetained;
                  const tugCell = tD != null ? tD + 'd' + (tH != null ? ' · ' + tH + 'h/d' : '') : '-';
                  const retCell = rD != null ? rD + 'd' + (rH != null ? ' · ' + rH + 'h/d' : '') : '-';
                  const src = s.source === 'timer' ? '<span title="From timer" style="color:var(--green)">T</span>' : '';
                  return `<tr style="border-bottom:1px solid var(--border)">
                  <td style="padding:5px 8px 5px 0;color:var(--muted)">${esc(s.date)} ${src}</td>
                  <td style="text-align:right;padding:5px 6px;color:var(--text);white-space:nowrap">${tugCell}</td>
                  <td style="text-align:right;padding:5px 6px;color:var(--text);white-space:nowrap">${retCell}</td>
                  <td style="padding:5px 6px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px">${esc(s.notes || '')}</td>
                  <td style="padding:5px 0"><button class="btn-icon" data-action="deleteWeekStat" data-a0="${s.id}" title="Delete">x</button></td>
                </tr>`;
                }).join('')}
              </tbody>
            </table>
          </div>
        </div>` : ''}
      </div>
    </details>`;
}

// ── Local date helpers ───────────────────────────────────────────────────────
// Always use local calendar date so "today" matches the user's clock, not UTC.
function _localDateStr(ts) {
  const d = new Date(ts);
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function _localTodayStr() { return _localDateStr(Date.now()); }

// ── Today timer totals ────────────────────────────────────────────────────────
// Returns accumulated tug/ret milliseconds from all completed sessions today (local date).
function getTodayTimerTotals() {
  const todayStr = _localTodayStr();
  let tugMs = 0, retMs = 0;
  const sessions = typeof getTimerSessions === 'function' ? getTimerSessions() : [];
  sessions.filter(s => s.durationMs > 0 && _localDateStr(s.startedAt) === todayStr)
    .forEach(s => { if (s.type === 'tugging') tugMs += s.durationMs; else retMs += s.durationMs; });
  return { tugMs, retMs };
}

// ── Profile Panel ──────────────────────────────────────────────────────────────

// Targeted timer badge update -- avoids full innerHTML rewrite that causes dot flicker.
// Only updates the timer/KOT container for a specific buddy (or self).
function _updateBuddyTimerBadge(pubKeyB64) {
  const slice = pubKeyB64 ? pubKeyB64.slice(0, 8) : 'self';
  const el = document.getElementById(`dash-timer-${slice}`);
  if (!el) { _renderDashProfile(); return; } // element doesn't exist yet -- need full render

  if (!pubKeyB64) {
    // Self row: show own active timer badge + received KOT count
    const active = typeof getActiveTimer === 'function' ? getActiveTimer() : null;
    let selfBadge = '', selfKot = '';
    if (active && !active.stopped && active.type) {
      const _dsel = typeof _getTimerDeviceSel === 'function' ? _getTimerDeviceSel() : {};
      const _dev = _dsel[active.type === 'tugging' ? 'tug' : 'ret'] || null;
      selfBadge = `<span style="display:inline-flex;align-items:center;gap:3px"><span class="${active.type === 'tugging' ? 'tug-pulse' : 'ret-pulse'}" style="font-size:12px;line-height:1">${active.type === 'tugging' ? '🟢' : '🔵'}</span><span style="font-family:var(--mono);font-size:11px;font-weight:600;color:var(--muted)">${active.type === 'tugging' ? 'tugging' : 'retaining'}${_dev ? ` · ${esc(_dev)}` : ''}</span></span>`;
      if (active.id) {
        const _rcvd = typeof getSessionKotCount === 'function' ? getSessionKotCount(active.id) : 0;
        if (_rcvd > 0) selfKot = `<span style="font-family:var(--mono);font-size:12px;color:var(--muted);display:inline-flex;align-items:center;gap:3px" title="${_rcvd} KOT${_rcvd > 1 ? 's' : ''} this session">✊ <span style="font-weight:600">${_rcvd}</span></span>`;
      }
    }
    el.innerHTML = (selfBadge || selfKot)
      ? `<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid var(--border2)">${selfKot}${selfBadge}</div>`
      : '';
    return;
  }

  const buddyTimer = typeof getBuddyTimerStatus === 'function'
    ? getBuddyTimerStatus(pubKeyB64) : null;
  const buddyTimerBadge = buddyTimer?.type
    ? `<span style="display:inline-flex;align-items:center;gap:3px"><span class="${buddyTimer.type === 'tugging' ? 'tug-pulse' : 'ret-pulse'}" style="font-size:12px;line-height:1">${buddyTimer.type === 'tugging' ? '🟢' : '🔵'}</span><span style="font-family:var(--mono);font-size:11px;font-weight:600;color:var(--muted)">${buddyTimer.type === 'tugging' ? 'tugging' : 'retaining'}${buddyTimer.device ? ` · ${esc(buddyTimer.device)}` : ''}</span></span>`
    : '';
  let kotArea = '';
  if (buddyTimer?.id) {
    const _canKot = buddyTimer.type === 'tugging' || buddyTimer.type === 'retaining';
    const _alreadyKot = typeof hasGivenSessionKot === 'function' && hasGivenSessionKot(buddyTimer.id);
    if (_canKot || _alreadyKot) {
      const _kotTitle = _alreadyKot ? 'KOT sent' : (buddyTimer.type === 'retaining' ? 'Keep on Retaining!' : 'Keep on Tugging!');
      kotArea = `<button class="btn btn-sm" style="padding:2px 7px;font-size:14px;line-height:1.2;min-width:0" data-action="sendSessionKot" data-a0="${esc(pubKeyB64)}" data-a1="${esc(buddyTimer.id)}" ${_alreadyKot ? 'disabled' : ''} title="${_kotTitle}">✊${_alreadyKot ? '<span style=\'font-family:var(--mono);font-size:10px;margin-left:2px\'>✓</span>' : ''}</button>`;
    }
  }
  const inner = (buddyTimerBadge || kotArea)
    ? `<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid var(--border2)">${kotArea}${buddyTimerBadge}</div>`
    : '';
  el.innerHTML = inner;
}

function _renderDashProfile() {
  // Debounce: cancel any pending RAF + timer, then wait 80ms before rendering.
  // This batches rapid-fire P2P events (journal patch, settings push, trust ack
  // all arriving within ~100 ms) into a single DOM write instead of three flickers.
  cancelAnimationFrame(_renderProfileRaf);
  clearTimeout(_renderProfileTimer);
  _renderProfileTimer = setTimeout(() => {
    _renderProfileTimer = null;
    _renderProfileRaf = requestAnimationFrame(_doRenderDashProfile);
  }, 80);
}

function _doRenderDashProfile() {
  _renderProfileRaf = 0;
  const profileEl = document.getElementById('dash-profile');
  if (!profileEl) return;
  const entries = getEntries().sort((a, b) => b.date.localeCompare(a.date));
  const html = renderDashProfile(entries) || '';
  // Skip the DOM write if content hasn't actually changed -- avoids flash on redundant calls.
  if (profileEl.innerHTML !== html) profileEl.innerHTML = html;
  // Start/stop the 60s tick for live buddy elapsed-time display
  _syncBuddyProfileTick();
}


function renderDashProfile(entries) {
  const contacts = (typeof getContacts === 'function') ? getContacts() : [];
  const weeklyStats = getWeeklyStats();
  const profile = (typeof getProfile === 'function') ? getProfile() : {};
  function deviceChips(devArr) {
    if (!devArr || !devArr.length) return '';
    return devArr.map(d => `<span style="background:var(--surface2);border:1px solid var(--border2);font-family:var(--mono);font-size:11px;padding:3px 8px;border-radius:4px;white-space:nowrap;color:var(--muted)">${esc(d)}</span>`).join('');
  }

  // entry badge chip: date pill + click nav (no hover preview)
  function entryBadge(date, _previewSrc, entryId, pubKeyB64) {
    const label = (typeof fmtMonthYear === 'function') ? fmtMonthYear(date) : date;
    const action = pubKeyB64 ? 'entryBadgeBuddy' : 'entryBadgeOwn';
    const a0 = pubKeyB64 ? esc(pubKeyB64) : esc(entryId);
    const a1 = pubKeyB64 ? esc(entryId) : '';
    return `<span data-action="${action}" data-a0="${a0}" ${a1 ? `data-a1="${a1}"` : ''} title="${esc(date)}" style="cursor:pointer;background:var(--surface2);border:1px solid var(--border2);font-family:var(--mono);font-size:11px;padding:3px 9px;border-radius:10px;white-space:nowrap;color:var(--muted);display:inline-block">${esc(label)}</span>`;
  }

  function profileRow(name, latestEntry, peekImgSrc, avgTug, avgRet, badges, namePubKey, showUpdateBtn, freqBadges, noPhotoText, isTrusted, peerOnline, avatarEmoji, achievementBadges, activityDays, sparkPoints, timerTypeOverride, kotSessionId) {
    const ci = latestEntry?.ci;
    const tugDevs = latestEntry?.tuggingDevices || [];
    const retDevs = latestEntry?.retainingDevices || [];
    // Online + idle (tab hidden / AFK) gets an amber dot instead of green.
    // Flat dots, no glow halo -- the bloom reads as "AI dashboard" rather than
    // "appliance status light," which is what these actually are.
    const peerIdle = peerOnline && namePubKey && typeof isBuddyIdle === 'function' && isBuddyIdle(namePubKey);
    const onlineDot = peerOnline
      ? (peerIdle
          ? `<span title="Away" style="width:7px;height:7px;border-radius:50%;background:#f97316;display:inline-block;vertical-align:middle;margin-right:5px;margin-bottom:1px"></span>`
          : `<span style="width:7px;height:7px;border-radius:50%;background:var(--green);display:inline-block;vertical-align:middle;margin-right:5px;margin-bottom:1px"></span>`)
      : '';
    const buddyTimer = timerTypeOverride ? { type: timerTypeOverride }
      : namePubKey ? ((typeof getBuddyTimerStatus === 'function') ? getBuddyTimerStatus(namePubKey) : null) : null;
    const buddyTimerBadge = buddyTimer?.type
      ? `<span style="display:inline-flex;align-items:center;gap:3px"><span class="${buddyTimer.type === 'tugging' ? 'tug-pulse' : 'ret-pulse'}" style="font-size:12px;line-height:1">${buddyTimer.type === 'tugging' ? '🟢' : '🔵'}</span><span style="font-family:var(--mono);font-size:11px;font-weight:600;color:var(--muted)">${buddyTimer.type === 'tugging' ? 'tugging' : 'retaining'}${buddyTimer.device ? ` · ${esc(buddyTimer.device)}` : ''}</span></span>`
      : '';
    const trustBadge = isTrusted === 'mutual'
      ? `<span style="background:#7c3aed22;border:1px solid #7c3aed55;color:#7c3aed;font-family:var(--mono);font-size:11px;padding:3px 8px;border-radius:8px;font-weight:600">Trusted</span>`
      : isTrusted === 'pending'
      ? `<span style="background:#3b82f622;border:1px solid #3b82f655;color:#3b82f6;font-family:var(--mono);font-size:11px;padding:3px 8px;border-radius:8px;font-weight:600">Trusting</span>`
      : '';
    const achBadges = (achievementBadges || []).map(b =>
      `<span title="${esc(b.tooltip)}" style="display:inline-flex;align-items:center;gap:3px;background:var(--surface2);border:1px solid var(--border2);border-radius:10px;padding:3px 8px 3px 6px;font-size:12px;line-height:1.3;white-space:nowrap">` +
      `<span style="font-size:${b.large ? '15px' : '13px'};line-height:1">${b.emoji}</span>` +
      `<span style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${esc(b.label)}</span>` +
      `</span>`
    ).join('');
    const allDevs = [...(tugDevs.length ? tugDevs : []), ...(retDevs.length ? retDevs : [])];

    // KOT + timer row -- stable container for targeted updates (avoids full-panel rewrite flicker)
    const _timerContainerId = namePubKey ? `dash-timer-${namePubKey.slice(0,8)}` : 'dash-timer-self';
    let kotRow = '';
    {
      let kotArea = '';
      if (namePubKey && kotSessionId) {
        const _canKot = buddyTimer?.type === 'tugging' || buddyTimer?.type === 'retaining';
        const _alreadyKot = typeof hasGivenSessionKot === 'function' && hasGivenSessionKot(kotSessionId);
        if (_canKot || _alreadyKot) {
          const _kotTitle = _alreadyKot ? 'KOT sent' : (buddyTimer?.type === 'retaining' ? 'Keep on Retaining!' : 'Keep on Tugging!');
          kotArea = `<button class="btn btn-sm" style="padding:2px 7px;font-size:14px;line-height:1.2;min-width:0" data-action="sendSessionKot" data-a0="${esc(namePubKey)}" data-a1="${esc(kotSessionId)}" ${_alreadyKot ? 'disabled' : ''} title="${_kotTitle}">✊${_alreadyKot ? '<span style=\'font-family:var(--mono);font-size:10px;margin-left:2px\'>✓</span>' : ''}</button>`;
        }
      } else if (!namePubKey && kotSessionId) {
        const _rcvd = typeof getSessionKotCount === 'function' ? getSessionKotCount(kotSessionId) : 0;
        if (_rcvd > 0) {
          kotArea = `<span style="font-family:var(--mono);font-size:12px;color:var(--muted);display:inline-flex;align-items:center;gap:3px" title="${_rcvd} KOT${_rcvd > 1 ? 's' : ''} this session">✊ <span style="font-weight:600">${_rcvd}</span></span>`;
        }
      }
      const inner = (buddyTimerBadge || kotArea)
        ? `<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid var(--border2)">${kotArea}${buddyTimerBadge}</div>`
        : '';
      kotRow = `<div id="${_timerContainerId}">${inner}</div>`;
    }

    // Left column: avatar, name, trust badge, photo (snaps to column width, 3:4 aspect),
    // CI pill, gallery button. When the dashboard photo setting is off (or no photo
    // available), the photo slot becomes a 📷 peek button instead.
    const _showAllImgs = localStorage.getItem('frj_dash_show_all_imgs') === '1';
    const _profileClickAttrs = namePubKey !== undefined ? `data-action="profileNameClick" ${namePubKey ? `data-a0="${esc(namePubKey)}"` : ''}` : '';

    // Photo slot: snaps to column width while keeping 3:4 aspect ratio
    let _photoSlot = '';
    if (_showAllImgs && peekImgSrc) {
      _photoSlot = `<div style="margin-top:7px;width:100%;border-radius:6px;overflow:hidden;border:1px solid var(--border2)"><img src="${peekImgSrc}" style="width:100%;display:block;aspect-ratio:3/4;object-fit:cover;cursor:pointer" data-no-context="1" data-no-drag="1" data-peek-src="${peekImgSrc}"></div>`;
    } else if (peekImgSrc) {
      _photoSlot = `<button class="btn btn-ghost btn-sm" style="margin-top:7px;font-size:18px;padding:5px 7px;line-height:1;border-color:transparent" title="Hold to preview photo" data-peek-src="${peekImgSrc}">📷</button>`;
    } else if (namePubKey && latestEntry?.hasImgFront) {
      _photoSlot = `<button class="btn btn-ghost btn-sm" style="margin-top:7px;font-size:18px;padding:5px 7px;line-height:1;border-color:transparent;opacity:.5" title="Photo available -- loading">📷</button>`;
    }

    const _galleryBtn = namePubKey
      ? (Object.keys((typeof getGalleryReceived === 'function' ? getGalleryReceived() : {})[namePubKey.slice(0,16)] || {}).length > 0 ? `<button class="btn btn-ghost btn-sm" style="margin-top:7px;font-size:16px;padding:5px 7px;line-height:1;border-color:transparent" data-action="openBuddyGallery" data-a0="${esc(namePubKey)}" title="Gallery">🖼️</button>` : '')
      : ((typeof getGalleries === 'function' && getGalleries().length > 0) ? `<button class="btn btn-ghost btn-sm" style="margin-top:7px;font-size:16px;padding:5px 7px;line-height:1;border-color:transparent" data-action="tab" data-a0="gallery" title="My Gallery">🖼️</button>` : '');

    const _hasPhoto = _showAllImgs && peekImgSrc;
    const _leftCompact = `<div class="profile-avatar-col${_hasPhoto ? ' has-photo' : ''}">
      <div style="font-size:42px;line-height:1;margin-bottom:6px${namePubKey !== undefined ? ';cursor:pointer' : ''}" ${_profileClickAttrs}>${avatarEmoji ? esc(avatarEmoji) : '🌱'}</div>
      <div style="font-size:12px;font-weight:600;text-align:center;color:var(--muted);word-break:break-word${namePubKey !== undefined ? ';cursor:pointer' : ''}" ${_profileClickAttrs}>${onlineDot}${esc(name)}</div>
      ${trustBadge ? `<div style="margin-top:4px">${trustBadge}</div>` : ''}
      ${_photoSlot}
      ${ci != null ? `<div style="margin-top:6px"><span class="ci-badge">CI-${ci}</span></div>` : ''}
      ${_galleryBtn}
    </div>`;

    return `<div style="display:flex;gap:0;border-bottom:1px solid ${isTrusted === 'mutual' ? '#7c3aed55' : 'var(--border)'}${isTrusted === 'mutual' ? ';background:#7c3aed08' : ''};align-items:stretch">
      ${_leftCompact}
      <!-- Right: timer+KOT row, sparklines, then bottom 2-col -->
      <div style="flex:1;min-width:0;padding:10px 10px 8px;display:flex;flex-direction:column;gap:0">
        ${kotRow}
        ${sparkPoints && sparkPoints.length
          ? `<div style="margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--border2)">${_weeklyStackedBarsSvg(sparkPoints)}</div>`
          : (namePubKey === null
              ? `<div style="margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--border2)">${_dailyStackedBarsFallback()}</div>`
              : '')}
        <div style="display:flex;gap:0;flex:1;align-items:stretch;min-height:0">
          <!-- Bottom-left: averages, devices, achievements, journal entries -->
          <div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;padding-right:8px">
            ${avgTug || avgRet ? `<div style="font-family:var(--mono);font-size:11px;color:var(--muted);margin-bottom:2px">Today: ${[avgTug ? avgTug + ' tugged' : '', avgRet ? avgRet + ' retained' : ''].filter(Boolean).join(' · ')}</div>` : ''}
            ${allDevs.length ? `<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;margin-top:2px"><div style="display:flex;flex-wrap:nowrap;gap:3px;min-width:max-content">${deviceChips(allDevs)}</div></div>` : ''}
            ${achBadges ? `<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;margin-top:3px"><div style="display:flex;flex-wrap:nowrap;gap:4px;min-width:max-content">${achBadges}</div></div>` : ''}
            <div style="overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;margin-top:auto;padding-top:6px;padding-bottom:2px">
              <div style="display:flex;flex-wrap:nowrap;gap:4px;min-width:max-content">${badges.join('')}</div>
            </div>
          </div>
          <!-- Bottom-right: daily activity bar chart -->
          ${activityDays && activityDays.length ? `<div style="flex:0 0 55%;min-width:0;border-left:1px solid var(--border2);padding-left:8px">${activityDays[0]?.sessions ? _activityTimelineSvg(activityDays) : _activityBarSvg(activityDays)}</div>` : ''}
        </div>
      </div>
    </div>`;
  }

  const rows = [];

  // ── Own profile ──
  const ownLatest = entries[0] || null;
  const ownImgEntry = entries.find(e => e.imgFront && imgURL(e.imgFront));
  const ownImgSrc = ownImgEntry ? imgURL(ownImgEntry.imgFront) : null;
  const ownBadges = [...entries].sort((a, b) => a.date.localeCompare(b.date)).slice(-30).map(e => {
    const previewSrc = e.imgFront ? imgURL(e.imgFront) : null;
    return entryBadge(e.date, previewSrc, e.id);
  });
  const ownAvatar = profile.avatar || '';
  const ownAchievements = (typeof computeBadges === 'function') ? computeBadges() : [];
  // Own activity-days panel is rendered separately above the timer hero (see
  // _renderDashTimer). No need to compute or pass activityDays here for the Me row.
  const ownSparkPoints = [...weeklyStats].sort((a, b) => a.date.localeCompare(b.date)).map(s => ({ tugPct: s.pctTugged || 0, tugHrs: s.hrsPerDayTugged || s.avgHrsTugged || 0, retPct: s.pctRetained || 0, retHrs: s.hrsPerDayRetained || s.avgHrsRetained || 0 }));
  const ownActive = (typeof getActiveTimer === 'function') ? getActiveTimer() : null;
  const ownTimerType = (ownActive && !ownActive.stopped) ? ownActive.type : null;

  // Today's hours: completed sessions + currently active timer if started today
  const _todayTotals = getTodayTimerTotals();
  let _ownTodayTug = _todayTotals.tugMs / 3600000;
  let _ownTodayRet = _todayTotals.retMs / 3600000;
  if (ownActive && !ownActive.stopped && ownActive.startedAt &&
      _localDateStr(ownActive.startedAt) === _localTodayStr()) {
    const _elapsed = (Date.now() - ownActive.startedAt) / 3600000;
    if (ownActive.type === 'tugging') _ownTodayTug += _elapsed;
    else _ownTodayRet += _elapsed;
  }
  function _fmtTodayH(h) {
    if (!h || h <= 0) return null;
    const m = Math.round(h * 60);
    if (m < 1) return null;
    return m < 60 ? m + 'm' : parseFloat(h.toFixed(1)) + 'h';
  }
  // Daily tracker (activityDays) is rendered as a separate full-width panel above the
  // timer buttons (see _renderDashTimer). Pass null here so the Me row matches buddy rows.
  rows.push(profileRow('Me', ownLatest, ownImgSrc || null, _fmtTodayH(_ownTodayTug), _fmtTodayH(_ownTodayRet), ownBadges, null, true, [], null, null, false, ownAvatar, ownAchievements, null, ownSparkPoints, ownTimerType, ownActive && !ownActive.stopped ? ownActive.id : null));

  // ── Buddy profiles -- mutual-trust first, then one-sided trusting, then regular ──
  if (localStorage.getItem('frj_hide_buddy_data') === '1') {
    if (!rows.length) return '';
    return `<div class="panel">${rows.join('')}</div>`;
  }
  const sortedContacts = [
    ...contacts.filter(c => c.trusted && c.senderTrusts),
    ...contacts.filter(c => c.trusted && !c.senderTrusts),
    ...contacts.filter(c => !c.trusted),
  ];
  sortedContacts.forEach(c => {
    const j = (typeof getPeerJournal === 'function') ? getPeerJournal(c.pubKeyB64) : c.journal;
    // Today's hours for buddy: from live timer state broadcast
    const bTimerState = (typeof getBuddyTimerStatus === 'function') ? getBuddyTimerStatus(c.pubKeyB64) : null;
    const bHasEntries = j && (j.entries || []).length > 0;
    // Show row if buddy has journal data OR a live timer state -- whichever arrives first
    if (!bHasEntries && !bTimerState) return;
    const mutualTrust = !!(c.trusted && c.senderTrusts);
    const trustState = mutualTrust ? 'mutual' : c.trusted ? 'pending' : null;
    const bEntries = (j?.entries || []).sort((a, b) => b.date.localeCompare(a.date));
    const bLatest = bEntries[0];
    // Show camera if buddy has any entry with hasImgFront (permission-based, not cache-based)
    const bWithImg = bEntries.find(e => e.hasImgFront);
    const bImgKey  = bWithImg ? `c_${c.pubKeyB64.slice(0, 16)}_${bWithImg.id}_front` : null;
    const bPeekSrc = bImgKey ? imgURL(bImgKey) : null; // may be null if not cached yet -- camera still shows
    let bTodayTug = bTimerState ? (bTimerState.todayTugMs || 0) / 3600000 : 0;
    let bTodayRet = bTimerState ? (bTimerState.todayRetMs || 0) / 3600000 : 0;
    if (bTimerState && bTimerState.type && bTimerState.startedAt &&
        new Date(bTimerState.startedAt).toISOString().slice(0, 10) === new Date().toISOString().slice(0, 10)) {
      const bElapsed = (Date.now() - bTimerState.startedAt) / 3600000;
      if (bTimerState.type === 'tugging') bTodayTug += bElapsed;
      else bTodayRet += bElapsed;
    }
    const bAvgTug = _fmtTodayH(bTodayTug);
    const bAvgRet = _fmtTodayH(bTodayRet);
    const bBadges = [...bEntries].sort((a, b) => a.date.localeCompare(b.date)).slice(-30).map(e => {
      const imgKey = `c_${c.pubKeyB64.slice(0, 16)}_${e.id}_front`;
      const previewSrc = imgURL(imgKey);
      return entryBadge(e.date, previewSrc, e.id, c.pubKeyB64);
    });
    const bOnline = typeof isOnline === 'function' ? isOnline(c.pubKeyB64) : false;
    const _bNewestPhoto = bEntries.find(e => e.hasImgFront);
    const _bDenied = _bNewestPhoto && typeof p2pIsImgDenied === 'function' && p2pIsImgDenied(c.pubKeyB64, _bNewestPhoto.id, 'front');
    const bNoPhotoText = _bDenied ? 'Photo unavailable' : (bOnline && _bNewestPhoto) ? 'Loading...' : null;
    const bAvatar = j?.avatar || '';
    const bAchievements = (typeof computeBadgesFrom === 'function') ? computeBadgesFrom(bEntries, j?.profile || {}, []) : [];
    // Add received gallery photos to the photo badge count
    const _bRecv = (typeof getGalleryReceived === 'function') ? getGalleryReceived() : {};
    const _bGals = _bRecv[c.pubKeyB64.slice(0, 16)] || {};
    let _bGalPhotoCount = 0;
    for (const gal of Object.values(_bGals)) _bGalPhotoCount += Object.keys(gal.items || {}).length;
    if (_bGalPhotoCount > 0) {
      const _pBadge = bAchievements.find(b => b.key === 'photos');
      if (_pBadge) {
        const total = parseInt(_pBadge.label) + _bGalPhotoCount;
        _pBadge.label = `${total} photo${total !== 1 ? 's' : ''}`;
        _pBadge.tooltip = `${total} photo${total !== 1 ? 's' : ''} accessible`;
      } else {
        bAchievements.push({ key: 'photos', emoji: '📷', label: `${_bGalPhotoCount} photo${_bGalPhotoCount !== 1 ? 's' : ''}`, tooltip: `${_bGalPhotoCount} gallery photo${_bGalPhotoCount !== 1 ? 's' : ''} accessible`, large: false });
      }
    }
    // Sparklines: aggregate journal entries into weekly averages (same format as own)
    const bSorted = [...bEntries].sort((a, b) => a.date.localeCompare(b.date));
    const _bWeekMap = new Map();
    bSorted.forEach(e => {
      const _d = new Date(e.date + 'T00:00:00');
      _d.setDate(_d.getDate() + 4 - (_d.getDay() || 7));
      const _wk = `${_d.getFullYear()}-W${String(Math.ceil(((_d - new Date(_d.getFullYear(), 0, 1)) / 86400000 + 1) / 7)).padStart(2, '0')}`;
      if (!_bWeekMap.has(_wk)) _bWeekMap.set(_wk, []);
      _bWeekMap.get(_wk).push(e);
    });
    const bSparkPoints = [..._bWeekMap.values()].map(wk => {
      const n = wk.length;
      return {
        tugPct: Math.round(wk.reduce((s, e) => s + (e.pctTugged || 0), 0) / n),
        tugHrs: parseFloat((wk.reduce((s, e) => s + (e.hrsPerDayTugged || e.avgHrsTugged || 0), 0) / n).toFixed(2)),
        retPct: Math.round(wk.reduce((s, e) => s + (e.pctRetained || 0), 0) / n),
        retHrs: parseFloat((wk.reduce((s, e) => s + (e.hrsPerDayRetained || e.avgHrsRetained || 0), 0) / n).toFixed(2)),
      };
    });
    // No detailed activity timeline for buddies -- pass null (privacy: no session-level data)
    rows.push(profileRow(
      c.nickname || 'Buddy', bLatest, bPeekSrc, bAvgTug, bAvgRet, bBadges,
      c.pubKeyB64,
      false, null, bNoPhotoText, trustState, bOnline, bAvatar, bAchievements,
      null, bSparkPoints, undefined, bTimerState?.id || null
    ));
  });

  if (!rows.length) return '';

  // Quick Check buttons
  let qcHtml = '';
  try {
    if (typeof qcTickPeriods === 'function') qcTickPeriods();
    const _qcs = typeof getQuickChecks === 'function' ? getQuickChecks() : [];
    const activeQcs = _qcs.filter(q => q.active);
    // Pending proposals (need accept/decline)
    const pending = activeQcs.filter(q => !q.acceptedAt && !q.initiatedByMe);
    // Active schedules
    const active = activeQcs.filter(q => q.acceptedAt);
    // Pending outbound (waiting for buddy to accept)
    const outbound = activeQcs.filter(q => !q.acceptedAt && q.initiatedByMe);
    if (pending.length || active.length || outbound.length) {
      let btns = '';
      for (const qc of pending) {
        const c = contacts.find(x => x.pubKeyB64 === qc.buddyPubKey);
        const name = c?.journal?.displayName || c?.nickname || '?';
        const avatar = c?.journal?.avatar || '📋';
        btns += `<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid #f59e0b;border-radius:8px;background:#f59e0b11;margin-bottom:6px">
          <span style="font-size:18px">${esc(avatar)}</span>
          <div style="flex:1;min-width:0">
            <div style="font-size:12px;font-weight:600;color:var(--text)">${esc(name)} wants to Quick Check</div>
            <div style="font-family:var(--mono);font-size:10px;color:var(--muted2)">${esc(qc.description)} · ${_QC_CADENCES[qc.cadence]?.label || qc.cadence}</div>
          </div>
          <button class="btn btn-sm" style="background:#10b981;color:#fff;border-color:#10b981" data-action="qcAccept" data-a0="${esc(qc.id)}">Accept</button>
          <button class="btn btn-ghost btn-sm" data-action="qcDecline" data-a0="${esc(qc.id)}">✕</button>
        </div>`;
      }
      for (const qc of active) {
        const c = contacts.find(x => x.pubKeyB64 === qc.buddyPubKey);
        const name = c?.journal?.displayName || c?.nickname || '?';
        const avatar = c?.journal?.avatar || '📋';
        const status = typeof qcDeadlineStatus === 'function' ? qcDeadlineStatus(qc) : 'idle';
        const color = typeof qcStatusColor === 'function' ? qcStatusColor(status) : '#6b7280';
        const timeLeft = typeof qcTimeLeft === 'function' ? qcTimeLeft(qc) : '';
        const spreeText = qc.spree > 0 ? ` · 🔥${qc.spree}` : '';
        const statusLabel = status === 'revealed' ? '✓ Revealed' : status === 'submitted' ? '⏳ Waiting' : status === 'grace' ? '⚠ Grace' : timeLeft;
        // Outer is a div (not a button) so the inline ✕ cancel button can be nested without nested-button issues
        btns += `<div style="display:flex;align-items:center;gap:6px;padding:6px 12px;border:2px solid ${color};background:${color}15;color:var(--text);border-radius:8px;margin-bottom:4px;cursor:pointer${status === 'grace' ? ';animation:pulse 1.5s infinite' : ''}"
          data-action="qcViewDetail" data-a0="${esc(qc.id)}">
          <span style="font-size:16px;pointer-events:none">${esc(avatar)}</span>
          <span style="flex:1;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none">${esc(name)} · ${esc(qc.description)}</span>
          <span style="font-family:var(--mono);font-size:10px;color:${color};pointer-events:none">${statusLabel}${spreeText}</span>
          <button class="btn btn-ghost btn-sm" data-action="qcCancel" data-a0="${esc(qc.id)}" title="End this Quick Check" style="margin-left:4px;font-size:12px;padding:2px 6px;line-height:1;color:#ef4444" onclick="event.stopPropagation()">✕</button>
        </div>`;
      }
      for (const qc of outbound) {
        const c = contacts.find(x => x.pubKeyB64 === qc.buddyPubKey);
        const name = c?.journal?.displayName || c?.nickname || '?';
        btns += `<div style="display:flex;align-items:center;gap:8px;padding:6px 12px;border:1px dashed var(--border2);border-radius:8px;margin-bottom:4px;opacity:.6">
          <span style="font-size:12px;color:var(--muted2)">📋 Proposed to ${esc(name)}: ${esc(qc.description)} -- waiting for response</span>
          <button class="btn btn-ghost btn-sm" data-action="qcCancel" data-a0="${esc(qc.id)}" style="margin-left:auto">✕</button>
        </div>`;
      }
      qcHtml = `<div style="padding:10px 14px;border-bottom:1px solid var(--border)">${btns}</div>`;
    }
  } catch(e) { console.warn('[dash] QC render error:', e); }

  return `<div class="panel" style="margin-bottom:1.5rem">
    <div class="ph">
      <h2 style="margin:0">Progress</h2>
    </div>
    ${qcHtml}
    <div>${rows.join('')}</div>
  </div>`;
}

// ── Photo peek (hold-to-preview) ──────────────────────────────────────────────

function _peekPhoto(src) {
  let el = document.getElementById('_peekOverlay');
  if (!el) {
    el = document.createElement('div');
    el.id = '_peekOverlay';
    Object.assign(el.style, {
      position: 'fixed', inset: '0', zIndex: '9999',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      background: 'rgba(0,0,0,.78)', pointerEvents: 'none'
    });
    const img = document.createElement('img');
    img.id = '_peekOverlayImg';
    Object.assign(img.style, {
      maxWidth: '88vw', maxHeight: '82vh', borderRadius: '8px',
      objectFit: 'contain', boxShadow: '0 4px 40px rgba(0,0,0,.7)',
      userSelect: 'none', WebkitUserSelect: 'none', pointerEvents: 'none'
    });
    img.draggable = false;
    el.appendChild(img);
    document.body.appendChild(el);
  }
  document.getElementById('_peekOverlayImg').src = src;
  el.style.display = 'flex';
}

function _unpeekPhoto() {
  const el = document.getElementById('_peekOverlay');
  if (el) el.style.display = 'none';
}

// ── Daily stacked bars: last 14 days of tugging + retaining hours ────────────
// Tugging stacks first (green), retaining on top (blue). Y-axis is fixed at
// 24h so consistency reads visually -- you can see at a glance whether you're
// at 1h/day or 8h/day without the chart auto-scaling away the difference.
// This replaces the weekly sparklines, which conflated "% days" + "hrs/day"
// and didn't communicate volume well.
function _dailyStackedBarsSvg() {
  const sessions = (typeof getTimerSessions === 'function') ? getTimerSessions() : [];
  // Build last 14 days, oldest to newest, keyed by local date string.
  const days = [];
  const byDate = {};
  const todayDate = new Date(); todayDate.setHours(0, 0, 0, 0);
  for (let i = 13; i >= 0; i--) {
    const d = new Date(todayDate.getTime() - i * 86400000);
    const ds = _localDateStr(d.getTime());
    const day = { date: ds, tug: 0, ret: 0 };
    days.push(day);
    byDate[ds] = day;
  }
  // Sum completed sessions into their day bucket.
  for (const s of sessions) {
    if (!s.startedAt || !s.durationMs) continue;
    const ds = _localDateStr(s.startedAt);
    const day = byDate[ds];
    if (!day) continue;
    const hrs = s.durationMs / 3600000;
    if (s.type === 'tugging') day.tug += hrs;
    else if (s.type === 'retaining') day.ret += hrs;
  }
  // Also add the currently-active timer's elapsed time to today's bar so the
  // chart updates in real time as you tug.
  const active = (typeof getActiveTimer === 'function') ? getActiveTimer() : null;
  if (active && !active.stopped && active.startedAt) {
    const ds = _localDateStr(active.startedAt);
    const day = byDate[ds];
    if (day) {
      const elapsedHrs = (Date.now() - active.startedAt) / 3600000;
      if (active.type === 'tugging') day.tug += elapsedHrs;
      else if (active.type === 'retaining') day.ret += elapsedHrs;
    }
  }

  const W = 600, H = 80;
  const PAD = { top: 6, right: 8, bottom: 14, left: 24 };
  const cW = W - PAD.left - PAD.right;
  const cH = H - PAD.top - PAD.bottom;
  const barGap = 4;
  const barW = (cW - barGap * (days.length - 1)) / days.length;
  const MAX_HRS = 24;
  const y = h => PAD.top + cH - (h / MAX_HRS) * cH;

  const bars = days.map((d, i) => {
    const x = PAD.left + i * (barW + barGap);
    const tugHrs = Math.max(0, Math.min(d.tug, MAX_HRS));
    const retCap = Math.max(0, MAX_HRS - tugHrs);
    const retHrs = Math.max(0, Math.min(d.ret, retCap));
    const tugPx = (tugHrs / MAX_HRS) * cH;
    const retPx = (retHrs / MAX_HRS) * cH;
    const baseY = PAD.top + cH;
    const tugY = baseY - tugPx;
    const retY = tugY - retPx;
    let g = '';
    if (tugPx > 0) g += `<rect x="${x.toFixed(1)}" y="${tugY.toFixed(1)}" width="${barW.toFixed(1)}" height="${tugPx.toFixed(1)}" fill="#22c55e" rx="1"><title>${d.date} · ${d.tug.toFixed(1)}h tugged</title></rect>`;
    if (retPx > 0) g += `<rect x="${x.toFixed(1)}" y="${retY.toFixed(1)}" width="${barW.toFixed(1)}" height="${retPx.toFixed(1)}" fill="#60a5fa" rx="1"><title>${d.date} · ${d.ret.toFixed(1)}h retained</title></rect>`;
    return g;
  }).join('');

  // Subtle 6/12/18h gridlines so the height of a bar reads quantitatively.
  const grid = [6, 12, 18].map(h => {
    const yPos = y(h).toFixed(1);
    return `<line x1="${PAD.left}" y1="${yPos}" x2="${PAD.left + cW}" y2="${yPos}" stroke="var(--border2)" stroke-width="1" stroke-dasharray="2,3" opacity=".5"/><text x="${(PAD.left - 4).toFixed(1)}" y="${(y(h) + 3).toFixed(1)}" font-family="var(--mono)" font-size="9" fill="var(--muted2)" text-anchor="end">${h}h</text>`;
  }).join('');

  // Day labels: today on the right, with -7 and -14 ticks for context.
  const dayLabels = days.map((d, i) => {
    const showLabel = (i === 0) || (i === 7) || (i === days.length - 1);
    if (!showLabel) return '';
    const xCenter = PAD.left + i * (barW + barGap) + barW / 2;
    const label = i === days.length - 1 ? 'today' : d.date.slice(5);
    return `<text x="${xCenter.toFixed(1)}" y="${(H - 3).toFixed(1)}" font-family="var(--mono)" font-size="8" fill="var(--muted2)" text-anchor="middle">${label}</text>`;
  }).join('');

  const totalTug = days.reduce((a, d) => a + d.tug, 0);
  const totalRet = days.reduce((a, d) => a + d.ret, 0);
  const anyData = totalTug + totalRet > 0;

  // Fallback ladder when there's no per-day session data in the last 14 days:
  //   1. Weekly stats → show last logged week + 4-week average
  //   2. Latest journal entry → show its averages
  //   3. Nothing → blank fallback
  if (!anyData) return _dailyStackedBarsFallback();

  return `<div>
    <div style="display:flex;align-items:center;gap:10px;font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:4px">
      <span><span style="display:inline-block;width:9px;height:9px;background:#22c55e;border-radius:2px;vertical-align:middle;margin-right:3px"></span>Tug</span>
      <span><span style="display:inline-block;width:9px;height:9px;background:#60a5fa;border-radius:2px;vertical-align:middle;margin-right:3px"></span>Ret</span>
      <span style="margin-left:auto;color:var(--text)">14d: ${totalTug.toFixed(1)}h tug${totalRet > 0 ? ` · ${totalRet.toFixed(1)}h ret` : ''}</span>
    </div>
    <svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${(H * 0.85).toFixed(0)}px;display:block" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
      ${grid}
      ${bars}
      ${dayLabels}
    </svg>
  </div>`;
}

// Fallback when there's no daily session data: show the most recent weekly
// log + a rolling 4-week average. If no weekly logs, fall back to the latest
// journal entry's stored averages. Mirrors the same color language as the
// chart so the dashboard reads consistently.
function _dailyStackedBarsFallback() {
  const weekly = (typeof getWeeklyStats === 'function') ? getWeeklyStats() : [];
  const entries = (typeof getEntries === 'function') ? getEntries() : [];

  const _pill = (label, value, color) => `<span style="font-family:var(--mono);font-size:11px;color:var(--muted2)"><span style="display:inline-block;width:9px;height:9px;background:${color};border-radius:2px;vertical-align:middle;margin-right:3px"></span>${label} <span style="color:var(--text);font-weight:600">${value}</span></span>`;
  const _row = (children) => `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:6px 0">${children}</div>`;

  if (weekly.length) {
    // Most recent week by date string
    const sorted = [...weekly].sort((a, b) => (b.date || '').localeCompare(a.date || ''));
    const last = sorted[0];
    const last4 = sorted.slice(0, 4);
    const avg = key => {
      const vals = last4.map(w => w[key]).filter(v => v != null && !isNaN(v));
      if (!vals.length) return null;
      return vals.reduce((a, v) => a + v, 0) / vals.length;
    };
    const lastTug = last.hrsPerDayTugged ?? last.avgHrsTugged;
    const lastRet = last.hrsPerDayRetained ?? last.avgHrsRetained;
    const avgTug = avg('hrsPerDayTugged') ?? avg('avgHrsTugged');
    const avgRet = avg('hrsPerDayRetained') ?? avg('avgHrsRetained');
    const fmt = v => v == null ? '—' : `${v.toFixed(1)}h/d`;
    return `<div>
      <div style="font-family:var(--mono);font-size:10px;color:var(--muted2);margin-bottom:6px;letter-spacing:.04em">From weekly logs · no daily timer data</div>
      ${_row(`<span style="font-family:var(--mono);font-size:11px;color:var(--muted2);min-width:80px">Last week</span>${_pill('tug', fmt(lastTug), '#22c55e')}${_pill('ret', fmt(lastRet), '#60a5fa')}`)}
      ${_row(`<span style="font-family:var(--mono);font-size:11px;color:var(--muted2);min-width:80px">4wk avg</span>${_pill('tug', fmt(avgTug), '#22c55e')}${_pill('ret', fmt(avgRet), '#60a5fa')}`)}
    </div>`;
  }

  if (entries.length) {
    const latest = [...entries].sort((a, b) => (b.date || '').localeCompare(a.date || ''))[0];
    const tug = latest.avgHrsTugged;
    const ret = latest.avgHrsRetained;
    const fmt = v => v == null ? '—' : `${(+v).toFixed(1)}h/d`;
    return `<div>
      <div style="font-family:var(--mono);font-size:10px;color:var(--muted2);margin-bottom:6px;letter-spacing:.04em">From last entry · no timer or weekly data</div>
      ${_row(`<span style="font-family:var(--mono);font-size:11px;color:var(--muted2);min-width:80px">${esc(latest.date || '—')}</span>${_pill('tug', fmt(tug), '#22c55e')}${_pill('ret', fmt(ret), '#60a5fa')}`)}
    </div>`;
  }

  return `<div style="font-family:var(--mono);font-size:11px;color:var(--muted2);padding:8px 0">No tugging or retaining data yet. Start a timer or log a weekly entry.</div>`;
}

// ── Weekly stacked bars (buddy version) ──────────────────────────────────────
// Same visual language as _dailyStackedBarsSvg (green tug stacked with blue ret,
// 24h Y-axis) but one bar PER WEEK using the same weekly aggregates the old
// sparklines used. Avoids leaking daily session data while still showing the
// buddy's overall pattern of effort.
function _weeklyStackedBarsSvg(points) {
  if (!points || !points.length) return '';
  const W = 600, H = 72;
  const PAD = { top: 6, right: 8, bottom: 12, left: 24 };
  const cW = W - PAD.left - PAD.right;
  const cH = H - PAD.top - PAD.bottom;
  const n = points.length;
  const barGap = Math.max(2, Math.min(6, Math.floor(cW / (n * 4))));
  const barW = (cW - barGap * (n - 1)) / n;
  const MAX_HRS = 24;
  const y = h => PAD.top + cH - (h / MAX_HRS) * cH;

  const bars = points.map((p, i) => {
    const x = PAD.left + i * (barW + barGap);
    const tug = Math.max(0, Math.min(p.tugHrs || 0, MAX_HRS));
    const retCap = Math.max(0, MAX_HRS - tug);
    const ret = Math.max(0, Math.min(p.retHrs || 0, retCap));
    const tugPx = (tug / MAX_HRS) * cH;
    const retPx = (ret / MAX_HRS) * cH;
    const baseY = PAD.top + cH;
    const tugY = baseY - tugPx;
    const retY = tugY - retPx;
    let g = '';
    if (tugPx > 0) g += `<rect x="${x.toFixed(1)}" y="${tugY.toFixed(1)}" width="${barW.toFixed(1)}" height="${tugPx.toFixed(1)}" fill="#22c55e" rx="1"><title>Week ${i + 1}: ${tug.toFixed(1)}h/d tugged</title></rect>`;
    if (retPx > 0) g += `<rect x="${x.toFixed(1)}" y="${retY.toFixed(1)}" width="${barW.toFixed(1)}" height="${retPx.toFixed(1)}" fill="#60a5fa" rx="1"><title>Week ${i + 1}: ${ret.toFixed(1)}h/d retained</title></rect>`;
    return g;
  }).join('');

  const grid = [6, 12, 18].map(h => {
    const yPos = y(h).toFixed(1);
    return `<line x1="${PAD.left}" y1="${yPos}" x2="${PAD.left + cW}" y2="${yPos}" stroke="var(--border2)" stroke-width="1" stroke-dasharray="2,3" opacity=".5"/><text x="${(PAD.left - 4).toFixed(1)}" y="${(y(h) + 3).toFixed(1)}" font-family="var(--mono)" font-size="9" fill="var(--muted2)" text-anchor="end">${h}h</text>`;
  }).join('');

  // Averages across the visible weeks for the legend line.
  const avgTug = points.reduce((a, p) => a + (p.tugHrs || 0), 0) / n;
  const avgRet = points.reduce((a, p) => a + (p.retHrs || 0), 0) / n;
  const anyTug = avgTug > 0;
  const anyRet = avgRet > 0;

  return `<div>
    <div style="display:flex;align-items:center;gap:10px;font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:4px">
      <span><span style="display:inline-block;width:9px;height:9px;background:#22c55e;border-radius:2px;vertical-align:middle;margin-right:3px"></span>Tug</span>
      <span><span style="display:inline-block;width:9px;height:9px;background:#60a5fa;border-radius:2px;vertical-align:middle;margin-right:3px"></span>Ret</span>
      <span style="margin-left:auto;color:var(--text)">${n}wk avg: ${anyTug ? avgTug.toFixed(1) + 'h/d tug' : '—'}${anyRet ? ` · ${avgRet.toFixed(1)}h/d ret` : ''}</span>
    </div>
    <svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${(H * 0.85).toFixed(0)}px;display:block" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
      ${grid}
      ${bars}
    </svg>
  </div>`;
}

// ── Sparkline Pair Charts (pure → SVG string) ─────────────────────────────────
// points: [{tugPct, tugHrs, retPct, retHrs}, ...] -- weekly data points
// Renders two stacked mini line charts: one for tugging, one for retaining.
// Each chart: solid line = % days (0–100), dashed line = avg hrs (normalized).
function _sparklinePairSvg(points) {
  if (!points || points.length < 2) return '';
  const W = 600, H = 70;
  const PAD = { top: 8, right: 10, bottom: 8, left: 28 };
  const cW = W - PAD.left - PAD.right;
  const cH = H - PAD.top - PAD.bottom;
  const n = points.length;
  const x = i => PAD.left + (n < 2 ? cW / 2 : (i / (n - 1)) * cW);

  function _miniChart(pctKey, hrsKey, color, label) {
    const pctVals = points.map(p => p[pctKey] || 0);
    const hrsVals = points.map(p => p[hrsKey] || 0);
    const maxHrs  = Math.max(...hrsVals, 0.1);
    const topPct  = Math.max(...pctVals);
    const yPct = v => PAD.top + cH - (v / 100) * cH;
    const yHrs = v => PAD.top + cH - Math.min(v / maxHrs, 1) * cH;
    const pctPts = points.map((p, i) => `${x(i).toFixed(1)},${yPct(p[pctKey] || 0).toFixed(1)}`).join(' ');
    const hrsPts = points.map((p, i) => `${x(i).toFixed(1)},${yHrs(p[hrsKey] || 0).toFixed(1)}`).join(' ');
    const pctArea = `${PAD.left},${(PAD.top + cH).toFixed(1)} ${pctPts} ${x(n-1).toFixed(1)},${(PAD.top + cH).toFixed(1)}`;
    const avgPct = topPct > 0 ? Math.round(pctVals.reduce((a, v) => a + v, 0) / pctVals.length) : null;
    const avgHrs = maxHrs > 0.1 ? (hrsVals.reduce((a, v) => a + v, 0) / hrsVals.length).toFixed(1) : null;
    return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H * 0.6}px;display:block" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
      <line x1="${PAD.left}" y1="${PAD.top}" x2="${PAD.left}" y2="${PAD.top + cH}" stroke="var(--border2)" stroke-width="1"/>
      <line x1="${PAD.left}" y1="${PAD.top + cH}" x2="${PAD.left + cW}" y2="${PAD.top + cH}" stroke="var(--border2)" stroke-width="1"/>
      <polygon points="${pctArea}" fill="${color}" opacity=".12"/>
      <polyline points="${pctPts}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
      <polyline points="${hrsPts}" fill="none" stroke="${color}" stroke-width="1.5" stroke-dasharray="4,3" stroke-linejoin="round" stroke-linecap="round" opacity=".8"/>
    </svg>
    <div style="display:flex;align-items:center;gap:10px;font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:6px">
      <span style="color:${color};font-weight:700">${label}</span>
      <span><span style="display:inline-block;width:12px;height:2px;background:${color};vertical-align:middle;margin-right:4px;border-radius:1px"></span>% days${avgPct !== null ? ` <span style="color:var(--text)">avg ${avgPct}%</span>` : ''}</span>
      <span><span style="display:inline-block;width:12px;height:0;border-top:2px dashed ${color};vertical-align:middle;margin-right:4px;opacity:.8"></span>hrs/d${avgHrs !== null ? ` <span style="color:var(--text)">${avgHrs}h avg</span>` : ''}</span>
      <span style="margin-left:auto;color:var(--muted2)">${n} wks</span>
    </div>`;
  }

  return `<div style="display:flex;flex-direction:column;gap:2px">
    <div>${_miniChart('tugPct','tugHrs','#22c55e','Tugging')}</div>
    <div>${_miniChart('retPct','retHrs','#60a5fa','Retaining')}</div>
  </div>`;
}

// ── Daily Activity Stacked Bar Chart (pure → HTML string) ─────────────────────
// dayData: [{date:'YYYY-MM-DD', tug: hours, ret: hours}, ...]

// ── Timeline chart: own sessions plotted by time-of-day ──────────────────────
// dayData: [{date, sessions:[{type,startMin,endMin}]}], newest day first (→ left).
// Y-axis = 24-hour clock (midnight top, next midnight bottom).
// Each day is a thin column; sessions are coloured bars at their actual time position.
function _activityTimelineSvg(dayData) {
  if (!dayData || !dayData.length) return '';

  const COL_W  = 10;
  const COL_GAP = 2;
  const STEP   = COL_W + COL_GAP;
  const MAX_DAYS = 21;
  const data   = dayData.slice(0, MAX_DAYS);
  const N      = data.length;

  const PAD    = { top: 10, right: 8, bottom: 18, left: 30 };
  const H_INNER = 168;                          // 24 h → 7 px/h
  const W_INNER = N * STEP - COL_GAP;
  const W      = PAD.left + W_INNER + PAD.right;
  const H      = PAD.top  + H_INNER + PAD.bottom;

  const minToY = m => PAD.top + (m / 1440) * H_INNER;
  const colX   = i => PAD.left + i * STEP;

  // Y-axis grid lines + labels
  const yGrid = [
    [0,    '12a'],
    [360,  '6a' ],
    [720,  '12p'],
    [1080, '6p' ],
    [1440, ''   ],
  ].map(([m, label]) => {
    const y      = minToY(m).toFixed(1);
    const dashed = m > 0 && m < 1440 ? 'stroke-dasharray="2,3"' : '';
    const lbl    = label
      ? `<text x="${(PAD.left - 3).toFixed(1)}" y="${(+y + 3).toFixed(1)}" text-anchor="end" font-size="7" fill="var(--muted2)" font-family="var(--mono)">${label}</text>`
      : '';
    return `<line x1="${PAD.left}" y1="${y}" x2="${(PAD.left + W_INNER).toFixed(1)}" y2="${y}" stroke="var(--border2)" stroke-width="0.5" ${dashed}/>${lbl}`;
  }).join('');

  // Column backgrounds (shows the 24 h slot even on rest days)
  const colBgs = data.map((_, i) =>
    `<rect x="${colX(i).toFixed(1)}" y="${PAD.top}" width="${COL_W}" height="${H_INNER}" fill="var(--surface2)" rx="1"/>`
  ).join('');

  // Session rectangles
  const rects = data.map((day, i) =>
    (day.sessions || []).map(s => {
      const y1   = minToY(Math.max(0, s.startMin));
      const y2   = minToY(Math.min(1440, s.endMin));
      const h    = Math.max(2, y2 - y1);
      const fill = s.type === 'tugging' ? '#22c55e' : '#60a5fa';
      const pad2 = n => Math.floor(n).toString().padStart(2, '0');
      const tip  = `${day.date} ${pad2(s.startMin/60)}:${pad2(s.startMin%60)}\u2013${pad2(s.endMin/60)}:${pad2(s.endMin%60)} ${s.type}`;
      return `<rect x="${colX(i).toFixed(1)}" y="${y1.toFixed(1)}" width="${COL_W}" height="${h.toFixed(1)}" fill="${fill}" rx="1" opacity="0.9"><title>${tip}</title></rect>`;
    }).join('')
  ).join('');

  // X-axis date labels -- one every 7 columns + always the oldest visible
  const xLabels = data.map((day, i) => {
    if (i % 7 !== 0 && i !== N - 1) return '';
    return `<text x="${(colX(i) + COL_W / 2).toFixed(1)}" y="${(PAD.top + H_INNER + 12).toFixed(1)}" text-anchor="middle" font-size="7" fill="var(--muted2)" font-family="var(--mono)">${day.date.slice(5)}</text>`;
  }).filter(Boolean).join('');

  // 7-day totals
  const cutoff7 = _localDateStr(Date.now() - 7 * 86400000);
  let wTug = 0, wRet = 0;
  data.filter(d => d.date >= cutoff7).forEach(d =>
    (d.sessions || []).forEach(s => {
      const h = (s.endMin - s.startMin) / 60;
      if (s.type === 'tugging') wTug += h; else wRet += h;
    })
  );
  const weekNote = wTug + wRet > 0
    ? `<span style="font-family:var(--mono);font-size:11px;color:var(--muted2)">7d: <span style="color:#22c55e">${wTug.toFixed(1)}h tug</span>${wRet > 0 ? ` · <span style="color:#60a5fa">${wRet.toFixed(1)}h ret</span>` : ''}</span>`
    : '';

  return `<div>
    <div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding-bottom:6px;margin-bottom:4px;border-bottom:1px solid var(--border2)">
      <span style="font-family:var(--mono);font-size:11px;font-weight:600;color:var(--text)">Daily Tracker</span>
      <div style="margin-left:auto;display:flex;align-items:center;gap:12px">
        ${weekNote}
        <div style="display:flex;gap:8px;font-family:var(--mono);font-size:11px;color:var(--muted2)">
          <span><span style="display:inline-block;width:8px;height:8px;background:#22c55e;border-radius:1px;vertical-align:middle;margin-right:3px"></span>Tug</span>
          <span><span style="display:inline-block;width:8px;height:8px;background:#60a5fa;border-radius:1px;vertical-align:middle;margin-right:3px"></span>Ret</span>
        </div>
      </div>
    </div>
    <svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block" preserveAspectRatio="xMinYMid meet" xmlns="http://www.w3.org/2000/svg">
      ${yGrid}
      ${colBgs}
      ${rects}
      ${xLabels}
    </svg>
  </div>`;
}

function _activityBarSvg(dayData) {
  if (!dayData || !dayData.length) return '';

  const PAD = { top: 14, right: 16, bottom: 26, left: 30 };
  const W = 600, H = 200;
  const cW = W - PAD.left - PAD.right;
  const cH = H - PAD.top - PAD.bottom;

  // Auto-scale Y-axis so bars are visible even for small avg-hrs-per-day values
  const _maxVal = Math.max(...dayData.map(d => (d.tug || 0) + (d.ret || 0)), 0.5);
  const MAX_HRS = _maxVal <= 2 ? 2 : _maxVal <= 4 ? 4 : _maxVal <= 6 ? 6 : _maxVal <= 8 ? 8 : _maxVal <= 12 ? 12 : 24;
  const _gridTicks = MAX_HRS <= 4 ? [0, MAX_HRS / 2, MAX_HRS] : MAX_HRS <= 8 ? [0, 2, 4, 6, 8].filter(v => v <= MAX_HRS) : MAX_HRS <= 12 ? [0, 3, 6, 9, 12] : [0, 6, 12, 18, 24];

  const barSpacing = cW / dayData.length;
  const barW = Math.min(18, Math.max(3, barSpacing - 2));
  const barX = i => PAD.left + i * barSpacing + (barSpacing - barW) / 2;
  const hToPx = h => cH * (h / MAX_HRS);

  const gridLines = _gridTicks.map(h => {
    const y = (PAD.top + cH - hToPx(h)).toFixed(1);
    const dashed = h > 0 && h < MAX_HRS ? 'stroke-dasharray="3,3"' : '';
    return `<line x1="${PAD.left}" y1="${y}" x2="${(PAD.left + cW).toFixed(1)}" y2="${y}" stroke="var(--border2)" stroke-width="1" ${dashed}/>
      <text x="${(PAD.left - 4).toFixed(1)}" y="${(parseFloat(y) + 3).toFixed(1)}" text-anchor="end" font-size="8" fill="var(--muted2)" font-family="var(--mono)">${h}</text>`;
  }).join('');

  // Stacked bars: green (tug) from baseline, blue (ret) stacked on top
  const bars = dayData.map((d, i) => {
    const tugH = Math.min(d.tug || 0, MAX_HRS);
    const retH = Math.min(d.ret || 0, MAX_HRS - tugH);
    const tugPx = hToPx(tugH);
    const retPx = hToPx(retH);
    const x = barX(i).toFixed(1);
    const baseline = PAD.top + cH;
    const tt = `${d.date}: ${tugH > 0 ? tugH.toFixed(1) + 'h tug' : ''}${tugH > 0 && retH > 0 ? ' + ' : ''}${retH > 0 ? retH.toFixed(1) + 'h ret' : ''}`;
    const tugBar = tugH > 0 ? `<rect x="${x}" y="${(baseline - tugPx).toFixed(1)}" width="${barW}" height="${tugPx.toFixed(1)}" fill="#22c55e" rx="1"><title>${tt}</title></rect>` : '';
    const retBar = retH > 0 ? `<rect x="${x}" y="${(baseline - tugPx - retPx).toFixed(1)}" width="${barW}" height="${retPx.toFixed(1)}" fill="#60a5fa" rx="1"><title>${tt}</title></rect>` : '';
    return tugBar + retBar;
  }).join('');

  // X-axis labels -- show up to 8, always show last
  const labelEvery = Math.max(1, Math.ceil(dayData.length / 8));
  const xLabels = dayData.map((d, i) => {
    if (i % labelEvery !== 0 && i !== dayData.length - 1) return '';
    return `<text x="${(barX(i) + barW / 2).toFixed(1)}" y="${(H - 4).toFixed(1)}" text-anchor="middle" font-size="8" fill="var(--muted2)" font-family="var(--mono)">${d.date.slice(5)}</text>`;
  }).filter(Boolean).join('');

  // Summary: total hrs this week
  const cutoff7 = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
  let wTug = 0, wRet = 0;
  dayData.filter(d => d.date >= cutoff7).forEach(d => { wTug += (d.tug || 0); wRet += (d.ret || 0); });
  const weekNote = wTug + wRet > 0
    ? `<span style="font-family:var(--mono);font-size:11px;color:var(--muted2)">7d: <span style="color:#22c55e">${wTug.toFixed(1)}h tug</span>${wRet > 0 ? ` · <span style="color:#60a5fa">${wRet.toFixed(1)}h ret</span>` : ''}</span>`
    : '';

  return `<div>
    <div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding-bottom:6px;margin-bottom:4px;border-bottom:1px solid var(--border2)">
      <span style="font-family:var(--mono);font-size:11px;font-weight:600;color:var(--text)">Daily Activity</span>
      <div style="margin-left:auto;display:flex;align-items:center;gap:12px">
        ${weekNote}
        <div style="display:flex;gap:8px;font-family:var(--mono);font-size:11px;color:var(--muted2)">
          <span><span style="display:inline-block;width:9px;height:9px;background:#22c55e;border-radius:2px;vertical-align:middle;margin-right:3px"></span>Tugging</span>
          <span><span style="display:inline-block;width:9px;height:9px;background:#60a5fa;border-radius:2px;vertical-align:middle;margin-right:3px"></span>Retaining</span>
        </div>
      </div>
    </div>
    <div>
      <svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;overflow:visible" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
        ${gridLines}
        ${bars}
        <line x1="${PAD.left}" y1="${(PAD.top + cH).toFixed(1)}" x2="${(PAD.left + cW).toFixed(1)}" y2="${(PAD.top + cH).toFixed(1)}" stroke="var(--border)" stroke-width="1"/>
        <line x1="${PAD.left}" y1="${PAD.top}" x2="${PAD.left}" y2="${(PAD.top + cH).toFixed(1)}" stroke="var(--border)" stroke-width="1"/>
        ${xLabels}
      </svg>
    </div>
  </div>`;
}

// ── Save / delete week stat ────────────────────────────────────────────────────

function saveQuickLog() {
  const date = document.getElementById('ql-date')?.value || new Date().toISOString().slice(0, 10);
  const daysTugged = Math.min(7, Math.max(0, parseInt(document.getElementById('ql-tug')?.value) || 0));
  const daysRetained = Math.min(7, Math.max(0, parseInt(document.getElementById('ql-ret')?.value) || 0));
  const pctTugged = Math.round(daysTugged / 7 * 100);
  const pctRetained = Math.round(daysRetained / 7 * 100);
  const _hrt = parseFloat(document.getElementById('ql-hrt')?.value);
  const hrsPerDay = isNaN(_hrt) ? null : _hrt;
  const _hrr = parseFloat(document.getElementById('ql-hrr')?.value);
  const hrsPerDayRet = isNaN(_hrr) ? null : _hrr;
  const notes = document.getElementById('ql-note')?.value || '';

  // Compute enriched fields
  const totalHrsTugged = hrsPerDay != null ? +(hrsPerDay * daysTugged).toFixed(2) : null;
  const totalHrsRetained = hrsPerDayRet != null ? +(hrsPerDayRet * daysRetained).toFixed(2) : null;

  const arr = getWeeklyStats();
  // Replace any auto-derived stat for the same date (manual takes precedence)
  const autoIdx = arr.findIndex(s => s.date === date && s.source === 'timer');
  const stat = {
    id: autoIdx >= 0 ? arr[autoIdx].id : uuid(),
    date,
    source: 'manual',
    // Enriched fields
    daysTugged,
    daysRetained,
    totalHrsTugged,
    totalHrsRetained,
    hrsPerDayTugged: hrsPerDay,
    hrsPerDayRetained: hrsPerDayRet,
    // Legacy fields (backward compat)
    pctTugged,
    pctRetained,
    avgHrsTugged: totalHrsTugged != null ? +(totalHrsTugged / 7).toFixed(1) : null,
    avgHrsRetained: totalHrsRetained != null ? +(totalHrsRetained / 7).toFixed(1) : null,
    notes
  };
  if (autoIdx >= 0) {
    arr[autoIdx] = stat;
  } else {
    arr.push(stat);
  }
  saveWeeklyStats(arr);
  toast('Week logged');
  renderDash();
}

function deleteWeekStat(id) {
  saveWeeklyStats(getWeeklyStats().filter(s => s.id !== id));
  renderDash();
}
