Source: pure-fns-tasks.js

/**
 * @file pure-fns-tasks.js
 * Task/day helpers: rapid-capture inline token grammar, carry-forward status
 * resolution, and per-day work-location helpers. Split out of pure-fns.js
 * (re-exported there as a 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';

// ── Rapid-capture inline token grammar ───────────────────────────────────────

/**
 * Signifier shortcode map for quick-capture inline tokens.
 * Maps `!<shortcode>` tokens to signifier values used on entry objects.
 * Unmapped tokens are left in the text unchanged.
 * @type {Object<string, string>}
 */
const RAPID_SIG_SHORTCUTS = {
  event: 'event',
  ev: 'event',
  e: 'event',
  flagged: 'flagged',
  flag: 'flagged',
  f: 'flagged',
  star: 'flagged',
  migrated: 'migrated',
  migrate: 'migrated',
  m: 'migrated',
  cancelled: 'cancelled',
  cancel: 'cancelled',
  x: 'cancelled',
  drop: 'cancelled',
  overtime: 'overtime',
  ot: 'overtime',
};

/**
 * Resolves a `>date` shorthand token to a YYYY-MM-DD date key.
 * Supported tokens: 'today', 'tomorrow', exact 'YYYY-MM-DD', and weekday abbreviations
 * 'mon'–'sun' (resolves to the next occurrence, never today).
 *
 * @param {string} token - Raw date token (without the leading `>`).
 * @param {Date} [now] - Reference date for relative resolution; defaults to new Date().
 * @returns {string|null} Resolved date key, or null if the token is unrecognised.
 */
function resolveRapidDate(token, now) {
  if (!token) return null;
  const ref = now || new Date();
  const t = token.toLowerCase();

  if (t === 'today') return dk(ref);

  if (t === 'tomorrow') {
    const d = new Date(ref);
    d.setDate(d.getDate() + 1);
    return dk(d);
  }

  if (/^\d{4}-\d{2}-\d{2}$/.test(token)) return token;

  const DOW = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
  const idx = DOW.indexOf(t.slice(0, 3));
  if (idx >= 0) {
    const d = new Date(ref);
    const diff = (idx - d.getDay() + 7) % 7 || 7; // always NEXT occurrence
    d.setDate(d.getDate() + diff);
    return dk(d);
  }

  return null;
}

/**
 * Parses inline shorthand tokens from a raw quick-capture input string and
 * returns a clean text plus structured token values.
 *
 * Supported tokens (each stripped from the returned `text`):
 * - `#<cat>`  — Category: matched against category ids and labels (case-insensitive;
 *               prefix match as fallback). Last occurrence wins.
 * - `!<sig>`  — Signifier shortcode (see RAPID_SIG_SHORTCUTS). Last occurrence wins.
 * - `><date>` — Date pointer: today, tomorrow, YYYY-MM-DD, or weekday mon–sun
 *               (next occurrence). Last occurrence wins.
 *
 * Unrecognised tokens are left in the text unchanged so the user sees them and can
 * correct them without data being silently discarded.
 *
 * @param {string} raw - Raw input text that may contain inline shorthand tokens.
 * @param {Array<{id: string, label: string}>} cats - Available categories for `#` resolution.
 * @param {Date} [now] - Reference date for relative date resolution; defaults to new Date().
 * @returns {{ text: string, tag: string|null, signifier: string|null, date: string|null }}
 */
