'use strict';
// ═══════════════════════════════════════════════
// js/journal.js -- journal CRUD and UI
// ═══════════════════════════════════════════════

// Note: TUGGING_DEVICES, RETAINING_DEVICES, CI_LABELS are defined in storage.js

const _MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December'];
function fmtMonthYear(dateStr) {
  if (!dateStr || dateStr.length < 7) return dateStr || '';
  const m = parseInt(dateStr.slice(5, 7)) - 1;
  const y = dateStr.slice(0, 4);
  return `${_MONTH_NAMES[m]} ${y}`;
}
function fmtNoteTimestamp(ts) {
  if (!ts) return '';
  const d = new Date(ts);
  if (isNaN(d)) return '';
  const mon = _MONTH_NAMES[d.getMonth()].slice(0, 3);
  const day = d.getDate();
  const h = d.getHours(), min = String(d.getMinutes()).padStart(2, '0');
  return `${mon} ${day} · ${h % 12 || 12}:${min} ${h >= 12 ? 'PM' : 'AM'}`;
}

var _selEntryId = null;
var _selBuddyKey = null; // null = own entry; pubKeyB64 = buddy entry
let _editMode = false;

function ciBadgeClass(ci) { return ''; }

// ── Journal filter bar ─────────────────────────────────────────────────────────

function _initJournalFilters() {
  const row = document.getElementById('journal-filter-row');
  if (!row || row.dataset.init === '1') return;
  row.dataset.init = '1';

  const allDevices = [...new Set([...TUGGING_DEVICES, ...RETAINING_DEVICES])].filter(d => d !== 'Other');
  const deviceOpts = allDevices.map(d => `<option value="${esc(d)}">${esc(d)}</option>`).join('');
  const ciOpts = [0,1,2,3,4,5,6,7,8,9,10].map(n => `<option value="${n}">CI-${n}</option>`).join('');

  row.innerHTML = `
    <div style="margin-top:6px;padding:6px 8px;background:var(--surface2);border-radius:6px;border:1px solid var(--border2)">
      <div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center">
        <select id="jf-ci-min" style="font-family:var(--mono);font-size:12px;padding:5px 8px;border-radius:4px;border:1px solid var(--border2);background:var(--surface);color:var(--text);width:82px">
          <option value="">CI min</option>${ciOpts}
        </select>
        <select id="jf-ci-max" style="font-family:var(--mono);font-size:12px;padding:5px 8px;border-radius:4px;border:1px solid var(--border2);background:var(--surface);color:var(--text);width:82px">
          <option value="">CI max</option>${ciOpts}
        </select>
        <select id="jf-device" style="font-family:var(--mono);font-size:12px;padding:5px 8px;border-radius:4px;border:1px solid var(--border2);background:var(--surface);color:var(--text);flex:1;min-width:100px">
          <option value="">Any device</option>${deviceOpts}
        </select>
        <label style="display:flex;align-items:center;gap:5px;font-family:var(--mono);font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap">
          <input type="checkbox" id="jf-photo-only"> Photo only
        </label>
        <label style="display:flex;align-items:center;gap:5px;font-family:var(--mono);font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap">
          <input type="checkbox" id="jf-bookmarked"> 🔖 only
        </label>
        <button class="btn btn-ghost btn-sm" data-action="clearJournalFilters">Clear</button>
      </div>
    </div>`;
}

function _clearJournalFilters() {
  const ids = ['jf-ci-min','jf-ci-max','jf-device'];
  ids.forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
  const cb = document.getElementById('jf-photo-only');
  if (cb) cb.checked = false;
  const bmCb = document.getElementById('jf-bookmarked');
  if (bmCb) bmCb.checked = false;
  const search = document.getElementById('journal-search');
  if (search) search.value = '';
  renderEntryList();
}

// ── Main render ────────────────────────────────────────────────────────────────

function _renderRecentComments() {
  if (typeof getBuddyNotes !== 'function') return '';
  const allNotes = getBuddyNotes(); // { entryId: [{ fromName, content, timestamp }] }
  const myEntries = typeof getEntries === 'function' ? getEntries() : [];
  const entryMap = new Map(myEntries.map(e => [e.id, e]));

  // Flatten to one row per note, attach entry date for display and sorting
  const rows = [];
  for (const [entryId, notes] of Object.entries(allNotes)) {
    const entry = entryMap.get(entryId);
    if (!entry) continue;
    for (const n of notes) {
      rows.push({ entryId, entry, note: n });
    }
  }
  if (!rows.length) return '';

  // Sort: chronological (oldest first)
  rows.sort((a, b) => {
    const ta = a.note.timestamp || a.entry.date;
    const tb = b.note.timestamp || b.entry.date;
    return ta.localeCompare(tb);
  });

  const items = rows.slice(0, 50).map(({ entryId, entry, note }) => {
    const snippet = (note.content || '').length > 100
      ? esc(note.content.slice(0, 100)) + '…'
      : esc(note.content || '');
    const who = note.fromName ? esc(note.fromName) : 'Buddy';
    const ts = fmtNoteTimestamp(note.timestamp) || fmtMonthYear(entry.date);
    const entryLabel = fmtMonthYear(entry.date);
    return `<div class="li" data-action="selectEntry" data-a0="${esc(entryId)}" style="padding:9px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .12s">
      <div style="display:flex;align-items:baseline;gap:6px;margin-bottom:3px">
        <span style="font-family:var(--mono);font-size:11px;font-weight:600;color:var(--accent)">${who}</span>
        <span style="font-family:var(--mono);font-size:10px;color:var(--muted2)">${esc(ts)}</span>
        <span style="font-family:var(--mono);font-size:10px;color:var(--muted2);margin-left:auto">re: ${esc(entryLabel)}</span>
      </div>
      <div style="font-size:13px;color:var(--text);word-break:break-word;line-height:1.4">${snippet}</div>
    </div>`;
  }).join('');

  return `<div style="border-top:1px solid var(--border)">${items}</div>`;
}

function renderJournalPage() {
  _initJournalFilters();
  renderEntryList();
  // Sync mobile entry-selected state
  document.querySelector('.journal-split')?.classList.toggle('entry-selected', !!_selEntryId);
  // On mobile: collapse entries drawer when viewing an entry
  if (window.innerWidth <= 640 && _selEntryId) {
    document.getElementById('jl-entries-panel')?.removeAttribute('open');
  }
  // Always populate buddy notes accordion
  _renderBuddyNotesPanel();
  const detailEl = document.getElementById('journal-entry-detail') || document.getElementById('journal-main');
  if (_selEntryId) {
    if (_editMode) renderEntryEditor(_selEntryId);
    else renderEntryViewer(_selEntryId);
  } else if (detailEl) {
    detailEl.innerHTML =
      `<div class="empty" style="height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center">
        <div class="icon">◳</div>
        <p>Select an entry or create a new one</p>
      </div>`;
  }
}

function _renderBuddyNotesPanel() {
  const panel = document.getElementById('buddy-notes-panel');
  const el    = document.getElementById('buddy-notes-content');
  if (!panel || !el) return;
  const hasBuddies = typeof getContacts === 'function' && getContacts().length > 0;
  panel.style.display = hasBuddies ? '' : 'none';
  if (!hasBuddies) return;
  const html = _renderRecentComments();
  el.innerHTML = html || `<div style="padding:12px 14px;color:var(--muted2);font-size:12px;font-family:var(--mono)">No comments yet</div>`;
}

// ── Entry list ─────────────────────────────────────────────────────────────────

function _renderEntryGroup(entries, selId, _unused, buddyPubKey) {
  const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  const groupKeys = [];
  const groupMap = new Map();
  entries.forEach(e => {
    const key = e.date.slice(0, 7);
    if (!groupMap.has(key)) { groupMap.set(key, []); groupKeys.push(key); }
    groupMap.get(key).push(e);
  });
  // Milestone set computed once for all entries
  const _allEntries = buddyPubKey
    ? ((typeof getPeerJournal === 'function' ? getPeerJournal(buddyPubKey) : null)?.entries || [])
    : (typeof getEntries === 'function' ? getEntries() : []);
  const milestones = (typeof getMilestoneEntryIds === 'function') ? getMilestoneEntryIds(_allEntries) : new Set();

  return groupKeys.map(key => {
    const [y, m] = key.split('-');
    const label = `${MONTHS[parseInt(m) - 1]} ${y}`;
    const rows = groupMap.get(key).map(e => {
      const thumbSrc = buddyPubKey
        ? imgURL(`c_${buddyPubKey.slice(0,16)}_${e.id}_front`)
        : imgURL(e.imgFront);
      const imgKey = buddyPubKey ? `c_${buddyPubKey.slice(0,16)}_${e.id}_front` : (e.imgFront || '');
      const thumb = thumbSrc
        ? `<img class="entry-item-thumb" src="${thumbSrc}" alt=""${imgKey ? ` data-img-key="${imgKey}"` : ''}${buddyPubKey ? ' data-no-context="1" data-no-drag="1"' : ''}>`
        : `<div class="entry-item-thumb placeholder"${imgKey ? ` data-img-key="${imgKey}"` : ''}></div>`;
      const tugStat = e.pctTugged != null ? `${Math.round(e.pctTugged * 7 / 100)}d tug` : '';

      const isMilestone = milestones.has(e.id);

      // Notes indicator -- count of buddy notes received on this entry (own entries only)
      let notesHtml = '';
      if (!buddyPubKey && typeof getNotesForEntry === 'function') {
        const buddyNotes = getNotesForEntry(e.id);
        if (buddyNotes.length) notesHtml = `<span title="${buddyNotes.length} comment${buddyNotes.length !== 1 ? 's' : ''}" style="font-size:10px;cursor:default;line-height:1">💬${buddyNotes.length}</span>`;
      }

      return `<div class="entry-item${selId === e.id ? ' sel' : ''}" data-action="entryClick" data-entry-id="${e.id}" data-buddy-pub="${buddyPubKey || ''}">
        ${thumb}
        <div class="entry-item-body">
          <div class="entry-item-date">${esc(fmtMonthYear(e.date))}${isMilestone ? ' ⭐' : ''}</div>
          <div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:2px">${esc(e.date)}</div>
          <div class="entry-item-meta" style="display:flex;align-items:center;gap:4px;flex-wrap:wrap">
            <span class="ci-badge ${ciBadgeClass(e.ci)}">CI-${e.ci}</span>
            ${tugStat ? `<span style="color:var(--muted2)">${esc(tugStat)}</span>` : ''}
            ${!buddyPubKey && e.imgPrivate ? `<span title="Private photo - not shared with buddies" style="font-size:10px;cursor:default;line-height:1">🔒</span>` : ''}
            ${(() => {
              const _bms = typeof getBookmarks === 'function' ? getBookmarks() : {};
              const _bmRcvd = !buddyPubKey && typeof getBookmarksReceived === 'function' ? getBookmarksReceived() : {};
              const _ownBm = !!_bms['journal:' + e.id];
              const _rcvdBms = (_bmRcvd['journal:' + e.id] || []);
              const _bmTotal = (_ownBm ? 1 : 0) + _rcvdBms.length;
              if (!_bmTotal) return '';
              const _bmNames = _rcvdBms.map(s => s.senderName || '?').join(', ');
              const _bmTitle = _ownBm && _rcvdBms.length ? `You + ${_bmNames}` : _ownBm ? 'Bookmarked' : _bmNames;
              return `<span title="${esc(_bmTitle)}" style="font-size:11px;cursor:default;line-height:1">🔖${_bmTotal > 1 ? _bmTotal : ''}</span>`;
            })()}
            ${notesHtml}
          </div>
        </div>
      </div>`;
    }).join('');
    return `<div class="entry-group-hdr">${esc(label)}</div>${rows}`;
  }).join('');
}

