Source: pure-fns-export.js

/**
 * @file pure-fns-export.js
 * Billable-export grouping and merging helpers: entries grouped by
 * category/task, the pasteable timesheet summary line, day bounds, and the
 * per-task note/link lookups the export body renders. The gap report,
 * weekly report draft, rolling summary, and backup helpers that used to live
 * here moved out to their own leaf modules (QA 2026-09-07, largest-module
 * finding) — see pure-fns-gapreport.js, pure-fns-weeklyreport.js,
 * pure-fns-rollingsummary.js, and pure-fns-backup.js. All are re-exported
 * together via the pure-fns.js barrel. Pure functions with no side-effects
 * and no global state — a leaf ES module with no dependencies of its own.
 */

/* ── Export grouping ── */

/**
 * Parses a task label into its Jira issue key and human-readable name.
 * Returns `ticket: null` when the label carries no leading Jira key.
 * @param {string} label - Raw task label, e.g. `'PROJ-42: Fix login'` or `'Write tests'`.
 * @returns {{ ticket: string|null, name: string }} Parsed parts.
 * @example
 * parseJiraLabel('PROJ-42: Fix login') // → { ticket: 'PROJ-42', name: 'Fix login' }
 * parseJiraLabel('Write tests')        // → { ticket: null, name: 'Write tests' }
 */
export function parseJiraLabel(label) {
  if (!label || typeof label !== 'string') return { ticket: null, name: label ?? '' };
  // eslint-disable-next-line security/detect-unsafe-regex -- linear match on a short task label: the [\s:_-]+ and .* groups are adjacent, not nested, so there is no catastrophic backtracking
  const m = label.match(/^([A-Z][A-Z0-9]*-\d+)([\s:_-]+(.*))?$/);
  if (!m) return { ticket: null, name: label };
  return { ticket: m[1], name: (m[3] || '').trim() };
}

/**
 * Groups a day's log entries by category and, within each category, by task
 * (case-insensitively), preserving first-seen order. Accumulates tracked time
 * (where `tsEnd > ts`) per task and per category.
 *
 * Pure data transform — reads only entry fields and performs no formatting, so
 * the caller decides how to render the durations and labels.
 *
 * @param {Array<Object>} dayEntries - Entries for the viewed day.
 * @returns {{catOrder: string[], catGrouped: Object}} `catOrder` is the list of
 *   category keys in first-seen order; `catGrouped[catKey]` is
 *   `{ totalMs, tasks: { [taskKey]: { label, totalMs, hasTime, sessions } }, taskOrder }`.
 *   `sessions` is the task's individual tracked `{ts, tsEnd}` pairs, in
 *   encounter order — kept separate (not merged) so a task worked in two
 *   sessions still shows both time ranges rather than one collapsed total.
 */
export function groupEntriesByCategory(dayEntries) {
  const catOrder = [];
  const catGrouped = {};
  dayEntries.forEach((entry) => {
    const catKey = entry.tag || 'other';
    const taskKey = entry.text.toLowerCase();
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    if (!catGrouped[catKey]) {
      catOrder.push(catKey);
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      catGrouped[catKey] = { totalMs: 0, tasks: {}, taskOrder: [] };
    }
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    if (!catGrouped[catKey].tasks[taskKey]) {
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      catGrouped[catKey].taskOrder.push(taskKey);
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      catGrouped[catKey].tasks[taskKey] = {
        label: entry.text,
        totalMs: 0,
        hasTime: false,
        sessions: [],
      };
    }
    if (entry.tsEnd && entry.tsEnd > entry.ts) {
      const ms = entry.tsEnd - entry.ts;
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      catGrouped[catKey].totalMs += ms;
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      catGrouped[catKey].tasks[taskKey].totalMs += ms;
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      catGrouped[catKey].tasks[taskKey].hasTime = true;
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      catGrouped[catKey].tasks[taskKey].sessions.push({ ts: entry.ts, tsEnd: entry.tsEnd });
    }
  });
  return { catOrder, catGrouped };
}

