Source: pure-fns-epics.js

/**
 * @file pure-fns-epics.js
 * Pure helpers for epic (category) lifecycle management: deciding which epics
 * have gone stale and should be archived out of the pickers, and filtering a
 * category list down to the ones a user should be offered.
 *
 * Archiving is deliberately non-destructive. A stale epic keeps its record in
 * the categories array with `archived: true`, so `getCat()` (02-utils.js) can
 * still resolve its label and colour for historical log entries, timesheets,
 * and the monthly/weekly reports. Hard-deleting instead would silently repaint
 * every past entry that used it as the grey "other" fallback.
 */

/**
 * A log entry or board task, seen only as evidence that an epic is in use.
 * @typedef {Object} EpicUsageRecord
 * @property {string} [tag] - Epic ID this record is tagged with, if any.
 * @property {string} [date] - ISO date (YYYY-MM-DD) the record belongs to, if any.
 */

/**
 * The two record collections scanned for evidence that an epic is in use.
 * @typedef {Object} EpicUsageSources
 * @property {Array<EpicUsageRecord>} [entries] - Log entries to scan.
 * @property {Array<EpicUsageRecord>} [planTasks] - Board tasks to scan.
 */

/**
 * An epic (category) record as persisted in `wl_cats_v1`.
 * @typedef {Object} EpicCategory
 * @property {string} id - Unique epic ID.
 * @property {string} label - Display name.
 * @property {string} [color] - CSS colour.
 * @property {boolean} [archived] - `true` hides the epic from the pickers.
 * @property {boolean} [billable] - Default billable flag; absent or `true` means billable.
 */

/** Days of inactivity after which an epic is offered up for archiving. */
export const EPIC_STALE_DAYS = 21;

/**
 * Built-in epic IDs that must never be archived. They are seeded on first run
 * (DEFAULT_CATS in app-constants.js) and 'other' doubles as the fallback that
 * `getCat()` resolves unknown tags to, so removing it from the pickers would
 * leave orphaned entries with nothing to point at.
 * @type {string[]}
 */
export const PROTECTED_CAT_IDS = ['work', 'meeting', 'focus', 'break', 'other'];

/**
 * Returns the ISO date (YYYY-MM-DD) that starts an inactivity window ending
 * today. The window is inclusive of both ends: with `windowDays` of 21 and a
 * `todayIso` of 2026-08-21, the cutoff is 2026-08-01 and an epic last used on
 * exactly that date still counts as active.
 * @param {string} todayIso - Today's date as YYYY-MM-DD.
 * @param {number} windowDays - Length of the activity window in days.
 * @returns {string} The inclusive cutoff date as YYYY-MM-DD.
 * @example
 * epicCutoffDate('2026-08-21', 21) // → '2026-08-01'
 */
export function epicCutoffDate(todayIso, windowDays) {
  const cutoff = new Date(`${todayIso}T00:00:00Z`);
  cutoff.setUTCDate(cutoff.getUTCDate() - (windowDays - 1));
  return cutoff.toISOString().slice(0, 10);
}

/**
 * Collects the IDs of every epic referenced by a log entry or a board task
 * dated on or after `cutoffIso`. Records with no `date` are ignored rather
 * than assumed recent — an undated record cannot vouch for an epic's
 * freshness, and treating it as recent would keep stale epics alive forever.
 * @param {EpicUsageSources} sources - Log entries and board tasks to scan.
 * @param {string} cutoffIso - Inclusive start of the window, as YYYY-MM-DD.
 * @returns {Set<string>} Epic IDs used within the window.
 */
export function collectRecentlyUsedCatIds({ entries = [], planTasks = [] }, cutoffIso) {
  const used = new Set();
  const scan = (records) => {
    records.forEach((record) => {
      if (!record || !record.tag || !record.date) return;
      if (record.date >= cutoffIso) used.add(record.tag);
    });
  };
  scan(entries);
  scan(planTasks);
  return used;
}

/**
 * Works out which epics have had no recorded activity inside the window and
 * are therefore safe to archive out of the pickers.
 *
 * An epic is kept when any of these hold: it is a built-in
 * (PROTECTED_CAT_IDS), it is the epic currently selected in the tag row, it is
 * already archived, or a log entry or board task dated within the window
 * references it.
 * @param {Object} params - Inputs for the staleness decision.
 * @param {Array<EpicCategory>} params.categories - All known epics.
 * @param {Array<EpicUsageRecord>} [params.entries] - Log entries to scan for usage.
 * @param {Array<EpicUsageRecord>} [params.planTasks] - Board tasks to scan for usage.
 * @param {string} params.todayIso - Today's date as YYYY-MM-DD.
 * @param {number} [params.windowDays] - Inactivity window in days.
 * @param {string} [params.selectedTag] - Epic currently selected in the tag row; never archived.
 * @returns {{ staleIds: string[], cutoffIso: string }} IDs to archive (in `categories` order) and the cutoff date used.
 */
export function findStaleCategories({
  categories = [],
  entries = [],
  planTasks = [],
  todayIso,
  windowDays = EPIC_STALE_DAYS,
  selectedTag = null,
}) {
  const cutoffIso = epicCutoffDate(todayIso, windowDays);
  const used = collectRecentlyUsedCatIds({ entries, planTasks }, cutoffIso);
  const staleIds = categories
    .filter(
      (cat) =>
        cat &&
        !cat.archived &&
        !PROTECTED_CAT_IDS.includes(cat.id) &&
        cat.id !== selectedTag &&
        !used.has(cat.id)
    )
    .map((cat) => cat.id);
  return { staleIds, cutoffIso };
}

/**
 * Filters a category list down to the epics a picker should offer. Archived
 * epics are hidden, except for `keepId` — the currently selected epic stays
 * visible even once archived so an entry already tagged with it still renders
 * its own selection instead of appearing untagged.
 * @param {Array<EpicCategory>} categories - All known epics.
 * @param {(string|null)} keepId - Epic ID to keep regardless of archived state; omit or pass null to hide every archived epic.
 * @returns {Array<EpicCategory>} The epics to offer, in input order.
 */
export function pickableCategories(categories = [], keepId = null) {
  return categories.filter((cat) => cat && (!cat.archived || cat.id === keepId));
}

/**
 * Returns a copy of `categories` with `archived: true` stamped on every epic
 * whose ID is in `staleIds`. Non-destructive: no record is dropped and every
 * other field is preserved untouched.
 * @param {Array<EpicCategory>} categories - All known epics.
 * @param {string[]} staleIds - Epic IDs to archive.
 * @returns {Array<EpicCategory>} A new array with the archived epics flagged.
 */
export function applyEpicArchive(categories = [], staleIds = []) {
  const stale = new Set(staleIds);
  return categories.map((cat) => (stale.has(cat.id) ? { ...cat, archived: true } : cat));
}

/**
 * Returns a copy of `categories` with the `archived` flag cleared from one
 * epic, bringing it back into the pickers.
 * @param {Array<EpicCategory>} categories - All known epics.
 * @param {string} catId - Epic ID to restore.
 * @returns {Array<EpicCategory>} A new array with the epic un-archived.
 */
export function restoreArchivedCategory(categories = [], catId) {
  return categories.map((cat) => {
    if (cat.id !== catId) return cat;
    const restored = { ...cat };
    delete restored.archived;
    return restored;
  });
}