ChatGPT Insight Tracker

Extract insights from your ChatGPT usage and export them

// ==UserScript==
// @name         ChatGPT Insight Tracker
// @namespace    https://greasyfork.org/en/users/1019658-aayush-dutt
// @version      1.0
// @description  Extract insights from your ChatGPT usage and export them
// @author       aayushdutt
// @match        https://chatgpt.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @run-at       document-start
// @all-frames   true
// @license      MIT
// @link
// ==/UserScript==

(function () {
  "use strict";
  // Prevent multi-frame double injection; only run in top window
  if (unsafeWindow.top !== unsafeWindow.self) {
    return;
  }

  class Config {
    static STORAGE_KEY = "chatgpt_usage_logs";
    static SETTINGS_KEY = "chatgpt_tracker_settings";
    static TARGET_URL = "https://chatgpt.com/backend-api/f/conversation";
    static DEFAULT_SETTINGS = {
      truncatePrompt: true,
      truncatePromptLen: 160,
      anonymizePrompt: false,
      retention: { maxCount: null, maxDays: null },
    };
    static HEADER_BUTTON_ID = "usage-logs-header-button";
  }

  class SettingsService {
    async get() {
      const s = await GM_getValue(Config.SETTINGS_KEY, Config.DEFAULT_SETTINGS);
      return {
        ...Config.DEFAULT_SETTINGS,
        ...(s || {}),
        retention: {
          ...Config.DEFAULT_SETTINGS.retention,
          ...(s?.retention || {}),
        },
      };
    }
    async set(next) {
      await GM_setValue(Config.SETTINGS_KEY, next);
    }
  }

  class LogsService {
    constructor(settings) {
      this.settings = settings;
    }
    async getAll() {
      const logs = await GM_getValue(Config.STORAGE_KEY, []);
      return (Array.isArray(logs) ? logs : []).map((l) => ({
        timestamp: l.timestamp || new Date().toISOString(),
        model: l.model ?? null,
        prompt: l.prompt ?? "",
        conversationId: l.conversationId ?? null,
        durations: l.durations ?? { msToFirstToken: null, msTotal: null },
      }));
    }
    async save(entry) {
      const logs = await this.getAll();
      logs.push(entry);
      const settings = await this.settings.get();
      const rawMaxDays = settings.retention?.maxDays;
      const maxDays =
        rawMaxDays == null || rawMaxDays === "" ? null : Number(rawMaxDays);
      const byAge = (() => {
        if (!maxDays || isNaN(maxDays) || maxDays <= 0) return logs;
        const cutoff = Date.now() - maxDays * 24 * 60 * 60 * 1000;
        return logs.filter((l) => {
          const t = Date.parse(l.timestamp);
          return isNaN(t) ? true : t >= cutoff;
        });
      })();
      const rawMaxCount = settings.retention?.maxCount;
      const maxCount =
        rawMaxCount == null || rawMaxCount === "" ? null : Number(rawMaxCount);
      const trimmed =
        !maxCount || isNaN(maxCount) || maxCount <= 0
          ? byAge
          : byAge.slice(-maxCount);
      await GM_setValue(Config.STORAGE_KEY, trimmed);
    }
    async clearAndRefreshUI() {
      await GM_setValue(Config.STORAGE_KEY, []);
      const existingModal = document.getElementById("usage-modal-container");
      if (existingModal) existingModal.remove();
      // UI will re-open via handler
    }
  }

  function csvEscape(val) {
    const s = String(val ?? "");
    if (s.includes("\n") || s.includes(",") || s.includes('"')) {
      return '"' + s.replace(/"/g, '""') + '"';
    }
    return s;
  }
  function safeStr(v) {
    return v == null ? "" : String(v);
  }
  function downloadBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      URL.revokeObjectURL(url);
      a.remove();
    }, 0);
  }

  class StreamProcessor {
    constructor(settings, logs) {
      this.settings = settings;
      this.logs = logs;
    }
    async process(response, requestMeta) {
      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      let lineBuffer = "";
      let userPrompt = null;
      let modelSlug = null;
      let defaultModelSlug = null;
      let conversationId = null;
      let serverMeta = null;
      let tFirstToken = null;
      let tDone = null;

      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          const chunk = decoder.decode(value, { stream: true });
          lineBuffer += chunk;
          const lines = lineBuffer.split("\n");
          lineBuffer = lines.pop() || "";
          for (const rawLine of lines) {
            const line = rawLine.trim();
            if (!line || !line.startsWith("data:")) continue;
            const dataStr = line.slice(5).trim();
            if (dataStr === "[DONE]") {
              tDone = tDone || Date.now();
              continue;
            }
            try {
              const data = JSON.parse(dataStr);
              if (data.type === "input_message") {
                conversationId = conversationId || data.conversation_id || null;
                const parts0 = data.input_message?.content?.parts?.[0];
                if (
                  !userPrompt &&
                  typeof parts0 === "string" &&
                  parts0.length
                ) {
                  userPrompt = parts0;
                }
              } else if (data.type === "server_ste_metadata") {
                conversationId = conversationId || data.conversation_id || null;
                serverMeta = data.metadata || serverMeta;
                if (serverMeta) {
                  modelSlug = modelSlug || serverMeta.model_slug || null;
                  defaultModelSlug =
                    defaultModelSlug || serverMeta.default_model_slug || null;
                }
              } else if (data.type === "message_marker") {
                if (
                  data.marker === "user_visible_token" &&
                  data.event === "first"
                ) {
                  tFirstToken = tFirstToken || Date.now();
                }
              } else if (data.type === "message_stream_complete") {
                tDone = tDone || Date.now();
              }
              if (data.v) {
                conversationId = conversationId || data.conversation_id || null;
                const vMsg = data.v.message;
                if (vMsg && !modelSlug && vMsg.metadata?.model_slug) {
                  modelSlug = vMsg.metadata.model_slug;
                }
              }
            } catch (_) {}
          }
        }

        const tRequest = requestMeta?.requestStartTs || Date.now();
        const msToFirstToken = tFirstToken ? tFirstToken - tRequest : null;
        const msTotal = (tDone || Date.now()) - tRequest;

        const settings = await this.settings.get();
        let promptForLog = (userPrompt || "").trim();
        const twoLines = promptForLog.split(/\r?\n/).slice(0, 2).join("\n");
        promptForLog = twoLines;
        if (settings.anonymizePrompt) {
          promptForLog = "[anonymized]";
        } else if (
          settings.truncatePrompt &&
          promptForLog.length > settings.truncatePromptLen
        ) {
          promptForLog = `${promptForLog.slice(
            0,
            settings.truncatePromptLen
          )}...`;
        }

        const resolvedModel = modelSlug || defaultModelSlug || null;
        if (!promptForLog || !resolvedModel) {
          return;
        }

        const logEntry = {
          timestamp: new Date().toISOString(),
          model: resolvedModel,
          prompt: promptForLog,
          conversationId,
          durations: { msToFirstToken, msTotal },
        };

        await this.logs.save(logEntry);
      } catch (error) {
        console.error("ChatGPT Tracker: Error processing stream:", error);
        try {
          reader.cancel();
        } catch (_) {}
      }
    }
  }

  class FetchInterceptor {
    constructor(streamProcessor) {
      this.streamProcessor = streamProcessor;
      this._installed = false;
      this._originalFetch = null;
    }
    install() {
      if (this._installed) return;
      this._installed = true;
      this._originalFetch = unsafeWindow.fetch;
      const self = this;
      unsafeWindow.fetch = async function (...args) {
        const url = typeof args[0] === "string" ? args[0] : args[0].url;
        if (!url || !String(url).startsWith(Config.TARGET_URL)) {
          return self._originalFetch.apply(this, args);
        }
        console.log("ChatGPT Tracker: Intercepted conversation request.");
        try {
          const requestStartTs = Date.now();
          const response = await self._originalFetch.apply(this, args);
          const cloned = response.clone();
          self.streamProcessor.process(cloned, { requestStartTs });
          return response;
        } catch (err) {
          console.error(
            "ChatGPT Tracker: Error intercepting fetch request:",
            err
          );
          return self._originalFetch.apply(this, args);
        }
      };
    }
  }

  function injectStyles() {
    GM_addStyle(`
        #usage-modal-container {
            position: fixed;
            z-index: 99999;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
            overflow: auto;
            background-color: rgba(0, 0, 0, 0.6);
            display: flex;
            align-items: center;
            justify-content: center;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            --color-bg: #202123;
            --color-surface: #2a2b32;
            --color-border: #3a3b44;
            --color-chip-border: rgba(255,255,255,0.35);
            --color-text: #e6e6e7;
            --color-muted: #b2b3b8;
            --color-primary: #e6e6e7;
            --color-primary-hover: #d4d4d8;
            --color-danger: #ef4444;
            --color-button-bg: #2d2f33;
            --color-button-hover: #3a3d42;
            --color-input-bg: #202123;
            --color-input-border: #3f4145;
            --color-focus-ring: rgba(230, 230, 231, 0.35);
        }

        #usage-modal-content {
            background-color: var(--color-surface);
            color: var(--color-text);
            margin: auto;
            padding: 24px;
            border: 1px solid var(--color-border);
            border-radius: 12px;
            width: 80%;
            max-width: 900px;
            max-height: 85vh;
            display: flex;
            flex-direction: column;
            box-shadow: 0 5px 15px rgba(0,0,0,0.5);
        }

        .modal-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding-bottom: 16px;
            border-bottom: 1px solid var(--color-border);
        }
        .modal-actions { display: inline-flex; gap: 8px; align-items: center; margin-left: auto; }
        .modal-button, .dropdown > .modal-button { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
        .modal-button svg { flex: 0 0 auto; }
        .dropdown > .modal-button svg { margin-left: 0; }

        .modal-header h2 {
            margin: 0;
            font-size: 20px;
        }

        .modal-button {
            border: none;
            background-color: var(--color-button-bg);
            color: var(--color-text);
            padding: 8px 12px;
            border-radius: 6px;
            cursor: pointer;
            transition: background-color 0.2s;
            border: 1px solid var(--color-border);
        }

        .modal-button.primary {
            background-color: var(--color-primary);
            border-color: transparent;
            color: #0b1613;
        }

        .modal-button.primary:hover {
            background-color: var(--color-primary-hover);
        }

        .modal-button.clear {
            background-color: transparent;
            color: var(--color-danger);
            border: 1px solid var(--color-danger);
        }

        .modal-button.clear:hover {
            background-color: transparent;
            color: #f87171;
            border-color: #f87171;
        }

        .modal-button.close {
            font-size: 24px;
            font-weight: bold;
            line-height: 1;
            padding: 4px 10px;
            background: transparent;
            color: var(--color-muted);
            border: none;
        }

        .modal-button:hover {
            background-color: var(--color-button-hover);
        }

        .modal-button:focus-visible {
            outline: none;
            box-shadow: 0 0 0 2px var(--color-focus-ring);
        }

        .modal-button.active {
            background: rgba(255, 255, 255, 0.12);
            border-color: #6b7280;
        }

        .dropdown { position: relative; }
        .dropdown-menu {
            position: absolute;
            top: calc(100% + 6px);
            right: 0;
            min-width: 140px;
            background: var(--color-bg);
            border: 1px solid var(--color-border);
            border-radius: 8px;
            padding: 6px;
            display: none;
            box-shadow: 0 8px 20px rgba(0,0,0,0.35);
            z-index: 100000;
        }
        .dropdown-menu.open { display: block; }
        .dropdown-item {
            background: transparent;
            color: var(--color-text);
            border: none;
            border-radius: 6px;
            padding: 8px 10px;
            width: 100%;
            text-align: left;
            cursor: pointer;
        }
        .dropdown-item:hover { background: var(--color-button-hover); }

        #usage-logs-header-button {
            border: 1px solid rgba(255, 255, 255, 0.15);
        }
        #usage-logs-header-button:hover,
        #usage-logs-header-button:focus-visible {
            border-color: rgba(255, 255, 255, 0.4);
        }

        .table-container { margin-top: 16px; overflow: auto; min-height: 0; flex: 1 1 auto; }
        .table-controls { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
        .model-filter { min-width: 150px; padding-right: 34px; background-position: right 8px center; }
        .summary { margin-top: 12px; }
        .tabs { display: flex; gap: 6px; flex-wrap: wrap; }
        .tabs .tab { background: var(--color-button-bg); color: var(--color-text); border: 1px solid var(--color-border); border-radius: 999px; padding: 4px 9px; cursor: pointer; transition: background-color 0.15s, border-color 0.15s; }
        .tabs .tab:hover { background: rgba(255, 255, 255, 0.08); border-color: #6b7280; }
        .tabs .tab.active { background: rgba(255, 255, 255, 0.12); border-color: #6b7280; color: var(--color-text); }
        .summary-content { margin-top: 10px; }
        .summary-content .cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
        .summary-content .card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 10px; padding: 10px 12px; box-shadow: 0 0 0 1px rgba(255,255,255,0.03) inset; }
        .summary-content .card .label { color: var(--color-muted); font-size: 12px; margin-bottom: 4px; }
        .summary-content .card .value { font-size: 18px; font-weight: 600; }
        .sparkline { margin-top: 10px; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px; }
        .sparkline canvas { width: 100%; height: 60px; display: block; }
        .view-tabs { margin-top: 12px; }
        .model-cards { display: grid; grid-template-columns: repeat( auto-fit, minmax(220px, 1fr) ); gap: 12px; margin-top: 8px; }
        .model-card .model-title { margin-bottom: 8px; }
        .model-card .model-metrics { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 6px; }
        .modal-input, .modal-select { background-color: var(--color-input-bg); color: var(--color-text); border: 1px solid var(--color-input-border); border-radius: 8px; padding: 8px 10px; transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s; }
        .input-w-80 { width: 80px; }
        .input-w-100 { width: 100px; }
        .modal-input::placeholder { color: var(--color-muted); }
        .modal-input:focus, .modal-select:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-focus-ring); }
        #usage-modal-content table { width: 100%; border-collapse: collapse; }
        .logs-view { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
        #usage-modal-content th, #usage-modal-content td { border-bottom: 1px solid var(--color-border); padding: 12px 8px; text-align: left; vertical-align: top; }
        #usage-modal-content th { font-weight: 600; position: sticky; top: 0; background-color: var(--color-surface); }
        #usage-modal-content td { font-size: 14px; }
        .model-slug { background-color: rgba(255, 255, 255, 0.08); color: var(--color-text); padding: 4px 8px; border-radius: 12px; font-family: monospace; font-size: 0.9em; white-space: nowrap; border: 1px solid var(--color-chip-border); box-shadow: 0 0 0 1px rgba(255,255,255,0.06) inset; }
        .prompt-cell { white-space: pre-wrap; word-break: break-word; max-width: 450px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
        .conv-cell { font-family: monospace; font-size: 12px; color: var(--color-muted); max-width: 200px; word-break: break-all; }
        .conv-link { color: var(--color-muted); text-decoration: underline dotted; text-underline-offset: 2px; cursor: pointer; }
        .conv-link:hover, .conv-link:focus-visible { text-decoration: underline; color: var(--color-text); }
        .conv-link:focus-visible { outline: none; }
        #usage-modal-content tbody tr:hover { background-color: rgba(255, 255, 255, 0.03); }
        #usage-modal-content, #usage-modal-content .table-container, #usage-modal-content .dropdown-menu { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.25) transparent; }
        #usage-modal-content::-webkit-scrollbar, #usage-modal-content .table-container::-webkit-scrollbar, #usage-modal-content .dropdown-menu::-webkit-scrollbar { width: 10px; height: 10px; }
        #usage-modal-content::-webkit-scrollbar-track, #usage-modal-content .table-container::-webkit-scrollbar-track, #usage-modal-content .dropdown-menu::-webkit-scrollbar-track { background: transparent; }
        #usage-modal-content::-webkit-scrollbar-thumb, #usage-modal-content .table-container::-webkit-scrollbar-thumb, #usage-modal-content .dropdown-menu::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.25); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
        #usage-modal-content::-webkit-scrollbar-thumb:hover, #usage-modal-content .table-container::-webkit-scrollbar-thumb:hover, #usage-modal-content .dropdown-menu::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.35); }
        .settings-panel { margin: 8px 0 0 0; padding: 16px; border: 1px solid var(--color-border); border-radius: 10px; background: var(--color-bg); display: flex; flex-direction: column; gap: 12px; }
        .settings-row { display: flex; gap: 12px; align-items: center; margin: 0; flex-wrap: wrap; }
        .settings-row label { display: flex; gap: 6px; align-items: center; color: var(--color-text); }
        .settings-panel input[type="number"] { background: var(--color-input-bg); color: var(--color-text); border: 1px solid var(--color-input-border); border-radius: 8px; padding: 6px 8px; }
        .settings-panel input[type="number"]:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-focus-ring); }
        .settings-panel input[type="checkbox"] { accent-color: var(--color-primary); }
      `);
  }

  class UiManager {
    constructor(settings, logs) {
      this.settings = settings;
      this.logs = logs;
    }
    async showModal() {
      const existingModal = document.getElementById("usage-modal-container");
      if (existingModal) existingModal.remove();
      const logs = await this.logs.getAll();
      const container = document.createElement("div");
      container.id = "usage-modal-container";
      const modal = document.createElement("div");
      modal.id = "usage-modal-content";
      const header = document.createElement("div");
      header.className = "modal-header";
      const title = document.createElement("h2");
      title.textContent = "ChatGPT Usage Logs";
      const clearButton = document.createElement("button");
      clearButton.className = "modal-button clear";
      clearButton.innerHTML = `
        <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
          <path d="M9 4h6a1 1 0 011 1v1h4a1 1 0 110 2h-1.08l-1.3 11.05A2 2 0 0115.63 21H8.37a2 2 0 01-1.99-1.95L5.08 8H4a1 1 0 110-2h4V5a1 1 0 011-1zm2 0v1h2V4h-2zM7.1 8l1.2 10h7.4l1.2-10H7.1z"/>
        </svg>
        Clear All Logs`;
      clearButton.onclick = async () => {
        if (
          confirm(
            "Are you sure you want to delete all usage logs? This cannot be undone."
          )
        ) {
          await this.logs.clearAndRefreshUI();
          container.remove();
          await this.showModal();
        }
      };
      const exportWrap = document.createElement("div");
      exportWrap.className = "dropdown";
      const exportBtn = document.createElement("button");
      exportBtn.className = "modal-button";
      exportBtn.innerHTML = `
        <span>Export</span>
        <svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
          <path d="M5.23 7.21a.75.75 0 011.06.02L10 11.126l3.71-3.896a.75.75 0 011.08 1.04l-4.24 4.46a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"/>
        </svg>
      `;
      const exportMenu = document.createElement("div");
      exportMenu.className = "dropdown-menu";
      const exportJsonItem = document.createElement("button");
      exportJsonItem.className = "dropdown-item";
      exportJsonItem.textContent = "JSON";
      exportJsonItem.onclick = async () => this.exportLogs("json");
      const exportCsvItem = document.createElement("button");
      exportCsvItem.className = "dropdown-item";
      exportCsvItem.textContent = "CSV";
      exportCsvItem.onclick = async () => this.exportLogs("csv");
      exportMenu.appendChild(exportJsonItem);
      exportMenu.appendChild(exportCsvItem);
      exportWrap.appendChild(exportBtn);
      exportWrap.appendChild(exportMenu);
      let onExportDocClick = null;
      exportBtn.onclick = (e) => {
        e.stopPropagation();
        const isOpen = exportMenu.classList.toggle("open");
        if (isOpen) {
          onExportDocClick = (evt) => {
            if (!exportWrap.contains(evt.target)) {
              exportMenu.classList.remove("open");
              document.removeEventListener("click", onExportDocClick, true);
              onExportDocClick = null;
            }
          };
          setTimeout(
            () => document.addEventListener("click", onExportDocClick, true),
            0
          );
        } else if (onExportDocClick) {
          document.removeEventListener("click", onExportDocClick, true);
          onExportDocClick = null;
        }
      };
      const settingsBtn = document.createElement("button");
      settingsBtn.textContent = "Settings";
      settingsBtn.className = "modal-button";
      settingsBtn.id = "usage-settings-btn";
      settingsBtn.onclick = () => this.toggleSettingsPanel();
      const closeButton = document.createElement("button");
      closeButton.innerHTML = "&times;";
      closeButton.className = "modal-button close";
      closeButton.onclick = () => container.remove();
      header.appendChild(title);
      const actions = document.createElement("div");
      actions.className = "modal-actions";
      actions.appendChild(clearButton);
      actions.appendChild(exportWrap);
      actions.appendChild(settingsBtn);
      actions.appendChild(closeButton);
      header.appendChild(actions);
      const controls = document.createElement("div");
      controls.className = "table-controls";
      const modelSelect = document.createElement("select");
      modelSelect.className = "modal-select model-filter";
      const models = Array.from(
        new Set(logs.map((l) => l.model).filter(Boolean))
      );
      const allOpt = document.createElement("option");
      allOpt.value = "";
      allOpt.textContent = "All models";
      modelSelect.appendChild(allOpt);
      models.forEach((m) => {
        const opt = document.createElement("option");
        opt.value = m;
        opt.textContent = m;
        modelSelect.appendChild(opt);
      });
      const searchInput = document.createElement("input");
      searchInput.type = "search";
      searchInput.placeholder = "Search prompt...";
      searchInput.className = "modal-input";
      controls.appendChild(modelSelect);
      controls.appendChild(searchInput);
      const summary = document.createElement("div");
      summary.className = "summary";
      summary.innerHTML = `
        <div class="tabs summary-tabs" role="tablist">
          <button class="tab active" data-range="hour" role="tab">Last hour</button>
          <button class="tab" data-range="today" role="tab">Today</button>
          <button class="tab" data-range="week" role="tab">This week</button>
          <button class="tab" data-range="month" role="tab">This month</button>
          <button class="tab" data-range="all" role="tab">All time</button>
        </div>
        <div class="summary-content">
          <div class="cards">
            <div class="card"><div class="label">Prompts</div><div class="value" id="sum-prompts">0</div></div>
            <div class="card"><div class="label">Avg first token (ms)</div><div class="value" id="sum-first">-</div></div>
            <div class="card"><div class="label">Avg total (s)</div><div class="value" id="sum-total">-</div></div>
          </div>
          <div class="sparkline"><canvas id="sum-sparkline"></canvas></div>
          <div class="model-cards" id="sum-model-cards"></div>
        </div>
      `;
      const tabContainer = summary.querySelector(".summary-tabs");
      const promptsEl = summary.querySelector("#sum-prompts");
      const avgFirstEl = summary.querySelector("#sum-first");
      const avgTotalEl = summary.querySelector("#sum-total");
      const modelCards = summary.querySelector("#sum-model-cards");
      const sparkCanvas = summary.querySelector("#sum-sparkline");
      const drawSparkline = (rows) => {
        if (!sparkCanvas) return;
        const dpr = Math.max(1, Math.floor(unsafeWindow.devicePixelRatio || 1));
        const cssHeight = 60;
        const cssWidth = sparkCanvas.clientWidth || 600;
        const w = Math.max(150, cssWidth);
        const h = cssHeight;
        if (sparkCanvas.width !== w * dpr) sparkCanvas.width = w * dpr;
        if (sparkCanvas.height !== h * dpr) sparkCanvas.height = h * dpr;
        const ctx = sparkCanvas.getContext("2d");
        if (!ctx) return;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        // Baseline
        ctx.strokeStyle = "rgba(255,255,255,0.15)";
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(0, h - 12);
        ctx.lineTo(w, h - 12);
        ctx.stroke();
        const times = rows
          .map((l) => Date.parse(l.timestamp))
          .filter((t) => !isNaN(t))
          .sort((a, b) => a - b);
        if (times.length === 0) return;
        const minT = times[0];
        const maxT =
          times[times.length - 1] === minT ? minT + 1 : times[times.length - 1];
        const bins = Math.min(60, Math.max(10, Math.round(w / 12)));
        const counts = new Array(bins).fill(0);
        const span = maxT - minT;
        for (const t of times) {
          const r = (t - minT) / span;
          let idx = Math.floor(r * bins);
          if (idx >= bins) idx = bins - 1;
          if (idx < 0) idx = 0;
          counts[idx] += 1;
        }
        const maxC = counts.reduce((m, v) => (v > m ? v : m), 0) || 1;
        const padX = 6;
        const padY = 12;
        const innerW = w - padX * 2;
        const innerH = h - padY * 2;
        ctx.strokeStyle = "#e6e6e7";
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        for (let i = 0; i < bins; i++) {
          const x =
            padX + (bins === 1 ? innerW / 2 : (innerW * i) / (bins - 1));
          const y = padY + (1 - counts[i] / maxC) * innerH;
          if (i === 0) ctx.moveTo(x, y);
          else ctx.lineTo(x, y);
        }
        ctx.stroke();
      };
      const tableContainer = document.createElement("div");
      tableContainer.className = "table-container";
      const table = document.createElement("table");
      table.innerHTML = `
              <thead>
                  <tr>
                      <th>Timestamp</th>
                      <th>Model Used</th>
                      <th>First Token (ms)</th>
                      <th>Total (s)</th>
                      <th>Conversation</th>
                      <th>Prompt</th>
                  </tr>
              </thead>
          `;
      const tbody = document.createElement("tbody");
      const getFilteredLogs = () => {
        const q = (searchInput.value || "").toLowerCase();
        const mf = modelSelect.value || "";
        return logs
          .slice()
          .reverse()
          .filter((log) => (mf ? log.model === mf : true))
          .filter((log) =>
            q ? (log.prompt || "").toLowerCase().includes(q) : true
          );
      };
      const startOfToday = () => {
        const d = new Date();
        d.setHours(0, 0, 0, 0);
        return d;
      };
      const startOfWeekMonday = () => {
        const d = new Date();
        d.setHours(0, 0, 0, 0);
        const day = (d.getDay() + 6) % 7;
        d.setDate(d.getDate() - day);
        return d;
      };
      const startOfMonth = () => {
        const d = new Date();
        d.setHours(0, 0, 0, 0);
        d.setDate(1);
        return d;
      };
      const startOfHour = () => {
        const d = new Date();
        d.setMinutes(0, 0, 0);
        return d;
      };
      const summarizeLogs = (rows) => {
        if (!rows.length) {
          return { count: 0, avgFirstMs: null, avgTotalSec: null, byModel: {} };
        }
        let count = 0;
        let sumFirst = 0;
        let sumTotalSec = 0;
        const byModel = {};
        for (const log of rows) {
          count++;
          const first =
            typeof log.durations?.msToFirstToken === "number"
              ? log.durations.msToFirstToken
              : null;
          const totalSec =
            typeof log.durations?.msTotal === "number"
              ? log.durations.msTotal / 1000
              : null;
          if (first != null) sumFirst += first;
          if (totalSec != null) sumTotalSec += totalSec;
          const m = log.model || "(unknown)";
          const mStat = byModel[m] || {
            count: 0,
            sumFirst: 0,
            sumTotalSec: 0,
            haveFirst: 0,
            haveTotal: 0,
          };
          mStat.count++;
          if (first != null) {
            mStat.sumFirst += first;
            mStat.haveFirst++;
          }
          if (totalSec != null) {
            mStat.sumTotalSec += totalSec;
            mStat.haveTotal++;
          }
          byModel[m] = mStat;
        }
        const avgFirstMs = count ? Math.round(sumFirst / count) : null;
        const avgTotalSec = count
          ? Number((sumTotalSec / count).toFixed(2))
          : null;
        return { count, avgFirstMs, avgTotalSec, byModel };
      };
      const filterByRange = (rows, range) => {
        if (range === "all") return rows;
        const now = Date.now();
        let t0;
        if (range === "hour") t0 = +startOfHour();
        else if (range === "today") t0 = +startOfToday();
        else if (range === "week") t0 = +startOfWeekMonday();
        else if (range === "month") t0 = +startOfMonth();
        else return rows;
        return rows.filter((log) => {
          const t = Date.parse(log.timestamp);
          return !isNaN(t) && t >= t0 && t <= now;
        });
      };
      let activeRange = "hour";
      const renderSummary = () => {
        const rows = logs.slice().reverse();
        const inRange = filterByRange(rows, activeRange);
        const stats = summarizeLogs(inRange);
        promptsEl.textContent = String(stats.count);
        avgFirstEl.textContent =
          stats.avgFirstMs == null ? "-" : String(stats.avgFirstMs);
        avgTotalEl.textContent =
          stats.avgTotalSec == null ? "-" : String(stats.avgTotalSec);
        modelCards.innerHTML = "";
        const allModelKeys = Array.from(
          new Set(rows.map((l) => l.model || "(unknown)").filter(Boolean))
        );
        const entries = allModelKeys.map((model) => {
          const m = stats.byModel[model] || {
            count: 0,
            sumFirst: 0,
            sumTotalSec: 0,
            haveFirst: 0,
            haveTotal: 0,
          };
          return [model, m];
        });
        drawSparkline(inRange);
        if (!entries.length) {
          const empty = document.createElement("div");
          empty.className = "card";
          empty.textContent = "No data";
          modelCards.appendChild(empty);
          return;
        }
        for (const [model, m] of entries) {
          const avgF = m.haveFirst ? Math.round(m.sumFirst / m.haveFirst) : "-";
          const avgT = m.haveTotal
            ? (m.sumTotalSec / m.haveTotal).toFixed(2)
            : "-";
          const card = document.createElement("div");
          card.className = "card model-card";
          card.innerHTML = `
            <div class="model-title"><span class="model-slug">${model}</span></div>
            <div class="model-metrics">
              <div><span class="label">Prompts</span><div class="value">${m.count}</div></div>
              <div><span class="label">Avg first (ms)</span><div class="value">${avgF}</div></div>
              <div><span class="label">Avg total (s)</span><div class="value">${avgT}</div></div>
            </div>
          `;
          modelCards.appendChild(card);
        }
      };
      const renderRows = () => {
        tbody.innerHTML = "";
        const rows = getFilteredLogs();
        if (rows.length === 0) {
          tbody.innerHTML = '<tr><td colspan="6">No matching logs.</td></tr>';
          renderSummary();
          return;
        }
        renderSummary();
        rows.forEach((log) => {
          const tr = document.createElement("tr");
          const formattedDate = new Date(log.timestamp).toLocaleString();
          const firstMs = log.durations?.msToFirstToken ?? "-";
          const totalSec =
            typeof log.durations?.msTotal === "number"
              ? (log.durations.msTotal / 1000).toFixed(2)
              : "-";
          const conv = log.conversationId || "-";
          const convCell =
            conv !== "-"
              ? `<a href="https://chatgpt.com/c/${conv}" target="_blank" rel="noopener" class="conv-link">${conv}</a>`
              : "-";
          tr.innerHTML = `
                      <td>${formattedDate}</td>
                      <td><span class="model-slug">${
                        log.model ?? "-"
                      }</span></td>
                      <td>${firstMs}</td>
                      <td>${totalSec}</td>
                      <td class="conv-cell">${convCell}</td>
                      <td class="prompt-cell">${log.prompt || ""}</td>
                  `;
          tbody.appendChild(tr);
        });
      };
      if (logs.length === 0) {
        tbody.innerHTML =
          '<tr><td colspan="6">No usage data recorded yet. Start a new chat to begin tracking.</td></tr>';
        renderSummary();
      } else {
        renderRows();
      }
      table.appendChild(tbody);
      tableContainer.appendChild(table);
      controls.oninput = renderRows;
      modelSelect.onchange = renderRows;
      searchInput.oninput = renderRows;
      tabContainer.onclick = (e) => {
        const btn = e.target.closest(".tab");
        if (!btn) return;
        tabContainer
          .querySelectorAll(".tab")
          .forEach((t) => t.classList.remove("active"));
        btn.classList.add("active");
        activeRange = btn.getAttribute("data-range");
        renderRows();
      };
      const viewTabs = document.createElement("div");
      viewTabs.className = "tabs view-tabs";
      viewTabs.innerHTML = `
        <button class="tab active" data-view="summary">Summary</button>
        <button class="tab" data-view="logs">All logs</button>
      `;
      const logsView = document.createElement("div");
      logsView.className = "logs-view";
      logsView.appendChild(controls);
      logsView.appendChild(tableContainer);
      logsView.style.display = "none";
      viewTabs.onclick = (e) => {
        const btn = e.target.closest(".tab");
        if (!btn) return;
        viewTabs
          .querySelectorAll(".tab")
          .forEach((t) => t.classList.remove("active"));
        btn.classList.add("active");
        const view = btn.getAttribute("data-view");
        if (view === "summary") {
          summary.style.display = "";
          logsView.style.display = "none";
          renderSummary();
        } else {
          summary.style.display = "none";
          logsView.style.display = "";
          renderRows();
        }
      };
      modal.appendChild(header);
      modal.appendChild(viewTabs);
      modal.appendChild(summary);
      modal.appendChild(logsView);
      container.appendChild(modal);
      container.onclick = (e) => {
        if (e.target === container) container.remove();
      };
      document.body.appendChild(container);
    }
    async toggleSettingsPanel() {
      let panel = document.getElementById("usage-settings-panel");
      if (panel) {
        const btn = document.getElementById("usage-settings-btn");
        if (btn) btn.classList.remove("active");
        panel.remove();
        return;
      }
      const modal = document.getElementById("usage-modal-content");
      if (!modal) return;
      const s = await this.settings.get();
      panel = document.createElement("div");
      panel.id = "usage-settings-panel";
      panel.className = "settings-panel";
      panel.innerHTML = `
        <div class="settings-row">
          <label><input type="checkbox" id="set-truncate"> Truncate prompts</label>
          <input type="number" id="set-truncate-len" min="10" max="5000" class="input-w-80" />
        </div>
        <div class="settings-row">
          <label><input type="checkbox" id="set-anon"> Anonymize prompts</label>
        </div>
        <div class="settings-row">
          <label>Retention days <input type="number" id="set-days" min="1" max="365" class="input-w-80" /></label>
          <label>Max logs <input type="number" id="set-count" min="10" max="100000" class="input-w-100" /></label>
          <button class="modal-button" id="set-save">Save</button>
        </div>
      `;
      modal.insertBefore(panel, modal.children[1]);
      panel.querySelector("#set-truncate").checked = !!s.truncatePrompt;
      panel.querySelector("#set-truncate-len").value = s.truncatePromptLen;
      panel.querySelector("#set-anon").checked = !!s.anonymizePrompt;
      panel.querySelector("#set-days").value = s.retention?.maxDays ?? "";
      panel.querySelector("#set-count").value = s.retention?.maxCount ?? "";
      panel.querySelector("#set-save").onclick = async () => {
        const next = {
          truncatePrompt: panel.querySelector("#set-truncate").checked,
          truncatePromptLen:
            parseInt(panel.querySelector("#set-truncate-len").value, 10) ||
            Config.DEFAULT_SETTINGS.truncatePromptLen,
          anonymizePrompt: panel.querySelector("#set-anon").checked,
          retention: {
            maxDays: (() => {
              const v = panel.querySelector("#set-days").value;
              const n = Number(v);
              return !v || isNaN(n) || n <= 0 ? null : n;
            })(),
            maxCount: (() => {
              const v = panel.querySelector("#set-count").value;
              const n = Number(v);
              return !v || isNaN(n) || n <= 0 ? null : n;
            })(),
          },
        };
        await this.settings.set(next);
        alert("Settings saved. New logs will use updated settings.");
      };
      const btn = document.getElementById("usage-settings-btn");
      if (btn) btn.classList.add("active");
    }
    async exportLogs(kind) {
      const logs = await this.logs.getAll();
      if (kind === "json") {
        const blob = new Blob([JSON.stringify(logs, null, 2)], {
          type: "application/json",
        });
        downloadBlob(
          blob,
          `chatgpt-usage-logs-${new Date().toISOString()}.json`
        );
      } else if (kind === "csv") {
        const header = [
          "timestamp",
          "model",
          "msToFirstToken",
          "totalSeconds",
          "conversationId",
          "prompt",
        ];
        const rows = logs.map((l) =>
          [
            l.timestamp,
            safeStr(l.model),
            l.durations?.msToFirstToken ?? "",
            typeof l.durations?.msTotal === "number"
              ? (l.durations.msTotal / 1000).toFixed(2)
              : "",
            safeStr(l.conversationId),
            safeStr(l.prompt),
          ]
            .map(csvEscape)
            .join(",")
        );
        const csv = header.join(",") + "\n" + rows.join("\n");
        const blob = new Blob([csv], { type: "text/csv" });
        downloadBlob(
          blob,
          `chatgpt-usage-logs-${new Date().toISOString()}.csv`
        );
      }
    }
  }

  function createHeaderButton(ui) {
    const btn = document.createElement("button");
    btn.id = Config.HEADER_BUTTON_ID;
    btn.type = "button";
    btn.setAttribute("aria-label", "View usage logs");
    btn.className = "btn relative btn-ghost text-token-text-primary mx-2";
    btn.innerHTML = `
      <div class="flex w-full items-center justify-center gap-1.5">
        <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="-ms-0.5 icon">
          <path d="M3 4.75C3 4.33579 3.33579 4 3.75 4H16.25C16.6642 4 17 4.33579 17 4.75C17 5.16421 16.6642 5.5 16.25 5.5H3.75C3.33579 5.5 3 5.16421 3 4.75Z"></path>
          <path d="M3 10C3 9.58579 3.33579 9.25 3.75 9.25H16.25C16.6642 9.25 17 9.58579 17 10C17 10.4142 16.6642 10.75 16.25 10.75H3.75C3.33579 10.75 3 10.4142 3 10Z"></path>
          <path d="M3.75 14.5C3.33579 14.5 3 14.8358 3 15.25C3 15.6642 3.33579 16 3.75 16H12.25C12.6642 16 13 15.6642 13 15.25C13 14.8358 12.6642 14.5 12.25 14.5H3.75Z"></path>
        </svg>
        Usage Logs
      </div>`;
    btn.onclick = () => ui.showModal();
    return btn;
  }
  function injectHeaderButton(ui) {
    const actionsContainer = document.getElementById(
      "conversation-header-actions"
    );
    if (!actionsContainer) return false;
    if (document.getElementById(Config.HEADER_BUTTON_ID)) return true;
    const btn = createHeaderButton(ui);
    if (actionsContainer.firstChild) {
      actionsContainer.insertBefore(btn, actionsContainer.firstChild);
    } else {
      actionsContainer.appendChild(btn);
    }
    return true;
  }
  function observeHeader(ui) {
    injectHeaderButton(ui);
    const root = document.body || document.documentElement;
    if (!root) return;
    let lastInject = 0;
    const observer = new MutationObserver(() => {
      const now = Date.now();
      if (now - lastInject < 300) return;
      lastInject = now;
      injectHeaderButton(ui);
    });
    observer.observe(root, { childList: true, subtree: true });
  }

  class App {
    constructor() {
      this.settings = new SettingsService();
      this.logs = new LogsService(this.settings);
      this.ui = new UiManager(this.settings, this.logs);
      this.streamProcessor = new StreamProcessor(this.settings, this.logs);
      this.fetchInterceptor = new FetchInterceptor(this.streamProcessor);
    }
    bootstrap() {
      this.fetchInterceptor.install();
      injectStyles();
      if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", () =>
          observeHeader(this.ui)
        );
      } else {
        observeHeader(this.ui);
      }
      GM_registerMenuCommand("View Usage Logs", () => this.ui.showModal());
    }
  }

  const app = new App();
  app.bootstrap();
})();