function renderEntryList() {
  const el = document.getElementById('entry-list');
  if (!el) return;
  const q = (document.getElementById('journal-search')?.value || '').toLowerCase();
  const ciMin = document.getElementById('jf-ci-min')?.value;
  const ciMax = document.getElementById('jf-ci-max')?.value;
  const deviceFilter = document.getElementById('jf-device')?.value || '';
  const photoOnly = document.getElementById('jf-photo-only')?.checked || false;
  const bookmarkedOnly = document.getElementById('jf-bookmarked')?.checked || false;
  const _jfBookmarks = bookmarkedOnly && typeof getBookmarks === 'function' ? getBookmarks() : null;

  function matchesFilters(e) {
    if (q && !e.date.includes(q) && !(e.notes||'').toLowerCase().includes(q) &&
        !(e.tuggingDevices||[]).some(d => d.toLowerCase().includes(q)) &&
        !(e.retainingDevices||[]).some(d => d.toLowerCase().includes(q))) return false;
    if (ciMin !== '' && ciMin != null && e.ci < parseInt(ciMin)) return false;
    if (ciMax !== '' && ciMax != null && e.ci > parseInt(ciMax)) return false;
    if (deviceFilter && ![...(e.tuggingDevices||[]),...(e.retainingDevices||[])].includes(deviceFilter)) return false;
    if (photoOnly && !e.imgFront) return false;
    if (_jfBookmarks && !_jfBookmarks['journal:' + e.id]) return false;
    return true;
  }

  const myEntries = getEntries()
    .sort((a, b) => b.date.localeCompare(a.date))
    .filter(matchesFilters);

  const contacts = (typeof getContacts === 'function') ? getContacts() : [];
  const buddySections = contacts.map(c => {
    const journal = (typeof getPeerJournal === 'function') ? getPeerJournal(c.pubKeyB64) : null;
    if (!journal) return null;
    const nameMatch = !q || (c.nickname||'').toLowerCase().includes(q);
    const buddyEntries = (journal.entries || [])
      .sort((a, b) => b.date.localeCompare(a.date))
      .filter(e => (nameMatch || matchesFilters(e)));
    if (!buddyEntries.length) return null;
    return { c, buddyEntries };
  }).filter(Boolean);

  const filtersActive = q || ciMin || ciMax || deviceFilter || photoOnly;
  if (!myEntries.length && !buddySections.length) {
    el.innerHTML = `<div class="empty" style="padding:1.5rem"><div class="icon">◳</div><p>${filtersActive ? 'No entries match filters' : 'No entries yet'}</p></div>`;
    return;
  }

  let html = '';
  if (myEntries.length) {
    const _myAvatar = (typeof getProfile === 'function') ? (getProfile().avatar || '') : '';
    html += `<div class="entry-person-hdr">${_myAvatar ? esc(_myAvatar) + ' ' : ''}My Journal</div>`;
    html += _renderEntryGroup(myEntries, _selBuddyKey === null ? _selEntryId : null, null);
  }
  buddySections.forEach(({ c, buddyEntries }) => {
    const bJournalAvatar = (typeof getPeerJournal === 'function' ? getPeerJournal(c.pubKeyB64) : c.journal)?.avatar || '';
    const isTrusted = c.trusted;
    html += `<div class="entry-person-hdr buddy" style="cursor:pointer" data-action="openBuddyProfile" data-a0="${esc(c.pubKeyB64)}" title="View ${esc(c.nickname||'Buddy')}'s profile">${bJournalAvatar ? esc(bJournalAvatar) + ' ' : ''}${esc(c.nickname || 'Buddy')} ›</div>`;

    const visibleEntries = buddyEntries;

    html += _renderEntryGroup(visibleEntries, _selBuddyKey === c.pubKeyB64 ? _selEntryId : null, null, c.pubKeyB64);
  });
  el.innerHTML = html;
}

// ── Select entry ───────────────────────────────────────────────────────────────

function _scrollToDetail() {
  // On mobile, reset scroll on the detail pane after selecting an entry
  if (window.innerWidth <= 500) {
    const pane = document.getElementById('journal-detail-pane');
    if (pane) pane.scrollTop = 0;
  }
}

function _journalBack() {
  _selEntryId = null;
  _selBuddyKey = null;
  _editMode = false;
  document.querySelector('.journal-split')?.classList.remove('entry-selected');
  renderEntryList();
}

function selectEntry(id) {
  _selEntryId = id;
  _selBuddyKey = null;
  _editMode = false;
  document.querySelector('.journal-split')?.classList.add('entry-selected');
  renderEntryList();
  renderEntryViewer(id);
  _scrollToDetail();
}

