Source: 19-monthlylog.js

// ── 19-monthlylog.js — Monthly Log heatmap + task inventory ──

// eslint-disable-next-line no-var -- var (not let): 11-timeflow.js loads before this module
// and reads these at runtime; let would cause a temporal dead zone ReferenceError.
var _mlYear = new Date().getFullYear(); // eslint-disable-line no-var
var _mlMonth = new Date().getMonth(); // eslint-disable-line no-var -- 0-indexed
var _mlActive = false; // eslint-disable-line no-var

/**
 * Returns the number of days in a given month.
 * @param {number} y - Full year (e.g. 2026).
 * @param {number} m - Month index, 0-based (0 = January).
 * @returns {number} Day count (28–31).
 */
function mlDaysInMonth(y, m) {
  return new Date(y, m + 1, 0).getDate();
}

/**
 * Sums tracked milliseconds for all non-cancelled entries on a given day.
 * @param {string} dateKey
 * @returns {number} Total hours (as a float).
 */
function mlHoursForDay(dateKey) {
  return (
    entries
      .filter((e) => e.date === dateKey && e.signifier !== 'cancelled' && e.tsEnd)
      .reduce((sum, e) => sum + (e.tsEnd - e.ts), 0) / 3600000
  );
}

/**
 * Maps a logged-hours value to a CSS colour for the heatmap grid.
 * Thresholds: 0h → bg3 (empty), <2h → faint blue, <5h → mid blue,
 * <7h → strong blue, ≥7h → solid blue.
 * @param {number} hours - Total logged hours for a single day.
 * @returns {string} A CSS colour value (variable or rgba/hex string).
 */
function mlHeatColor(hours) {
  if (!hours) return 'var(--bg3)';
  if (hours < 2) return 'rgba(24,95,165,0.15)';
  if (hours < 5) return 'rgba(24,95,165,0.40)';
  if (hours < 7) return 'rgba(24,95,165,0.70)';
  return '#185fa5';
}

/**
 * Renders the heatmap calendar grid: navigation header, day labels, day cells,
 * and the colour legend. Binds cell-click (navigate to that day) and prev/next
 * month buttons. Writes its full HTML to `calEl`.
 *
 * Implicit dependency: the prev/next handlers mutate module-level `_mlYear` /
 * `_mlMonth` and then call `renderMonthlyLog()` to re-render the whole view.
 * Both globals must therefore be in scope when this function is called.
 *
 * @param {HTMLElement} calEl - The `#mlCalendar` container.
 * @param {number} year - Full year to render.
 * @param {number} month - Month index, 0-based.
 * @returns {void}
 * @see renderMonthlyLog
 */
