Source: pure-fns-validate.js

/**
 * @file pure-fns-validate.js
 * Validators: localStorage schema validators, backup-import validation, and
 * external-API response validators. Split out of pure-fns.js (re-exported
 * there as a barrel). Pure functions with no side-effects, no global state,
 * and no imports — a leaf ES module.
 */

/* ── Schema validators ── */
// These are used by 01-state.js load() to strip malformed records from localStorage.
// They are pure: each validates only its argument with no side-effects.

/**
 * Returns true if `e` is a well-formed work-log entry safe to load from localStorage.
 * @param {*} e - Candidate value parsed from JSON.
 * @returns {boolean} True if the entry is well-formed.
 * @example
 * validEntry({ id: '1', text: 'Write report', ts: 1234567890, date: '2026-05-25' }) // → true
 * validEntry(null)                           // → false
 * validEntry({ id: 1, text: 'x', ts: 0, date: '2026-05-25' }) // → false (numeric id)
 * validEntry({ id: '1', text: 'x', ts: 0, date: '25-05-2026' }) // → false (wrong date format)
 * validEntry({ id: '1', text: 'x', ts: 0, date: '2026-05-25', link: 42 }) // → false (non-string link)
 */
export function validEntry(e) {
  return !!(
    e &&
    typeof e.id === 'string' &&
    typeof e.text === 'string' &&
    typeof e.ts === 'number' &&
    typeof e.date === 'string' &&
    /^\d{4}-\d{2}-\d{2}$/.test(e.date) &&
    (e.link === undefined || typeof e.link === 'string') &&
    (e.note === undefined || typeof e.note === 'string')
  );
}

/**
 * Returns true if `c` is a well-formed category object.
 * @param {*} c - Candidate value parsed from JSON.
 * @returns {boolean} True if the category is well-formed.
 */
export function validCategory(c) {
  return !!(
    c &&
    typeof c.id === 'string' &&
    typeof c.label === 'string' &&
    typeof c.color === 'string'
  );
}

/**
 * Returns true if `t` is a well-formed plan task with a recognised status value.
 * @param {*} t - Candidate value parsed from JSON.
 * @returns {boolean} True if the plan task is well-formed.
 * @example
 * validPlanTask({ id: 'pk1', text: 'Build form', date: '2026-05-25', status: 'todo' }) // → true
 * validPlanTask({ id: 'pk1', text: 'x', date: '2026-05-25', status: 'finished' }) // → false (unknown status)
 * validPlanTask(null) // → false
 */
export function validPlanTask(t) {
  return !!(
    t &&
    typeof t.id === 'string' &&
    typeof t.text === 'string' &&
    typeof t.date === 'string' &&
    /^\d{4}-\d{2}-\d{2}$/.test(t.date) &&
    ['todo', 'inprogress', 'done', 'pending', 'blocked', 'upcoming'].includes(t.status)
  );
}

/**
 * Returns true if `b` is a well-formed timeblock record.
 * @param {*} b - Candidate value parsed from JSON.
 * @returns {boolean} True if the timeblock is well-formed.
 */
export function validBlock(b) {
  return !!(
    b &&
    typeof b.id === 'string' &&
    typeof b.date === 'string' &&
    typeof b.slot === 'number' &&
    typeof b.duration === 'number' &&
    typeof b.text === 'string'
  );
}

/**
 * Returns true if `t` is a resumable timer state.
 * Handles both running (startTs is set) and paused (paused=true, accumulatedMs is set) forms.
 * @param {*} t - Candidate value parsed from JSON.
 * @returns {boolean} True if the timer state is well-formed.
 * @example
 * validTimer({ entryId: 'e1', startTs: 1234567890 })              // → true  (running)
 * validTimer({ entryId: 'e1', paused: true, accumulatedMs: 900000 }) // → true  (paused)
 * validTimer({ entryId: 'e1' })                                   // → false (neither running nor paused)
 * validTimer(null)                                                 // → false
 */
export function validTimer(t) {
  if (!t || typeof t.entryId !== 'string') return false;
  // Running timer: startTs is a number (when the current run started)
  // Paused timer:  startTs is null, accumulatedMs holds the work time so far
  if (t.paused === true) return typeof t.accumulatedMs === 'number';
  return typeof t.startTs === 'number';
}

/**
 * Returns true if `e` is a valid Pomodoro session log entry.
 * @param {*} e - Candidate value.
 * @returns {boolean} True if the Pomodoro entry is well-formed.
 */
export function validPomoEntry(e) {
  return !!(e && typeof e.ts === 'number' && typeof e.mins === 'number');
}

/* ── Backup import validation ── */