function selectBuddyEntry(pubKeyB64, entryId) {
  _selEntryId = entryId;
  _selBuddyKey = pubKeyB64;
  _editMode = false;
  document.querySelector('.journal-split')?.classList.add('entry-selected');
  renderEntryList();
  const c = getContacts().find(x => x.pubKeyB64 === pubKeyB64);
  const journal = typeof getPeerJournal === 'function' ? getPeerJournal(pubKeyB64) : null;
  const e = (journal?.entries || []).find(x => x.id === entryId);
  if (!c || !e) return;
  const online = typeof isOnline === 'function' ? isOnline(pubKeyB64) : false;
  const mutualTrust = c.trusted && c.senderTrusts;
  // Only the newest entry with a photo is served (unless mutual trust allows all)
  const newestWithPhoto = [...(journal?.entries || [])].sort((a, b) => b.date.localeCompare(a.date)).find(x => x.hasImgFront);
  const isNewestPhoto = newestWithPhoto?.id === entryId;
  if (online && e.hasImgFront && (mutualTrust || isNewestPhoto) && typeof p2pRequestImage === 'function') {
    p2pRequestImage(pubKeyB64, entryId, 'front');
  }
  _scrollToDetail();
  const main = _getDetailEl();
  if (!main) return;
  function cImg(f) { return imgURL(`c_${pubKeyB64.slice(0,16)}_${entryId}_${f}`); }
  const photoSrc = cImg('front');
  const _denied = typeof p2pIsImgDenied === 'function' && p2pIsImgDenied(pubKeyB64, entryId, 'front');
  const photoHtml = (() => {
    if (photoSrc) return photoCanvas(`c_${pubKeyB64.slice(0,16)}_${entryId}_front`, { watermarkName: c.journal?.displayName || c.nickname || 'Buddy', style: 'width:100%;max-width:320px;border-radius:7px;display:block;border:1px solid var(--border2)' });
    if (e.hasImgFront && (mutualTrust || isNewestPhoto)) {
      if (_denied) return `<div style="aspect-ratio:3/4;max-width:200px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;color:var(--muted2)">Photo unavailable</div>`;
      if (online) return `<div style="aspect-ratio:3/4;max-width:200px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;color:var(--muted2)">Loading...</div>`;
      return `<div style="aspect-ratio:3/4;max-width:200px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;color:var(--muted2)">Buddy offline</div>`;
    }
    return ''; // older entry, won't load -- omit photo section entirely
  })();
  main.innerHTML = `
    <div class="journal-main-inner">
      <div class="ev-hdr" style="display:flex;align-items:center;gap:10px;margin-bottom:1rem;flex-wrap:wrap">
        <div style="flex:1;min-width:0">
          <div style="font-size:15px;font-weight:600">${esc(c.nickname || 'Buddy')} &middot; ${esc(fmtMonthYear(e.date))}</div>
          <div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${esc(e.date)} &middot; ${online ? 'live' : 'offline'}</div>
        </div>
        ${e.ci != null ? `<span class="ci-badge ${ciBadgeClass(e.ci)}">CI-${e.ci}</span>` : ''}
      </div>
      ${photoHtml ? `<div class="form-section"><div class="form-section-title">Photo</div>${photoHtml}</div>` : ''}
      <div class="form-section">
        <div class="form-section-title">Metrics</div>
        <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
          ${metricViewItem('Days Tugged', e.pctTugged != null ? Math.round(e.pctTugged * 7 / 100) + ' / 7' : '-')}
          ${metricViewItem('Days Retained', e.pctRetained != null ? Math.round(e.pctRetained * 7 / 100) + ' / 7' : '-')}
          ${metricViewItem('Hrs/day Tugged', e.avgHrsTugged != null ? e.avgHrsTugged + 'h/day' : '-')}
          ${metricViewItem('Hrs/day Retained', e.avgHrsRetained != null ? e.avgHrsRetained + 'h/day' : '-')}
        </div>
      </div>
      ${e.notes ? `<div class="form-section">
        <div class="form-section-title">Notes</div>
        <div class="notes-block">${esc(e.notes)}</div>
      </div>` : ''}
      ${(() => {
        const linked = e.linkedGalleries || [];
        if (!linked.length) return '';
        const senderSlice16 = pubKeyB64.slice(0, 16);
        const recv = typeof getGalleryReceived === 'function' ? getGalleryReceived() : {};
        const byGal = recv[senderSlice16] || {};
        const accessible = linked.filter(gid => byGal[gid]).map(gid => ({ gid, data: byGal[gid] }));
        if (!accessible.length) return '';
        return `<div class="form-section">
          <div class="form-section-title">Linked Galleries</div>
          <div style="display:flex;flex-wrap:wrap;gap:6px">
            ${accessible.map(({ gid, data }) => `<button class="btn btn-ghost btn-sm" style="font-size:12px;border-radius:12px;padding:4px 10px" data-action="openReceivedGallery" data-a0="${esc(senderSlice16)}" data-a1="${esc(gid)}">🖼 ${esc(data.title || 'Gallery')} (${Object.keys(data.items || {}).length})</button>`).join('')}
          </div>
        </div>`;
      })()}
      <div class="form-section" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
        ${(() => {
          const sent = (typeof getSentKot === 'function') ? getSentKot(pubKeyB64, entryId) : null;
          if (sent) return `<button class="btn btn-sm kot-btn kot-active" disabled title="Already KOT'd">✊ KOT'd</button>`;
          return `<button class="btn btn-sm kot-btn" data-action="toggleKot" data-a0="${esc(pubKeyB64)}" data-a1="${esc(entryId)}">✊ KOT</button>`;
        })()}
        ${(() => {
          const _bms = typeof getBookmarks === 'function' ? getBookmarks() : {};
          const _bmKey = 'journal:' + entryId;
          const isBookmarked = !!_bms[_bmKey];
          return `<button class="btn btn-sm ${isBookmarked ? '' : 'btn-ghost'}" data-action="toggleBookmark" data-a0="${esc(_bmKey)}" style="${isBookmarked ? 'background:var(--accent2);border-color:var(--accent);color:var(--accent)' : ''}">${isBookmarked ? '🔖 Bookmarked' : '🔖 Bookmark'}</button>`;
        })()}
      </div>
      ${(() => {
        const existing = (typeof getSentNote === 'function') ? getSentNote(pubKeyB64, entryId) : null;
        const btnLabel = existing ? 'Edit comment' : 'Send comment';
        return `<div class="form-section">
          <div class="form-section-title">${existing ? 'Your comment' : 'Leave a comment'}</div>
          <textarea id="buddy-note-ta" rows="4" maxlength="1000" style="width:100%;resize:vertical;font-size:13px;font-family:var(--body);padding:8px;border-radius:5px;border:1px solid var(--border2);background:var(--surface2);color:var(--text);box-sizing:border-box" placeholder="Leave a comment…">${existing ? esc(existing.content) : ''}</textarea>
          <div style="display:flex;justify-content:flex-end;margin-top:6px">
            <button class="btn btn-primary btn-sm" data-action="sendNoteClick" data-a0="${esc(pubKeyB64)}" data-a1="${esc(entryId)}">${btnLabel}</button>
          </div>
        </div>`;
      })()}
    </div>`;
}

// ── New entry ──────────────────────────────────────────────────────────────────

function newEntry() {
  const entries = getEntries().sort((a, b) => b.date.localeCompare(a.date));
  const last = entries[0];
  const today = new Date().toISOString().slice(0, 10);

  // Collect weekly stats spanning from the previous entry to today,
  // plus the current incomplete week's live timer summary
  const prevDate = last ? last.date : '0000-00-00';
  const weeklyStats = (typeof getWeeklyStats === 'function' ? getWeeklyStats() : [])
    .filter(s => s.date > prevDate && s.date <= today);

  // Add the current (incomplete) week's live timer data as a pseudo-stat
  const ts = (typeof computeWeekSummaryFromSessions === 'function')
    ? computeWeekSummaryFromSessions(today)
    : { hasSessions: false };
  const allStats = [...weeklyStats];
  if (ts.hasSessions) {
    allStats.push({
      daysTugged: ts.daysTugged,
      daysRetained: ts.daysRetained,
      totalHrsTugged: ts.totalHrsTugged,
      totalHrsRetained: ts.totalHrsRetained,
      hrsPerDayTugged: ts.hrsPerDayTugged,
      hrsPerDayRetained: ts.hrsPerDayRetained,
      pctTugged: ts.daysTugged != null ? Math.round(ts.daysTugged / 7 * 100) : null,
      pctRetained: ts.daysRetained != null ? Math.round(ts.daysRetained / 7 * 100) : null,
      avgHrsTugged: ts.avgHrsTugged,
      avgHrsRetained: ts.avgHrsRetained
    });
  }

  // Per-active-day weighted average across all stats (weekly + current week)
  let pctTugged, pctRetained, avgHrsTugged, avgHrsRetained;
  if (allStats.length) {
    // Sum total hours and total active days across all weeks for weighted average
    let sumTugHrs = 0, sumTugDays = 0, sumRetHrs = 0, sumRetDays = 0;
    let sumPctTug = 0, sumPctRet = 0, pctCount = 0;
    for (const s of allStats) {
      const tD = s.daysTugged ?? (s.pctTugged != null ? Math.round(s.pctTugged * 7 / 100) : 0);
      const rD = s.daysRetained ?? (s.pctRetained != null ? Math.round(s.pctRetained * 7 / 100) : 0);
      const tH = s.totalHrsTugged ?? (s.avgHrsTugged != null ? s.avgHrsTugged * 7 : 0);
      const rH = s.totalHrsRetained ?? (s.avgHrsRetained != null ? s.avgHrsRetained * 7 : 0);
      sumTugHrs += tH; sumTugDays += tD;
      sumRetHrs += rH; sumRetDays += rD;
      if (s.pctTugged != null) { sumPctTug += s.pctTugged; pctCount++; }
      if (s.pctRetained != null) sumPctRet += s.pctRetained;
    }
    pctTugged    = pctCount ? Math.round(sumPctTug / pctCount) : (last ? last.pctTugged : null);
    pctRetained  = pctCount ? Math.round(sumPctRet / pctCount) : (last ? last.pctRetained : null);
    avgHrsTugged   = sumTugDays ? parseFloat((sumTugHrs / sumTugDays).toFixed(1)) : (last ? last.avgHrsTugged : null);
    avgHrsRetained = sumRetDays ? parseFloat((sumRetHrs / sumRetDays).toFixed(1)) : (last ? last.avgHrsRetained : null);
  } else {
    // No weekly stats and no timer sessions -- fall back to previous entry
    pctTugged      = last ? last.pctTugged : null;
    pctRetained    = last ? last.pctRetained : null;
    avgHrsTugged   = last ? last.avgHrsTugged : null;
    avgHrsRetained = last ? last.avgHrsRetained : null;
  }

  const e = createEntry({
    // Carry forward from last entry
    tuggingDevices: last ? [...(last.tuggingDevices || [])] : [],
    retainingDevices: last ? [...(last.retainingDevices || [])] : [],
    ci: last ? last.ci : 3,
    pctTugged,
    pctRetained,
    avgHrsTugged,
    avgHrsRetained
  });
  const arr = getEntries();
  arr.push(e);
  saveEntries(arr);
  _selEntryId = e.id;
  _editMode = true;
  renderEntryList();
  renderEntryEditor(e.id);
}

// ── Helper: get the detail element ────────────────────────────────────────────

function _getDetailEl() {
  return document.getElementById('journal-entry-detail') || document.getElementById('journal-main');
}

// ── CI selector helpers ────────────────────────────────────────────────────────

function _ciSelector(currentCi) {
  return Array.from({length: 11}, (_, i) =>
    `<button type="button" class="ci-btn${i === currentCi ? ' active' : ''}"
      data-action="setCiValue" data-a0="${i}">${i}</button>`
  ).join('');
}

function _setCiValue(val) {
  // Update hidden input / data attribute
  const hidden = document.getElementById('ef-ci');
  if (hidden) hidden.value = String(val);
  // Update button active states
  document.querySelectorAll('.ci-btn').forEach((btn, i) => {
    btn.classList.toggle('active', i === val);
  });
  // Update description label
  const desc = document.getElementById('ci-label-desc');
  if (desc) desc.textContent = `CI-${val}: ${CI_LABELS[val] || ''}`;
}

// ── CI illustrated reference modal ─────────────────────────────────────────────
// Cards are built on open (not at startup) so the 11 reference images only
// load when someone actually asks for them.
// When set, picking a card writes to this input/select id (e.g. the profile's
// pf-startci / pf-goalci); when null, it targets the entry editor's ef-ci.
let _ciRefTarget = null;

function openCiRef(targetId) {
  const body = document.getElementById('ciref-body');
  if (!body) return;
  _ciRefTarget = targetId || null;
  const src = document.getElementById(_ciRefTarget || 'ef-ci');
  const current = parseInt(src?.value ?? '-1', 10);
  body.innerHTML = Array.from({length: 11}, (_, i) => `
    <button type="button" class="ciref-card${i === current ? ' current' : ''}" data-action="ciRefPick" data-a0="${i}" title="${src ? 'Set CI-' + i : 'CI-' + i}">
      <img src="img/CIref/CI-${i}.png" alt="CI-${i} illustration" loading="lazy">
      <div class="ciref-card-label">CI-${i} · ${CI_LABELS[i] || ''}</div>
      <div class="ciref-card-desc">${CI_DESCRIPTIONS[i] || ''}</div>
    </button>`).join('');
  openModal('ciRefModal');
}

function ciRefPick(val) {
  const level = parseInt(val, 10);
  if (Number.isNaN(level)) return;
  if (_ciRefTarget) {
    const el = document.getElementById(_ciRefTarget);
    if (el) el.value = String(level);
  } else if (document.getElementById('ef-ci')) {
    // Entry editor: update hidden input + button row + description label
    _setCiValue(level);
  }
  closeModal('ciRefModal');
}

// ── Device chip helpers ────────────────────────────────────────────────────────

function _deviceChips(selectedDevices, allDevices, type, entryId) {
  // Merge standard list with any custom selected devices
  const combined = [...new Set([...allDevices, ...selectedDevices])];
  return combined.map(d => {
    const active = selectedDevices.includes(d);
    return `<button type="button" class="device-chip${active ? ' active' : ''}"
      data-action="toggleDevice" data-a0="${esc(d)}" data-a1="${type}" data-a2="${entryId}">${esc(d)}</button>`;
  }).join('');
}

function toggleLinkedGallery(galleryId, entryId) {
  const e = getEntry(entryId);
  if (!e) return;
  const current = [...(e.linkedGalleries || [])];
  const idx = current.indexOf(galleryId);
  if (idx >= 0) current.splice(idx, 1);
  else current.push(galleryId);
  updateEntry(entryId, { linkedGalleries: current });
  // Re-render chips
  const container = document.getElementById('ef-linked-galleries');
  if (container) {
    const allGals = typeof getGalleries === 'function' ? getGalleries() : [];
    container.innerHTML = allGals.map(g => {
      const active = current.includes(g.id);
      return `<button type="button" class="device-chip${active ? ' active' : ''}"
        data-action="toggleLinkedGallery" data-a0="${esc(g.id)}" data-a1="${entryId}">${esc(g.title)} (${g.items.length})</button>`;
    }).join('');
  }
}

function toggleDevice(device, type, entryId) {
  const e = getEntry(entryId);
  if (!e) return;
  const field = type === 'tugging' ? 'tuggingDevices' : 'retainingDevices';
  const current = [...(e[field] || [])];
  const idx = current.indexOf(device);
  if (idx >= 0) current.splice(idx, 1);
  else current.push(device);
  updateEntry(entryId, { [field]: current });
  // Re-render chips only (include any custom devices in the current list)
  const container = document.getElementById(`ef-chips-${type}`);
  if (container) {
    const builtIn = type === 'tugging' ? TUGGING_DEVICES : RETAINING_DEVICES;
    const allDevs = [...new Set([...builtIn, ...current])];
    container.innerHTML = _deviceChips(current, allDevs, type, entryId);
  }
}

// ── Entry viewer ───────────────────────────────────────────────────────────────

function renderEntryViewer(id) {
  const el = _getDetailEl();
  if (!el) return;
  const e = getEntry(id);
  if (!e) { el.innerHTML = `<div class="empty"><p>Entry not found</p></div>`; return; }

  const frontSrc = imgURL(e.imgFront);

  function photoHtml(src, label) {
    if (!src) return `<div style="background:var(--surface2);border:1px dashed var(--border2);border-radius:7px;aspect-ratio:3/4;display:flex;align-items:center;justify-content:center;color:var(--muted2);font-family:var(--mono);font-size:11px;text-align:center;padding:8px">${label}<br>No photo</div>`;
    return `<div><img src="${src}" alt="${label}" style="width:100%;max-height:360px;object-fit:contain;border-radius:7px;cursor:pointer;border:1px solid var(--border2);background:var(--surface3);display:block" data-action="openLb" data-a0="${src}"></div>`;
  }

  function deviceBadge(d, color) {
    return `<span style="font-family:var(--mono);font-size:11px;padding:3px 8px;border-radius:4px;border:1px solid ${color}22;background:${color}12;color:${color}">${esc(d)}</span>`;
  }

  el.innerHTML = `
    <div class="journal-main-inner">
      <div class="entry-actions">
        <button class="btn btn-primary btn-sm" data-action="startEdit" data-a0="${id}">Edit</button>
        ${(() => {
          const bm = typeof getBookmarks === 'function' ? getBookmarks() : {};
          const bmKey = 'journal:' + id;
          const isB = !!bm[bmKey];
          return '<button class="btn btn-sm ' + (isB ? '' : 'btn-ghost') + '" data-action="toggleBookmark" data-a0="' + esc(bmKey) + '" style="' + (isB ? 'background:var(--accent2);border-color:var(--accent);color:var(--accent)' : '') + '">' + (isB ? '🔖' : '🔖') + '</button>';
        })()}
        <button class="btn btn-danger btn-sm" data-action="deleteEntryConfirm" data-a0="${id}">Delete</button>
      </div>

      <div class="form-section">
        <div class="form-section-title">Assessment</div>
        <div style="margin-bottom:8px">
          <div style="font-size:15px;font-weight:600">${esc(fmtMonthYear(e.date))}</div>
          <div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${esc(e.date)}</div>
        </div>
        <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
          <div>
            <div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:3px;text-transform:uppercase;letter-spacing:.06em">Coverage Index</div>
            <span class="ci-badge ${ciBadgeClass(e.ci)}">CI-${e.ci}</span>
            <span style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-left:8px">${CI_LABELS[e.ci] || ''}</span>
          </div>
        </div>
      </div>

      <div class="form-section">
        <div class="form-section-title">Weekly averages since last update</div>
        <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
          ${metricViewItem('Days Tugged', e.pctTugged != null ? Math.round(e.pctTugged * 7 / 100) + ' / 7' : '-')}
          ${metricViewItem('Days Retained', e.pctRetained != null ? Math.round(e.pctRetained * 7 / 100) + ' / 7' : '-')}
          ${metricViewItem('Hrs/day Tugged', e.avgHrsTugged != null ? e.avgHrsTugged + 'h/day' : '-')}
          ${metricViewItem('Hrs/day Retained', e.avgHrsRetained != null ? e.avgHrsRetained + 'h/day' : '-')}
        </div>
      </div>

      ${(e.tuggingDevices?.length || e.retainingDevices?.length) ? `
      <div class="form-section">
        <div class="form-section-title">Devices</div>
        ${e.tuggingDevices?.length ? `<div style="margin-bottom:8px">
          <div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:4px;text-transform:uppercase;letter-spacing:.06em">Tugging</div>
          <div style="display:flex;flex-wrap:wrap;gap:4px">${e.tuggingDevices.map(d => deviceBadge(d, 'var(--accent)')).join('')}</div>
        </div>` : ''}
        ${e.retainingDevices?.length ? `<div>
          <div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:4px;text-transform:uppercase;letter-spacing:.06em">Retaining</div>
          <div style="display:flex;flex-wrap:wrap;gap:4px">${e.retainingDevices.map(d => deviceBadge(d, 'var(--muted)')).join('')}</div>
        </div>` : ''}
      </div>` : ''}

      <div class="form-section">
        <div class="form-section-title">Photo</div>
        ${frontSrc
          ? `<img src="${frontSrc}" alt="Progress Photo" style="width:100%;max-width:360px;max-height:480px;object-fit:contain;border-radius:7px;cursor:pointer;border:1px solid var(--border2);background:var(--surface3);display:block" data-action="openLb" data-a0="${frontSrc}">`
          : `<div style="background:var(--surface2);border:1px dashed var(--border2);border-radius:7px;aspect-ratio:3/4;max-width:200px;display:flex;align-items:center;justify-content:center;color:var(--muted2);font-family:var(--mono);font-size:11px;text-align:center;padding:8px">No photo yet</div>`}
      </div>

      ${(() => {
        const linked = e.linkedGalleries || [];
        if (!linked.length) return '';
        const galleries = typeof getGalleries === 'function' ? getGalleries() : [];
        const linkedGals = linked.map(gid => galleries.find(g => g.id === gid)).filter(Boolean);
        if (!linkedGals.length) return '';
        return `<div class="form-section">
          <div class="form-section-title">Linked Galleries</div>
          ${linkedGals.map(g => {
            const thumbs = (g.items || []).slice(0, 12).map(it => {
              const src = it.imgKey && typeof imgURL === 'function' ? imgURL(it.imgKey) : null;
              const title = it.title || '';
              if (src) {
                return `<button class="linked-gal-thumb" data-action="openLinkedGalleryImage" data-a0="${esc(g.id)}" data-a1="${esc(it.id)}" title="${esc(title)}" style="padding:0;border:1px solid var(--border2);border-radius:6px;overflow:hidden;background:var(--surface2);cursor:pointer;width:72px;height:72px;flex-shrink:0">
                  <img src="${src}" style="width:100%;height:100%;object-fit:cover;display:block" alt="${esc(title)}">
                </button>`;
              }
              // Image not yet decoded -- show placeholder; storage.imgURL() will
              // trigger a re-render once the IDB blob is loaded.
              return `<button class="linked-gal-thumb" data-action="openLinkedGalleryImage" data-a0="${esc(g.id)}" data-a1="${esc(it.id)}" title="${esc(title)}" style="padding:0;border:1px solid var(--border2);border-radius:6px;background:var(--surface2);cursor:pointer;width:72px;height:72px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--muted2);font-size:18px">📷</button>`;
            }).join('');
            const overflow = (g.items || []).length > 12
              ? `<button class="btn btn-ghost btn-sm" style="font-size:11px" data-action="openGalleryFolder" data-a0="${esc(g.id)}" data-a1="mine">+ ${g.items.length - 12} more</button>`
              : '';
            return `<div style="margin-bottom:10px">
              <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
                <button class="btn btn-ghost btn-sm" style="font-size:12px;padding:3px 9px" data-action="openGalleryFolder" data-a0="${esc(g.id)}" data-a1="mine">🖼 ${esc(g.title)}</button>
                <span style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${g.items.length} photo${g.items.length !== 1 ? 's' : ''}</span>
              </div>
              <div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center">${thumbs}${overflow}</div>
            </div>`;
          }).join('')}
        </div>`;
      })()}

      ${e.notes ? `<div class="form-section">
        <div class="form-section-title">Notes</div>
        <div class="notes-block">${esc(e.notes)}</div>
      </div>` : ''}

      ${(() => {
        if (typeof getNotesForEntry !== 'function') return '';
        const notes = getNotesForEntry(id);
        if (!notes.length) return '';
        if (typeof markNotesSeen === 'function') markNotesSeen(id);
        return `<div class="form-section">
          <div class="form-section-title">Comments</div>
          ${notes.map(n => {
            const _ts = n.timestamp ? timeAgo(n.timestamp) : '';
            return `<div class="comment-card">
            <div class="comment-card-author">${n.fromAvatar ? esc(n.fromAvatar) + ' ' : ''}${esc(n.fromName)}${_ts ? `<span class="comment-ts">${esc(_ts)}</span>` : ''}</div>
            <div class="comment-card-body">${esc(n.content)}</div>
            ${n.editedAt ? '<div class="comment-card-edited">edited</div>' : ''}
          </div>`;
          }).join('')}
        </div>`;
      })()}

      ${(() => {
        if (typeof getKotsForEntry !== 'function') return '';
        const kots = getKotsForEntry(id);
        if (!kots.length) return '';
        return `<div class="form-section">
          <div class="form-section-title">✊ ${kots.length} KOT${kots.length > 1 ? 's' : ''}</div>
          ${kots.map(k => `<div style="font-size:12px;margin-bottom:4px">${k.fromAvatar ? esc(k.fromAvatar) + ' ' : ''}${esc(k.fromName)} <span style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${timeAgoJ(k.timestamp)}</span></div>`).join('')}
        </div>`;
      })()}

    </div>`;
}

// ── Sizing guide toggle ──────────────────────────────────────────────────────
// The checkbox lives in the photo-slot actions row. When toggled, we just flip
// every visible overlay -- no re-render needed (the next renderEntryEditor will
// read localStorage and re-emit the right state).
function toggleSizingGuide(evt) {
  const cb = evt?.target?.closest('input[type="checkbox"]');
  const on = cb ? !!cb.checked : (localStorage.getItem('frj_sizing_guide') === '0');
  localStorage.setItem('frj_sizing_guide', on ? '1' : '0');
  document.querySelectorAll('.photo-sizing-guide').forEach(el => {
    el.style.display = on ? '' : 'none';
  });
}

// ── Helper functions ───────────────────────────────────────────────────────────

function timeAgoJ(iso) {
  if (!iso) return '';
  const m = Math.floor((Date.now() - new Date(iso)) / 60000);
  if (m < 1) return 'just now';
  if (m < 60) return m + 'm ago';
  const h = Math.floor(m / 60);
  if (h < 24) return h + 'h ago';
  return Math.floor(h / 24) + 'd ago';
}

function metricViewItem(label, value) {
  return `<div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:3px;text-transform:uppercase;letter-spacing:.06em">${label}</div><div style="font-size:13px">${esc(String(value))}</div></div>`;
}