/**
 * Builds the pasteable end-of-day summary line: one semicolon-separated
 * `Label (duration)` item per distinct task, each carrying its *full-day*
 * total — not per-session — so a task worked in two separate blocks (e.g.
 * 09:30–13:00 and 13:30–16:00) still collapses to one line with one total,
 * matching how a Jira worklog is checked against a ticket. The per-session
 * breakdown lives in the report body instead (see {@link formatGroupedLines}'s
 * `sessions` rendering) — this line's job is the checkable total, not the
 * timeline. Distinct tasks are kept in first-seen order; entries are grouped
 * by task text, category (`tag`, missing normalised to `other`), and
 * `_billable` status combined, so a task worked once billable and once
 * internal renders as two line items rather than one status silently
 * overwriting the other.
 *
 * Non-billable items are suffixed `, internal` so the billable/internal split
 * — already visible in the header totals — is also checkable at the level of
 * an individual line item.
 *
 * Unlike the grouped report body (whose task labels feed a client-facing
 * read), this line keeps the entry's raw text, Jira key included: the line's
 * job is proving logged hours against ticket worklogs, so the ticket key is
 * exactly the part that must stay visible.
 *
 * @param {Array<Object>} timedEntries - Timed entries (`ts`, `tsEnd`, `text`,
 *   optional `tag`), each optionally carrying a `_billable` flag (`false`
 *   renders as internal; anything else, including absent, renders as billable).
 * @param {function(number): string} fmtDuration - Formats a duration in ms
 *   (e.g. `fmtDurLong`). Injected so this function stays free of global state.
 * @returns {string} The summary line, or `''` when there are no entries.
 * @example
 * buildTimesheetSummaryLine(
 *   [
 *     { text: 'AITO-183656', ts: 0, tsEnd: 4 * 3600000 },
 *     { text: '📅 Meeting', ts: 4 * 3600000, tsEnd: 4.5 * 3600000, _billable: false },
 *     { text: 'AITO-183656', ts: 4.5 * 3600000, tsEnd: 7.5 * 3600000 },
 *   ],
 *   fmtDurLong
 * )
 * // → 'AITO-183656 (7h); 📅 Meeting (30min, internal)'
 */
export function buildTimesheetSummaryLine(timedEntries, fmtDuration) {
  const order = [];
  const totals = {};
  timedEntries.forEach((entry) => {
    const key = `${entry.tag || 'other'} ${entry.text.toLowerCase()} ${entry._billable}`;
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    if (!totals[key]) {
      order.push(key);
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      totals[key] = { text: entry.text, billable: entry._billable, ms: 0 };
    }
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    totals[key].ms += entry.tsEnd - entry.ts;
  });
  return order
    .map((key) => {
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      const { text, billable, ms } = totals[key];
      const suffix = billable === false ? ', internal' : '';
      return `${text} (${fmtDuration(ms)}${suffix})`;
    })
    .join('; ');
}

/**
 * Computes the day's start and end timestamps for the plaintext export header.
 *
 * Start: the supplied day start (today only) or, failing that, the earliest
 * entry start. End: the latest tracked end among timed entries, extended by the
 * active timer's effective end so "Ended:" reflects work still in progress.
 *
 * Pure: all environmental inputs (day start, the active timer, the current time)
 * are injected via `opts` so the function can be unit-tested without globals.
 *
 * @param {Array<Object>} dayEntries   - All entries for the viewed day.
 * @param {Array<Object>} timedEntries - Entries with a real tracked duration (`tsEnd`).
 * @param {Object} opts                - Injected environment.
 * @param {boolean} opts.isViewingToday - Whether the viewed day is today.
 * @param {number|null} opts.dayStart   - Configured day-start ts, or null if not today.
 * @param {Object|null} opts.activeTimer - The running/paused timer, or null.
 * @param {number} opts.now             - Current time in ms (`Date.now()`).
 * @returns {{dayStartTs: (number|null), dayEndTs: (number|null)}} Day bounds in ms.
 */
