/**
* @file pure-fns-backup.js
* Backup retention and JSON-backup payload construction. 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 { dk } from './pure-fns-format.js';
/**
* Checks whether a `YYYY-MM-DD` date string is on or after the cutoff date —
* the predicate `applyBackupRetention()` filters entries by. Extracted as its
* own function so the well-formed-date check and the chronological
* comparison are named and testable independently of the filtering loop.
*
* @param {string|undefined} date - Candidate entry's `date` field.
* @param {string} cutoffDate - Cutoff as `YYYY-MM-DD` (lexicographic compare).
* @returns {boolean} True when `date` is well-formed and not before `cutoffDate`.
*/
function isWithinRetentionWindow(date, cutoffDate) {
return Boolean(date) && /^\d{4}-\d{2}-\d{2}$/.test(date) && date >= cutoffDate;
}
/**
* Filters an entries array to those whose `date` field falls within the
* retention window (today minus `retentionDays`, inclusive). Entries with a
* missing or unparseable date are excluded to keep backups clean.
*
* The cutoff is computed from `nowMs` so the function stays pure and testable.
*
* @param {Array<{date: (string|undefined)}>} entries - Raw entries array from localStorage.
* @param {number} retentionDays - How many days back to keep (the app uses 21).
* @param {number} nowMs - Current time as a Unix timestamp in milliseconds.
* @returns {{ retainedEntries: Array, dropped: number }} The filtered entries
* and count of dropped ones.
* @example
* applyBackupRetention(entries, 21, Date.now())
* // → { retainedEntries: [...], dropped: 12 }
*/
export function applyBackupRetention(entries, retentionDays, nowMs) {
// Compare as YYYY-MM-DD strings (lexicographic = chronological) to avoid
// the UTC-midnight parse problem: new Date('2026-03-11') is UTC midnight,
// which can be earlier than a cutoff derived from local-time arithmetic.
const cutoffDate = dk(new Date(nowMs - retentionDays * 24 * 60 * 60 * 1000));
const retainedEntries = [];
let dropped = 0;
for (const e of entries) {
if (isWithinRetentionWindow(e.date, cutoffDate)) {
retainedEntries.push(e);
} else {
dropped++;
}
}
return { retainedEntries, dropped };
}
/**
* Builds the JSON-backup payload with the retention window applied to *every*
* time-series array, so the backup file size stays bounded over time.
*
* Previously only `entries` was trimmed while `planTasks`, `blocks`, `devLog`,
* and `distractions` were bundled in full — those grow every day (carried
* tasks, per-day time blocks, appended distraction/dev-log records) and were
* the reason "21-day" backups kept getting bigger. Each of those arrays carries
* a `YYYY-MM-DD` `date` field, so they prune through the same window as
* `entries`.
*
* Non-time-series data is intentionally kept whole: `categories` and `qpHidden`
* are small reference sets with no date, and `pomoLog` is already capped at
* source (100 records) so it needs no further trimming here.
*
* Kept pure (no `localStorage`, no ambient clock) so the bounded-size guarantee
* is unit-testable — the caller supplies the current state and the clock.
*
* @param {object} state - Current application state arrays.
* @param {Array} [state.entries] - Log entries (dated; trimmed).
* @param {Array} [state.categories] - Category definitions (undated; kept whole).
* @param {Array} [state.planTasks] - Plan/board tasks (dated; trimmed).
* @param {Array} [state.blocks] - Time blocks (dated; trimmed).
* @param {Array} [state.pomoLog] - Pomodoro log (capped at source; kept whole).
* @param {Array} [state.devLog] - Dev-changelog entries (dated; trimmed).
* @param {Array} [state.distractions] - Distraction records (dated; trimmed).
* @param {Array} [state.qpHidden] - Hidden quick-pick ids (undated; kept whole).
* @param {number} retentionDays - How many days back to keep for dated arrays.
* @param {number} nowMs - Current time as a Unix timestamp in milliseconds.
* @returns {{ payload: object, dropped: Object<string, number> }} The backup
* payload object and a per-array count of records excluded from it (only
* arrays that dropped at least one record are included). The count folds
* together records that aged out of the window and records dropped for a
* missing/malformed `date` — both are legitimately excluded, so they are not
* distinguished here.
* @example
* buildBackupPayload({ entries, planTasks, blocks }, 21, Date.now())
* // → { payload: { version: '1', entries: [...], planTasks: [...], ... }, dropped: { blocks: 4 } }
*/
export function buildBackupPayload(state, retentionDays, nowMs) {
const dropped = {};
const trim = (arr, label) => {
const { retainedEntries, dropped: n } = applyBackupRetention(arr || [], retentionDays, nowMs);
// eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
if (n > 0) dropped[label] = n;
return retainedEntries;
};
const payload = {
version: '1',
exported: new Date(nowMs).toISOString(),
retentionDays,
entries: trim(state.entries, 'entries'),
categories: state.categories || [],
planTasks: trim(state.planTasks, 'planTasks'),
blocks: trim(state.blocks, 'blocks'),
pomoLog: state.pomoLog || [],
devLog: trim(state.devLog, 'devLog'),
distractions: trim(state.distractions, 'distractions'),
qpHidden: [...(state.qpHidden || [])],
};
return { payload, dropped };
}