// ── Entry editor ───────────────────────────────────────────────────────────────

function startEdit(id) {
  _selEntryId = id;
  _editMode = true;
  renderEntryEditor(id);
}

// Returns averaged habit values from weekly logs that fall between the previous
// journal entry's date and this entry's date. Returns null if no logs in range.
function _computeHabitsForEntry(entryId) {
  const entries = getEntries().sort((a, b) => a.date.localeCompare(b.date));
  const idx = entries.findIndex(e => e.id === entryId);
  if (idx < 0) return null;
  const thisDate = entries[idx].date;
  const prevDate = idx > 0 ? entries[idx - 1].date : '0000-00-00';
  const stats = (typeof getWeeklyStats === 'function' ? getWeeklyStats() : [])
    .filter(s => s.date > prevDate && s.date <= thisDate);

  // Include current incomplete week's live timer data
  const allStats = [...stats];
  if (typeof computeWeekSummaryFromSessions === 'function') {
    const ts = computeWeekSummaryFromSessions(thisDate);
    if (ts.hasSessions) {
      allStats.push({
        daysTugged: ts.daysTugged,
        daysRetained: ts.daysRetained,
        totalHrsTugged: ts.totalHrsTugged,
        totalHrsRetained: ts.totalHrsRetained,
        hrsPerDayTugged: ts.hrsPerDayTugged,
        hrsPerDayRetained: ts.hrsPerDayRetained,
        pctTugged: ts.daysTugged != null ? Math.round(ts.daysTugged / 7 * 100) : null,
        pctRetained: ts.daysRetained != null ? Math.round(ts.daysRetained / 7 * 100) : null,
        avgHrsTugged: ts.avgHrsTugged,
        avgHrsRetained: ts.avgHrsRetained
      });
    }
  }

  if (!allStats.length) return null;

  // Weighted per-active-day averaging across all weeks
  let sumTugHrs = 0, sumTugDays = 0, sumRetHrs = 0, sumRetDays = 0;
  let sumPctTug = 0, sumPctRet = 0, pctCount = 0;
  for (const s of allStats) {
    const tD = s.daysTugged ?? (s.pctTugged != null ? Math.round(s.pctTugged * 7 / 100) : 0);
    const rD = s.daysRetained ?? (s.pctRetained != null ? Math.round(s.pctRetained * 7 / 100) : 0);
    const tH = s.totalHrsTugged ?? (s.avgHrsTugged != null ? s.avgHrsTugged * 7 : 0);
    const rH = s.totalHrsRetained ?? (s.avgHrsRetained != null ? s.avgHrsRetained * 7 : 0);
    sumTugHrs += tH; sumTugDays += tD;
    sumRetHrs += rH; sumRetDays += rD;
    if (s.pctTugged != null) { sumPctTug += s.pctTugged; pctCount++; }
    if (s.pctRetained != null) sumPctRet += s.pctRetained;
  }

  return {
    count: allStats.length,
    pctTugged:      pctCount ? Math.round(sumPctTug / pctCount) : null,
    pctRetained:    pctCount ? Math.round(sumPctRet / pctCount) : null,
    avgHrsTugged:   sumTugDays ? parseFloat((sumTugHrs / sumTugDays).toFixed(1)) : null,
    avgHrsRetained: sumRetDays ? parseFloat((sumRetHrs / sumRetDays).toFixed(1)) : null,
    daysTugged:     pctCount ? Math.round(sumTugDays / allStats.length) : null,
    daysRetained:   pctCount ? Math.round(sumRetDays / allStats.length) : null,
  };
}

