Source: 13-calendar.js

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

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

/**
 * 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. "LähiTapiola"), 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)) {
    if (key.toLowerCase() === lower) return CAL_ACCOUNT_LABELS[key];
  }
  // 2. Email-style: extract second-level domain (e.g. "x@gofore.com" → "gofore")
  const emailMatch = lower.match(/@([^.@\s]+)\./);
  if (emailMatch && CAL_ACCOUNT_LABELS[emailMatch[1]]) return CAL_ACCOUNT_LABELS[emailMatch[1]];
  // 3. Substring match (e.g. "Gofore Mailbox" contains "gofore")
  for (const key of Object.keys(CAL_ACCOUNT_LABELS)) {
    if (lower.includes(key.toLowerCase())) return CAL_ACCOUNT_LABELS[key];
  }
  return null;
}

/**
 * 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 = `<button class="cal-task-btn" data-subject="${escHtml(ev.subject)}">▶ start</button>`;
      const deleteBtn = `<button class="cal-delete-btn" data-meeting-idx="${idx}" title="Hide this meeting">✕</button>`;
      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());
      // 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 : selectedTag;
      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 (activeTimer) stopTimer();
      const entry = {
        id: Date.now() + '',
        text: subject,
        tag: meetingTag,
        ts: safeRoundedStart(),
        date: todayKey,
      };
      entries.push(entry);
      const task = planTasks.find(
        (t) => t.date === todayKey && t.text.toLowerCase() === subject.toLowerCase()
      );
      if (task && task.status === 'todo') {
        task.status = 'inprogress';
        savePlan();
      }
      viewDate = new Date();
      save();
      startTimer(entry.id);
      render();
    });
  });

  // Wire up delete buttons
  const todayKey = dk(new Date());
  const hiddenMeetings = (() => {
    try {
      return JSON.parse(localStorage.getItem('wl_hidden_meetings_' + todayKey) || '[]');
    } catch (e) {
      return [];
    }
  })();

  el.querySelectorAll('.cal-delete-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      const idx = parseInt(btn.dataset.meetingIdx);
      const meeting = meetings[idx];
      if (meeting) {
        // Add to hidden list
        if (!hiddenMeetings.includes(meeting.subject)) {
          hiddenMeetings.push(meeting.subject);
          localStorage.setItem('wl_hidden_meetings_' + todayKey, JSON.stringify(hiddenMeetings));
        }
        // Remove from display
        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 (!section._collapseRestored) {
    section._collapseRestored = true;
    section.classList.toggle('collapsed', readCollapseState('calSection', false));
  }

  // Collapsible header
  const hdr = document.getElementById('calHeader');
  if (hdr && !hdr._calBound) {
    hdr._calBound = true;
    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;
    }
    const invalidMeetings = data.filter((m) => !validCalendarMeeting(m));
    if (invalidMeetings.length) {
      wlLog.warn(
        `fetchAndRenderCalendar: dropped ${invalidMeetings.length} malformed meeting(s)`,
        invalidMeetings
      );
    }
    const validMeetings = data.filter(validCalendarMeeting);
    _calMeetingsCache = validMeetings;

    // Filter out hidden meetings for today
    const todayKey = dk(new Date());
    const hiddenMeetings = (() => {
      try {
        return JSON.parse(localStorage.getItem('wl_hidden_meetings_' + todayKey) || '[]');
      } catch (e) {
        return [];
      }
    })();
    const filteredData = validMeetings.filter((m) => !hiddenMeetings.includes(m.subject));

    renderCalStrip(filteredData);
  } 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 = '';
  }
}

// ── Transition bridge (Feature 3) ──
const STORE_SEEN_ENDED = 'wl_seen_ended_v1';

/**
 * Returns the set of meeting keys (`subject|start`) that have already triggered
 * a bridge banner in this session, loaded from localStorage.
 * @returns {Set<string>}
 */
function getSeenEnded() {
  try {
    return new Set(JSON.parse(localStorage.getItem(STORE_SEEN_ENDED) || '[]'));
  } catch (e) {
    return new Set();
  }
}
/**
 * Persists the set of seen-ended meeting keys to localStorage.
 * @param {Set<string>} s - Updated set to persist.
 */
function setSeenEnded(s) {
  localStorage.setItem(STORE_SEEN_ENDED, JSON.stringify([...s]));
}

/**
 * Returns a stable string key for a meeting used to deduplicate bridge banners.
 * @param {{subject: string, start: string}} m - Meeting object.
 * @returns {string}
 */