export function parseRapidTokens(raw, cats, now) {
  let text = raw;
  let tag = null;
  let signifier = null;
  let date = null;

  // ── #category ──────────────────────────────────────────────────────────────
  text = text.replace(/#([\w-]+)/g, function (match, tok) {
    const lower = tok.toLowerCase();
    const catArr = cats || [];
    // Exact id match → label match → id-prefix match → label-prefix match
    const resolved =
      catArr.find((c) => c.id.toLowerCase() === lower) ||
      catArr.find((c) => c.label.toLowerCase() === lower) ||
      catArr.find((c) => c.id.toLowerCase().startsWith(lower)) ||
      catArr.find((c) => c.label.toLowerCase().startsWith(lower)) ||
      null;
    if (resolved) {
      tag = resolved.id;
      return '';
    }
    return match; // unrecognised — leave in text
  });

  // ── !signifier ─────────────────────────────────────────────────────────────
  text = text.replace(/!(\w+)/g, function (match, tok) {
    const key = tok.toLowerCase();
    if (Object.prototype.hasOwnProperty.call(RAPID_SIG_SHORTCUTS, key)) {
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      signifier = RAPID_SIG_SHORTCUTS[key];
      return '';
    }
    return match; // unrecognised — leave in text
  });

  // ── >date ──────────────────────────────────────────────────────────────────
  text = text.replace(/>([A-Za-z0-9-]+)/g, function (match, tok) {
    const resolved = resolveRapidDate(tok, now);
    if (resolved) {
      date = resolved;
      return '';
    }
    return match; // unrecognised — leave in text
  });

  // Collapse extra whitespace produced by token removal
  text = text.replace(/\s{2,}/g, ' ').trim();

  return { text, tag, signifier, date };
}

/**
 * Determines the carry-forward status a today-task should adopt based on its
 * most recent past peer, implementing the following rules:
 *
 * - `pending` or `blocked` prev overrides `todo` or `inprogress` today — the
 *   task is still blocked so the blocking state wins.
 * - `upcoming` prev overrides **only** a `todo` today — a todo placeholder
 *   created from an even-older copy should reflect the more recent intent to
 *   defer. It must NOT override `inprogress`, because the user explicitly
 *   started the task and a reload must not undo that.
 * - `inprogress` prev promotes a `todo` today — the task was already being
 *   worked on and the carry placeholder should show that.
 *
 * Returns `null` when no change is needed.
 *
 * @param {{ status: string, text: string }} todayTask - Today's task object.
 * @param {{ status: string, text: string, date: string }} prev - Most recent
 *   past task with the same text (case-insensitive).
 * @returns {string|null} The new status to apply, or `null` for no change.
 * @example
 * resolveCarryStatus({ status: 'todo' }, { status: 'upcoming' }) // → 'upcoming'
 * resolveCarryStatus({ status: 'inprogress' }, { status: 'upcoming' }) // → null
 * resolveCarryStatus({ status: 'todo' }, { status: 'pending' })  // → 'pending'
 */
export function resolveCarryStatus(todayTask, prev) {
  const prevIsBlocking = prev.status === 'pending' || prev.status === 'blocked';
  const todayIsTodo = todayTask.status === 'todo';
  const todayIsActive = todayIsTodo || todayTask.status === 'inprogress';

  if (prevIsBlocking && todayIsActive) return prev.status;

  if (prev.status === 'upcoming' && todayIsTodo) return 'upcoming';

  if (todayIsTodo && prev.status === 'inprogress') return 'inprogress';

  return null;
}

/**
 * Finds plan tasks explicitly scheduled as 'upcoming' whose date falls within
 * `[weekStartKey, weekEndKey)` — candidates for the weekly plan review
 * checklist, run once a new ISO week begins so stale "upcoming" tasks (ones
 * already finished elsewhere before their target week arrived) get caught
 * and reconciled instead of silently resurfacing on their date.
 *
 * Compares `date` as strings, not timestamps: `YYYY-MM-DD` keys already sort
 * and compare correctly lexicographically, so no Date conversion is needed.
 *
 * @param {Array<Object>} planTasks - All plan/board tasks.
 * @param {string} weekStartKey - Inclusive YYYY-MM-DD (e.g. this week's Monday).
 * @param {string} weekEndKey - Exclusive YYYY-MM-DD (e.g. next week's Monday).
 * @returns {Array<Object>} Matching tasks, sorted by date ascending.
 * @example
 * findWeeklyPlanReviewTasks(
 *   [{ id: '1', text: 'PROJ-1: Fix login', status: 'upcoming', date: '2026-06-03' }],
 *   '2026-06-01', '2026-06-08'
 * )
 * // → [{ id: '1', text: 'PROJ-1: Fix login', status: 'upcoming', date: '2026-06-03' }]
 */
export function findWeeklyPlanReviewTasks(planTasks, weekStartKey, weekEndKey) {
  return (planTasks || [])
    .filter((t) => t.status === 'upcoming' && t.date >= weekStartKey && t.date < weekEndKey)
    .sort((a, b) => a.date.localeCompare(b.date));
}