function renderEntryEditor(id) {
  const el = _getDetailEl();
  if (!el) return;
  const e = getEntry(id);
  if (!e) { el.innerHTML = `<div class="empty"><p>Entry not found</p></div>`; return; }

  const tugDevices = e.tuggingDevices || [];
  const retDevices = e.retainingDevices || [];

  function photoSlotHtml(field, imgKey, label) {
    const src = imgURL(imgKey);
    const inputId = `ph-${id}-${field}`;
    const isMain = field === 'imgFront';
    // Sizing-guide overlay: defaults ON so people see the suggested proportion
    // immediately. Persists across sessions so power users who turn it off
    // don't keep getting nagged. The icon is purely a UI hint -- never written
    // into the stored image.
    const guideOn = localStorage.getItem('frj_sizing_guide') !== '0';
    return `<div class="photo-slot${src ? ' has-img' : ''}">
      <div class="photo-slot-label">${label}</div>
      ${src
        ? `<div class="photo-slot-img-wrap" style="position:relative">
             <img class="photo-slot-img thumb-hover" src="${src}" alt="${label}" data-action="openLb" data-a0="${src}">
             ${isMain ? `<img class="photo-sizing-guide" src="img/penis_icon.png" alt="" aria-hidden="true"${guideOn ? '' : ' style="display:none"'}>` : ''}
           </div>`
        : `<label for="${inputId}" class="photo-slot-placeholder" style="cursor:pointer;${isMain ? 'justify-content:space-between;padding:20px 12px 12px' : ''}">
            ${isMain
              ? `<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:0 10px"><img src="img/penis_icon.png" alt="" style="width:80%;max-width:220px;mix-blend-mode:multiply;opacity:.4"></div>
                 <span style="font-size:11px;color:var(--muted2);text-align:center;line-height:1.4;max-width:240px;flex-shrink:0">Flaccid photo without a device for future reference. Attach a gallery for multiple angles or device photos.</span>`
              : `<span style="font-size:22px;color:var(--muted2)">+</span><span>${label}</span>`}
          </label>`
      }
      <div class="photo-slot-actions">
        <label class="btn btn-ghost btn-sm" style="cursor:pointer">
          Upload<input type="file" id="${inputId}" accept="image/*" data-upload-id="${id}" data-upload-field="${field}">
        </label>
        <button class="btn btn-ghost btn-sm" data-action="webcamOpen" data-a0="${id}" data-a1="${field}">Webcam</button>
        ${src ? `<button class="btn btn-ghost btn-sm" data-action="adjustImage" data-a0="${id}" data-a1="${field}">Adjust</button>` : ''}
        ${src ? `<button class="btn btn-danger btn-sm" data-action="removeImage" data-a0="${id}" data-a1="${field}">Remove</button>` : ''}
        ${src && isMain ? `<label style="display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);cursor:pointer;padding:4px 6px;margin-left:auto">
          <input type="checkbox" data-action="toggleSizingGuide"${guideOn ? ' checked' : ''} style="margin:0">
          <span>Sizing guide</span>
        </label>` : ''}
      </div>
    </div>`;
  }

  el.innerHTML = `
    <div class="journal-main-inner">
      <div class="entry-actions">
        <button class="btn btn-green btn-sm" data-action="saveEntryForm" data-a0="${id}">Save Entry</button>
        <button class="btn btn-ghost btn-sm" data-action="cancelEdit" data-a0="${id}">Cancel</button>
        <button class="btn btn-danger btn-sm" style="margin-left:auto" data-action="deleteEntryConfirm" data-a0="${id}">Delete</button>
      </div>

      <!-- Section: Assessment -->
      <div class="form-section">
        <div class="form-section-title">Assessment</div>
        <div id="ef-date-title" style="font-size:14px;font-weight:600;margin-bottom:8px">${esc(fmtMonthYear(e.date))}</div>
        <div class="fg"><label>Date</label><input type="date" id="ef-date" value="${esc(e.date)}"></div>

        <div class="fg">
          <label>Coverage Index (CI)</label>
          <input type="hidden" id="ef-ci" value="${e.ci}">
          <div class="ci-selector">${_ciSelector(e.ci)}</div>
          <div class="ci-label-desc" id="ci-label-desc">CI-${e.ci}: ${CI_LABELS[e.ci] || ''}</div>
          <button type="button" class="ciref-open-btn" data-action="openCiRef">Not sure? Open the illustrated guide</button>
        </div>
      </div>

      <!-- Section: Weekly Habits -->
      ${(() => {
        const hab = _computeHabitsForEntry(id);
        const dTug = hab?.daysTugged ?? (hab?.pctTugged != null ? Math.round(hab.pctTugged * 7 / 100) : null);
        const dRet = hab?.daysRetained ?? (hab?.pctRetained != null ? Math.round(hab.pctRetained * 7 / 100) : null);
        const hTug = hab?.avgHrsTugged ?? null;
        const hRet = hab?.avgHrsRetained ?? null;
        const statTile = (label, val, unit) => `<div style="background:var(--bg2);border-radius:6px;padding:8px 10px">
          <div style="font-family:var(--mono);font-size:11px;color:var(--muted2);text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px">${label}</div>
          <div style="font-family:var(--mono);font-size:16px;font-weight:700;color:var(--text)">${val != null ? val + unit : '-'}</div>
        </div>`;
        if (hab) {
          return `<div class="form-section">
            <div class="form-section-title">Weekly averages since last update <span style="font-family:var(--mono);font-size:10px;color:var(--accent);font-weight:400">from ${hab.count} week${hab.count > 1 ? 's' : ''}</span></div>
            <label style="display:flex;align-items:center;gap:7px;font-size:12px;cursor:pointer;color:var(--muted);margin-bottom:8px">
              <input type="checkbox" id="ef-habit-manual" checked
                data-pct-tugged="${hab.pctTugged ?? ''}"
                data-pct-retained="${hab.pctRetained ?? ''}"
                data-hrs-tugged="${hTug ?? ''}"
                data-hrs-retained="${hRet ?? ''}"
              > Derive from weekly averages
            </label>
            <div id="ef-hab-computed" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
              ${statTile('Days Tugged', dTug, ' / 7')}
              ${statTile('Days Retained', dRet, ' / 7')}
              ${statTile('Hrs/day Tugged', hTug, 'h/day')}
              ${statTile('Hrs/day Retained', hRet, 'h/day')}
            </div>
            <div id="ef-hab-inputs" style="display:none;grid-template-columns:1fr 1fr;gap:12px">
              <div class="fg" style="margin:0"><label>Days Tugged (0–7)</label>
                <input type="number" id="ef-pctTugged" min="0" max="7" step="1" inputmode="numeric" value="${dTug ?? ''}"></div>
              <div class="fg" style="margin:0"><label>Days Retained (0–7)</label>
                <input type="number" id="ef-pctRetained" min="0" max="7" step="1" inputmode="numeric" value="${dRet ?? ''}"></div>
              <div class="fg" style="margin:0"><label>Hrs/day Tugged</label>
                <input type="number" id="ef-avgHrsTugged" min="0" max="24" step="0.5" inputmode="decimal" value="${hTug ?? ''}"></div>
              <div class="fg" style="margin:0"><label>Hrs/day Retained</label>
                <input type="number" id="ef-avgHrsRetained" min="0" max="24" step="0.5" inputmode="decimal" value="${hRet ?? ''}"></div>
            </div>
          </div>`;
        } else {
          return `<div class="form-section">
            <div class="form-section-title">Weekly averages since last update</div>
            <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
              <div class="fg" style="margin:0"><label>Days Tugged (0–7)</label>
                <input type="number" id="ef-pctTugged" min="0" max="7" step="1" inputmode="numeric" value="${e.pctTugged != null ? Math.round(e.pctTugged * 7 / 100) : ''}"></div>
              <div class="fg" style="margin:0"><label>Days Retained (0–7)</label>
                <input type="number" id="ef-pctRetained" min="0" max="7" step="1" inputmode="numeric" value="${e.pctRetained != null ? Math.round(e.pctRetained * 7 / 100) : ''}"></div>
              <div class="fg" style="margin:0"><label>Hrs/day Tugged</label>
                <input type="number" id="ef-avgHrsTugged" min="0" max="24" step="0.5" inputmode="decimal" value="${e.avgHrsTugged ?? ''}"></div>
              <div class="fg" style="margin:0"><label>Hrs/day Retained</label>
                <input type="number" id="ef-avgHrsRetained" min="0" max="24" step="0.5" inputmode="decimal" value="${e.avgHrsRetained ?? ''}"></div>
            </div>
          </div>`;
        }
      })()}

      <!-- Section: Devices -->
      <div class="form-section">
        <div class="form-section-title">Devices</div>

        <div class="fg" style="margin-bottom:14px">
          <label>Tugging Devices</label>
          <div class="device-chips-row" id="ef-chips-tugging">
            ${_deviceChips(tugDevices, TUGGING_DEVICES, 'tugging', id)}
          </div>
          <input type="text" placeholder="Other device..." class="input" style="font-size:11px;margin-top:4px"
            data-device-type="tugging" data-entry-id="${id}">
        </div>

        <div class="fg" style="margin:0">
          <label>Retaining Devices</label>
          <div class="device-chips-row" id="ef-chips-retaining">
            ${_deviceChips(retDevices, RETAINING_DEVICES, 'retaining', id)}
          </div>
          <input type="text" placeholder="Other device..." class="input" style="font-size:11px;margin-top:4px"
            data-device-type="retaining" data-entry-id="${id}">
        </div>
      </div>

      <!-- Section: Photo -->
      <div class="form-section">
        <div class="form-section-title">Photo</div>
        <div style="max-width:320px">
          ${photoSlotHtml('imgFront', e.imgFront, 'Progress Photo')}
        </div>
        <label style="display:flex;align-items:center;gap:7px;margin-top:8px;cursor:pointer;font-size:12px;color:var(--muted)">
          <input type="checkbox" id="ef-img-private" ${e.imgPrivate ? 'checked' : ''}>
          Private photo - not shared with any buddies
        </label>
      </div>

      <!-- Section: Linked Galleries -->
      ${(() => {
        const allGals = typeof getGalleries === 'function' ? getGalleries() : [];
        if (!allGals.length) return '';
        const linked = e.linkedGalleries || [];
        return `<div class="form-section">
          <div class="form-section-title">Linked Galleries</div>
          <div class="device-chips-row" id="ef-linked-galleries">
            ${allGals.map(g => {
              const active = linked.includes(g.id);
              return `<button type="button" class="device-chip${active ? ' active' : ''}"
                data-action="toggleLinkedGallery" data-a0="${esc(g.id)}" data-a1="${id}">${esc(g.title)} (${g.items.length})</button>`;
            }).join('')}
          </div>
          <div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-top:4px">Buddies with gallery access will see a link to view photos</div>
        </div>`;
      })()}

      <!-- Section: Notes -->
      <div class="form-section">
        <div class="form-section-title">Notes</div>
        <textarea id="ef-notes" placeholder="Notes, observations, how you felt..." maxlength="2000" style="min-height:100px">${esc(e.notes || '')}</textarea>
      </div>
    </div>`;
}

