Source: pure-fns-format.js

/**
 * @file pure-fns-format.js
 * CSS/HTML safety, date/time/duration formatting, and billing time rounding.
 * 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.
 */

/* ── CSS / HTML safety ── */

/**
 * Returns `c` if it is a safe CSS colour (hex or hsl()), otherwise returns a neutral fallback.
 * Prevents malformed user-supplied colour values from breaking layout or injecting CSS.
 * @param {string} c - CSS colour string to validate.
 * @returns {string} A safe CSS colour string.
 * @example
 * safeCssColor('#7B61FF')      // → '#7B61FF'
 * safeCssColor('hsl(200,60%,50%)') // → 'hsl(200,60%,50%)'
 * safeCssColor('red')          // → '#888780'  (name blocked)
 * safeCssColor('')             // → '#888780'
 */
export function safeCssColor(c) {
  if (c == null) return '#888780';
  // Allow hex (#rgb, #rrggbb, #rrggbbaa) and hsl() only — block anything else
  return /^(#[0-9a-fA-F]{3,8}|hsl\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*\))$/.test(String(c))
    ? c
    : '#888780';
}

/**
 * Escapes a string for safe insertion as HTML text content.
 * @param {string} s - Raw string to escape.
 * @returns {string} HTML-escaped string safe for insertion into the DOM.
 * @example
 * escHtml('<b>bold</b>')   // → '&lt;b&gt;bold&lt;/b&gt;'
 * escHtml('a & b')         // → 'a &amp; b'
 * escHtml('"quoted"')      // → '&quot;quoted&quot;'
 * escHtml(42)              // → '42'
 */
export function escHtml(s) {
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

/**
 * Builds the anchor HTML for a Jira ticket key. The link opens in a new tab,
 * so a visually hidden suffix tells screen-reader users that before they
 * activate it (WCAG 3.2.2 / 2.4.4); sighted users see no change.
 * @param {string} baseUrl - Jira browse URL, without a trailing slash.
 * @param {string} key - Ticket key, e.g. `AITO-1234`.
 * @returns {string} HTML string for the `<a>` element.
 * @example
 * jiraKeyLinkHtml('https://x.atlassian.net/browse', 'AB-1')
 * // → '<a class="jira-key-link" href="https://x.atlassian.net/browse/AB-1" …>AB-1<span class="sr-only"> (opens in new tab)</span></a>'
 */
export function jiraKeyLinkHtml(baseUrl, key) {
  return `<a class="jira-key-link" href="${escHtml(baseUrl)}/${escHtml(key)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${escHtml(key)}<span class="sr-only"> (opens in new tab)</span></a>`;
}

/* ── Date / time formatting ── */

/**
 * Formats a Date as YYYY-MM-DD using local calendar date.
 * Uses local date components (getFullYear / getMonth / getDate) so the returned
 * string always matches the date the user sees on their clock, regardless of timezone.
 * @param {Date} d - Date to format.
 * @returns {string} e.g. '2026-05-26'
 * @example
 * dk(new Date(2026, 4, 26, 12, 0, 0))  // → '2026-05-26'  (noon local)
 * dk(new Date(2026, 11, 31, 23, 59, 0)) // → '2026-12-31' (11 PM local)
 * dk(new Date(2026, 0, 1, 0, 0, 0))    // → '2026-01-01'  (midnight local)
 */
export function dk(d) {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}

/**
 * Returns the Unix timestamp (ms) of Monday 00:00 local time for the week
 * containing `ts` — the app's calendar-week boundary, matching the "Week
 * N/52" header label (`getISOWeek()` in `09-clock-weather.js`).
 * @param {number} [ts] - Reference time in ms; defaults to `Date.now()`.
 * @returns {number} Monday 00:00 local time, in ms.
 * @example
 * // 2026-06-03 is a Wednesday → Monday of that week is 2026-06-01
 * new Date(mondayOfWeek(new Date(2026, 5, 3, 14, 30).getTime())).toDateString()
 * // → 'Mon Jun 01 2026'
 * // 2026-06-01 is already a Monday → returns that same day at 00:00
 * new Date(mondayOfWeek(new Date(2026, 5, 1, 9, 0).getTime())).getHours() // → 0
 */
export function mondayOfWeek(ts = Date.now()) {
  const d = new Date(ts);
  d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
  d.setHours(0, 0, 0, 0);
  return d.getTime();
}

/**
 * Formats a Unix timestamp as HH:MM in 24-hour local time.
 * @param {number} ts - Unix timestamp in milliseconds.
 * @returns {string} e.g. '09:30'
 * @example
 * fmtTime(new Date('2026-05-25T09:05:00').getTime()) // → '09:05'
 * fmtTime(new Date('2026-05-25T14:30:00').getTime()) // → '14:30'
 */
export function fmtTime(ts) {
  const d = new Date(ts);
  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}

/**
 * Formats a duration in milliseconds as a compact time string.
 * @param {number} ms - Duration in milliseconds.
 * @returns {string} 'MM:SS' for durations under an hour; 'HH:MM:SS' otherwise.
 * @example
 * fmtElapsed(0)              // → '00:00'
 * fmtElapsed(90 * 1000)      // → '01:30'
 * fmtElapsed(3600 * 1000)    // → '01:00:00'
 * fmtElapsed(5461 * 1000)    // → '01:31:01'
 */
export function fmtElapsed(ms) {
  const s = Math.floor(ms / 1000);
  const hh = Math.floor(s / 3600),
    mm = Math.floor((s % 3600) / 60),
    ss = s % 60;
  if (hh > 0)
    return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
  return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
}

/**
 * Formats a duration in milliseconds as a compact human-readable string ("Xh Ym").
 * Used throughout the UI for tracked-time display in the timeline, chart, and plan.
 * @param {number} ms - Duration in milliseconds.
 * @returns {string} e.g. '1h 30m', '45m', '2h'
 * @example
 * fmtDur(0)                  // → '0m'
 * fmtDur(45 * 60 * 1000)     // → '45m'
 * fmtDur(90 * 60 * 1000)     // → '1h 30m'
 * fmtDur(120 * 60 * 1000)    // → '2h'
 */
export function fmtDur(ms) {
  const mins = Math.round(ms / 60000);
  const h = Math.floor(mins / 60);
  const m = mins % 60;
  return h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
}

/**
 * Formats a past timestamp as a relative "ago" string for the last-note reference line.
 * Uses an injected `now` parameter so it is fully unit-testable without mocking Date.now.
 * @param {number} ts  - Unix timestamp in milliseconds of the past event.
 * @param {number} [now] - Current time in ms; defaults to Date.now().
 * @returns {string} 'just now' | 'X min ago' | 'Xh ago'
 * @example
 * fmtAgo(Date.now() - 30000)          // → 'just now'
 * fmtAgo(Date.now() - 2 * 60000)      // → '2 min ago'
 * fmtAgo(Date.now() - 90 * 60000)     // → '1h ago'
 */
export function fmtAgo(ts, now = Date.now()) {
  const mins = Math.floor((now - ts) / 60000);
  if (mins < 1) return 'just now';
  if (mins < 60) return `${mins} min ago`;
  return `${Math.floor(mins / 60)}h ago`;
}

/**
 * Formats a duration in milliseconds as a human-readable string using the
 * long "min" suffix.  Used in plaintext exports where readability matters more
 * than compactness.
 * @param {number} ms - Duration in milliseconds.
 * @returns {string} e.g. '1h 30min', '45min', '2h'
 * @example
 * fmtDurLong(0)                 // → '0min'
 * fmtDurLong(45 * 60 * 1000)    // → '45min'
 * fmtDurLong(90 * 60 * 1000)    // → '1h 30min'
 * fmtDurLong(120 * 60 * 1000)   // → '2h'
 */
export function fmtDurLong(ms) {
  const mins = Math.round(ms / 60000);
  const h = Math.floor(mins / 60);
  const m = mins % 60;
  return h > 0 ? (m > 0 ? `${h}h ${m}min` : `${h}h`) : `${m}min`;
}

/**
 * Returns true if a timer's elapsed time has crossed a long-running safety
 * threshold. Pure boundary check, extracted so the threshold logic is
 * unit-testable without mocking the DOM or the timer interval.
 * @param {number} elapsedMs - Elapsed timer time in milliseconds.
 * @param {number} [thresholdMins=240] - Threshold in minutes (default 4h).
 * @returns {boolean} True once `elapsedMs` exceeds the threshold.
 * @example
 * isLongRunningTimer(3 * 60 * 60 * 1000)          // → false (3h)
 * isLongRunningTimer(4 * 60 * 60 * 1000)           // → false (exactly 4h)
 * isLongRunningTimer(4 * 60 * 60 * 1000 + 1)       // → true  (just past 4h)
 * isLongRunningTimer(31 * 60 * 1000, 30)           // → true  (custom 30 min threshold)
 */
export function isLongRunningTimer(elapsedMs, thresholdMins = 240) {
  return elapsedMs > thresholdMins * 60 * 1000;
}

/* ── Billing time rounding ── */

/**
 * Rounds a timestamp to the nearest 30-minute clock mark.
 *
 * Tie-breaking rule (at exactly 15 min into a 30-min slot): rounds DOWN.
 * Rationale: conservative for billing — a task must exceed the midpoint of the slot
 * before the next slot is claimed.
 *
 * Assumption: rounding ties (exactly 15 min past a slot start) are resolved in
 * favour of the earlier slot to avoid inflating billable time. This matches the
 * intent documented in DATA.md under wl_entries.ts.
 *
 * @param {number} ts - Unix timestamp in milliseconds.
 * @returns {number} Rounded Unix timestamp in milliseconds.
 */
export function roundToNearest30(ts) {
  const d = new Date(ts);
  const m = d.getMinutes();
  const blockStart = Math.floor(m / 30) * 30; // 0 or 30
  const withinBlock = m - blockStart; // 0–29
  // withinBlock <= 15 → keep blockStart (rounds down / tie goes down)
  // withinBlock >  15 → advance to blockStart + 30
  const roundedMins = withinBlock <= 15 ? blockStart : blockStart + 30;
  const result = new Date(d);
  result.setSeconds(0, 0);
  if (roundedMins >= 60) {
    if (d.getHours() === 23) {
      // Clamp to 23:30 — don't cross the day boundary
      result.setMinutes(30);
    } else {
      result.setMinutes(0);
      result.setHours(d.getHours() + 1);
    }
  } else {
    result.setMinutes(roundedMins);
  }
  return result.getTime();
}

/**
 * Counts consecutive days with at least one logged entry, looking backwards
 * from yesterday. Today is excluded so the streak only increments once the
 * day has been completed.
 * @param {Array<{date: string}>} allEntries - The full entries array.
 * @returns {number} The current streak length (0 if no streak).
 */
export function calcStreak(allEntries) {
  const days = new Set(allEntries.map((entry) => entry.date));
  let streak = 0;
  const d = new Date();
  d.setDate(d.getDate() - 1); // start from yesterday, not today
  while (days.has(dk(d))) {
    streak++;
    d.setDate(d.getDate() - 1);
  }
  return streak;
}