function getMeetingKey(m) {
  return `${m.subject}|${m.start}`;
}

const bannerQueue = [];
let bannerShowing = false;

/**
 * Shows the post-meeting bridge banner for the given meeting.
 * Queues the meeting if another banner is already visible.
 * @param {{subject: string, start: string, end: string}} meeting - The ended meeting.
 */
function showBridgeBanner(meeting) {
  if (bannerShowing) {
    bannerQueue.push(meeting);
    return;
  }
  bannerShowing = true;
  const banner = document.getElementById('newdayBanner');
  const msg = document.getElementById('newdayMsg');
  const expanded = document.getElementById('newdayExpanded');
  const bridgeBtn = document.getElementById('newdayBridgeBtn');
  const dismissBtn = document.getElementById('newdayDismiss');
  if (!banner || !msg) {
    bannerShowing = false;
    return;
  }

  msg.textContent = `Just finished "${meeting.subject || '(untitled)'}" — build a bridge to your next thing?`;
  expanded.innerHTML = '';
  expanded.style.display = 'none';
  banner.classList.add('show');

  const onDismiss = () => {
    banner.classList.remove('show');
    bannerShowing = false;
    if (bannerQueue.length) showBridgeBanner(bannerQueue.shift());
  };
  dismissBtn.onclick = onDismiss;
  bridgeBtn.onclick = async (e) => {
    e.stopPropagation();
    await buildBridge(meeting, expanded, bridgeBtn, onDismiss);
  };
}

/**
 * Determines the next task to transition to and delegates to {@link fetchBridge}.
 * If multiple tasks are in-flight the user picks from a list; a single in-progress
 * task is auto-selected; the only remaining task is auto-selected.
 * @param {{subject: string}} meeting  - The meeting that just ended.
 * @param {HTMLElement} expandedEl     - Container for the bridge content.
 * @param {HTMLElement} bridgeBtn      - "Build bridge" button (disabled during fetch).
 * @param {Function}    onDismiss      - Callback to dismiss the banner.
 * @returns {Promise<void>}
 */
async function buildBridge(meeting, expandedEl, bridgeBtn, onDismiss) {
  const todayKey = dk(new Date());
  const notDone = planTasks.filter((t) => t.date === todayKey && t.status !== 'done');
  const inProgress = notDone.filter((t) => t.status === 'inprogress');

  let nextTask = null;
  if (inProgress.length) {
    nextTask = inProgress[0];
  } else if (notDone.length === 1) {
    nextTask = notDone[0];
  } else if (notDone.length > 1) {
    expandedEl.innerHTML = '<div style="font-size:11px;margin-bottom:6px">Pick next task:</div>';
    const list = document.createElement('div');
    list.style.cssText = 'display:flex;flex-direction:column;gap:4px';
    notDone.forEach((t) => {
      const b = document.createElement('button');
      b.style.cssText =
        'font-size:11px;padding:4px 8px;background:var(--bg2);border:0.5px solid var(--border);border-radius:var(--radius);cursor:pointer;text-align:left;color:var(--text2)';
      b.textContent = t.text;
      b.onclick = async () => {
        list.style.display = 'none';
        await fetchBridge(meeting, t, expandedEl, bridgeBtn, onDismiss);
      };
      list.appendChild(b);
    });
    expandedEl.appendChild(list);
    expandedEl.style.display = 'block';
    return;
  }

  if (!nextTask) {
    expandedEl.textContent = 'No next task found for today.';
    expandedEl.style.display = 'block';
    return;
  }
  await fetchBridge(meeting, nextTask, expandedEl, bridgeBtn, onDismiss);
}

/**
 * Calls the Claude API via `/api/ai` to generate 3 concrete physical steps for
 * transitioning from the ended meeting to the next task. Displays the result in
 * `expandedEl`; falls back to copying the prompt to the clipboard on API error.
 * @param {{subject: string}} meeting  - The meeting that just ended.
 * @param {{text: string}}    task     - The next plan task to transition to.
 * @param {HTMLElement} expandedEl     - Container for the bridge content.
 * @param {HTMLElement} bridgeBtn      - "Build bridge" button (disabled during fetch).
 * @param {Function}    onDismiss      - Callback to dismiss the banner.
 * @returns {Promise<void>}
 */
