Source: 12a-changelog.js

/* ── Dev changelog ── */

/**
 * Merges the hardcoded {@link DEV_CHANGES} array into the persisted dev log in
 * localStorage, adding only entries whose `id` is not already stored.
 * Maintains chronological sort order by `id`.
 */
function mergeDevLog() {
  try {
    const stored = JSON.parse(localStorage.getItem(STORE_DEV_LOG) || '[]');
    const storedIds = new Set(stored.map((change) => change.id));
    const newEntries = DEV_CHANGES.filter((change) => !storedIds.has(change.id));
    if (newEntries.length) {
      const merged = [...stored, ...newEntries].sort((a, b) => a.id.localeCompare(b.id));
      localStorage.setItem(STORE_DEV_LOG, JSON.stringify(merged));
    }
  } catch (err) {
    wlLog.warn('mergeDevChanges: failed to merge dev changelog entries', err);
  }
}

/** Label markup each copy button had before its first "Copied!" flash. */
const copyButtonOriginalLabels = new WeakMap();

/**
 * Briefly replaces a copy button's label with "Copied!", then restores the
 * original markup (emoji span included). The original is remembered on first
 * use, not read at each click: a second click inside the flash would otherwise
 * capture "Copied!" as the label to restore, leaving the button stuck.
 * @param {HTMLElement} button - The copy button to flash.
 * @param {number} [durationMs] - How long "Copied!" stays visible.
 * @returns {void}
 */
function flashCopiedLabel(button, durationMs = 2000) {
  if (!copyButtonOriginalLabels.has(button)) copyButtonOriginalLabels.set(button, button.innerHTML);
  button.textContent = 'Copied!';
  setTimeout(() => (button.innerHTML = copyButtonOriginalLabels.get(button)), durationMs);
}

/**
 * Opens the end-of-day modal for the day in view: auto-exports the time log and
 * JSON backup, saves that day's EOD timestamp, populates handoff notes for its
 * unfinished tasks, renders its dev changelog entries, and lists the test areas
 * to review. Viewing an earlier day ends that day, not the current one.
 */
