Source: 13-calendar.js

/* ── Calendar (Outlook COM via local server) ── */

// CAL_ACCOUNT_LABELS is defined in 00-config.js
let _calMeetingsCache = null;

// Elements already set up by renderCalStrip(). Tracked in WeakSets rather than as
// expando properties on the DOM nodes, so the state lives in the module and the
// elements stay free of ad-hoc fields.
const calSectionsWithRestoredCollapse = new WeakSet();
const calHeadersWithClickBound = new WeakSet();

/** localStorage key prefix for the meetings a user has hidden on a given day. */
const HIDDEN_MEETINGS_PREFIX = 'wl_hidden_meetings_';

/**
 * Reads the occurrence keys of the meetings hidden for one day.
 * @param {string} dayKey - Day key from `dk()`, e.g. '2026-09-01'.
 * @returns {string[]} Stored keys, or an empty array if none are stored.
 */
function readHiddenMeetingKeys(dayKey) {
  try {
    const stored = JSON.parse(localStorage.getItem(HIDDEN_MEETINGS_PREFIX + dayKey) || '[]');
    if (Array.isArray(stored)) return stored;
    wlLog.warn('readHiddenMeetingKeys: stored value is not an array — ignoring it', stored);
    return [];
  } catch (err) {
    wlLog.warn('readHiddenMeetingKeys: stored value is not valid JSON — ignoring it', err);
    return [];
  }
}

/**
 * Hides one meeting for the rest of the day.
 *
 * Stores the occurrence key rather than the subject, so hiding one instance of a
 * recurring meeting leaves that day's other instances on the strip.
 *
 * @param {string} dayKey - Day key from `dk()`.
 * @param {{subject: string, start: string}} meeting - The meeting to hide.
 * @returns {void}
 */
function hideMeetingForDay(dayKey, meeting) {
  const key = calendarMeetingKey(meeting);
  const keys = readHiddenMeetingKeys(dayKey);
  if (keys.includes(key)) return;
  keys.push(key);
  localStorage.setItem(HIDDEN_MEETINGS_PREFIX + dayKey, JSON.stringify(keys));
}

/**
 * Drops the meetings the user has hidden today.
 * @param {Array<Object>} meetings - Validated meetings from the calendar API.
 * @returns {Array<Object>} The meetings still to be shown.
 */
function visibleMeetings(meetings) {
  const hidden = readHiddenMeetingKeys(dk(new Date()));
  return meetings.filter((meeting) => !isMeetingHidden(meeting, hidden));
}

/**
 * Resolves a human-readable account label for an Outlook calendar account.
 * The PowerShell server sends the raw `DisplayName` which may be an email
 * address, a display name, or free-form company text. Tries three strategies
 * in order: exact match, email domain extraction, and substring match.
 * @param {string|null} account - Raw Outlook account identifier.
 * @returns {string|null} Display label (e.g. "Example Corp"), or null if unknown.
 */
function calAccountLabel(account) {
  if (!account) return null;
  const raw = String(account);
  const lower = raw.toLowerCase();

  // 1. Exact match (case-insensitive)
  for (const key of Object.keys(CAL_ACCOUNT_LABELS)) {
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    if (key.toLowerCase() === lower) return CAL_ACCOUNT_LABELS[key];
  }
  // 2. Email-style: extract second-level domain (e.g. "x@example.com" → "example")
  const emailMatch = lower.match(/@([^.@\s]+)\./);
  const emailDomain = emailMatch ? emailMatch[1] : null;
  // Own-property check: a domain such as "constructor" must not resolve to an inherited member.
  if (emailDomain && Object.hasOwn(CAL_ACCOUNT_LABELS, emailDomain)) {
    // eslint-disable-next-line security/detect-object-injection -- emailDomain comes from calendar account text, but Object.hasOwn guarantees it is an own key of CAL_ACCOUNT_LABELS
    return CAL_ACCOUNT_LABELS[emailDomain];
  }
  // 3. Substring match (e.g. "Example Corp Mailbox" contains "example corp")
  for (const key of Object.keys(CAL_ACCOUNT_LABELS)) {
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    if (lower.includes(key.toLowerCase())) return CAL_ACCOUNT_LABELS[key];
  }
  return null;
}

/**
 * Builds the two per-meeting buttons ("▶ start" and "✕ hide"). Both carry an
 * aria-label naming the meeting: the visible glyph text is identical on every
 * row, so without it a screen-reader user cannot tell which meeting a button
 * acts on (WCAG 4.1.2 / 2.4.6).
 * @param {{ subject: string }} meeting - The meeting the buttons act on.
 * @param {number} index - Position of the meeting in the rendered list; the hide handler reads it back.
 * @returns {{ taskBtn: string, deleteBtn: string }} HTML for each button.
 */
function buildMeetingButtonsHtml(meeting, index) {
  const subject = escHtml(meeting.subject);
  return {
    taskBtn: `<button class="cal-task-btn" data-subject="${subject}" aria-label="Start tracking: ${subject}">▶ start</button>`,
    deleteBtn: `<button class="cal-delete-btn" data-meeting-idx="${index}" aria-label="Hide meeting: ${subject}" title="Hide this meeting">✕</button>`,
  };
}

