Source: 01-state.js

// STORE_* key constants are defined in app-constants.js (a leaf ES module
// imported at the top of the built bundle), not here.

// Lowercase task texts the user has dismissed from the recent-tasks list
const qpHidden = (() => {
  try {
    const raw = JSON.parse(localStorage.getItem(STORE_QP_HIDDEN) || '[]');
    return new Set(Array.isArray(raw) ? raw.map((s) => String(s).toLowerCase()) : []);
  } catch (err) {
    return new Set();
  }
})();
function saveQpHidden() {
  localStorage.setItem(STORE_QP_HIDDEN, JSON.stringify([...qpHidden]));
}

// DEFAULT_CATS and CUSTOM_PALETTE are defined in app-constants.js (a leaf
// ES module imported at the top of the built bundle), not here.

/**
 * Returns the next visually distinct colour from the palette for a new category.
 * Falls back to golden-angle HSL generation once all palette colours are in use.
 * @returns {string} A CSS colour string (hex or hsl).
 */
function nextDistinctColor() {
  const usedColors = new Set(getCategories().map((c) => c.color.toLowerCase()));
  const pick = CUSTOM_PALETTE.find((c) => !usedColors.has(c.toLowerCase()));
  if (pick) return pick;
  // All palette colours used — generate by golden-angle hue steps
  const hue = (usedColors.size * 137) % 360;
  return `hsl(${hue}, 65%, 52%)`;
}

/**
 * Creates and appends a new category from a raw label, unless a category
 * with the same label already exists (case-insensitive). Shared by the
 * task board's and the log entry's "+ new epic" pickers so both stay in
 * sync (10b-tasks-events.js, 04-render.js).
 * @param {string} rawLabel - User-entered label text, not yet trimmed.
 * @returns {{ id: string, label: string, color: string }|null} The new category, or null when the label is empty or already taken.
 */
function createCategory(rawLabel) {
  const label = String(rawLabel).trim();
  if (!label) return null;
  if (getCategories().find((cat) => cat.label.toLowerCase() === label.toLowerCase())) {
    wlLog.warn('createCategory: rejected duplicate label', { label });
    return null;
  }
  const category = { id: 'cat_' + Date.now(), label, color: nextDistinctColor() };
  getCategories().push(category);
  return category;
}

// The shared mutable variables (entries, activeTimer, timerInterval, categories,
// selectedTag, viewDate, blocks, logNotes) live in state.js (#423); read and
// write them through its getX()/setX() accessors, never as bare globals.

/* ── Load / Save ── */
// Schema validators (validEntry, validCategory, validPlanTask, validBlock, validTimer,
// validPomoEntry) are defined in 00-pure-fns.js (concatenated earlier in the build).

/**
 * Loads all persistent state from localStorage into module-level variables.
 * Invalid records are dropped per-item (rather than rejecting entire arrays)
 * and any drops are reported via wlLog.warn so data-quality issues are visible
 * in DevTools rather than silently disappearing.
 * Falls back to the last snapshot if entries are missing from primary storage.
 */
function load() {
  try {
    const parsedEntries = JSON.parse(localStorage.getItem(STORE_ENTRIES) || '[]');
    const allEntries = Array.isArray(parsedEntries) ? parsedEntries : [];
    setEntries(allEntries.filter(validEntry));
    if (getEntries().length < allEntries.length)
      wlLog.warn(
        `load: dropped ${allEntries.length - getEntries().length} invalid entry record(s)`,
        {
          total: allEntries.length,
          kept: getEntries().length,
        }
      );
  } catch (err) {
    setEntries([]);
    wlLog.error('load: failed to parse entries from localStorage', err);
  }
  try {
    const parsedTimer = JSON.parse(localStorage.getItem(STORE_TIMER) || 'null');
    setActiveTimer(parsedTimer && validTimer(parsedTimer) ? parsedTimer : null);
    if (parsedTimer && !validTimer(parsedTimer))
      wlLog.warn('load: discarded invalid timer state', parsedTimer);
  } catch (err) {
    setActiveTimer(null);
    wlLog.error('load: failed to parse timer state', err);
  }
  try {
    const parsedCategories = JSON.parse(localStorage.getItem(STORE_CATS) || 'null');
    if (Array.isArray(parsedCategories) && parsedCategories.length) {
      setCategories(parsedCategories.filter(validCategory));
      if (getCategories().length < parsedCategories.length)
        wlLog.warn(
          `load: dropped ${parsedCategories.length - getCategories().length} invalid category record(s)`,
          {
            total: parsedCategories.length,
            kept: getCategories().length,
          }
        );
    }
  } catch (err) {
    wlLog.error('load: failed to parse categories', err);
  }
  // Auto-restore from snapshot if entries are unexpectedly empty
  if (!getEntries().length) {
    try {
      const snap = JSON.parse(localStorage.getItem('wl_snapshot') || 'null');
      if (snap && Array.isArray(snap.entries) && snap.entries.length) {
        setEntries(snap.entries.filter(validEntry));
        if (Array.isArray(snap.categories) && snap.categories.length)
          setCategories(snap.categories.filter(validCategory));
        wlLog.warn('load: restored from snapshot — entries were missing from primary storage');
      }
    } catch (err) {
      wlLog.warn('load: failed to parse snapshot from localStorage', err);
    }
  }
  loadLogNotes();
  loadTrackers();
}

function loadLogNotes() {
  try {
    const raw = JSON.parse(localStorage.getItem(STORE_LOGNOTES) || '[]');
    setLogNotes(Array.isArray(raw) ? raw : []);
  } catch (err) {
    setLogNotes([]);
    wlLog.warn('loadLogNotes: failed to parse log notes from localStorage', err);
  }
}

function saveLogNotes() {
  localStorage.setItem(STORE_LOGNOTES, JSON.stringify(getLogNotes()));
}

// save(), showSaveFailureBanner(), and hideSaveFailureBanner() have been
// extracted to src/js/01c-save.js (leaf ES module, issue #336, extraction #15).