/**
 * Finds today's plan task matching `text` (case-insensitive), for promotion
 * to "in progress" when a timer starts on that text — regardless of which UI
 * control (hero composer, recent chip, capture input, board's own "track"
 * button) started it. Returns `null` when there's no match, or the match
 * isn't in a promotable state (only `todo`/`upcoming` tasks are promoted —
 * an already-`inprogress`, `pending`, `blocked`, or `done` task is left
 * alone). Doesn't mutate `planTasks`; callers apply the status change.
 *
 * Task/entry linkage is by date + case-insensitive text match — the same
 * convention {@link buildTaskNoteMap} uses, because plan tasks and log
 * entries share no `taskId` field.
 *
 * @param {Array<Object>} planTasks - All plan/board tasks.
 * @param {string} text - The entry text a timer was just started on.
 * @param {string} todayKey - Today's date key (YYYY-MM-DD).
 * @returns {Object|null} The matching plan task to promote, or null.
 * @example
 * findPromotableTask(
 *   [{ id: 't1', text: 'Fix login', date: '2026-06-04', status: 'todo' }],
 *   'Fix login',
 *   '2026-06-04'
 * ) // → { id: 't1', text: 'Fix login', date: '2026-06-04', status: 'todo' }
 * @example
 * findPromotableTask(
 *   [{ id: 't1', text: 'Fix login', date: '2026-06-04', status: 'inprogress' }],
 *   'Fix login',
 *   '2026-06-04'
 * ) // → null — already in progress, nothing to promote
 */
export function findPromotableTask(planTasks, text, todayKey) {
  const task = (planTasks || []).find(
    (t) => t.date === todayKey && t.text.toLowerCase() === (text || '').toLowerCase()
  );
  if (!task || (task.status !== 'todo' && task.status !== 'upcoming')) return null;
  return task;
}

/* ── Work location ── */

/**
 * Work-location presets keyed by their stored id. Each entry carries the emoji
 * and human-readable label shown in the date-nav header. The first key is the
 * default applied to any day with no stored location.
 * @type {Readonly<Record<string, { emoji: string, label: string }>>}
 */
export const WORK_LOCATIONS = Object.freeze({
  remote: { emoji: '🏠', label: 'Remote' },
  office: { emoji: '🏢', label: 'Office' },
});

/** Location id used for any day that has no stored value. */
const DEFAULT_WORK_LOCATION = 'remote';

/**
 * Resolves the stored work location for a given day, falling back to the
 * default when the day is unset or holds an unknown value.
 * @param {Record<string, string>} map - Date-key → location-id map.
 * @param {string} dateKey - Day key in YYYY-MM-DD form (see {@link dk}).
 * @returns {string} A valid location id present in {@link WORK_LOCATIONS}.
 * @example
 * locationFor({ '2026-06-03': 'office' }, '2026-06-03') // → 'office'
 * locationFor({}, '2026-06-03')                         // → 'remote'
 * locationFor({ '2026-06-03': 'bogus' }, '2026-06-03')  // → 'remote'
 */
export function locationFor(map, dateKey) {
  // eslint-disable-next-line security/detect-object-injection -- dateKey is a YYYY-MM-DD string built by dk(), and the result is re-validated against WORK_LOCATIONS' own keys below
  const stored = map && map[dateKey];
  return Object.prototype.hasOwnProperty.call(WORK_LOCATIONS, stored)
    ? stored
    : DEFAULT_WORK_LOCATION;
}

/**
 * Returns the next location id when toggling, cycling through the keys of
 * {@link WORK_LOCATIONS}. With the two presets this flips Remote ↔ Office.
 * An unrecognised `loc` (indexOf → -1) is treated as "before the first" and
 * wraps to the first location, so the toggle always recovers to a valid state.
 * @param {string} loc - Current location id.
 * @returns {string} The following location id (wraps around).
 * @example
 * nextLocation('remote') // → 'office'
 * nextLocation('office') // → 'remote'
 * nextLocation('bogus')  // → 'remote'  (unknown input recovers to the first)
 */
export function nextLocation(loc) {
  const ids = Object.keys(WORK_LOCATIONS);
  const idx = ids.indexOf(loc);
  return ids[(idx + 1) % ids.length];
}