/**
 * Renders the calendar meetings strip for today.
 * Sorts meetings by start time, marks past meetings grey/italic, pulses
 * ongoing meetings, and provides ▶ start and ✕ hide buttons per meeting.
 * @param {Array<Object>} meetings - Array of meeting objects from the PS server.
 */
function renderCalStrip(meetings) {
  const section = document.getElementById('calSection');
  const el = document.getElementById('calMeetings');
  const countEl = document.getElementById('calCount');
  if (!section || !el) return;
  // Sort by start time regardless of calendar source
  if (Array.isArray(meetings))
    meetings = [...meetings].sort((a, b) => new Date(a.start) - new Date(b.start));

  if (!meetings || meetings.length === 0) {
    section.style.display = '';
    el.innerHTML = '<div class="cal-empty">No meetings today</div>';
    if (countEl) countEl.textContent = '';
    return;
  }

  const now = new Date();
  const fmtTime = (d) =>
    `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
  const upcoming = meetings.filter((ev) => new Date(ev.end) > now).length;
  if (countEl) countEl.textContent = upcoming ? `${upcoming} upcoming` : '';

  // Populate the collapsed-state next-meeting summary
  const nextInfoEl = document.getElementById('calNextInfo');
  if (nextInfoEl) {
    const nextMeeting = meetings.find((ev) => new Date(ev.end) > now);
    if (nextMeeting) {
      const start = new Date(nextMeeting.start);
      const timeStr = fmtTime(start);
      const maxLen = 28;
      const subject = nextMeeting.subject || '';
      const title = subject.length > maxLen ? subject.slice(0, maxLen) + '…' : subject;
      nextInfoEl.textContent = `${timeStr} · ${title}`;
    } else {
      nextInfoEl.textContent = '';
    }
  }

  el.innerHTML = meetings
    .map((ev, idx) => {
      const start = new Date(ev.start);
      const end = new Date(ev.end);
      const isPast = end < now;
      const isNow = start <= now && end > now;
      const cls = isNow ? 'now' : isPast ? 'past' : '';
      const dur = `<span class="cal-meeting-dur">${fmtDur(end - start)}</span>`;
      const join = ev.joinUrl
        ? `<a class="cal-meeting-join" href="${escHtml(ev.joinUrl)}" target="_blank" rel="noopener">Join</a>`
        : '';
      const label = calAccountLabel(ev.account);
      const acct = label ? `<span class="cal-account-label">[${escHtml(label)}]</span>` : '';
      const { taskBtn, deleteBtn } = buildMeetingButtonsHtml(ev, idx);
      return `<div class="cal-meeting ${cls}">
        <span class="cal-meeting-time">${fmtTime(start)}</span>
        <span class="cal-meeting-title">${escHtml(ev.subject)}</span>
        ${acct} ${dur} ${join} ${taskBtn} ${deleteBtn}
      </div>`;
    })
    .join('');

  // Wire up "▶ start" buttons
  el.querySelectorAll('.cal-task-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      const subject = btn.dataset.subject;
      const todayKey = dk(new Date());
      const categories = getCategories();
      const planTasks = getPlanTasks();
      // Meetings always default to the "meeting" category. Try the default id first,
      // then any category whose label matches; fall back to selectedTag if absent.
      const meetingCat =
        categories.find((c) => c.id === 'meeting') ||
        categories.find((c) => (c.label || '').toLowerCase() === 'meeting') ||
        null;
      const meetingTag = meetingCat ? meetingCat.id : getSelectedTag();
      const exists = planTasks.find(
        (t) => t.date === todayKey && t.text.toLowerCase() === subject.toLowerCase()
      );
      if (!exists) {
        planTasks.push({
          id: Date.now() + '',
          text: subject,
          status: 'todo',
          date: todayKey,
          tag: meetingTag,
        });
        savePlan();
      }
      if (getActiveTimer()) stopTimer();
      const entry = {
        id: Date.now() + '',
        text: subject,
        tag: meetingTag,
        ts: safeRoundedStart(),
        date: todayKey,
      };
      getEntries().push(entry);
      const task = planTasks.find(
        (t) => t.date === todayKey && t.text.toLowerCase() === subject.toLowerCase()
      );
      if (task && task.status === 'todo') {
        task.status = 'inprogress';
        savePlan();
      }
      setViewDate(new Date());
      save();
      startTimer(entry.id);
      render();
    });
  });

  // Wire up delete buttons
  const todayKey = dk(new Date());
  el.querySelectorAll('.cal-delete-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      const meeting = meetings[parseInt(btn.dataset.meetingIdx, 10)];
      if (!meeting) return;
      hideMeetingForDay(todayKey, meeting);
      // Show it as struck through until the next render drops it entirely
      btn.closest('.cal-meeting').style.opacity = '0.5';
      btn.closest('.cal-meeting').style.textDecoration = 'line-through';
      btn.disabled = true;
      btn.textContent = '✓';
    });
  });

  section.style.display = '';
  // Restore stored collapse state the first time the section is shown.
  // The flag prevents re-applying on subsequent re-renders.
  if (!calSectionsWithRestoredCollapse.has(section)) {
    calSectionsWithRestoredCollapse.add(section);
    section.classList.toggle('collapsed', readCollapseState('calSection', false));
  }

  // Collapsible header
  const hdr = document.getElementById('calHeader');
  if (hdr && !calHeadersWithClickBound.has(hdr)) {
    calHeadersWithClickBound.add(hdr);
    hdr.addEventListener('click', () => {
      section.classList.toggle('collapsed');
      writeCollapseState('calSection', section.classList.contains('collapsed'));
    });
  }
}

/**
 * Fetches today's meetings from the local PowerShell proxy (`/api/calendar`),
 * caches the result, filters out user-hidden meetings, and calls
 * {@link renderCalStrip}. Logs a warning and shows a fallback message on error.
 * @returns {Promise<void>}
 */
async function fetchAndRenderCalendar() {
  try {
    const res = await fetch('/api/calendar');
    if (!res.ok) throw new Error(`Server ${res.status}`);
    const data = await res.json();
    if (data.error) throw new Error(data.error);
    if (!Array.isArray(data)) {
      wlLog.warn('fetchAndRenderCalendar: response is not an array — skipping render', data);
      return;
    }
    // Normalise first: an untitled Outlook appointment arrives with no subject,
    // which the validator would reject even though its times are perfectly good.
    const meetings = data.map(normalizeCalendarMeeting);
    const invalidMeetings = meetings.filter((m) => !validCalendarMeeting(m));
    if (invalidMeetings.length) {
      wlLog.warn(
        `fetchAndRenderCalendar: dropped ${invalidMeetings.length} malformed meeting(s)`,
        invalidMeetings
      );
    }
    const validMeetings = meetings.filter(validCalendarMeeting);
    _calMeetingsCache = validMeetings;
    renderCalStrip(visibleMeetings(validMeetings));
  } catch (err) {
    console.warn('[wl] Calendar unavailable:', err.message);
    const el = document.getElementById('calMeetings');
    if (el)
      el.innerHTML = `<div class="cal-empty" title="${escHtml(err.message)}">📅 Calendar unavailable — restart server with Outlook open</div>`;
    const sec = document.getElementById('calSection');
    if (sec) sec.style.display = '';
  }
}

// Fetch from server every 10 minutes; re-render from cache every minute
// so past/now/upcoming states update without hammering the server
fetchAndRenderCalendar();
setInterval(fetchAndRenderCalendar, 10 * 60 * 1000);
setInterval(() => {
  if (!_calMeetingsCache) return;
  const filteredData = visibleMeetings(_calMeetingsCache);
  renderCalStrip(filteredData);
}, 60 * 1000);

loadParked();
renderParked();

// Test harness — only active when ?test=1 in URL
if (new URLSearchParams(window.location.search).get('test') === '1') {
  window.__wl = {
    roundToNearest30,
    dk,
    getISOWeek,
    totalISOWeeks,
    entries: getEntries(),
    categories: getCategories(),
    planTasks: getPlanTasks(),
    blocks: getBlocks(),
    activeTimer: () => getActiveTimer(),
    getTimerInterval,
    getSelectedTag,
    tbStartBlock,
    load,
    save,
    savePlan,
    loadPlan,
    loadParked,
    autoCarryTasks,
    patchCarriedTasks,
    render,
    renderPlan,
    renderCompleted,
    renderCalStrip,
    calendarMeetingKey,
    isMeetingHidden,
    renderParked,
    openEodModal,
    parkedThoughts,
    startTimer,
    stopTimer,
    pauseTimer,
    getCat,
    escHtml,
    renderDistractionCount,
    getIterationExpiry,
    loadExpiryDates,
    exportTxt,
    exportBackup,
    importBackup,
    validateBackupFile,
    getHook,
    saveHook,
    getState: () => ({
      entries: getEntries(),
      categories: getCategories(),
      planTasks: getPlanTasks(),
      blocks: getBlocks(),
      activeTimer: getActiveTimer(),
      logNotes: getLogNotes(),
      trackers: getTrackers(),
    }),
    cycleSignifier,
    isEntryBillable,
    addLogNote,
    openSprintSetup,
    getSprintLog: () => sprintLog,
    renderTrackers,
    trackerDayStatus,
    saveTrackers,
    getTrackers,
    renderMonthlyLog,
    mlHoursForDay,
    openMigration,
    getMigrationRecord,
    setFlowView,
    getFlowView,
    renderTodayFlow,
    initTodayFlow,
    findLargestGap,
    // Pomodoro — exposed for smoke tests
    initPomo,
    startPomo,
    pausePomo,
    pomoAddTime,
    pomoTapOut,
  };
  // Live viewDate getter/setter so tests can change the view date
  // and renderCompleted re-runs automatically
  Object.defineProperty(window.__wl, 'viewDate', {
    get: () => getViewDate(),
    set: (v) => {
      setViewDate(v instanceof Date ? v : new Date(v));
      renderCompleted();
    },
    enumerable: true,
    configurable: true,
  });
}