export function computeDayBounds(dayEntries, timedEntries, opts) {
  const { isViewingToday, dayStart, activeTimer, now } = opts;
  let dayStartTs = isViewingToday ? dayStart : null;
  if (!dayStartTs && dayEntries.length) {
    dayStartTs = Math.min(...dayEntries.map((entry) => entry.ts));
  }
  let dayEndTs = timedEntries.length ? Math.max(...timedEntries.map((entry) => entry.tsEnd)) : null;
  // Factor in the active timer's effective end so "Ended:" reflects live work
  if (activeTimer && isViewingToday) {
    const timerEntry = dayEntries.find((entry) => entry.id === activeTimer.entryId);
    if (timerEntry) {
      const liveEnd = activeTimer.paused
        ? timerEntry.ts + (activeTimer.accumulatedMs || 0) // paused → start + accumulated
        : Math.max(now, activeTimer.startTs || timerEntry.ts); // running → now (or startTs if test setup is ahead of wall clock)
      dayEndTs = dayEndTs ? Math.max(dayEndTs, liveEnd) : liveEnd;
    }
  }
  return { dayStartTs, dayEndTs };
}

/**
 * Returns true if today's workday looks like it may be over and hasn't been
 * exported yet: the day was started, has at least one entry, hasn't already
 * been ended, and `workdayHours` have passed since it started. Used to show
 * a reminder nudging the user toward "end the day" (which triggers the real
 * export) — this function only decides whether to nudge, it never exports
 * or ends the day itself.
 *
 * Pure: all environmental inputs (day-start/day-end timestamps, whether
 * today has entries, the current time) are injected via `opts` so the
 * function can be unit-tested without touching localStorage or Date.now(),
 * matching {@link computeDayBounds}'s style.
 *
 * @param {Object} opts
 * @param {number|null} opts.sodTs - Today's day-start timestamp (ms), or null if not started.
 * @param {number|null} opts.eodTs - Today's day-end timestamp (ms), or null if not yet ended.
 * @param {boolean} opts.hasEntriesToday - Whether at least one entry exists for today.
 * @param {number} opts.now - Current time in ms.
 * @param {number} [opts.workdayHours=8] - Hours after day-start to consider the day likely over.
 * @returns {boolean} True if the reminder should be shown.
 * @example
 * isWorkdayLikelyOver({ sodTs: 1000, eodTs: null, hasEntriesToday: true, now: 1000 + 8 * 3600000 })
 * // → true (exactly 8h after day-start)
 * isWorkdayLikelyOver({ sodTs: 1000, eodTs: null, hasEntriesToday: true, now: 1000 + 7 * 3600000 })
 * // → false (only 7h in)
 * isWorkdayLikelyOver({ sodTs: null, eodTs: null, hasEntriesToday: true, now: 999999 })
 * // → false (day never started)
 */
export function isWorkdayLikelyOver({ sodTs, eodTs, hasEntriesToday, now, workdayHours = 8 }) {
  if (!sodTs || eodTs || !hasEntriesToday) return false;
  return now >= sodTs + workdayHours * 60 * 60 * 1000;
}

/**
 * Builds a lookup of task notes for a single day, keyed by lowercased task
 * text so it lines up with the task keys produced by
 * {@link groupEntriesByCategory}. Only tasks dated `dateKey` with a non-blank
 * `note` are included.
 *
 * Task/entry linkage is by date + case-insensitive text match — the same
 * convention `addEntry` and `flatSort` already use to find a task's plan row —
 * because plan tasks and log entries share no `taskId` field.
 *
 * @param {Array<Object>} planTasks - All plan/board tasks.
 * @param {string} dateKey - The exported day's date key (YYYY-MM-DD).
 * @returns {Object<string, string>} Map of lowercased task text to trimmed note.
 * @example
 * buildTaskNoteMap(
 *   [{ text: 'Fix login', date: '2026-06-04', note: 'waiting on staging creds' }],
 *   '2026-06-04'
 * )
 * // → { 'fix login': 'waiting on staging creds' }
 */