// ── Save / cancel / delete ─────────────────────────────────────────────────────

async function saveEntryForm(id) {
  const e = getEntry(id);
  if (!e) return;
  // Devices come from IDB state (toggled in real-time), but re-read to be safe
  const storedEntry = getEntry(id);
  const patch = {
    date: document.getElementById('ef-date')?.value || e.date,
    ci: parseInt(document.getElementById('ef-ci')?.value ?? e.ci),
    ...(() => {
      const manualCb = document.getElementById('ef-habit-manual');
      if (manualCb && manualCb.checked) {
        // Derive mode (checked): use computed values stored in data attrs
        const attr = (k) => { const v = parseFloat(manualCb.dataset[k]); return isNaN(v) ? null : v; };
        return {
          pctTugged:      attr('pctTugged')    != null ? Math.round(attr('pctTugged'))    : null,
          pctRetained:    attr('pctRetained')  != null ? Math.round(attr('pctRetained'))  : null,
          avgHrsTugged:   attr('hrsTugged'),
          avgHrsRetained: attr('hrsRetained'),
        };
      }
      // Manual mode or no weekly logs -- read from inputs
      const days = (id) => { const d = parseInt(document.getElementById(id)?.value); return isNaN(d) ? null : Math.round(Math.min(7, Math.max(0, d)) / 7 * 100); };
      return {
        pctTugged:      days('ef-pctTugged'),
        pctRetained:    days('ef-pctRetained'),
        avgHrsTugged:   ((v) => isNaN(v) ? null : v)(parseFloat(document.getElementById('ef-avgHrsTugged')?.value)),
        avgHrsRetained: ((v) => isNaN(v) ? null : v)(parseFloat(document.getElementById('ef-avgHrsRetained')?.value)),
      };
    })(),
    tuggingDevices: storedEntry?.tuggingDevices || e.tuggingDevices,
    retainingDevices: storedEntry?.retainingDevices || e.retainingDevices,
    imgPrivate: !!(document.getElementById('ef-img-private')?.checked),
    notes: (document.getElementById('ef-notes')?.value || '').slice(0, 2000)
  };
  updateEntry(id, patch);
  if (typeof pushNotification === 'function') {
    const ciLabel = (typeof CI_LABELS !== 'undefined' && CI_LABELS[patch.ci]) ? CI_LABELS[patch.ci] : `CI-${patch.ci}`;
    if (patch.ci != null && e.ci != null && patch.ci > e.ci) {
      pushNotification('kot', `CI-${patch.ci} reached!`, ciLabel);
    }
    pushNotification('journal', 'Journal entry saved', patch.date || '', { silent: true });
    if (patch.notes && patch.notes.trim()) pushNotification('note', 'Note saved', patch.notes.slice(0, 60), { silent: true });
  }
  // Stamp sync fields on every save (Sprint 0+)
  if (typeof stampSyncFields === 'function') {
    const full = getEntry(id);
    if (full) {
      const stamped = await stampSyncFields({ ...full });
      updateEntry(id, {
        deviceId:  stamped.deviceId,
        updatedAt: stamped.updatedAt,
        version:   stamped.version,
        checksum:  stamped.checksum
      });
    }
  }
  // Trigger sync after save (Sprint 3)
  if (typeof syncAfterSave === 'function') syncAfterSave();
  _editMode = false;
  renderEntryList();
  renderEntryViewer(id);
  toast('Entry saved');
  if (typeof p2pBroadcastManifest === 'function') p2pBroadcastManifest();
  else if (typeof p2pBroadcastJournal === 'function') p2pBroadcastJournal();
  // Relay latest entry to offline buddies via signal server
  if (typeof p2pRelayJournalUpdate === 'function') p2pRelayJournalUpdate();
  // Push buddy snapshot (24h presence cache)
  if (typeof p2pPushBuddySnapshot === 'function') p2pPushBuddySnapshot();
}

function cancelEdit(id) {
  _editMode = false;
  renderEntryViewer(id);
}

