/**
* @file pure-fns-gapreport.js
* Gap-report and export-warning helpers: finding undocumented billable work,
* flagging anomalies (long unbroken blocks, a full day with no break), and
* rendering the grouped-by-category export body. 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 (imports only from pure-fns-format.js).
*/
import { isLongRunningTimer } from './pure-fns-format.js';
/* ── Gap report ── */
/**
* Texts of utility entries logged via logUtilEntry() in 03-timer.js. They
* never carry documentation, so they would just be noise in the gap report.
* Exported so pure-fns-weeklyreport.js's buildWeeklyTicketSummary() can reuse
* the same exclusion rather than declaring a second copy.
* @type {Set<string>}
*/
export const GAP_REPORT_UTILITY_TEXTS = new Set(['☕ Break', '🥪 Lunch', '📅 Meeting']);
/**
* Finds finished, non-cancelled, billable work entries within
* `[weekStart, weekEnd)` that have neither a proof link nor a note —
* candidates for the end-of-week gap report. Uses the same "finished and
* not cancelled" filter as every other report/aggregation in this codebase
* ({@link buildRollingSummary}, `findLargestGap` in `11-timeflow.js`,
* `exportTxt`'s billable summary), plus excludes break/lunch/meeting
* utility entries which never need documentation.
*
* Non-billable entries are never flagged: this is a pure module with no
* access to the category/task lookups {@link isEntryBillable} needs, so
* callers resolve billable status themselves and annotate each entry with
* `_billable` before calling this (see `exportTxt`'s
* `entriesWithBillingStatus` for the same convention). An entry with no
* `_billable` property is treated as billable, matching
* {@link isEntryBillable}'s own "undefined means billable" default.
*
* @param {Array<Object>} entries - All log entries, each optionally carrying
* a resolved `_billable` flag (`false` excludes it from the report).
* @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 {Array<Object>} Matching entries, sorted by `ts` ascending.
* @example
* findGapReportEntries(
* [{ id: '1', text: 'Fix login', ts: 100, tsEnd: 200, date: '2026-06-01' }],
* 0, 1000
* )
* // → [{ id: '1', text: 'Fix login', ts: 100, tsEnd: 200, date: '2026-06-01' }]
*/
export function findGapReportEntries(entries, weekStart, weekEnd) {
return (entries || [])
.filter(
(entry) =>
entry.tsEnd &&
entry.signifier !== 'cancelled' &&
entry.ts >= weekStart &&
entry.ts < weekEnd &&
!GAP_REPORT_UTILITY_TEXTS.has(entry.text) &&
// `!== false` (not `=== true`): an entry with no _billable property
// defaults to billable, mirroring isEntryBillable()'s own
// "undefined means billable" convention.
entry._billable !== false &&
!(entry.link && entry.link.trim()) &&
!(entry.note && entry.note.trim())
)
.sort((a, b) => a.ts - b.ts);
}
// The single-block "too long" threshold reuses isLongRunningTimer's own 4h
// default below rather than a third local constant.
/** Day span, in ms, above which a workday is expected to contain a real break. */
const LONG_DAY_SPAN_MS = 6 * 60 * 60000;
/** Untracked time, in ms, below which a long day is flagged as missing a break. */
const MIN_EXPECTED_BREAK_MS = 15 * 60000;
/**
* Flags anomalies in a day's entries that would otherwise only surface when
* someone questions the timesheet later: work logged with no way to check it,
* suspiciously long unbroken stretches, and a full workday with no break-sized
* gap in it. Surfaced as a warnings section at the end of the plaintext export
* so the person doing the logging finds the gap before anyone asking about it
* does. Non-billable entries are never flagged for a missing note/link — see
* {@link findGapReportEntries} for why the `_billable` flag must already be
* resolved on each entry before it reaches this function.
*
* Pure: the duration formatter is injected (same convention as
* {@link formatGroupedLines}) so this has no dependency on global state.
*
* @param {Array<Object>} dayEntries - All entries for the exported day, each
* optionally carrying a resolved `_billable` flag (`false` excludes it from
* the missing-note/link check).
* @param {Object} opts
* @param {number} [opts.workdaySpanMs=0] - Ended-minus-started span for the day.
* @param {number} [opts.untrackedMs=0] - `workdaySpanMs` minus total tracked time —
* i.e. the implied, unlabelled break/gap time within the day.
* @param {function(number): string} opts.fmtDuration - Formats a duration in ms.
* @returns {string[]} Human-readable warning lines; empty when nothing stands out.
* @example
* findExportWarnings(
* [{ text: 'Fix login', ts: 0, tsEnd: 3600000, signifier: '' }],
* { workdaySpanMs: 3600000, untrackedMs: 0, fmtDuration: (ms) => `${ms}ms` }
* )
* // → ['No note or link: Fix login']
*/
export function findExportWarnings(dayEntries, opts) {
const { workdaySpanMs = 0, untrackedMs = 0, fmtDuration } = opts;
const warnings = [];
(dayEntries || [])
.filter(
(entry) =>
entry.tsEnd &&
entry.tsEnd > entry.ts &&
entry.signifier !== 'cancelled' &&
!GAP_REPORT_UTILITY_TEXTS.has(entry.text) &&
entry._billable !== false &&
!(entry.link && entry.link.trim()) &&
!(entry.note && entry.note.trim())
)
.forEach((entry) => warnings.push(`No note or link: ${entry.text}`));
(dayEntries || [])
.filter(
(entry) =>
entry.tsEnd && entry.signifier !== 'cancelled' && isLongRunningTimer(entry.tsEnd - entry.ts)
)
.forEach((entry) =>
warnings.push(`Long unbroken block: ${entry.text} (${fmtDuration(entry.tsEnd - entry.ts)})`)
);
if (workdaySpanMs >= LONG_DAY_SPAN_MS && untrackedMs < MIN_EXPECTED_BREAK_MS) {
warnings.push(
`No break logged despite a ${fmtDuration(workdaySpanMs)} day ` +
`(only ${fmtDuration(untrackedMs)} untracked)`
);
}
return warnings;
}
/**
* Renders the grouped-by-category structure into indented text lines: one line
* per category (with its total), each followed by its indented task lines and,
* when the task carries tracked sessions, a note, and/or a proof link, further
* indented sub-lines for each — session time ranges first, then note lines,
* then a link line — so a task worked in two separate sessions still shows
* both time ranges instead of only their collapsed total.
*
* Pure: the duration/time formatters and category-label resolver are injected
* so this function has no dependency on global state and can be unit-tested
* directly.
*
* @param {string[]} catOrder - Category keys in display order.
* @param {Object} catGrouped - Grouping produced by {@link groupEntriesByCategory}.
* @param {function(number): string} fmtDuration - Formats a duration in ms (e.g. `fmtDurLong`).
* @param {function(string): string} getCatLabel - Resolves a category key to its label.
* @param {Object<string, string>} [taskNotes] - Map of lowercased task text to note,
* as produced by {@link buildTaskNoteMap}. Omitted tasks render with no note line.
* @param {Object<string, string>} [taskLinks] - Map of lowercased task text to a
* comma-separated link list, as produced by {@link buildEntryLinkMap}. Omitted
* tasks render with no link line.
* @param {function({ts: number, tsEnd: number}): string} [fmtSessionRange] - Formats
* one tracked session as a time-range string (e.g. `'09:30–13:00'`). When
* omitted, session lines are skipped entirely (e.g. for callers that never
* populated `sessions` on their task objects).
* @returns {string[]} The body lines for the export file.
*/
export function formatGroupedLines(
catOrder,
catGrouped,
fmtDuration,
getCatLabel,
taskNotes = {},
taskLinks = {},
fmtSessionRange
) {
const lines = [];
catOrder.forEach((catKey) => {
// eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
const { totalMs, tasks, taskOrder } = catGrouped[catKey];
const catTimeStr = totalMs > 0 ? fmtDuration(totalMs) : '--';
lines.push(`${catTimeStr} - ${getCatLabel(catKey)}`);
taskOrder.forEach((taskKey) => {
// 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: taskMs, hasTime, sessions } = tasks[taskKey];
const taskTimeStr = hasTime ? fmtDuration(taskMs) : '--';
lines.push(` ${taskTimeStr} - ${label}`);
if (fmtSessionRange && sessions && sessions.length) {
sessions.forEach((session) => lines.push(` ${fmtSessionRange(session)}`));
}
// eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
const note = taskNotes[taskKey];
if (note) {
note
.split('\n')
.map((noteLine) => noteLine.trim())
.filter(Boolean)
.forEach((noteLine) => lines.push(` note: ${noteLine}`));
}
// eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
const link = taskLinks[taskKey];
if (link) lines.push(` link: ${link}`);
});
});
return lines;
}