export function buildTaskNoteMap(planTasks, dateKey) {
  const notes = {};
  (planTasks || []).forEach((task) => {
    if (task.date !== dateKey) return;
    const note = task.note && task.note.trim();
    if (!note) return;
    notes[task.text.toLowerCase()] = note;
  });
  return notes;
}

/**
 * Builds a lookup of entry-level notes for a single day, keyed by lowercased
 * task text so it lines up with {@link buildTaskNoteMap} and the task keys
 * produced by {@link groupEntriesByCategory}. Entry notes are written at the
 * time the work happens (unlike a plan task's note, which describes the task
 * in general), so multiple entries sharing a task text each contribute their
 * own note line rather than overwriting one another.
 *
 * @param {Array<Object>} dayEntries - Entries for the exported day.
 * @returns {Object<string, string>} Map of lowercased task text to
 *   newline-joined notes, one line per entry that carries a note.
 * @example
 * buildEntryNoteMap([{ text: 'Fix login', note: 'reproduced in staging' }])
 * // → { 'fix login': 'reproduced in staging' }
 */
export function buildEntryNoteMap(dayEntries) {
  const notes = {};
  (dayEntries || []).forEach((entry) => {
    const note = entry.note && entry.note.trim();
    if (!note) return;
    const key = entry.text.toLowerCase();
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    notes[key] = notes[key] ? `${notes[key]}\n${note}` : note;
  });
  return notes;
}

/**
 * Combines two task-keyed note maps — e.g. plan-task notes and entry notes —
 * into one, concatenating notes for the same key with a newline so both
 * survive as separate `note:` lines in the exported text (see
 * {@link formatGroupedLines}, which splits each map value on `\n`).
 *
 * @param {Object<string, string>} a - First note map (rendered first).
 * @param {Object<string, string>} b - Second note map, appended after `a`.
 * @returns {Object<string, string>} Combined map.
 * @example
 * mergeNoteMaps({ x: 'from task' }, { x: 'from entry' })
 * // → { x: 'from task\nfrom entry' }
 */
export function mergeNoteMaps(a, b) {
  const merged = { ...a };
  Object.entries(b || {}).forEach(([key, note]) => {
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    merged[key] = merged[key] ? `${merged[key]}\n${note}` : note;
  });
  return merged;
}

/**
 * Builds a lookup of proof links for a single day, keyed by lowercased task
 * text so it lines up with {@link buildEntryNoteMap} and the task keys
 * produced by {@link groupEntriesByCategory}. Unlike notes (free text, one
 * line per entry), links are short reference codes — Zephyr keys, Confluence
 * pages, ticket URLs — so multiple links for the same task are deduplicated
 * and joined onto a single comma-separated line rather than one per entry.
 *
 * @param {Array<Object>} dayEntries - Entries for the exported day.
 * @returns {Object<string, string>} Map of lowercased task text to a
 *   comma-separated, deduplicated list of that task's proof links.
 * @example
 * buildEntryLinkMap([
 *   { text: 'Update test steps', link: 'T197797' },
 *   { text: 'Update test steps', link: 'T197805' },
 * ])
 * // → { 'update test steps': 'T197797, T197805' }
 */
export function buildEntryLinkMap(dayEntries) {
  const linksByTask = {};
  (dayEntries || []).forEach((entry) => {
    const link = entry.link && entry.link.trim();
    if (!link) return;
    const key = entry.text.toLowerCase();
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    if (!linksByTask[key]) linksByTask[key] = [];
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    if (!linksByTask[key].includes(link)) linksByTask[key].push(link);
  });
  return Object.fromEntries(
    Object.entries(linksByTask).map(([key, links]) => [key, links.join(', ')])
  );
}