function deleteEntryConfirm(id) {
  const e = getEntry(id);
  if (!e) return;
  confirm2('Delete this entry?', `Entry from ${e.date} will be permanently deleted.`, () => {
    deleteEntry(id);
    _selEntryId = null;
    _editMode = false;
    renderEntryList();
    const el = _getDetailEl();
    if (el) el.innerHTML =
      `<div class="empty" style="height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center">
        <div class="icon">◳</div>
        <p>Select an entry or create a new one</p>
      </div>`;
    toast('Entry deleted');
    // Notify buddies so they evict cached photos and update hasImgFront
    if (typeof p2pBroadcastManifest === 'function') p2pBroadcastManifest();
    else if (typeof p2pBroadcastJournal === 'function') p2pBroadcastJournal();
  });
}

// ── Image handling ─────────────────────────────────────────────────────────────

// Returns true if the entry date is consistent with the user being 18+ at the time.
// Returns false (and should trigger a warning) if the date implies the user may have
// been a minor. Only fires when the user has set an ageRange in their profile.
function _imgAgeOk(entryId) {
  const profile = getProfile ? getProfile() : null;
  if (!profile || !profile.ageRange) return true;
  const entry = getEntry(entryId);
  if (!entry || !entry.date) return true;
  // Parse the lower bound from e.g. "18–25", "25–40", "70+"
  const lowerBound = parseInt(profile.ageRange, 10);
  if (isNaN(lowerBound)) return true;
  const currentYear = new Date().getFullYear();
  const approxBirthYear = currentYear - lowerBound;
  const adultYear = approxBirthYear + 18;
  const entryYear = new Date(entry.date + 'T12:00:00').getFullYear();
  return entryYear >= adultYear;
}

const _IMG_AGE_TITLE = 'Age verification required';
const _IMG_AGE_BODY = 'Based on your age setting, you may have been under 18 at the time of this journal entry.\n\nImages you upload must only depict yourself from a point in time at which you were an adult (18 years of age or older). Uploading images of yourself as a minor is prohibited and may expose you to legal liability. By continuing, you confirm that you were 18 or older when this image was taken.';

async function handleImageUpload(input, entryId, field) {
  const f = input.files[0];
  if (!f) return;
  if (typeof requireEncryptionForPhotos === 'function' && !(await requireEncryptionForPhotos())) {
    input.value = '';
    toast('Encryption is required to store photos. Set up a password first.', true);
    return;
  }
  const entry = getEntry(entryId);
  if (!entry) return;
  if (!_imgAgeOk(entryId)) {
    const blob = await compressImage(f);
    input.value = '';
    confirm2(_IMG_AGE_TITLE, _IMG_AGE_BODY, () => _ptOpen(blob, entryId, field, entry[field] || null));
    return;
  }
  const blob = await compressImage(f);
  input.value = '';
  _ptOpen(blob, entryId, field, entry[field] || null);
}

function removeImage(entryId, field) {
  const entry = getEntry(entryId);
  if (!entry) return;
  const old = entry[field];
  const currentDate = document.getElementById('ef-date')?.value;
  updateEntry(entryId, { [field]: null, ...(currentDate ? { date: currentDate } : {}) });
  if (old) idbDel(old);
  renderEntryEditor(entryId);
}

// Re-open the Photo Transform Modal on the photo that's ALREADY saved to this
// entry, so the user can keep adjusting zoom / move / rotate (and align to the
// sizing guide) without having to re-upload. We fetch the current blob from
// IDB and feed it back through the same `_ptOpen` path the upload flow uses.
async function adjustImage(entryId, field) {
  const entry = getEntry(entryId);
  if (!entry) return;
  const imgKey = entry[field];
  if (!imgKey) { toast('No photo to adjust', true); return; }
  try {
    const blob = await idbGet(imgKey);
    if (!blob) { toast('Photo not found', true); return; }
    _ptOpen(blob, entryId, field, imgKey);
  } catch (e) {
    console.warn('[journal] adjustImage failed:', e);
    toast('Could not load photo', true);
  }
}

async function openWebcamForField(entryId, field) {
  if (typeof requireEncryptionForPhotos === 'function' && !(await requireEncryptionForPhotos())) {
    toast('Encryption is required to store photos. Set up a password first.', true);
    return;
  }
  if (!_imgAgeOk(entryId)) {
    confirm2(_IMG_AGE_TITLE, _IMG_AGE_BODY, () => webcamOpen(entryId, field));
    return;
  }
  webcamOpen(entryId, field);
}

// ── Photo transform tool ────────────────────────────────────────────────────────
// State: { img, blob, entryId, field, oldKey, rot, scale, ox, oy, dragging, lastX, lastY }
let _pt = null;

const _PT_MAX = 560; // max canvas side (px)

// Compute canvas dimensions respecting crop ratio (if set) or image aspect ratio.
function _ptCanvasDims() {
  const cropRatio = _pt?.cropRatio || null; // w/h, e.g. 3/4 portrait, 4/3 landscape
  if (cropRatio) {
    return cropRatio < 1
      ? { w: Math.round(_PT_MAX * cropRatio), h: _PT_MAX }   // portrait
      : { w: _PT_MAX, h: Math.round(_PT_MAX / cropRatio) };  // landscape / square
  }
  if (!_pt?.img) return { w: _PT_MAX, h: Math.round(_PT_MAX * 3 / 4) };
  const isOdd = Math.abs(_pt.rot) % 180 === 90;
  const fw = isOdd ? _pt.img.naturalHeight : _pt.img.naturalWidth;
  const fh = isOdd ? _pt.img.naturalWidth  : _pt.img.naturalHeight;
  const s = Math.min(_PT_MAX / fw, _PT_MAX / fh);
  return { w: Math.round(fw * s), h: Math.round(fh * s) };
}

function _ptSetCrop(ratio) {
  if (!_pt) return;
  _pt.cropRatio = ratio;
  // Update button states
  const key = ratio == null ? 'null' : String(ratio);
  document.querySelectorAll('[data-crop]').forEach(b => {
    const active = b.dataset.crop === key;
    b.classList.toggle('btn-primary', active);
    b.classList.toggle('btn-ghost', !active);
  });
  _ptDraw();
}

function _ptOpen(blob, entryId, field, oldKey) {
  const pendingDate = document.getElementById('ef-date')?.value || null;
  _pt = { blob, entryId, field, oldKey, pendingDate, rot: 0, scale: 1, ox: 0, oy: 0,
          dragging: false, lastX: 0, lastY: 0, img: null };
  const url = URL.createObjectURL(blob);
  const img = new Image();
  img.onload = () => {
    // Keep url alive in _pt -- revoking before _ptApply draws causes iOS canvas taint
    _pt.blobUrl = url;
    _pt.img = img;
    _ptSyncSliders();
    _ptInitSizingGuide();
    document.getElementById('photoTransformModal')?.classList.add('open');
    _ptSetupEvents();
    _ptDraw();
  };
  img.onerror = () => { URL.revokeObjectURL(url); toast('Failed to load image', true); _pt = null; };

  img.src = url;
}

// Gallery variant -- opens same modal but saves back to a gallery item instead of a journal entry
function _ptOpenGallery(blob, galleryId, itemId, oldKey) {
  _pt = { blob, galleryId, itemId, oldKey, rot: 0, scale: 1, ox: 0, oy: 0,
          dragging: false, lastX: 0, lastY: 0, img: null };
  const url = URL.createObjectURL(blob);
  const img = new Image();
  img.onload = () => {
    _pt.blobUrl = url;
    _pt.img = img;
    _ptSyncSliders();
    _ptInitSizingGuide();
    document.getElementById('photoTransformModal')?.classList.add('open');
    _ptSetupEvents();
    _ptDraw();
  };
  img.onerror = () => { URL.revokeObjectURL(url); toast('Failed to load image', true); _pt = null; };
  img.src = url;
}

// Sync the sizing-guide overlay + toggle to the user's persisted preference.
// Idempotent and wires the change listener once per element lifetime.
function _ptInitSizingGuide() {
  const guideOn = localStorage.getItem('frj_sizing_guide') !== '0';
  const guideEl = document.getElementById('ptSizingGuide');
  const toggle = document.getElementById('ptSizingGuideToggle');
  if (guideEl) guideEl.style.display = guideOn ? '' : 'none';
  if (toggle) {
    toggle.checked = guideOn;
    if (!toggle._ptWired) {
      toggle.addEventListener('change', () => {
        const on = toggle.checked;
        localStorage.setItem('frj_sizing_guide', on ? '1' : '0');
        if (guideEl) guideEl.style.display = on ? '' : 'none';
      });
      toggle._ptWired = true;
    }
  }
}

function _ptDraw() {
  const cv = document.getElementById('ptCanvas');
  if (!cv || !_pt?.img) return;
  const { w, h } = _ptCanvasDims();
  cv.width = w; cv.height = h;

  const ctx = cv.getContext('2d');
  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, w, h);

  const img = _pt.img;
  const nw = img.naturalWidth, nh = img.naturalHeight;
  const isOdd = Math.abs(_pt.rot) % 180 === 90;
  const fitW = isOdd ? nh : nw;
  const fitH = isOdd ? nw : nh;
  const fitScale = Math.min(w / fitW, h / fitH);
  const s = fitScale * _pt.scale;

  ctx.save();
  ctx.translate(w / 2 + _pt.ox, h / 2 + _pt.oy);
  ctx.rotate(_pt.rot * Math.PI / 180);
  ctx.drawImage(img, -nw / 2 * s, -nh / 2 * s, nw * s, nh * s);
  ctx.restore();
}

function _ptRotate(deg) {
  if (!_pt) return;
  // Keep rotation in [-180, 180] to match the slider range so the rotate buttons
  // and the slider operate on the same scalar without surprise wraparound.
  let r = _pt.rot + parseFloat(deg);
  while (r >  180) r -= 360;
  while (r < -180) r += 360;
  _pt.rot = r;
  _ptSyncSliders();
  _ptDraw();
}

function _ptSetZoom(val) {
  if (!_pt) return;
  _pt.scale = parseFloat(val) / 100;
  _ptSyncSliders();
  _ptDraw();
}

// New sliders: rotation in degrees (-180..180), pan in canvas pixels.
function _ptSetRot(val) {
  if (!_pt) return;
  _pt.rot = parseFloat(val);
  _ptSyncSliders();
  _ptDraw();
}
function _ptSetOx(val) {
  if (!_pt) return;
  _pt.ox = parseFloat(val);
  _ptSyncSliders();
  _ptDraw();
}
function _ptSetOy(val) {
  if (!_pt) return;
  _pt.oy = parseFloat(val);
  _ptSyncSliders();
  _ptDraw();
}