/**
 * Validates a parsed JSON backup object created by `exportBackup()`.
 *
 * Separated from the import flow so the validation logic can be unit-tested
 * without any browser APIs. `importBackup()` calls this before writing to
 * localStorage.
 *
 * @param {*} backup - Parsed backup object (typically from `JSON.parse`).
 * @returns {{ valid: boolean, error: (string|undefined) }}
 *   `{ valid: true }` when the backup is usable;
 *   `{ valid: false, error: string }` with a human-readable reason otherwise.
 * @example
 * validateBackupFile({ version: '1', entries: [], categories: [], planTasks: [] })
 *   // → { valid: true }
 * validateBackupFile(null)
 *   // → { valid: false, error: 'Not a valid backup object.' }
 * validateBackupFile({ version: '2', entries: [], categories: [], planTasks: [] })
 *   // → { valid: false, error: 'Unrecognised backup version "2"...' }
 * validateBackupFile({ version: '1', entries: [], categories: [] })
 *   // → { valid: false, error: '...missing required field "planTasks".' }
 */
export function validateBackupFile(backup) {
  if (!backup || typeof backup !== 'object' || Array.isArray(backup)) {
    return { valid: false, error: 'Not a valid backup object.' };
  }
  if (backup.version !== '1') {
    return {
      valid: false,
      error: `Unrecognised backup version "${backup.version}". Only version 1 is supported.`,
    };
  }
  for (const key of ['entries', 'categories', 'planTasks']) {
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    if (!Array.isArray(backup[key])) {
      return { valid: false, error: `Invalid backup — missing required field "${key}".` };
    }
  }
  return { valid: true };
}

/**
 * Returns entries from `backupEntries` that are both schema-valid and absent
 * from `currentEntries` (compared by `id`). Pure: no side-effects, no browser
 * APIs.
 *
 * Separated from the import flow so the deduplication logic can be
 * unit-tested independently of localStorage and the File API.
 * `mergeBackupEntries()` calls this before writing to localStorage.
 *
 * @param {Array<Object>} currentEntries - Entries already in the local store.
 * @param {Array<Object>} backupEntries  - Entries from the backup file.
 * @param {function(Object): boolean} isValid - Entry-schema predicate (e.g. `validEntry`).
 * @returns {Array<Object>} Valid entries from `backupEntries` whose `id` does
 *   not appear in `currentEntries`.
 */
export function filterNewBackupEntries(currentEntries, backupEntries, isValid) {
  const existingIds = new Set(currentEntries.map((e) => e.id));
  return backupEntries.filter((e) => isValid(e) && !existingIds.has(e.id));
}

/* ── External API response validators ── */
// Pure validators for shapes received from external data sources.
// Each function returns a boolean; callers are responsible for logging and
// providing safe fallbacks when validation fails.

/**
 * Returns true if `data` is a well-formed Open-Meteo forecast response
 * containing the fields that `fetchWeather()` reads.
 *
 * Required shape:
 * - `data.current.temperature_2m` — number
 * - `data.current.weather_code`   — number
 * - `data.hourly.time`                      — array
 * - `data.hourly.precipitation_probability` — array
 *
 * The `daily` block is optional (used only when present).
 *
 * @param {*} data - Value parsed from the Open-Meteo JSON response.
 * @returns {boolean} True if the response is a usable forecast object.
 * @example
 * validWeatherResponse({
 *   current: { temperature_2m: 15, weather_code: 3 },
 *   hourly: { time: ['2026-05-28T00:00'], precipitation_probability: [10] },
 * }) // → true
 * validWeatherResponse(null)                  // → false
 * validWeatherResponse({ current: {} })       // → false  (missing hourly)
 * validWeatherResponse({ hourly: { time: [], precipitation_probability: [] } })
 *   // → false  (missing current)
 */
export function validWeatherResponse(data) {
  return !!(
    data &&
    typeof data === 'object' &&
    data.current &&
    typeof data.current.temperature_2m === 'number' &&
    typeof data.current.weather_code === 'number' &&
    data.hourly &&
    Array.isArray(data.hourly.time) &&
    Array.isArray(data.hourly.precipitation_probability)
  );
}

/**
 * Returns true if `meeting` is a well-formed Outlook calendar event object
 * as returned by the PowerShell `/api/calendar` endpoint.
 *
 * Required fields: `subject` (string), `start` (string), `end` (string).
 * Optional fields (`joinUrl`, `account`) are not validated here — their
 * absence is handled gracefully by `renderCalStrip`.
 *
 * @param {*} meeting - Candidate meeting object from the calendar API response.
 * @returns {boolean} True if the meeting object is well-formed.
 * @example
 * validCalendarMeeting({ subject: 'Standup', start: '2026-05-28T09:00', end: '2026-05-28T09:30' })
 *   // → true
 * validCalendarMeeting(null)                   // → false
 * validCalendarMeeting({ subject: 'x' })       // → false  (missing start/end)
 * validCalendarMeeting({ subject: 42, start: '2026-05-28T09:00', end: '2026-05-28T09:30' })
 *   // → false  (subject not a string)
 */
export function validCalendarMeeting(meeting) {
  return !!(
    meeting &&
    typeof meeting === 'object' &&
    typeof meeting.subject === 'string' &&
    typeof meeting.start === 'string' &&
    typeof meeting.end === 'string'
  );
}

