Source: pure-fns-weeklyreport.js

/**
 * @file pure-fns-weeklyreport.js
 * Weekly report draft helpers: grouping a week's entries by Jira ticket and
 * rendering that grouping into text. Split out of pure-fns-export.js (QA
 * 2026-09-07, largest-module finding) — re-exported there via the
 * pure-fns.js barrel. Pure functions with no side-effects and no global
 * state — a leaf ES module.
 */

import { parseJiraLabel } from './pure-fns-export.js';
import { GAP_REPORT_UTILITY_TEXTS } from './pure-fns-gapreport.js';

/* ── Weekly report draft ── */

/** Sentinel ticket key for entries whose text carries no parseable Jira prefix. */
export const WEEKLY_REPORT_NO_TICKET_KEY = '__no_ticket__';

/**
 * Groups a week's finished, non-cancelled, non-utility entries by Jira ticket
 * key (via {@link parseJiraLabel}), then by task name within each ticket —
 * the data layer behind the weekly report draft. Entries with no parseable
 * ticket prefix fall into the {@link WEEKLY_REPORT_NO_TICKET_KEY} bucket,
 * sub-grouped by their own task text so untracked work stays legible rather
 * than collapsing into one opaque blob.
 *
 * Uses the same "finished, not cancelled, in-window" filter as
 * `findGapReportEntries` (whose `GAP_REPORT_UTILITY_TEXTS` exclusion
 * it also reuses — break/lunch/meeting entries never belong in a status
 * report any more than they belong in the gap report).
 *
 * @param {Array<Object>} entries - All log entries.
 * @param {number} weekStart - Inclusive week-start timestamp in ms (e.g. Monday 00:00).
 * @param {number} weekEnd - Exclusive week-end timestamp in ms (e.g. the following Monday 00:00).
 * @returns {{
 *   ticketOrder: string[],
 *   grouped: Object<string, {
 *     totalMs: number,
 *     nameOrder: string[],
 *     names: Object<string, {label: string, totalMs: number}>,
 *     notes: string[],
 *     links: string[]
 *   }>
 * }} `ticketOrder` lists real ticket keys sorted by total time descending,
 *   with the no-ticket bucket (if present) always last regardless of its
 *   total — it is a catch-all, not a reportable line item.
 * @example
 * buildWeeklyTicketSummary(
 *   [{ id: '1', text: 'PROJ-1: Fix login', ts: 100, tsEnd: 3700, date: '2026-06-01' }],
 *   0, 604800000
 * )
 * // → { ticketOrder: ['PROJ-1'], grouped: { 'PROJ-1': { totalMs: 3600, ... } } }
 */
export function buildWeeklyTicketSummary(entries, weekStart, weekEnd) {
  const grouped = {};
  (entries || [])
    .filter(
      (entry) =>
        entry.tsEnd &&
        entry.signifier !== 'cancelled' &&
        entry.ts >= weekStart &&
        entry.ts < weekEnd &&
        !GAP_REPORT_UTILITY_TEXTS.has(entry.text)
    )
    .forEach((entry) => {
      const { ticket, name } = parseJiraLabel(entry.text);
      const ticketKey = ticket || WEEKLY_REPORT_NO_TICKET_KEY;
      const nameKey = name.toLowerCase();
      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
      if (!grouped[ticketKey]) {
        // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
        grouped[ticketKey] = { totalMs: 0, nameOrder: [], names: {}, notes: [], links: [] };
      }
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      const bucket = grouped[ticketKey];

      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      if (!bucket.names[nameKey]) {
        bucket.nameOrder.push(nameKey);
        // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
        bucket.names[nameKey] = { label: name, totalMs: 0 };
      }
      bucket.totalMs += ms;
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      bucket.names[nameKey].totalMs += ms;

      const note = entry.note && entry.note.trim();
      if (note && !bucket.notes.includes(note)) bucket.notes.push(note);
      const link = entry.link && entry.link.trim();
      if (link && !bucket.links.includes(link)) bucket.links.push(link);
    });

  const ticketOrder = Object.keys(grouped)
    .filter((key) => key !== WEEKLY_REPORT_NO_TICKET_KEY)
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    .sort((a, b) => grouped[b].totalMs - grouped[a].totalMs);
  // The no-ticket bucket is a catch-all grouping, not a reportable ticket —
  // it always sorts last regardless of its total time, even if that time
  // exceeds a real ticket's.
  // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
  if (grouped[WEEKLY_REPORT_NO_TICKET_KEY]) ticketOrder.push(WEEKLY_REPORT_NO_TICKET_KEY);

  return { ticketOrder, grouped };
}

/**
 * Renders the structure produced by {@link buildWeeklyTicketSummary} into
 * text lines: one header line per ticket (with its total), each followed by
 * an indented bullet per distinct task name (with its own subtotal) and any
 * collected `note:`/`link:` lines — same indentation convention as
 * `formatGroupedLines` in `pure-fns-gapreport.js`. Ticket blocks are
 * separated by a blank line.
 *
 * Pure: the duration formatter is injected so this has no dependency on
 * global state and can be unit-tested directly.
 *
 * @param {string[]} ticketOrder - Ticket keys in display order.
 * @param {Object} grouped - Grouping produced by {@link buildWeeklyTicketSummary}.
 * @param {function(number): string} fmtDuration - Formats a duration in ms (e.g. `fmtDurLong`).
 * @returns {string[]} The report body lines; empty when `ticketOrder` is empty.
 */
export function formatWeeklyTicketSummaryText(ticketOrder, grouped, fmtDuration) {
  const lines = [];
  ticketOrder.forEach((ticketKey, index) => {
    if (index > 0) lines.push('');
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    const { totalMs, nameOrder, names, notes, links } = grouped[ticketKey];
    const ticketLabel = ticketKey === WEEKLY_REPORT_NO_TICKET_KEY ? 'No ticket' : ticketKey;
    lines.push(`${ticketLabel} — ${fmtDuration(totalMs)}`);
    nameOrder.forEach((nameKey) => {
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      const { label, totalMs: nameMs } = names[nameKey];
      lines.push(label ? `    ${fmtDuration(nameMs)} - ${label}` : `    ${fmtDuration(nameMs)}`);
    });
    notes.forEach((note) => lines.push(`    note: ${note}`));
    links.forEach((link) => lines.push(`    link: ${link}`));
  });
  return lines;
}