// Mirror current _pt transform state back into the slider DOM + value labels.
// Called after drag/wheel/pinch so the sliders reflect reality.
function _ptSyncSliders() {
  if (!_pt) return;
  const z = document.getElementById('ptZoom');
  const r = document.getElementById('ptRot');
  const x = document.getElementById('ptOx');
  const y = document.getElementById('ptOy');
  const zV = document.getElementById('ptZoomVal');
  const rV = document.getElementById('ptRotVal');
  const xV = document.getElementById('ptOxVal');
  const yV = document.getElementById('ptOyVal');
  if (z)  z.value  = Math.round(_pt.scale * 100);
  if (r)  r.value  = Math.round(_pt.rot);
  if (x)  x.value  = Math.round(_pt.ox);
  if (y)  y.value  = Math.round(_pt.oy);
  if (zV) zV.textContent = Math.round(_pt.scale * 100) + '%';
  if (rV) rV.textContent = Math.round(_pt.rot) + '°';
  if (xV) xV.textContent = Math.round(_pt.ox);
  if (yV) yV.textContent = Math.round(_pt.oy);
}

function _ptReset() {
  if (!_pt) return;
  _pt.rot = 0; _pt.scale = 1; _pt.ox = 0; _pt.oy = 0;
  _ptSyncSliders();
  _ptDraw();
}

function _ptCancel() {
  if (_pt?.blobUrl) URL.revokeObjectURL(_pt.blobUrl);
  _pt = null;
  closeModal('photoTransformModal');
}

async function _ptApply() {
  if (!_pt?.img) return;

  try {
    const { w: CW, h: CH } = _ptCanvasDims();
    const outScale = Math.min(1200 / CW, 1200 / CH, 2);
    const OW = Math.round(CW * outScale), OH = Math.round(CH * outScale);

    const out = document.createElement('canvas');
    out.width = OW; out.height = OH;
    const ctx = out.getContext('2d');
    if (!ctx) throw new Error('Could not get canvas context');

    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, OW, OH);

    const nw = _pt.img.naturalWidth, nh = _pt.img.naturalHeight;
    const source = (typeof createImageBitmap === 'function')
      ? await createImageBitmap(_pt.blob)
      : _pt.img;
    const isOdd = Math.abs(_pt.rot) % 180 === 90;
    const fitW = isOdd ? nh : nw;
    const fitH = isOdd ? nw : nh;
    const fitScale = Math.min(CW / fitW, CH / fitH);
    const s = fitScale * _pt.scale * outScale;

    ctx.save();
    ctx.translate(OW / 2 + _pt.ox * outScale, OH / 2 + _pt.oy * outScale);
    ctx.rotate(_pt.rot * Math.PI / 180);
    ctx.drawImage(source, -nw / 2 * s, -nh / 2 * s, nw * s, nh * s);
    ctx.restore();
    if (source.close) source.close();

    const outBlob = await new Promise(resolve => {
      try { out.toBlob(b => resolve(b), 'image/jpeg', 0.85); }
      catch(e) { resolve(null); }
    }).then(b => {
      if (b) return b;
      const dataUrl = out.toDataURL('image/jpeg', 0.85);
      const bytes = atob(dataUrl.split(',')[1]);
      const arr = new Uint8Array(bytes.length);
      for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
      return new Blob([arr], { type: 'image/jpeg' });
    });

    // ── Gallery path ──
    if (_pt.galleryId) {
      const galleryId = _pt.galleryId;
      const itemId    = _pt.itemId;
      const old       = _pt.oldKey;
      if (_pt.blobUrl) URL.revokeObjectURL(_pt.blobUrl);
      _pt = null;
      const key = await storeBlob(outBlob);
      const galleries = typeof getGalleries === 'function' ? getGalleries() : [];
      const gi = galleries.findIndex(g => g.id === galleryId);
      if (gi >= 0) {
        const ii = galleries[gi].items.findIndex(i => i.id === itemId);
        if (ii >= 0) {
          galleries[gi].items[ii].imgKey = key;
          galleries[gi].items[ii].updatedAt = new Date().toISOString();
        }
      }
      if (typeof saveGalleries === 'function') saveGalleries(galleries);
      if (old && old !== key) idbDel(old);
      closeModal('photoTransformModal');
      if (typeof renderGallery === 'function') renderGallery();
      toast('Photo saved');
      return;
    }

    // ── Journal path ──
    const old = _pt.oldKey;
    const entryId = _pt.entryId;
    const field = _pt.field;
    const pendingDate = _pt.pendingDate;
    if (_pt.blobUrl) URL.revokeObjectURL(_pt.blobUrl);
    _pt = null;

    const key = await storeBlob(outBlob);
    updateEntry(entryId, { [field]: key, ...(pendingDate ? { date: pendingDate } : {}) });
    if (old) idbDel(old);
    closeModal('photoTransformModal');
    renderEntryEditor(entryId);
    toast('Photo saved');
  } catch(err) {
    console.error('[_ptApply]', err);
    toast('Failed to save photo', true);
  }
}

function _ptSetupEvents() {
  const cv = document.getElementById('ptCanvas');
  if (!cv || cv.dataset.ptInit === '1') return;
  cv.dataset.ptInit = '1';

  // Mouse pan
  cv.addEventListener('mousedown', e => {
    if (!_pt) return;
    _pt.dragging = true; _pt.lastX = e.clientX; _pt.lastY = e.clientY;
    cv.style.cursor = 'grabbing';
  });
  document.addEventListener('mouseup', () => {
    if (!_pt) return;
    _pt.dragging = false; cv.style.cursor = 'grab';
  });
  cv.addEventListener('mousemove', e => {
    if (!_pt?.dragging) return;
    const rect = cv.getBoundingClientRect();
    const pr = cv.width / rect.width; // canvas px per display px
    _pt.ox += (e.clientX - _pt.lastX) * pr;
    _pt.oy += (e.clientY - _pt.lastY) * pr;
    _pt.lastX = e.clientX; _pt.lastY = e.clientY;
    _ptSyncSliders();
    _ptDraw();
  });

  // Scroll zoom
  cv.addEventListener('wheel', e => {
    if (!_pt) return;
    e.preventDefault();
    _pt.scale = Math.max(0.5, Math.min(5, _pt.scale * (e.deltaY < 0 ? 1.1 : 0.9)));
    _ptSyncSliders();
    _ptDraw();
  }, { passive: false });

  // Touch pan + pinch zoom
  let _ptLastDist = null;
  cv.addEventListener('touchstart', e => {
    if (!_pt) return;
    e.preventDefault(); // must be non-passive to prevent iOS scroll/gesture interference
    if (e.touches.length === 1) {
      _pt.dragging = true; _pt.lastX = e.touches[0].clientX; _pt.lastY = e.touches[0].clientY;
    } else if (e.touches.length === 2) {
      _pt.dragging = false;
      _ptLastDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
    }
  }, { passive: false });
  cv.addEventListener('touchmove', e => {
    if (!_pt) return;
    e.preventDefault();
    if (e.touches.length === 1 && _pt.dragging) {
      const rect = cv.getBoundingClientRect();
      const pr = cv.width / rect.width;
      _pt.ox += (e.touches[0].clientX - _pt.lastX) * pr;
      _pt.oy += (e.touches[0].clientY - _pt.lastY) * pr;
      _pt.lastX = e.touches[0].clientX; _pt.lastY = e.touches[0].clientY;
      _ptSyncSliders();
      _ptDraw();
    } else if (e.touches.length === 2 && _ptLastDist) {
      const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
      _pt.scale = Math.max(0.5, Math.min(5, _pt.scale * dist / _ptLastDist));
      _ptLastDist = dist;
      _ptSyncSliders();
      _ptDraw();
    }
  }, { passive: false });
  cv.addEventListener('touchend', () => {
    if (_pt) _pt.dragging = false;
    _ptLastDist = null;
  }, { passive: true });
}

function _toggleHabitManual() {
  const cb = document.getElementById('ef-habit-manual');
  const inputs = document.getElementById('ef-hab-inputs');
  const computed = document.getElementById('ef-hab-computed');
  if (!cb || !inputs) return;
  // checked = derive from weekly averages (show tiles, hide inputs)
  // unchecked = manual entry (show inputs, dim tiles)
  if (cb.checked) {
    inputs.style.display = 'none';
    if (computed) computed.style.opacity = '1';
  } else {
    inputs.style.display = 'grid';
    if (computed) computed.style.opacity = '0.4';
  }
}

function _sendNoteClick(contactPubKeyB64, entryId) {
  const ta = document.getElementById('buddy-note-ta');
  if (!ta) return;

  // Sanitize: strip HTML tags, control characters, collapse excessive whitespace
  let content = ta.value
    .replace(/<[^>]*>/g, '')           // strip any HTML tags
    .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // strip control chars (keep \t \n \r)
    .trim();
  if (!content) return;
  if (content.length > 1000) content = content.slice(0, 1000);

  // If a note already exists for this entry, mark it as an edit
  const existingNote = (typeof getSentNote === 'function') ? getSentNote(contactPubKeyB64, entryId) : null;
  const editedAt = existingNote ? new Date().toISOString() : null;

  // Record synchronously so re-render sees it immediately
  if (typeof recordNoteSent === 'function') recordNoteSent(contactPubKeyB64, entryId, content);

  if (typeof p2pSendNote === 'function') p2pSendNote(contactPubKeyB64, entryId, content, editedAt);

  if (typeof toast === 'function') toast('Comment sent');

  if (typeof _selBuddyKey !== 'undefined' && _selBuddyKey === contactPubKeyB64 &&
      typeof _selEntryId !== 'undefined' && _selEntryId === entryId) {
    selectBuddyEntry(contactPubKeyB64, entryId);
  }
}

function _toggleKot(pubKeyB64, entryId) {
  if (typeof getSentKot === 'function' && getSentKot(pubKeyB64, entryId)) return; // already KOT'd
  if (typeof p2pSendKot === 'function') p2pSendKot(pubKeyB64, entryId); // records + sends
  selectBuddyEntry(pubKeyB64, entryId);
}