function openEodModal() {
  const viewDay = getViewDate();
  const dayKey = dk(viewDay);
  const isViewingToday = dayKey === dk(new Date());
  const dateStr = viewDay.toLocaleDateString('en', {
    weekday: 'long',
    month: 'long',
    day: 'numeric',
  });

  // Auto-export
  exportTxt();
  exportBackup();
  localStorage.setItem('wl_last_export', dk(new Date()));
  // Save EOD timestamp against the day in view. An earlier day is stamped with
  // its last entry's end (or the end of that day) so it does not claim to have
  // ended today.
  const allEntries = getEntries();
  if (!getEodTs(viewDay)) {
    const eodTs = resolveEodTimestamp(
      viewDay,
      allEntries.filter((entry) => entry.date === dayKey && entry.tsEnd > entry.ts),
      Date.now()
    );
    localStorage.setItem(eodKey(viewDay), String(eodTs));
  }
  wlLog.info(`openEodModal: ending ${isViewingToday ? 'today' : 'earlier day'} ${dayKey}`);
  renderEodBtn();
  renderEodReminder();
  // Note: portable deploy is triggered by the "Done — close" button, NOT here,
  // so the JSON + .txt have time to flush to disk before the build script reads them.
  document.getElementById('eodExportStatus').innerHTML =
    `<div class="eod-exported">✅ Time log (.txt) and backup (.json) exported automatically</div>`;

  document.getElementById('eodSubtitle').textContent = dateStr;

  // Notes for the next day — only tasks that were actually worked on the viewed day
  const unfinishedTasks = selectUnfinishedWorkedTasks(dayKey, allEntries, getPlanTasks());
  let handoffNotes = {};
  try {
    handoffNotes = parseHandoffNotes(localStorage.getItem('wl_handoff'));
  } catch (err) {
    // Silently fall back to empty object — existing notes are unavailable but EOD modal still works
    wlLog.warn('openEodModal: failed to parse wl_handoff', err);
  }
  const taskNotesEl = document.getElementById('eodTaskNotes');
  if (unfinishedTasks.length) {
    const statusLabel = {
      todo: 'to do',
      inprogress: 'in progress',
      pending: 'pending',
      blocked: 'blocked',
    };
    taskNotesEl.innerHTML = unfinishedTasks
      .map(
        (task) =>
          `<div class="eod-task-note-row">
          <span class="eod-task-note-label" title="${escHtml(task.text)}">${task.emoji ? escHtml(task.emoji) + ' ' : ''}${escHtml(task.text)}</span>
          <span class="eod-task-note-status ${task.status || 'todo'}">${statusLabel[task.status || 'todo'] || task.status}</span>
          <input class="eod-task-note-input" data-task="${escHtml(task.text.toLowerCase().trim())}"
            value="${escHtml(handoffNotes[task.text.toLowerCase().trim()] || '')}"
            placeholder="where to continue…" />
        </div>`
      )
      .join('');
  } else {
    taskNotesEl.innerHTML = `<div class="eod-empty">no tasks worked on this day — or all done 🎉</div>`;
  }

  // The viewed day's dev changes
  let allLog = [];
  try {
    allLog = JSON.parse(localStorage.getItem(STORE_DEV_LOG) || '[]');
  } catch (err) {
    wlLog.warn('openEodModal: failed to parse dev changelog from localStorage', err);
  }
  const viewedDayChanges = selectDevChangesForDay(allLog, dayKey);
  const changesEl = document.getElementById('eodChanges');
  if (viewedDayChanges.length) {
    changesEl.innerHTML = viewedDayChanges
      .map(
        (change) =>
          `<div class="eod-change">
          <span class="eod-change-desc">${escHtml(change.desc)}</span>
          <span class="eod-change-areas">${change.areas.length ? 'Test ' + escHtml(change.areas.join(', ')) : '—'}</span>
        </div>`
      )
      .join('');
  } else {
    changesEl.innerHTML = `<div class="eod-empty">No code changes logged this day</div>`;
  }

  // Affected test areas (deduplicated)
  const affectedAreas = [...new Set(viewedDayChanges.flatMap((change) => change.areas))].sort(
    (a, b) => a - b
  );
  const areasEl = document.getElementById('eodTestAreas');
  if (affectedAreas.length) {
    areasEl.innerHTML = affectedAreas
      .map((area) => {
        // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
        const areaName = TEST_AREA_NAMES[area] || 'Unknown';
        return `<div class="eod-test-area">
          <span class="eod-test-num">#${escHtml(String(area))}</span>
          <span>${escHtml(areaName)}</span>
        </div>`;
      })
      .join('');
  } else {
    areasEl.innerHTML = `<div class="eod-empty">No test areas flagged for review</div>`;
  }

  // Copy to clipboard
  document.getElementById('eodCopyBtn').onclick = () => {
    const lines = [
      `End of day: ${dateStr}`,
      '',
      'Changes implemented:',
      ...viewedDayChanges.map(
        (change) =>
          `  - ${change.desc}${change.areas.length ? ' (Test ' + change.areas.join(', ') + ')' : ''}`
      ),
      '',
      'Test areas to review:',
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      ...affectedAreas.map((area) => `  - Test ${area}: ${TEST_AREA_NAMES[area]}`),
    ];
    navigator.clipboard.writeText(lines.join('\n')).then(() => {
      flashCopiedLabel(document.getElementById('eodCopyBtn'));
    });
  };

  renderEodTimesheet(dayKey);

  document.getElementById('eodOverlay').classList.add('show');
}

document.getElementById('eodBtn').addEventListener('click', () => {
  const ready = confirm(
    '📎 Before closing the day:\n\n' +
      "Have you shared work-log.html with Claude to log today's changes?\n\n" +
      'OK — yes, changes are logged, continue\n' +
      'Cancel — not yet, go back'
  );
  if (ready) openEodModal();
});
/**
 * Parses the stored `wl_handoff` value into a prototype-less map of task key to
 * note. A prototype-less object keeps keys such as `__proto__` and
 * `constructor` as ordinary own properties, so task names cannot reach
 * Object.prototype.
 * @param {string|null} raw - Raw localStorage value (may be null or malformed JSON).
 * @returns {Object<string, string>} Map of task key to note; empty if the value is
 *   missing or is not a plain JSON object (e.g. an array, string or null).
 * @throws {SyntaxError} If `raw` is non-empty and not valid JSON.
 */
function parseHandoffNotes(raw) {
  const parsed = JSON.parse(raw || '{}');
  const isPlainObject = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed);
  return Object.assign(Object.create(null), isPlainObject ? parsed : {});
}