function renderMonthlyCalendar(calEl, year, month) {
  const days = mlDaysInMonth(year, month);
  const firstDow = new Date(year, month, 1).getDay(); // 0 = Sun
  const offset = (firstDow + 6) % 7; // shift to Mon-start

  const monthPrefix = `${year}-${String(month + 1).padStart(2, '0')}`;
  const monthName = new Date(year, month, 1).toLocaleString('default', {
    month: 'long',
    year: 'numeric',
  });

  const dayLabels = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
  const emptyCells = Array(offset).fill('<div></div>').join('');
  const dayCells = Array.from({ length: days }, (_, i) => {
    const d = i + 1;
    const dateKey = `${monthPrefix}-${String(d).padStart(2, '0')}`;
    const hrs = mlHoursForDay(dateKey);
    const refl = typeof getReflectionForDate === 'function' ? getReflectionForDate(dateKey) : null;
    const reflDot = refl
      ? `<div class="ml-refl-dot" title="Focus: ${refl.focus}/5 · Energy: ${refl.energy}/5"></div>`
      : '';
    return `<div class="ml-cell" data-date="${dateKey}"
                  title="${d} — ${hrs.toFixed(1)}h"
                  style="background:${mlHeatColor(hrs)};position:relative">${reflDot}</div>`;
  }).join('');

  calEl.innerHTML = `
    <div class="ml-nav">
      <button class="ml-nav-btn" id="mlPrev">←</button>
      <span class="ml-month-title">${monthName}</span>
      <button class="ml-nav-btn" id="mlNext">→</button>
    </div>
    <div class="ml-grid">
      ${dayLabels.map((d) => `<div class="ml-day-lbl">${d}</div>`).join('')}
      ${emptyCells}
      ${dayCells}
    </div>
    <div class="ml-legend">
      ${[
        [0, '0h'],
        [2, '2h'],
        [5, '5h'],
        [7, '7h+'],
      ]
        .map(
          ([v, l]) =>
            `<div class="ml-legend-item">
              <div class="ml-legend-swatch" style="background:${mlHeatColor(v + 0.1)}"></div>
              <span>${l}</span>
            </div>`
        )
        .join('')}
    </div>`;

  // Cell click → navigate to that day and switch to the Log view
  calEl.querySelectorAll('.ml-cell').forEach((cell) => {
    cell.addEventListener('click', () => {
      wlLog.info('monthlyLog: cell clicked, navigating', { dateKey: cell.dataset.date });
      viewDate = new Date(cell.dataset.date + 'T12:00:00');
      _mlActive = false;
      setFlowView('log');
      render();
    });
  });

  // Month navigation — module-level _mlYear / _mlMonth advance, then re-render.
  document.getElementById('mlPrev')?.addEventListener('click', () => {
    _mlMonth--;
    if (_mlMonth < 0) {
      _mlMonth = 11;
      _mlYear--;
    }
    renderMonthlyLog();
  });
  document.getElementById('mlNext')?.addEventListener('click', () => {
    _mlMonth++;
    if (_mlMonth > 11) {
      _mlMonth = 0;
      _mlYear++;
    }
    renderMonthlyLog();
  });
}

/**
 * Computes monthly summary statistics from a flat entries array. Pure: takes
 * its own data dependency, no DOM, no module globals. Excludes entries with
 * no `tsEnd` (still running) or `signifier === 'cancelled'`.
 * @param {Array<Object>} allEntries - The full entries array to filter.
 * @param {string} monthPrefix - Date prefix `YYYY-MM` used to select entries.
 * @param {function(Object): boolean} isBillable - Predicate identifying
 *   billable entries; the caller supplies the project's `isEntryBillable`.
 * @returns {{ totalMs: number, billableMs: number, topTag: (string|null) }}
 */
function calcMonthSummaryStats(allEntries, monthPrefix, isBillable) {
  const monthEntries = allEntries.filter(
    (e) => e.date.startsWith(monthPrefix) && e.tsEnd && e.signifier !== 'cancelled'
  );
  const totalMs = monthEntries.reduce((s, e) => s + (e.tsEnd - e.ts), 0);
  const billableMs = monthEntries.filter(isBillable).reduce((s, e) => s + (e.tsEnd - e.ts), 0);

  const tagTotals = {};
  monthEntries.forEach((e) => {
    tagTotals[e.tag] = (tagTotals[e.tag] || 0) + (e.tsEnd - e.ts);
  });
  const topTagEntry = Object.entries(tagTotals).sort((a, b) => b[1] - a[1])[0];

  return {
    totalMs,
    billableMs,
    topTag: topTagEntry ? topTagEntry[0] : null,
  };
}

/**
 * Computes open / done / migrated task counts for a given month. Pure.
 * `_migrated` (programmatic) and `signifier === 'migrated'` (BuJo marker)
 * both count toward the migrated total.
 * @param {Array<Object>} allTasks - The full plan-tasks array.
 * @param {string} monthPrefix - Date prefix `YYYY-MM` used to select tasks.
 * @returns {{ open: number, done: number, migrated: number }}
 */