async function fetchBridge(meeting, task, expandedEl, bridgeBtn, onDismiss) {
  const meetingSubject = meeting.subject || '(untitled)';
  const taskText = task.text || '(untitled)';
  const prompt = `Meeting just finished: "${meetingSubject}"\nNext task to start: "${taskText}"\n\nProvide exactly 3 concrete physical steps to transition from this meeting to starting the task. Each step specific and actionable. Total time: ~3 min. No preamble, no numbering, no labels, plain text only.`;

  expandedEl.innerHTML = '<div style="font-size:11px;color:var(--text3)">thinking…</div>';
  expandedEl.style.display = 'block';
  bridgeBtn.disabled = true;

  try {
    const response = await fetch('/api/ai', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: 'claude-haiku-4-5-20251001',
        max_tokens: 600,
        system:
          'You help ADHD users switch between tasks smoothly. Reply with exactly 3 concrete physical steps, no preamble, no numbering, no labels. Plain text separated by line breaks.',
        messages: [{ role: 'user', content: prompt }],
      }),
    });
    if (!response.ok) throw new Error(`API error: ${response.status}`);
    const data = await response.json();
    const bridgeText = data.content?.[0]?.text || '';
    if (!bridgeText) throw new Error('No content in response');
    expandedEl.textContent = bridgeText;
  } catch (err) {
    navigator.clipboard.writeText(prompt).catch(() => {});
    expandedEl.innerHTML =
      '<span style="color:var(--red,#e74c3c)">AI unavailable — prompt copied to clipboard. (Set AnthropicApiKey in config.local.ps1)</span>';
  }
  bridgeBtn.disabled = false;
}

// 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 todayKey = dk(new Date());
  const hiddenMeetings = (() => {
    try {
      return JSON.parse(localStorage.getItem('wl_hidden_meetings_' + todayKey) || '[]');
    } catch (e) {
      return [];
    }
  })();
  const filteredData = _calMeetingsCache.filter((m) => !hiddenMeetings.includes(m.subject));
  renderCalStrip(filteredData);

  // Detect newly-ended meetings and offer a bridge
  const seen = getSeenEnded();
  const now = new Date();
  filteredData.forEach((m) => {
    const key = getMeetingKey(m);
    const endTime = new Date(m.end);
    if (endTime > now || seen.has(key)) return;
    seen.add(key);
    const nextTooSoon = filteredData.some((other) => {
      const diff = (new Date(other.start) - endTime) / 60000;
      return diff > 0 && diff < 10;
    });
    if (!nextTooSoon) showBridgeBanner(m);
  });
  setSeenEnded(seen);
}, 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,
    categories,
    planTasks,
    blocks,
    activeTimer: () => activeTimer,
    load,
    save,
    savePlan,
    loadPlan,
    loadParked,
    autoCarryTasks,
    patchCarriedTasks,
    render,
    renderPlan,
    renderCompleted,
    renderCalStrip,
    renderParked,
    openEodModal,
    parkedThoughts,
    startTimer,
    stopTimer,
    pauseTimer,
    getCat,
    escHtml,
    renderDistractionCount,
    getIterationExpiry,
    loadExpiryDates,
    exportTxt,
    exportBackup,
    importBackup,
    validateBackupFile,
    getHook,
    saveHook,
    _showBridgeBanner: showBridgeBanner,
    getState: () => ({ entries, categories, planTasks, blocks, activeTimer, logNotes, trackers }),
    cycleSignifier,
    isEntryBillable,
    addLogNote,
    openReflection,
    getReflectionForDate,
    openSprintSetup,
    getSprintLog: () => sprintLog,
    renderTrackers,
    trackerDayStatus,
    saveTrackers,
    getTrackers: () => trackers,
    renderMonthlyLog,
    mlHoursForDay,
    openMigration,
    getMigrationRecord,
    setFlowView,
    getFlowView,
    renderTodayFlow,
    initTodayFlow,
    findLargestGap,
    // Pomodoro — exposed for smoke tests
    initPomo,
    startPomo,
    pausePomo,
    pomoAddTime,
    pomoTapOut,
    // Header tracking — exposed for smoke tests
    updateHeaderTracking,
  };
  // Live viewDate getter/setter so tests can change the view date
  // and renderCompleted re-runs automatically
  Object.defineProperty(window.__wl, 'viewDate', {
    get: () => viewDate,
    set: (v) => {
      viewDate = v instanceof Date ? v : new Date(v);
      renderCompleted();
    },
    enumerable: true,
    configurable: true,
  });
}