/**
 * Reads all handoff-note inputs in the EOD modal and persists their values to
 * the `wl_handoff` localStorage key. Empty values are removed from the map.
 */
function saveEodHandoffNotes() {
  try {
    const notes = parseHandoffNotes(localStorage.getItem('wl_handoff'));
    document.querySelectorAll('.eod-task-note-input').forEach((inp) => {
      const key = inp.dataset.task;
      const val = inp.value.trim();
      // eslint-disable-next-line security/detect-object-injection -- key comes from a DOM data attribute (task text), but notes has no prototype, so any key is an own property
      if (val) notes[key] = val;
      // eslint-disable-next-line security/detect-object-injection -- same as above: prototype-less map, `delete` only removes an own key
      else delete notes[key];
    });
    localStorage.setItem('wl_handoff', JSON.stringify(notes));
  } catch (err) {
    wlLog.warn('saveEodHandoffNotes: failed to persist handoff notes to localStorage', err);
  }
}
document.getElementById('expiryBtn').addEventListener('click', openExpiryModal);
document.getElementById('expirySave').addEventListener('click', saveExpiryDates);
document.getElementById('expiryCancel').addEventListener('click', () => {
  document.getElementById('expiryOverlay').classList.remove('show');
});
document.getElementById('expiryOverlay').addEventListener('click', (event) => {
  if (event.target === document.getElementById('expiryOverlay'))
    document.getElementById('expiryOverlay').classList.remove('show');
});
document.getElementById('expiryTextarea').addEventListener('keydown', (event) => {
  if (event.key === 'Enter') {
    event.stopPropagation();
  } // allow newlines
  if (event.key === 'Escape') document.getElementById('expiryOverlay').classList.remove('show');
});

document.getElementById('eodClose').addEventListener('click', () => {
  saveEodHandoffNotes();
  // Trigger portable deploy now — the export ran when the modal opened, so the
  // file system has had a few seconds to flush JSON + .txt to OneDrive before
  // the build script reads them.
  triggerPortableDeploy();
  document.getElementById('eodOverlay').classList.remove('show');
  renderPlan();
});

/**
 * Fire-and-forget portable deploy: calls `POST /api/portable-deploy` to trigger
 * the PowerShell build script, then displays a transient top-right toast with the
 * result (success, failure, or server unreachable). Never throws.
 */
function triggerPortableDeploy() {
  const toast = document.createElement('div');
  toast.className = 'wl-toast wl-toast-info';
  toast.textContent = '⏳ Deploying portable build…';
  document.body.appendChild(toast);

  const setToast = (cls, text, lifetimeMs = 5000) => {
    toast.className = 'wl-toast ' + cls;
    toast.textContent = text;
    setTimeout(() => toast.remove(), lifetimeMs);
  };

  (async () => {
    let res;
    try {
      res = await fetch('/api/portable-deploy', { method: 'POST' });
    } catch (err) {
      setToast('wl-toast-err', '⚠ Portable deploy skipped: PS server unreachable');
      return;
    }
    const bodyText = await res.text().catch(() => '');
    let data = null;
    if (bodyText) {
      try {
        data = JSON.parse(bodyText);
      } catch {}
    }

    if (res.status === 404) {
      setToast(
        'wl-toast-err',
        '⚠ Portable deploy unavailable: restart PS server (.\\launch.bat) to pick up updated start-server.ps1'
      );
      return;
    }
    if (res.status === 503) {
      setToast('wl-toast-err', '⚠ Portable deploy skipped: PS server not running');
      return;
    }
    if (res.ok && data && data.ok) {
      const s = (data.durationMs / 1000).toFixed(1);
      setToast('wl-toast-ok', `📦 Portable deployed in ${s}s`, 4000);
      return;
    }
    const msg =
      data && (data.error || data.output)
        ? String(data.error || data.output).slice(0, 200)
        : bodyText
          ? bodyText.slice(0, 200)
          : `HTTP ${res.status} empty body`;
    setToast('wl-toast-err', `⚠ Portable deploy failed: ${msg}`, 8000);
  })();
}
document.getElementById('eodOverlay').addEventListener('click', (event) => {
  if (event.target === document.getElementById('eodOverlay')) {
    saveEodHandoffNotes();
    document.getElementById('eodOverlay').classList.remove('show');
    renderPlan();
  }
});