function calcMonthTaskCounts(allTasks, monthPrefix) {
  const monthTasks = allTasks.filter((t) => t.date.startsWith(monthPrefix));
  return {
    open: monthTasks.filter((t) => t.status !== 'done').length,
    done: monthTasks.filter((t) => t.status === 'done').length,
    migrated: monthTasks.filter((t) => t.signifier === 'migrated' || t._migrated).length,
  };
}

/**
 * Renders the time-totals summary panel: total logged, billable, top category.
 * Writes its full HTML to `sumEl`. No event binding. Early-returns if `sumEl`
 * is absent so a partial DOM doesn't throw on innerHTML assignment.
 * @param {HTMLElement} sumEl - The `#mlSummary` container.
 * @param {string} monthPrefix - Date prefix `YYYY-MM` used to filter entries.
 * @returns {void}
 */
function renderMonthlySummary(sumEl, monthPrefix) {
  if (!sumEl) return;
  const { totalMs, billableMs, topTag } = calcMonthSummaryStats(
    entries,
    monthPrefix,
    isEntryBillable
  );

  sumEl.innerHTML = `
    <div class="ml-sum-title">Summary</div>
    <div class="ml-sum-row"><span>Total logged</span><span>${fmtDur(totalMs)}</span></div>
    <div class="ml-sum-row"><span>Billable</span><span class="ml-sum-blue">${fmtDur(billableMs)}</span></div>
    ${topTag ? `<div class="ml-sum-row"><span>Top category</span><span>${escHtml(getCatLabel(topTag))}</span></div>` : ''}`;
}

/**
 * Renders the task-inventory panel: open / done / migrated counts plus
 * a "Run Migration" button. Writes its full HTML to `taskEl` and binds
 * the button to `openMigration()` if that helper is loaded. Early-returns
 * if `taskEl` is absent so a partial DOM doesn't throw on innerHTML.
 * @param {HTMLElement} taskEl - The `#mlTasks` container.
 * @param {string} monthPrefix - Date prefix `YYYY-MM` used to filter plan tasks.
 * @returns {void}
 */
function renderMonthlyTasks(taskEl, monthPrefix) {
  if (!taskEl) return;
  const { open, done, migrated } = calcMonthTaskCounts(planTasks, monthPrefix);

  taskEl.innerHTML = `
    <div class="ml-sum-title">Task inventory</div>
    <div class="ml-sum-row"><span>Open</span><span class="ml-sum-amber">${open}</span></div>
    <div class="ml-sum-row"><span>Done</span><span class="ml-sum-green">${done}</span></div>
    <div class="ml-sum-row"><span>Migrated</span><span class="ml-sum-muted">${migrated}</span></div>
    <button class="add-btn ml-migrate-btn" id="mlRunMigration">→ Run Migration</button>`;

  document.getElementById('mlRunMigration')?.addEventListener('click', () => {
    if (typeof openMigration === 'function') openMigration();
  });
}

/**
 * Orchestrates the Monthly Log view: resolves DOM targets and delegates
 * each panel to a single-purpose renderer.
 * @returns {void}
 */
function renderMonthlyLog() {
  const calEl = document.getElementById('mlCalendar');
  const sumEl = document.getElementById('mlSummary');
  const taskEl = document.getElementById('mlTasks');
  if (!calEl) return;

  const monthPrefix = `${_mlYear}-${String(_mlMonth + 1).padStart(2, '0')}`;

  renderMonthlyCalendar(calEl, _mlYear, _mlMonth);
  renderMonthlySummary(sumEl, monthPrefix);
  renderMonthlyTasks(taskEl, monthPrefix);
}

/**
 * Bootstraps the Monthly Log feature.
 * The Monthly Log is now the "Month" tab in Today's Flow; its visibility and
 * rendering are driven by renderTodayFlow(). Month sync happens in initTodayFlow()
 * when the Month tab is clicked. This function is kept as a no-op so the call
 * site in 07-lifecycle.js does not need to change.
 * @returns {void}
 */
function initMonthlyLog() {
  // no-op: see initTodayFlow() in 11-timeflow.js
}