/**
 * Returns a copy of `meeting` with a subject the rest of the app can rely on.
 *
 * Outlook returns no subject at all for an appointment saved without a title,
 * so the meeting arrives with `subject: null` and `validCalendarMeeting`
 * rejects it — an untitled meeting used to disappear from the strip entirely
 * rather than showing up unnamed. Normalising first keeps it, since its times
 * are what the strip actually needs.
 *
 * The input is not modified; a non-object is returned unchanged so the caller's
 * validator still gets to reject it.
 *
 * @param {*} meeting - Candidate meeting object from the calendar API response.
 * @returns {*} A copy with a non-empty string subject, or the input unchanged.
 * @example
 * normalizeCalendarMeeting({ subject: null, start: 'a', end: 'b' })
 *   // → { subject: '(no title)', start: 'a', end: 'b' }
 * normalizeCalendarMeeting({ subject: '  Standup  ', start: 'a', end: 'b' })
 *   // → { subject: 'Standup', start: 'a', end: 'b' }
 */
export function normalizeCalendarMeeting(meeting) {
  if (!meeting || typeof meeting !== 'object') return meeting;
  const subject = typeof meeting.subject === 'string' ? meeting.subject.trim() : '';
  return { ...meeting, subject: subject || '(no title)' };
}

/**
 * Returns the stable per-occurrence key for a meeting.
 *
 * Subject alone is not an identity: a recurring meeting has the same subject at
 * every occurrence, so anything keyed on it treats them all as one. Pairing the
 * subject with the start time identifies the single occurrence on screen.
 *
 * @param {*} meeting - Meeting object from the calendar API response.
 * @returns {string} Key of the form `subject|start`, or '' for a non-object.
 * @example
 * calendarMeetingKey({ subject: 'Standup', start: '2026-05-28T09:00' })
 *   // → 'Standup|2026-05-28T09:00'
 */
export function calendarMeetingKey(meeting) {
  if (!meeting || typeof meeting !== 'object') return '';
  return `${meeting.subject ?? ''}|${meeting.start ?? ''}`;
}

/**
 * Returns true if the user has hidden this meeting for the day.
 *
 * Hidden meetings are stored per day as occurrence keys. Entries saved before
 * that were bare subjects, which hid every meeting sharing a title — the whole
 * day's standups would vanish on hiding one. Those legacy entries are still
 * honoured so a meeting hidden earlier today stays hidden; the store is keyed by
 * date, so they clear on their own overnight.
 *
 * @param {*} meeting - Meeting object from the calendar API response.
 * @param {string[]} hiddenKeys - Stored keys for the day.
 * @returns {boolean} True if this meeting should stay off the strip.
 * @example
 * isMeetingHidden({ subject: 'Standup', start: '09:00' }, ['Standup|09:00'])  // → true
 * isMeetingHidden({ subject: 'Standup', start: '14:00' }, ['Standup|09:00'])  // → false
 */
export function isMeetingHidden(meeting, hiddenKeys) {
  if (!Array.isArray(hiddenKeys) || hiddenKeys.length === 0) return false;
  const key = calendarMeetingKey(meeting);
  const subject = meeting && typeof meeting === 'object' ? meeting.subject : undefined;
  return hiddenKeys.some(
    (entry) =>
      typeof entry === 'string' && (entry === key || (!entry.includes('|') && entry === subject))
  );
}

/**
 * Returns true if `row` — a single object produced by `parseCSV()` — contains
 * at least the key and summary columns that `jiraParseAndRender()` expects.
 *
 * The Jira CSV export uses several possible column names for the same field
 * (matching the lenient lookup in `jiraParseAndRender`):
 * - Key column: `Issue key`, `Key`, or `Issue Key`
 * - Summary column: `Summary` or `summary`
 *
 * A row that has neither column set (e.g. from a semicolon-delimited file
 * parsed as single-column rows) fails validation and triggers a warning.
 *
 * @param {*} row - Single row object from `parseCSV()`.
 * @returns {boolean} True if the row has the key and summary fields Jira import needs.
 * @example
 * validJiraCsvRow({ 'Issue key': 'AITO-1', Summary: 'Fix login bug', Status: 'Open' })
 *   // → true
 * validJiraCsvRow({ Key: 'PROJ-2', Summary: 'Add dark mode', Status: 'To Do' })
 *   // → true
 * validJiraCsvRow({})                           // → false  (no key or summary)
 * validJiraCsvRow({ 'Issue key': 'AITO-1' })    // → false  (missing summary)
 * validJiraCsvRow({ Summary: 'Fix login bug' }) // → false  (missing key)
 */
export function validJiraCsvRow(row) {
  if (!row || typeof row !== 'object') return false;
  const hasKey = !!(
    (row['Issue key'] || '').trim() ||
    (row['Key'] || '').trim() ||
    (row['Issue Key'] || '').trim()
  );
  const hasSummary = !!((row['Summary'] || '').trim() || (row['summary'] || '').trim());
  return hasKey && hasSummary;
}