Imgur Comment Analytics Dashboard

Personal comment analytics dashboard — only activates on your own profile

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Imgur Comment Analytics Dashboard
// @namespace    http://tampermonkey.net/
// @version      2026-06-08
// @description  Personal comment analytics dashboard — only activates on your own profile
// @author       You
// @match        https://imgur.com/user/*/comments
// @icon         https://www.google.com/s2/favicons?sz=64&domain=imgur.com
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(async () => {
'use strict';

// ═══════════════════════════════════════════════════════════
//  CONFIG — locks to one username, prompts on first run
// ═══════════════════════════════════════════════════════════

const USERNAME = (window.location.pathname.match(/\/user\/([^\/]+)/) || [])[1];
if (!USERNAME) return;

// Load stored username (GM storage survives reinstalls unlike localStorage)
let MY_USERNAME = GM_getValue('imgurDashUser', '');

if (!MY_USERNAME) {
    // First run — ask which account to track
    const input = prompt(
        `Imgur Analytics Dashboard\n\nEnter your Imgur username to activate the dashboard.\n` +
        `(Currently viewing: ${USERNAME})\n\nLeave blank to use the current page's username.`
    );
    if (input === null) return; // user cancelled
    MY_USERNAME = (input.trim() || USERNAME).toLowerCase();
    GM_setValue('imgurDashUser', MY_USERNAME);
}

// Only run on the configured user's page
if (USERNAME.toLowerCase() !== MY_USERNAME) return;

// Auth — uses client ID from Imgur's page state if available, otherwise prompts once
const _state   = window.imgur?._sharedState?.apiClient
              || window.Imgur?._sharedState?.apiClient
              || {};

const CRED_KEY = 'imgurDashClientId';
const CLIENT_ID = _state.clientId
               || GM_getValue(CRED_KEY, '')
               || '546c25a59c58ad7';

const ACCESS_TOKEN = _state.accessToken || null;

// OAuth — needed for private/hidden posts and images
const OAUTH_CLIENT_ID    = CLIENT_ID || '';
const OAUTH_REDIRECT_URI = "https://imgur.com/";
const OAUTH_TOKEN_KEY    = "imgurOAuthToken";
const OAUTH_EXPIRY_KEY   = "imgurOAuthExpiry";

const DB_NAME        = "ImgurAnalyticsStorage";
const STORE_COMMENTS = "comments";
const STORE_POSTS    = "posts";
const FETCH_DELAY    = 600;   // ms between comment/post page fetches
const BATCH_SIZE     = 16;    // media cards per scroll batch (DOM only, no requests)
const PARALLEL       = 2;     // concurrent score refresh requests (keep low — Imgur rate limits hard)
const PARALLEL_DELAY = 500;   // ms between score refresh batches (2 req/500ms = 4 req/s, well under limit)

const URL_RE = /https?:\/\/[^\s$.?#].[^\s]*/gi;

// Imgur thumbnail suffix — insert before extension to get resized version
// 'm' = 320px, 'l' = 1024px. For video/gifv use jpg poster frame.
function imgurThumb(url, size='lq') {
    if (!url) return url;
    // Only transform i.imgur.com URLs — leave giphy, external links etc untouched
    if (!url.includes('i.imgur.com/')) return url;
    // Videos/gifv: use _lq.mp4 variant
    if (/\.(gifv|mp4|webm)(\?|$)/i.test(url))
        return url.replace(/\.(gifv|mp4|webm)(\?.*)?$/i, `_${size}.mp4`);
    // Animated gif: use _lq.mp4
    if (/\.gif(\?|$)/i.test(url))
        return url.replace(/\.gif(\?.*)?$/i, `_${size}.mp4`);
    // Static images: _d with maxwidth query param
    return url.replace(/\.(jpeg|jpg|png|webp)(\?.*)?$/i, `_d.$1?maxwidth=520&shape=thumb&fidelity=high`);
}
const IMG_RE = /\.(jpeg|jpg|png|webp)(\?.*)?$/i;
const GIF_RE = /\.(gif|gifv|mp4|webm)(\?.*)?$/i;

const DAY_LABELS   = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
const HOUR_LABELS  = Array.from({length:24}, (_,i) => `${i}:00`);
const MONTH_LABELS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];

const URL_NOISE = new Set([
    "http","https","www","com","net","org","io","co","imgur","reddit","gif","gifv",
    "png","jpg","jpeg","webp","mp4","gif","html","php","the","you","that","and",
    "gallery","image","images","post","thread","link","url","cdn","static","media",
    "content","upload","uploads","file","files","user","users","account","profile",
]);

const STOPWORDS = new Set(["the","a","and","to","of","is","in","it","i","that","you","he",
    "was","for","on","are","as","with","his","they","this","but","be","at","or","an","if",
    "not","my","your","from","so","have","just","like","about","there","what","can","out",
    "all","get","has","no","one","do","we","up","me","more","would","think"]);

// ═══════════════════════════════════════════════════════════
//  INDEXEDDB
// ═══════════════════════════════════════════════════════════

function openDB() {
    return new Promise((res, rej) => {
        // v2 adds the posts store
        const req = indexedDB.open(DB_NAME, 2);
        req.onupgradeneeded = e => {
            const db = e.target.result;
            if (!db.objectStoreNames.contains(STORE_COMMENTS))
                db.createObjectStore(STORE_COMMENTS, { keyPath: "id" });
            if (!db.objectStoreNames.contains(STORE_POSTS))
                db.createObjectStore(STORE_POSTS, { keyPath: "id" });
        };
        req.onsuccess = e => res(e.target.result);
        req.onerror   = e => rej(e.target.error);
        req.onblocked = () => rej(new Error("DB blocked — close other Imgur tabs and reload"));
    });
}

function dbGetAll(db, store = STORE_COMMENTS) {
    return new Promise((res, rej) => {
        const req = db.transaction(store, "readonly").objectStore(store).getAll();
        req.onsuccess = () => res(req.result);
        req.onerror   = () => rej(req.error);
    });
}

function dbPutAll(db, items, store = STORE_COMMENTS) {
    if (!items.length) return Promise.resolve();
    return new Promise((res, rej) => {
        const tx = db.transaction(store, "readwrite");
        const s  = tx.objectStore(store);
        items.forEach(item => s.put(item));
        tx.oncomplete = res;
        tx.onerror    = () => rej(tx.error);
    });
}

function dbClear(db, store = STORE_COMMENTS) {
    return new Promise((res, rej) => {
        const tx = db.transaction(store, "readwrite");
        tx.objectStore(store).clear();
        tx.oncomplete = res;
        tx.onerror    = () => rej(tx.error);
    });
}

// ═══════════════════════════════════════════════════════════
//  ENRICHMENT
// ═══════════════════════════════════════════════════════════

function classifyMedia(text) {
    const urls = text.match(URL_RE) || [];
    let hasImg = false, hasGif = false, hasLink = false;
    const parsed = urls.map(url => {
        if      (IMG_RE.test(url))                             { hasImg  = true; return {type:'img',  url}; }
        else if (GIF_RE.test(url)||/imgur\.com\/(a|gallery)\//.test(url)) { hasGif = true; return {type:'gif', url}; }
        else                                                   { hasLink = true; return {type:'link', url}; }
    });
    const bucket = hasImg && hasGif ? "Mixed Media"
                 : hasGif           ? "GIF/Video"
                 : hasImg           ? "Static Image"
                 : hasLink          ? "Generic URL"
                 :                    "Plain Text";
    return { urls: parsed, bucket, hasMedia: parsed.length > 0 };
}

function toDate(raw) {
    if (!raw) return null;
    const d = typeof raw === 'number' ? new Date(raw * 1000) : new Date(raw);
    return isNaN(d) ? null : d;
}

function temporalFields(d, fallback = {}) {
    if (d) return {
        _hour:  d.getHours(),
        _day:   d.getDay(),
        _month: d.getMonth(),
        _year:  d.getFullYear(),
        _ymKey: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`,
    };
    return {
        _hour:  fallback._hour  ?? null,
        _day:   fallback._day   ?? null,
        _month: fallback._month ?? null,
        _year:  fallback._year  ?? null,
        _ymKey: fallback._ymKey ?? null,
    };
}

function enrichComment(c) {
    const text  = c.comment || c.caption || "";
    const words = text.split(/\s+/).filter(Boolean);
    const clean = text.toLowerCase().match(/[a-z']+/g) || [];
    const pts   = c.point_count  ?? c.points ?? 0;
    const ups   = c.upvote_count ?? c.ups    ?? 0;
    const downs = c.downvote_count ?? c.downs ?? 0;
    const d     = toDate(c.created_at || c.datetime);
    const postId = c.post_id || c.image_id || "";
    const { urls: mediaUrls, bucket: mediaBucket, hasMedia } = classifyMedia(text);
    const alphaWords = words.filter(w => /[a-zA-Z]/.test(w));
    const isAllCaps  = alphaWords.length > 0
        && alphaWords.filter(w => w === w.toUpperCase()).length / alphaWords.length >= 0.7;

    return {
        // DB-persisted fields (raw + stable computed)
        id: c.id, post_id: postId, parent_id: c.parent_id ?? null,
        text, points: pts, ups, downs,
        created_at: c.created_at || c.datetime,
        permalink: postId
            ? `https://imgur.com/gallery/${postId}/comment/${c.id}`
            : `https://imgur.com/user/${USERNAME}/comments`,
        // NOTE: _words/_cleanWords NOT stored — recomputed on load from text
        _pts: pts, _ups: ups, _downs: downs,
        _totalVotes: ups + downs,
        _wordCount:  words.length,
        _charCount:  text.length,
        _uniqueWords: new Set(words.map(w => w.toLowerCase())).size,
        _date: d ? d.toISOString() : null,
        ...temporalFields(d),
        _isAllCaps: isAllCaps,
        _isUnseen:  (ups + downs) <= 1,
        // Imgur: top-level comments have parent_id === their own id (or 0 or null/absent)
        _isTopLevel: !c.parent_id || c.parent_id === c.id || +c.parent_id === 0,
        _mediaUrls: mediaUrls,
        _mediaBucket: mediaBucket,
        _hasMedia:  hasMedia,
    };
}

// Rehydrate after loading from DB: restore Date objects + recompute word arrays
function hydrateComment(c) {
    const words = (c.text || '').split(/\s+/).filter(Boolean);
    const clean = (c.text || '').toLowerCase().match(/[a-z']+/g) || [];
    const d     = c._date ? new Date(c._date) : null;
    // Always recompute media from text — stored bucket names changed across versions
    const { urls: mediaUrls, bucket: mediaBucket, hasMedia } = classifyMedia(c.text || '');
    return {
        ...c,
        _date:       d,
        _words:      words,
        _cleanWords: clean,
        ...temporalFields(d, c),
        _mediaBucket: mediaBucket,
        _mediaUrls:   mediaUrls,
        _hasMedia:    hasMedia,
        // Recompute — Imgur: top-level = parent_id equals own id, or 0, or absent
        _isTopLevel: !c.parent_id || c.parent_id === c.id || +c.parent_id === 0,
    };
}

// ─────────────────────────────────────────────
//  PRIVATE POST FETCH (requires OAuth)
// ─────────────────────────────────────────────

async function fetchPrivateImagePage(page) {
    if (!getStoredToken()) return null;
    const res = await fetch(
        `https://api.imgur.com/3/account/me/images/${page}`,
        { headers: oauthHeaders() }
    );
    if (!res.ok) return null;
    const { success, data } = await res.json();
    return success && Array.isArray(data) && data.length ? data : null;
}

async function fetchPrivateAlbumPage(page) {
    if (!getStoredToken()) return null;
    const res = await fetch(
        `https://api.imgur.com/3/account/me/albums/${page}`,
        { headers: oauthHeaders() }
    );
    if (!res.ok) return null;
    const { success, data } = await res.json();
    return success && Array.isArray(data) && data.length ? data : null;
}

// ═══════════════════════════════════════════════════════════
//  POST ENRICHMENT
// ═══════════════════════════════════════════════════════════

async function fetchPostPage(page) {
    const res = await fetch(
        `https://api.imgur.com/3/account/${USERNAME}/submissions/${page}/newest`,
        { headers: apiHeaders() }
    );
    if (!res.ok) return null;
    const { success, data } = await res.json();
    return success && Array.isArray(data) && data.length ? data : null;
}

function enrichPost(p) {
    const d = toDate(p.datetime);
    const title  = p.title || '';
    const desc   = p.description || '';
    const pts    = p.points ?? p.score ?? 0;
    const ups    = p.ups   ?? 0;
    const downs  = p.downs ?? 0;
    const views  = p.views ?? 0;
    const isAlbum    = !!p.is_album;
    const inGallery  = !!p.in_gallery;
    const privacy    = p.privacy || (inGallery ? 'public' : 'hidden');
    const nsfw       = !!p.nsfw;
    const commentCt  = p.comment_count ?? 0;
    const favoriteCt = p.favorite_count ?? 0;
    const tags       = (p.tags || []).map(t => t.name || t.display_name || '').filter(Boolean);
    // Cover image link — first image if album, direct link otherwise
    const images  = p.images || (p.link ? [{id: p.id, link: p.link, type: p.type}] : []);
    const coverImg = images[0] || null;
    const imageCount = images.length;

    return {
        // Persisted
        id: p.id, title, description: desc,
        link: p.link || `https://imgur.com/gallery/${p.id}`,
        cover_link: coverImg?.link || null,
        image_count: imageCount,
        is_album: isAlbum, in_gallery: inGallery,
        privacy, nsfw,
        points: pts, ups, downs, views,
        comment_count: commentCt, favorite_count: favoriteCt,
        datetime: p.datetime,
        tags,
        // Computed
        _pts:     pts,
        _ups:     ups,
        _downs:   downs,
        _views:   views,
        _commentCt: commentCt,
        _favCt:   favoriteCt,
        _inGallery: inGallery,
        _privacy:   privacy,     // 'public' | 'hidden' | 'secret'
        _nsfw:      nsfw,
        _isAlbum:   isAlbum,
        _imageCount: imageCount,
        _tags:      tags,
        _date: d ? d.toISOString() : null,
        ...temporalFields(d),
        _totalVotes: ups + downs,
        _engRate: views > 0 ? parseFloat((pts / views * 1000).toFixed(2)) : 0, // pts per 1k views
    };
}

function hydratePost(p) {
    const d = p._date ? new Date(p._date) : null;
    return {
        ...p,
        _date: d, ...temporalFields(d, p),
    };
}

// ═══════════════════════════════════════════════════════════

function apiHeaders() {
    // Prefer bearer token (full access incl. private) over client-ID (public only)
    if (ACCESS_TOKEN) return { Authorization: `Bearer ${ACCESS_TOKEN}` };
    return { Authorization: `Client-ID ${CLIENT_ID}` };
}

// ─────────────────────────────────────────────
//  OAUTH HELPERS
// ─────────────────────────────────────────────

function getStoredToken() {
    const token  = GM_getValue(OAUTH_TOKEN_KEY,  '');
    const expiry = GM_getValue(OAUTH_EXPIRY_KEY, 0);
    if (!token || Date.now() > expiry) return null;
    return token;
}

function storeToken(token, expiresInSecs) {
    GM_setValue(OAUTH_TOKEN_KEY,  token);
    GM_setValue(OAUTH_EXPIRY_KEY, Date.now() + expiresInSecs * 1000);
}

function clearToken() {
    GM_setValue(OAUTH_TOKEN_KEY,  '');
    GM_setValue(OAUTH_EXPIRY_KEY, 0);
}

function oauthHeaders() {
    // Prefer stored OAuth token (from explicit OAuth flow),
    // then page session token, then client-ID
    const token = getStoredToken();
    if (token) return { Authorization: `Bearer ${token}` };
    return apiHeaders();
}

// Opens the OAuth popup, waits for redirect, extracts token
function doOAuthFlow() {
    return new Promise((resolve, reject) => {
        const url = `https://api.imgur.com/oauth2/authorize?client_id=${OAUTH_CLIENT_ID}&response_type=token`;
        const popup = window.open(url, 'imgurOAuth', 'width=600,height=700');
        if (!popup) { reject(new Error('Popup blocked — allow popups for imgur.com')); return; }

        const interval = setInterval(() => {
            try {
                const hash = popup.location.hash;
                if (!hash) return;
                const params = new URLSearchParams(hash.slice(1));
                const token  = params.get('access_token');
                const expiry = parseInt(params.get('expires_in') || '3600');
                if (token) {
                    clearInterval(interval);
                    popup.close();
                    storeToken(token, expiry);
                    resolve(token);
                }
            } catch { /* cross-origin, popup still on imgur auth page */ }
        }, 500);

        // Timeout after 3 minutes
        setTimeout(() => {
            clearInterval(interval);
            if (!popup.closed) popup.close();
            reject(new Error('OAuth timed out'));
        }, 180000);
    });
}

async function fetchCommentPage(page) {
    const res = await fetch(
        `https://api.imgur.com/3/account/${USERNAME}/comments/newest/${page}`,
        { headers: apiHeaders() }
    );
    if (!res.ok) return null;
    const { success, data } = await res.json();
    return success && Array.isArray(data) && data.length ? data : null;
}

async function fetchCommentScore(id) {
    try {
        const res = await fetch(`https://api.imgur.com/3/comment/${id}`, { headers: apiHeaders() });
        if (!res.ok) return null;
        const { success, data } = await res.json();
        if (!success || !data) return null;
        // The single-comment endpoint uses: points, ups, downs
        // The account/comments list uses: point_count, upvote_count, downvote_count
        // Support both to be safe
        const pts   = data.point_count   ?? data.points   ?? null;
        const ups   = data.upvote_count  ?? data.ups      ?? null;
        const downs = data.downvote_count ?? data.downs   ?? null;
        // If ups/downs not in response, we can only compare pts
        return { id: data.id, pts: pts ?? 0, ups, downs };
    } catch { return null; }
}

// ═══════════════════════════════════════════════════════════
//  DOM HELPERS
// ═══════════════════════════════════════════════════════════

function el(tag, styles = {}, html = "") {
    const n = document.createElement(tag);
    Object.assign(n.style, styles);
    if (html) n.innerHTML = html;
    return n;
}

// ═══════════════════════════════════════════════════════════
//  TOOLTIP FACTORY
// ═══════════════════════════════════════════════════════════

function makeTooltip(id) {
    let t = document.getElementById(id);
    if (!t) {
        t = document.createElement('div');
        t.id = id;
        t.style.cssText = 'position:fixed;pointer-events:none;background:#18181b;color:#e4e4e7;' +
            'border:1px solid #3f3f46;border-radius:5px;padding:5px 9px;font-size:12px;' +
            'font-family:monospace;z-index:9999999;display:none;white-space:nowrap;' +
            'box-shadow:0 4px 12px rgba(0,0,0,0.6);';
        document.body.appendChild(t);
    }
    return t;
}

function wireTooltips(container, selector, tip) {
    container.querySelectorAll(selector).forEach(el => {
        el.addEventListener('mouseenter', () => { tip.textContent = el.dataset.tip; tip.style.display = 'block'; });
        el.addEventListener('mousemove',  e => {
            tip.style.left = Math.min(e.clientX+14, window.innerWidth-tip.offsetWidth-8) + 'px';
            tip.style.top  = Math.max(e.clientY-10, 4) + 'px';
        });
        el.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
    });
}


// ─────────────────────────────────────────────
//  SHARED MEDIA RENDER HELPER
// ─────────────────────────────────────────────

// Replace a data-src placeholder with a real img or video using imgurThumb
function swapPlaceholder(ph, rawSrc, imgStyle, videoStyle, autoplay=true) {
    const src = rawSrc || ph.dataset.src;
    if (!src) return;
    const thumbSrc     = imgurThumb(src, 'lq');
    const isThumbVideo = thumbSrc.endsWith('.mp4');
    if (isThumbVideo) {
        const v = document.createElement('video');
        v.src = thumbSrc;
        v.muted = true; v.playsInline = true; v.loop = true;
        v.preload = 'metadata'; // fetch just enough to show first frame, not full stream
        if (autoplay) v.autoplay = true;
        v.style.cssText = videoStyle || imgStyle;
        v.onerror = () => { v.style.display = 'none'; };
        ph.replaceWith(v);
    } else {
        const img = Object.assign(document.createElement('img'), { src: thumbSrc });
        img.style.cssText = imgStyle;
        img.onerror = () => { img.style.display = 'none'; };
        ph.replaceWith(img);
    }
}

// ═══════════════════════════════════════════════════════════
//  STATUS BANNER
// ═══════════════════════════════════════════════════════════

const banner = el('div', {
    position:'fixed', top:'20px', right:'20px',
    background:'#18181b', color:'#eab308', border:'1px solid #eab308',
    padding:'12px 20px', borderRadius:'8px', fontFamily:'monospace',
    fontSize:'13px', fontWeight:'bold', zIndex:'1000000',
    boxShadow:'0 4px 20px rgba(0,0,0,0.6)'
});
const setBanner = (msg, color='#eab308') => {
    banner.innerText = msg;
    banner.style.color = banner.style.borderColor = color;
};
setBanner("Initializing…");
document.body.appendChild(banner);

// ═══════════════════════════════════════════════════════════
//  INIT
// ═══════════════════════════════════════════════════════════

let db;
try { db = await openDB(); }
catch(e) { setBanner(`DB error: ${e.message}`, "#ef4444"); return; }

const cachedRaw    = await dbGetAll(db);
const localIdSet   = new Set(cachedRaw.map(c => c.id));

const cachedPostsRaw = await dbGetAll(db, STORE_POSTS);

// Fetch new pages until cache hit
const freshRaw = [];
for (let page = 0; ; page++) {
    setBanner(`Scanning page ${page}…`);
    try {
        const data = await fetchCommentPage(page);
        if (!data) break;
        let hitCache = false;
        for (const c of data) {
            if (localIdSet.has(c.id)) { hitCache = true; break; }
            freshRaw.push(enrichComment(c));
        }
        if (hitCache) break;
        await new Promise(r => setTimeout(r, FETCH_DELAY));
    } catch { break; }
}

if (freshRaw.length) {
    setBanner(`Saving ${freshRaw.length} new…`);
    await dbPutAll(db, freshRaw);
}

// Hydrate all comments (Date objects + word arrays)
const processedData = [...cachedRaw, ...freshRaw].map(hydrateComment);

// Hydrate posts
const processedPosts = [...cachedPostsRaw].map(hydratePost);

// Build ID→index Maps for O(1) lookups
const idMap     = new Map(processedData.map((c, i) => [c.id, i]));
const postIdMap = new Map(processedPosts.map((p, i) => [p.id, i]));

// Compute bounds
const bounds = processedData.reduce((acc, c) => ({
    minScore: Math.min(acc.minScore, c._pts),
    maxScore: Math.max(acc.maxScore, c._pts),
    maxWords: Math.max(acc.maxWords, c._wordCount),
    maxVotes: Math.max(acc.maxVotes, c._totalVotes),
}), { minScore:0, maxScore:0, maxWords:0, maxVotes:0 });

setBanner(`Ready — ${processedData.length} comments`, "#47cf73");
setTimeout(() => banner.remove(), 2500);

// ═══════════════════════════════════════════════════════════
//  LAUNCHER
// ═══════════════════════════════════════════════════════════

const launcher = el('button', {
    position:'fixed', bottom:'20px', right:'20px',
    background:'#47cf73', color:'#09090b', border:'0',
    padding:'10px 18px', borderRadius:'30px', fontFamily:'monospace',
    fontWeight:'bold', fontSize:'13px', cursor:'pointer',
    zIndex:'999999', boxShadow:'0 4px 15px rgba(0,0,0,0.5)',
    transition:'transform 0.15s'
});
launcher.innerText = '[ Analytics ]';
launcher.onmouseenter = () => launcher.style.transform = "scale(1.05)";
launcher.onmouseleave = () => launcher.style.transform = "scale(1)";
document.body.appendChild(launcher);

// ═══════════════════════════════════════════════════════════
//  DASHBOARD — built once on first click
// ═══════════════════════════════════════════════════════════

launcher.onclick = async () => {
    const existing = document.getElementById('imgur-dash');
    if (existing) {
        const visible = existing.style.display !== 'none';
        existing.style.display = visible ? 'none' : 'block';
        document.body.style.overflow = visible ? '' : 'hidden';
        return;
    }

    // Load Chart.js
    if (!window.Chart) await new Promise(resolve => {
        const s = document.createElement('script');
        s.src = 'https://cdn.jsdelivr.net/npm/chart.js';
        s.onload = resolve;
        document.head.appendChild(s);
    });

    // ─── CHART HELPERS ───────────────────────────────────────

    const C = '#a1a1aa', G = '#27272a';
    const chartBase = {
        responsive: true, maintainAspectRatio: false,
        layout: { padding: 0 },
        plugins: {
            legend: {
                labels: { color:'#e4e4e7', font:{size:10}, boxWidth:8, padding:4 },
                padding: 0,
            },
        },
        scales: {
            x: { grid:{color:G}, ticks:{color:C, font:{size:10}, maxRotation:0}, border:{display:false} },
            y: { grid:{color:G}, ticks:{color:C, font:{size:10}}, border:{display:false} }
        }
    };

    function titled(text) {
        return { ...chartBase, plugins:{ ...chartBase.plugins,
            title:{
                display:true, text, color:'#e4e4e7', font:{size:11},
                padding:{ top:0, bottom:2 }
            }
        }};
    }

    function dualAxis(t) {
        return { ...titled(t), scales:{
            x: chartBase.scales.x,
            y1:{ type:'linear', position:'left',  grid:{color:G}, ticks:{color:C, font:{size:10}}, border:{display:false} },
            y2:{ type:'linear', position:'right', grid:{drawOnChartArea:false}, ticks:{color:C, font:{size:10}}, border:{display:false} }
        }};
    }

    const chartRegistry = {}; // id → Chart instance

    function renderChart(id, type, data, options) {
        if (chartRegistry[id]) {
            Object.assign(chartRegistry[id], { data, options });
            chartRegistry[id].update('none');
        } else {
            const ctx = document.getElementById(id)?.getContext('2d');
            if (ctx) chartRegistry[id] = new Chart(ctx, { type, data, options });
        }
    }

    // ─── HTML FACTORIES ──────────────────────────────────────

    const S = {  // style snippets
        input:  `background:#09090b;border:1px solid #444;color:#fff;border-radius:4px;`,
        card:   `background:#18181b;border:1px solid #27272a;border-radius:8px;padding:12px;box-sizing:border-box;`,
        btn:    `border-radius:6px;cursor:pointer;font-size:12px;font-weight:700;padding:8px 12px;`,
    };

    function fmtSelect(id, opts) {
        return `<select id="${id}" style="width:100%;${S.input}padding:4px;">`
            + opts.map(([v,l]) => `<option value="${v}">${l}</option>`).join('')
            + `</select>`;
    }
    function fmtRange(id, min, max, val) {
        return `<input type="range" id="${id}" min="${min}" max="${max}" value="${val}" style="width:100%;accent-color:#47cf73;">`;
    }
    function fmtNum(id, val) {
        return `<input type="number" id="${id}" value="${val}" style="width:100%;${S.input}padding:3px 5px;">`;
    }
    function fmtDate(id) {
        return `<input type="date" id="${id}" style="width:100%;${S.input}padding:3px 5px;font-size:11px;color:#fff;">`;
    }
    function canvasCard(id, extra='') {
        return `<div style="${S.card}height:260px;padding:6px;${extra}"><canvas id="${id}"></canvas></div>`;
    }
    function sectionHeader(t) {
        return `<div style="grid-column:1/-1;color:#a1a1aa;font-size:11px;font-weight:700;
            letter-spacing:0.1em;text-transform:uppercase;padding:6px 0 2px;
            border-bottom:1px solid #27272a;margin-top:8px;">${t}</div>`;
    }
    function statCard(id, label, color='#e4e4e7') {
        return `<div style="background:#09090b;border-radius:6px;padding:8px;text-align:center;">
            <div style="font-size:18px;font-weight:700;font-family:monospace;color:${color};" id="${id}">—</div>
            <div style="font-size:11px;color:#a1a1aa;text-transform:uppercase;letter-spacing:0.05em;margin-top:2px;">${label}</div>
        </div>`;
    }

    // ─── SHELL ───────────────────────────────────────────────

    const dash = el('div', {
        position:'fixed', top:0, left:0, width:'100%', height:'100%',
        background:'#09090b', zIndex:'99999',
        fontFamily:'system-ui,-apple-system,sans-serif',
    });
    dash.id = 'imgur-dash';

    // Hide all scrollbars inside dashboard
    const scrollStyle = document.createElement('style');
    scrollStyle.textContent = `
        #imgur-dash * { scrollbar-width: none; -ms-overflow-style: none; }
        #imgur-dash *::-webkit-scrollbar { display: none; }
    `;
    document.head.appendChild(scrollStyle);

    const inner = el('div', {
        width:'100%', height:'100%', boxSizing:'border-box',
        padding:'14px', display:'grid',
        gridTemplateColumns:'290px 1fr', gap:'14px',
        color:'#e4e4e7', transformOrigin:'top left',
    });
    dash.appendChild(inner);

    inner.innerHTML = `
    <!-- ══ SIDEBAR ══ -->
    <div style="background:#18181b;border-radius:10px;padding:14px;display:flex;
        flex-direction:column;gap:10px;overflow-y:auto;
        max-height:calc(100vh - 28px);box-sizing:border-box;">

        <div style="display:flex;justify-content:space-between;align-items:center;">
            <h2 style="margin:0;color:#47cf73;font-size:15px;">Analytics</h2>
            <span style="color:#a1a1aa;font-size:12px;font-family:monospace;">${USERNAME}</span>
        </div>

        <!-- Scale -->
        <div style="background:#202024;padding:8px;border-radius:6px;display:flex;align-items:center;gap:8px;">
            <span style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Scale</span>
            ${fmtRange('uiScale', 70, 130, 100)}
            <span id="uiScaleVal" style="font-size:12px;color:#47cf73;font-family:monospace;min-width:34px;text-align:right;">100%</span>
        </div>

        <!-- Filters -->
        <div style="background:#202024;padding:10px;border-radius:6px;display:flex;flex-direction:column;gap:8px;font-size:12px;">

            <div><label style="color:#a1a1aa;">Date Preset</label>
            <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;margin-top:3px;">
                ${['7d','30d','90d','1yr','All'].map((l,i) => {
                    const days = [7,30,90,365,0][i];
                    return `<button class="date-preset-btn" data-days="${days}"
                        style="padding:4px 2px;background:#18181b;color:#a1a1aa;border:1px solid #3f3f46;
                        border-radius:4px;cursor:pointer;font-size:11px;font-family:monospace;">${l}</button>`;
                }).join('')}
            </div></div>

            <div><label style="color:#a1a1aa;">Date Range</label>
            <div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:3px;">
                ${fmtDate('filterDateFrom')} ${fmtDate('filterDateTo')}
            </div></div>

            <div><label style="color:#a1a1aa;">Min Words: <b id="valMinWords" style="color:#fff;">0</b></label>
            ${fmtRange('filterMinWords', 0, bounds.maxWords, 0)}</div>

            <div><label style="color:#a1a1aa;">Score Range</label>
            <div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:3px;">
                ${fmtNum('filterMinScore', bounds.minScore)} ${fmtNum('filterMaxScore', bounds.maxScore)}
            </div></div>

            <div><label style="color:#a1a1aa;">Min Votes: <b id="valMinVotes" style="color:#fff;">0</b></label>
            ${fmtRange('filterMinVotes', 0, bounds.maxVotes, 0)}</div>

            <div><label style="color:#a1a1aa;">Hour: <b id="valHourRange" style="color:#fff;">0–23</b></label>
            <div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;">
                ${fmtRange('filterMinHour',0,23,0)} ${fmtRange('filterMaxHour',0,23,23)}
            </div></div>

            <div><label style="color:#a1a1aa;">Media</label>
            ${fmtSelect('filterMedia',[['all','All'],['media','Has Media'],['nomedia','Text Only']])}
            </div>

            <div><label style="color:#a1a1aa;">Search</label>
            <input type="text" id="filterText" placeholder="keyword…" style="width:100%;${S.input}padding:4px 6px;margin-top:3px;box-sizing:border-box;">
            </div>

            <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
                <input type="checkbox" id="filterUnseen"> <span style="color:#fbbf24;">Unseen only (≤1 vote)</span>
            </label>

        </div>

        <!-- Stats -->
        <div id="statBlock" style="display:grid;grid-template-columns:1fr 1fr;gap:4px;"></div>

        <!-- Personal Records -->
        <div id="recordsBlock" style="background:#202024;padding:10px;border-radius:6px;display:none;flex-direction:column;gap:6px;">
            <div style="font-size:11px;color:#a1a1aa;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;">Personal Records</div>
            <div id="recordsList" style="font-size:12px;display:flex;flex-direction:column;gap:4px;"></div>
        </div>

        <!-- Export -->
        <div style="display:flex;flex-direction:column;gap:5px;">
            <button id="btnExportCSV"  style="${S.btn}background:#27272a;color:#fff;border:1px solid #3f3f46;width:100%;">Export CSV</button>
            <button id="btnExportJSON" style="${S.btn}background:#27272a;color:#fff;border:1px solid #3f3f46;width:100%;">Export JSON</button>
        </div>

        <button id="btnClose" style="${S.btn}background:#ef4444;color:#fff;border:0;margin-top:auto;">✕ Close</button>
    </div>

    <!-- ══ MAIN ══ -->
    <div style="display:flex;flex-direction:column;gap:10px;max-height:calc(100vh - 28px);overflow:hidden;box-sizing:border-box;">

        <!-- Tabs -->
        <div style="display:flex;gap:4px;border-bottom:2px solid #27272a;padding-bottom:2px;flex-shrink:0;">
            <button class="tab-btn" data-tab="charts"  style="padding:7px 14px;background:#27272a;color:#fff;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid #47cf73;">Charts</button>
            <button class="tab-btn" data-tab="heatmap" style="padding:7px 14px;background:#18181b;color:#a1a1aa;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid transparent;">Heatmap</button>
            <button class="tab-btn" data-tab="top"     style="padding:7px 14px;background:#18181b;color:#a1a1aa;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid transparent;">Top Comments</button>
            <button class="tab-btn" data-tab="media"   style="padding:7px 14px;background:#18181b;color:#a1a1aa;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid transparent;">Media</button>
            <button class="tab-btn" data-tab="posts"  style="padding:7px 14px;background:#18181b;color:#a1a1aa;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid transparent;">Posts</button>
            <button class="tab-btn" data-tab="sync"    style="padding:7px 14px;background:#18181b;color:#a1a1aa;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid transparent;">⟳ Sync</button>
        </div>

        <!-- Charts Tab -->
        <div id="tabCharts" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:12px;overflow-y:auto;flex-grow:1;align-content:start;padding-right:4px;">
            ${sectionHeader('Performance by Format')}
            ${canvasCard('cvMediaPerf')} ${canvasCard('cvWordLen')}
            ${sectionHeader('Temporal')}
            ${canvasCard('cvHourly')} ${canvasCard('cvWeekday')} ${canvasCard('cvMonthly')}
            ${canvasCard('cvTrend')}  ${canvasCard('cvVoteStack')} ${canvasCard('cvEngagement')}
            ${sectionHeader('Score Distribution')}
            ${canvasCard('cvScoreDist')} 
            <div style="${S.card}height:260px;" id="cvControversyWrap"></div>
            ${canvasCard('cvLenScatter')}
            ${sectionHeader('Writing')}
            ${canvasCard('cvVocab')} ${canvasCard('cvWordStats')}
            ${canvasCard('cvPunc')} ${canvasCard('cvSyntax')}
            ${sectionHeader('Activity')}
            ${canvasCard('cvVelocity')} ${canvasCard('cvThread')}
            <div style="${S.card}grid-column:1/-1;min-height:160px;" id="cvStreakWrap"></div>
        </div>

        <!-- Heatmap Tab -->
        <div id="tabHeatmap" style="display:none;flex-direction:column;flex-grow:1;overflow:hidden;gap:10px;">
            <div style="${S.card}display:flex;flex-direction:column;gap:10px;flex-shrink:0;">
                <div style="display:grid;grid-template-columns:auto 1fr auto 1fr auto 1fr;gap:8px;align-items:center;flex-wrap:wrap;">
                    <label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Row axis</label>
                    ${fmtSelect('hmRow',[
                        ['dow',      'Day of Week'],
                        ['hour',     'Hour of Day'],
                        ['month',    'Month (repeating)'],
                        ['dom',      'Day of Month'],
                        ['week',     'Week of Year (repeating)'],
                        ['year',     'Year (unique)'],
                        ['yearmonth','Year-Month (unique)'],
                        ['yearweek', 'Year-Week (unique)'],
                    ])}
                    <label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Col axis</label>
                    ${fmtSelect('hmCol',[
                        ['hour',     'Hour of Day'],
                        ['dow',      'Day of Week'],
                        ['month',    'Month (repeating)'],
                        ['dom',      'Day of Month'],
                        ['week',     'Week of Year (repeating)'],
                        ['year',     'Year (unique)'],
                        ['yearmonth','Year-Month (unique)'],
                        ['yearweek', 'Year-Week (unique)'],
                    ])}
                    <label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Metric</label>
                    ${fmtSelect('hmMetric',[['count','Count'],['score','Total Score'],['avg','Avg Score']])}
                </div>
                <div style="font-size:11px;color:#a1a1aa;" id="hmDesc">Select row and column axes above.</div>
            </div>
            <div id="hmContainer" style="overflow:auto;flex-grow:1;"></div>
        </div>

        <!-- Top Comments Tab -->
        <div id="tabTop" style="display:none;flex-direction:column;flex-grow:1;overflow:hidden;gap:8px;">
            <div style="display:flex;gap:8px;align-items:center;flex-shrink:0;padding-bottom:6px;border-bottom:1px solid #27272a;">
                <label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Sort by</label>
                ${fmtSelect('topSort',[['pts','Score ↑'],['pts_asc','Score ↓'],['newest','Newest'],['oldest','Oldest'],['controversy','Most Controversial'],['words','Longest']])}
                <label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Show</label>
                ${fmtSelect('topLimit',[['25','Top 25'],['50','Top 50'],['100','Top 100']])}
            </div>
            <div id="topList" style="display:flex;flex-direction:column;gap:8px;overflow-y:auto;flex-grow:1;"></div>
        </div>

        <!-- Media Tab -->
        <div id="tabMedia" style="display:none;flex-direction:column;flex-grow:1;overflow-y:auto;position:relative;">
            <div style="display:flex;align-items:center;gap:8px;padding-bottom:8px;border-bottom:1px solid #27272a;flex-shrink:0;margin-bottom:10px;">
                <label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Sort by</label>
                ${fmtSelect('mediaSort',[['newest','Newest'],['oldest','Oldest'],['highest','Score ↑'],['lowest','Score ↓'],['engagement','Most Votes']])}
            </div>
            <div style="display:flex;gap:10px;width:100%;align-items:flex-start;" id="mediaCols">
                <div class="media-col" style="flex:1;display:flex;flex-direction:column;gap:10px;min-width:0;"></div>
                <div class="media-col" style="flex:1;display:flex;flex-direction:column;gap:10px;min-width:0;"></div>
                <div class="media-col" style="flex:1;display:flex;flex-direction:column;gap:10px;min-width:0;"></div>
                <div class="media-col" style="flex:1;display:flex;flex-direction:column;gap:10px;min-width:0;"></div>
            </div>
            <div id="mediaScrollTrigger" style="height:1px;margin-top:20px;"></div>
        </div>

        <!-- Posts Tab -->
        <div id="tabPosts" style="display:none;flex-direction:column;flex-grow:1;overflow:hidden;gap:8px;">
            <!-- Filter bar -->
            <div style="display:flex;gap:6px;align-items:center;flex-shrink:0;">
                <label style="font-size:11px;color:#71717a;white-space:nowrap;">Show</label>
                <select id="postFilterVis" style="${S.input}padding:3px 5px;font-size:11px;width:auto;">
                    <option value="all">All</option><option value="gallery">Gallery</option>
                    <option value="hidden">Hidden</option><option value="nsfw">NSFW</option>
                </select>
                <label style="font-size:11px;color:#71717a;white-space:nowrap;">Type</label>
                <select id="postFilterType" style="${S.input}padding:3px 5px;font-size:11px;width:auto;">
                    <option value="all">All</option><option value="album">Albums</option><option value="image">Images</option>
                </select>
                <label style="font-size:11px;color:#71717a;white-space:nowrap;">Sort</label>
                <select id="postSort" style="${S.input}padding:3px 5px;font-size:11px;width:auto;">
                    <option value="newest">Newest</option><option value="oldest">Oldest</option>
                    <option value="score">Score</option><option value="views">Views</option>
                    <option value="comments">Comments</option>
                </select>
                <div style="margin-left:auto;display:flex;gap:0;border:1px solid #3f3f46;border-radius:6px;overflow:hidden;">
                    <button class="post-sub-btn" data-sub="gallery" style="padding:5px 14px;background:#f97316;color:#09090b;border:0;cursor:pointer;font-size:11px;font-weight:700;">🖼 Gallery</button>
                    <button class="post-sub-btn" data-sub="charts"  style="padding:5px 14px;background:#18181b;color:#a1a1aa;border:0;border-left:1px solid #3f3f46;cursor:pointer;font-size:11px;font-weight:600;">📊 Charts</button>
                </div>
            </div>

            <!-- Stat pills -->
            <div id="postStatBlock" style="display:flex;gap:4px;flex-wrap:nowrap;overflow-x:auto;flex-shrink:0;"></div>

            <!-- Gallery sub-tab -->
            <div id="postSubGallery" style="overflow-y:auto;flex-grow:1;">
                <div id="postGalleryHeader" style="font-size:11px;color:#52525b;margin-bottom:6px;"></div>
                <div id="postGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px;align-content:start;"></div>
            </div>

            <!-- Charts sub-tab — hidden by default -->
            <div id="postSubCharts" style="display:none;overflow-y:auto;flex-grow:1;">
                <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(400px,1fr));gap:10px;align-content:start;">
                    <div style="${S.card}height:220px;"><canvas id="cvPostTrend"></canvas></div>
                    <div style="${S.card}height:220px;"><canvas id="cvPostViews"></canvas></div>
                    <div style="${S.card}height:220px;"><canvas id="cvPostEngRate"></canvas></div>
                    <div style="${S.card}height:220px;"><canvas id="cvPostHour"></canvas></div>
                    <div style="${S.card}height:220px;"><canvas id="cvPostScoreDist"></canvas></div>
                    <div style="${S.card}height:220px;"><canvas id="cvPostTags"></canvas></div>
                    <div style="${S.card}height:220px;"><canvas id="cvPostVisibility"></canvas></div>
                </div>
            </div>
        </div>

        <!-- Sync Tab -->
        <div id="tabSync" style="display:none;overflow-y:auto;flex-grow:1;">
            <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">

                <!-- Scan -->
                <div style="${S.card}display:flex;flex-direction:column;gap:10px;">
                    <div style="font-size:13px;font-weight:700;">🔍 Scan New Comments</div>
                    <div style="font-size:11px;color:#a1a1aa;">Pages newest→oldest, stops on first cached ID.</div>
                    <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
                        ${statCard('syncCached','Cached','#47cf73')}
                        ${statCard('syncNew','New Found','#38bdf8')}
                        ${statCard('syncPage','API Page','#a1a1aa')}
                    </div>
                    <div style="background:#09090b;border-radius:4px;height:6px;overflow:hidden;">
                        <div id="scanBar" style="height:100%;width:0%;background:#38bdf8;transition:width 0.2s;border-radius:4px;"></div>
                    </div>
                    <div id="scanLog" style="background:#09090b;border-radius:4px;padding:8px;font-size:11px;font-family:monospace;color:#a1a1aa;min-height:60px;max-height:120px;overflow-y:auto;line-height:1.7;">Ready.</div>
                    <div style="display:flex;gap:6px;">
                        <button id="btnScanStart"  style="${S.btn}flex:1;background:#1e3050;color:#38bdf8;border:1px solid #1d4ed8;">▶ Start</button>
                        <button id="btnScanCancel" style="${S.btn}background:#1a1a1f;color:#52525b;border:1px solid #27272a;" disabled>■ Cancel</button>
                    </div>
                </div>

                <!-- Score Refresh -->
                <div style="${S.card}display:flex;flex-direction:column;gap:10px;">
                    <div style="font-size:13px;font-weight:700;">↻ Refresh Scores</div>
                    <div style="font-size:11px;color:#a1a1aa;">Re-fetches live scores. No pagination.</div>

                    <div style="background:#09090b;border-radius:6px;padding:8px;display:flex;flex-direction:column;gap:6px;">
                        <div style="font-size:11px;color:#a1a1aa;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Target Window</div>
                        <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:4px;" id="scopeBtns">
                            ${[['7','7d'],['30','30d'],['90','90d'],['365','1yr'],['0','All']].map(([v,l],i)=>
                                `<button class="scope-btn" data-days="${v}" style="${S.btn}padding:5px 2px;background:${i===1?'#1e3a2f':'#18181b'};color:${i===1?'#47cf73':'#a1a1aa'};border:1px solid ${i===1?'#166534':'#3f3f46'};font-family:monospace;font-size:11px;">${l}</button>`
                            ).join('')}
                        </div>
                        <div style="font-size:11px;color:#a1a1aa;text-align:center;">~<span id="scopeCount">?</span> comments &nbsp;<span id="scopeWarn" style="color:#eab308;display:none;">⚠️ large scope — runs slow to avoid rate limits</span></div>
                    </div>

                    <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
                        ${statCard('refreshChanged','Changed','#eab308')}
                        ${statCard('refreshGained','Gained','#47cf73')}
                        ${statCard('refreshLost','Lost','#ef4444')}
                    </div>
                    <div style="background:#09090b;border-radius:4px;height:6px;overflow:hidden;">
                        <div id="refreshBar" style="height:100%;width:0%;background:#47cf73;transition:width 0.15s;border-radius:4px;"></div>
                    </div>
                    <div id="refreshLbl" style="font-size:11px;color:#a1a1aa;font-family:monospace;text-align:center;min-height:16px;"></div>
                    <div style="display:flex;gap:6px;">
                        <button id="btnRefreshStart"  style="${S.btn}flex:1;background:#1e3a2f;color:#47cf73;border:1px solid #166534;">▶ Start</button>
                        <button id="btnRefreshCancel" style="${S.btn}background:#1a1a1f;color:#52525b;border:1px solid #27272a;" disabled>■ Cancel</button>
                    </div>
                </div>

                <!-- Diff Table -->
                <div id="diffWrap" style="display:none;grid-column:1/-1;${S.card}flex-direction:column;gap:8px;">
                    <div style="font-size:13px;font-weight:700;color:#eab308;">Score Changes</div>
                    <div id="diffSummary" style="font-size:12px;color:#d4d4d8;font-family:monospace;"></div>
                    <div style="overflow-x:auto;">
                        <table style="width:100%;border-collapse:collapse;font-size:12px;font-family:monospace;">
                            <thead><tr style="color:#a1a1aa;border-bottom:1px solid #27272a;text-align:left;font-size:11px;">
                                <th style="padding:4px 6px;">Δ Score</th>
                                <th style="padding:4px 6px;">Δ ↑</th>
                                <th style="padding:4px 6px;">Δ ↓</th>
                                <th style="padding:4px 6px;">Date</th>
                                <th style="padding:4px 6px;">Comment</th>
                                <th style="padding:4px 6px;"></th>
                            </tr></thead>
                            <tbody id="diffBody"></tbody>
                        </table>
                    </div>
                </div>

                <!-- Post Stats Refresh -->
                <div style="${S.card}grid-column:1/-1;display:flex;flex-direction:column;gap:10px;">
                    <div style="font-size:13px;font-weight:700;">📊 Refresh Post Stats</div>
                    <div style="font-size:11px;color:#a1a1aa;">Re-scans submissions (public + private if connected) to update views, score, ups/downs, comment count. Scope applies to each source separately.</div>
                    <div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
                        <span style="font-size:11px;color:#a1a1aa;">Stop after:</span>
                        ${['1 page','5 pages','20 pages','All'].map((l,i) => {
                            const pages=[1,5,20,9999][i];
                            return `<button class="post-scope-btn" data-pages="${pages}"
                                style="padding:4px 10px;background:#18181b;color:#a1a1aa;border:1px solid #3f3f46;border-radius:4px;cursor:pointer;font-size:11px;">${l}</button>`;
                        }).join('')}
                        <span style="font-size:11px;color:#52525b;">(50 posts/page)</span>
                    </div>
                    <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
                        ${statCard('postRefreshScanned','Updated','#38bdf8')}
                        ${statCard('postRefreshNew','New Found','#47cf73')}
                    </div>
                    <div style="background:#09090b;border-radius:4px;height:6px;overflow:hidden;">
                        <div id="postRefreshBar" style="height:100%;width:0%;background:#47cf73;transition:width 0.15s;border-radius:4px;"></div>
                    </div>
                    <div id="postRefreshLbl" style="font-size:11px;color:#a1a1aa;font-family:monospace;text-align:center;min-height:16px;"></div>
                    <div style="display:flex;gap:6px;">
                        <button id="btnPostRefreshStart"  style="${S.btn}flex:1;background:#1e3050;color:#38bdf8;border:1px solid #1d4ed8;">▶ Refresh Post Stats</button>
                        <button id="btnPostRefreshCancel" style="${S.btn}background:#1a1a1f;color:#52525b;border:1px solid #27272a;" disabled>■ Cancel</button>
                    </div>
                </div>

                <!-- OAuth / Account Connection -->
                <div style="${S.card}grid-column:1/-1;display:flex;flex-direction:column;gap:8px;" id="oauthPanel">
                    <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
                        <div style="flex:1;">
                            <div style="font-size:13px;font-weight:700;">🔐 Imgur Account Connection</div>
                            <div style="font-size:11px;color:#a1a1aa;margin-top:2px;">If your page session token has full access, private posts may already work. Use this only if the private scan fails.</div>
                        </div>
                        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;">
                            <button id="btnOAuthConnect" style="${S.btn}background:#1e3050;color:#38bdf8;border:1px solid #1d4ed8;white-space:nowrap;">🔑 Connect Account</button>
                            <button id="btnOAuthDisconnect" style="${S.btn}background:#1a1a1f;color:#52525b;border:1px solid #27272a;font-size:10px;display:none;">Disconnect</button>
                        </div>
                    </div>
                    <div id="oauthStatus" style="font-size:11px;font-family:monospace;color:#52525b;">Not connected — public posts only</div>
                </div>

                <!-- Scan Posts -->
                <div style="${S.card}grid-column:1/-1;display:flex;flex-direction:column;gap:10px;">
                    <div style="font-size:13px;font-weight:700;">📸 Scan Posts / Submissions</div>
                    <div style="font-size:11px;color:#a1a1aa;">
                        <b style="color:#47cf73;">Public scan</b> — fetches gallery submissions (no auth needed).<br>
                        <b style="color:#38bdf8;">Private scan</b> — fetches all images &amp; albums including hidden (requires account connection above).
                    </div>
                    <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
                        ${statCard('postSyncCached','Cached','#f97316')}
                        ${statCard('postSyncNew','New Found','#38bdf8')}
                        ${statCard('postSyncPage','API Page','#a1a1aa')}
                    </div>
                    <div style="background:#09090b;border-radius:4px;height:6px;overflow:hidden;">
                        <div id="postScanBar" style="height:100%;width:0%;background:#f97316;transition:width 0.2s;border-radius:4px;"></div>
                    </div>
                    <div id="postScanLog" style="background:#09090b;border-radius:4px;padding:8px;font-size:11px;font-family:monospace;color:#a1a1aa;min-height:50px;max-height:100px;overflow-y:auto;line-height:1.7;">Ready.</div>
                    <div style="display:flex;gap:6px;flex-wrap:wrap;">
                        <button id="btnPostScanStart"        style="${S.btn}flex:1;background:#2a1800;color:#f97316;border:1px solid #7c2d12;">▶ Scan Public</button>
                        <button id="btnPostScanPrivateStart" style="${S.btn}flex:1;background:#1e3050;color:#38bdf8;border:1px solid #1d4ed8;" disabled>🔐 Scan Private</button>
                        <button id="btnPostScanCancel"       style="${S.btn}background:#1a1a1f;color:#52525b;border:1px solid #27272a;" disabled>■ Cancel</button>
                    </div>
                </div>

                <!-- DB Management -->
                <div style="${S.card}grid-column:1/-1;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
                    <span style="font-size:12px;color:#a1a1aa;flex:1;">
                        DB: <b style="color:#fff;" id="dbCount">${processedData.length}</b> comments &nbsp;|&nbsp; <b style="color:#f97316;" id="postDbCount">${processedPosts.length}</b> posts
                    </span>
                    <button id="btnResetUser"     style="${S.btn}background:#1a1a2e;color:#a1a1aa;border:1px solid #3f3f46;">Reset Username</button>
                    <button id="btnClearPostsDB" style="${S.btn}background:#1a0a0a;color:#f97316;border:1px solid #7c2d12;">Clear Posts Cache</button>
                    <button id="btnClearDB" style="${S.btn}background:#1a0a0a;color:#ef4444;border:1px solid #7f1d1d;">Clear Comments Cache</button>
                </div>
            </div>
        </div>
    </div>
    `;

    document.body.appendChild(dash);

    // ─── SCALE ──────────────────────────────────────────────

    const SCALE_KEY = 'imgurDashScale';
    const scaleSlider = document.getElementById('uiScale');
    const scaleVal    = document.getElementById('uiScaleVal');

    function applyScale(pct) {
        inner.style.zoom = pct + '%';
        scaleVal.textContent = pct + '%';
        localStorage.setItem(SCALE_KEY, pct);
    }
    const savedScale = parseInt(localStorage.getItem(SCALE_KEY) || '100');
    scaleSlider.value = savedScale;
    applyScale(savedScale);
    scaleSlider.addEventListener('input', () => applyScale(+scaleSlider.value));

    // ─── SCROLL TRAP ─────────────────────────────────────────

    const SCROLL_KEYS = new Set(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','PageUp','PageDown','Home','End',' ']);
    dash.addEventListener('wheel',     e => e.stopPropagation(), { passive:true, capture:true });
    dash.addEventListener('touchmove', e => e.stopPropagation(), { passive:true, capture:true });
    dash.addEventListener('keydown',   e => { if (SCROLL_KEYS.has(e.key)) e.stopPropagation(); }, { capture:true });

    const savedOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';

    const closeDash = () => {
        dash.style.display = 'none';
        document.body.style.overflow = savedOverflow;
    };
    const openDash = () => {
        dash.style.display = 'block';
        document.body.style.overflow = 'hidden';
    };

    document.getElementById('btnClose').onclick = closeDash;
    launcher.onclick = () => dash.style.display === 'none' ? openDash() : closeDash();

    // ─── TAB SWITCHING ───────────────────────────────────────

    const TAB_COLORS = { charts:'#47cf73', heatmap:'#38bdf8', top:'#eab308', media:'#a855f7', posts:'#f97316', sync:'#71717a' };

    function switchTab(active) {
        document.querySelectorAll('.tab-btn').forEach(btn => {
            const id = btn.dataset.tab;
            const on = id === active;
            btn.style.background       = on ? '#27272a' : '#18181b';
            btn.style.color            = on ? '#fff'    : '#a1a1aa';
            btn.style.borderBottomColor = on ? TAB_COLORS[id] : 'transparent';
        });
        ['charts','heatmap','top','media','posts','sync'].forEach(id => {
            const el = document.getElementById(`tab${id.charAt(0).toUpperCase()+id.slice(1)}`);
            if (!el) return;
            if (id !== active) { el.style.display = 'none'; return; }
            if (id === 'charts') el.style.display = 'grid';
            else el.style.display = 'flex';
        });
        if (active === 'media') initMediaScroll();
        if (active === 'posts') renderPostsTab();
    }

    document.querySelectorAll('.tab-btn').forEach(btn =>
        btn.addEventListener('click', () => switchTab(btn.dataset.tab))
    );

    // ─── MEDIA STREAM ─────────────────────────────────────────
    // Design: mediaPool holds all items. Cards are DOM placeholders (<div class="lazy-ph">)
    // with data-src. Two observers:
    //   scrollObs — watches the sentinel div at the bottom; appends more card placeholders
    //   lazyObs   — watches each placeholder; swaps in real img/video when near viewport
    // Both use root:null (viewport) so they work whether the tab is hidden or visible.
    // Filter/sort changes only rebuild mediaPool and reset the sentinel — no network requests.

    let mediaPool = [], mediaIdx = 0, scrollObs = null, lazyObs = null;

    function setupLazy() {
        if (lazyObs) lazyObs.disconnect();
        const mediaRoot = document.getElementById('tabMedia');
        lazyObs = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                if (!entry.isIntersecting) return;
                const ph   = entry.target;
                const src  = ph.dataset.src;
                const type = ph.dataset.type;
                if (!src) return;
                swapPlaceholder(ph, src, 'width:100%;height:auto;display:block;border-radius:4px;object-fit:contain;');
                lazyObs.unobserve(ph);
            });
        }, { root: mediaRoot, rootMargin: '400px' });
    }

    function renderMediaBatch() {
        const cols = [...document.querySelectorAll('.media-col')];
        if (!cols.length) return;
        const slice = mediaPool.slice(mediaIdx, mediaIdx + BATCH_SIZE);
        if (!slice.length) return;
        slice.forEach(({comment:c, media:m}, i) => {
            const isVideo = /\.(mp4|gifv)/.test(m.url);
            const src     = m.url.replace('.gifv', '.mp4');
            const pts     = c._pts;
            const ptsStr  = (pts > 0 ? '+' : '') + pts;
            const card = document.createElement('div');
            card.style.cssText = 'background:#18181b;border:1px solid #27272a;border-radius:8px;' +
                'padding:8px;display:flex;flex-direction:column;gap:6px;box-sizing:border-box;';
            const ph = document.createElement('div');
            ph.className = 'lazy-ph';
            ph.dataset.src  = src;
            ph.dataset.type = isVideo ? 'video' : 'img';
            ph.style.cssText = 'min-height:100px;background:#101012;border-radius:4px;display:flex;align-items:center;justify-content:center;color:#a1a1aa;font-size:12px;';
            ph.textContent = 'Loading…';
            card.appendChild(ph);
            card.insertAdjacentHTML('beforeend', `
                <div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;">
                    <span style="color:${pts>=0?'#eab308':'#ef4444'};font-weight:600;">${ptsStr} pts</span>
                    <span style="color:#71717a;font-size:10px;font-family:monospace;">${c._date ? c._date.toLocaleDateString(undefined,{month:'short',day:'numeric',year:'2-digit'}) : ''}</span>
                    <a href="${c.permalink}" target="_blank" style="color:#38bdf8;text-decoration:none;">↗</a>
                </div>
                <div style="color:#d4d4d8;font-size:11px;line-height:1.4;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;font-style:italic;">"${c.text.replace(/"/g,'&quot;').replace(/</g,'&lt;')}"</div>
            `);
            // Round-robin — even distribution by count, not height
            // (height-based balancing doesn't work because images load async)
            cols[(mediaIdx + i) % cols.length].appendChild(card);
            lazyObs.observe(ph);
        });
        mediaIdx += BATCH_SIZE;

        // Keep loading until container is scrollable
        const root = document.getElementById('tabMedia');
        if (root && mediaIdx < mediaPool.length && root.scrollHeight <= root.clientHeight) {
            renderMediaBatch();
        }
    }

    function initMediaScroll() {
        // Remove old scroll listener
        if (scrollObs) {
            const mediaRoot = document.getElementById('tabMedia');
            if (mediaRoot) mediaRoot.removeEventListener('scroll', scrollObs);
            scrollObs = null;
        }
        setupLazy();
        document.querySelectorAll('.media-col').forEach(c => c.innerHTML = '');
        mediaIdx = 0;
        document.getElementById('mediaLoadPrompt')?.remove();

        if (!mediaPool.length) {
            document.querySelector('.media-col').innerHTML =
                '<div style="color:#a1a1aa;font-size:13px;padding:40px 0;text-align:center;">No media in current filter.</div>';
            return;
        }

        const mediaRoot = document.getElementById('tabMedia');

        // Simple scroll listener — loads next batch when near bottom
        scrollObs = () => {
            if (mediaIdx >= mediaPool.length) return;
            const mediaRoot = document.getElementById('tabMedia');
            if (!mediaRoot) return;
            const { scrollTop, clientHeight } = mediaRoot;
            const cols = [...document.querySelectorAll('.media-col')];
            // Fire when the shortest column's bottom is within 200px of the visible area
            const shortestBottom = Math.min(...cols.map(c => c.offsetHeight));
            if (scrollTop + clientHeight >= shortestBottom - 200) {
                renderMediaBatch();
            }
        };
        mediaRoot.addEventListener('scroll', scrollObs);
        renderMediaBatch(); // load first batch
    }

    // ─── HEATMAP ─────────────────────────────────────────────

    let hmComments = [];
    ['hmRow','hmCol','hmMetric'].forEach(id =>
        document.getElementById(id)?.addEventListener('change', () => renderHeatmap(hmComments))
    );

    function renderHeatmap(comments) {
        hmComments = comments;
        const rowDim = document.getElementById('hmRow')?.value  || 'dow';
        const colDim = document.getElementById('hmCol')?.value  || 'hour';
        const metric = document.getElementById('hmMetric').value;
        const target = document.getElementById('hmContainer');
        const desc   = document.getElementById('hmDesc');

        // ── Axis definitions ──────────────────────────────────────────────────
        // Each dim: { key(c)→string, allKeys(comments)→string[], label(k)→string, shortLabel(k)→string }

        function isoWeek(d) {
            // ISO week number
            const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
            tmp.setUTCDate(tmp.getUTCDate() + 4 - (tmp.getUTCDay() || 7));
            const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
            return { w: Math.ceil((((tmp - yearStart) / 86400000) + 1) / 7), y: tmp.getUTCFullYear() };
        }

        const DIMS = {
            hour: {
                key:   c => String(c._date.getHours()),
                allKeys: () => Array.from({length:24}, (_,i) => String(i)),
                label:  k => `${k}:00`,
                short:  k => +k % 3 === 0 ? `${k}h` : '',
            },
            dow: {
                key:   c => String(c._date.getDay()),
                allKeys: () => ['0','1','2','3','4','5','6'],
                label:  k => DAY_LABELS[+k],
                short:  k => DAY_LABELS[+k],
            },
            month: {
                key:   c => String(c._date.getMonth()),
                allKeys: () => Array.from({length:12}, (_,i) => String(i)),
                label:  k => MONTH_LABELS[+k],
                short:  k => MONTH_LABELS[+k],
            },
            dom: {
                key:   c => String(c._date.getDate() - 1),
                allKeys: () => Array.from({length:31}, (_,i) => String(i)),
                label:  k => String(+k + 1),
                short:  k => (+k + 1) % 5 === 1 ? String(+k + 1) : '',
            },
            week: {
                key:   c => String(isoWeek(c._date).w),
                allKeys: () => Array.from({length:53}, (_,i) => String(i+1)),
                label:  k => `W${k}`,
                short:  k => +k % 4 === 1 ? `W${k}` : '',
            },
            year: {
                key:   c => String(c._date.getFullYear()),
                allKeys: cmts => [...new Set(cmts.filter(c=>c._date).map(c=>String(c._date.getFullYear())))].sort(),
                label:  k => k,
                short:  k => k,
            },
            yearmonth: {
                key:   c => `${c._date.getFullYear()}-${String(c._date.getMonth()+1).padStart(2,'0')}`,
                allKeys: cmts => [...new Set(cmts.filter(c=>c._date).map(c=>`${c._date.getFullYear()}-${String(c._date.getMonth()+1).padStart(2,'0')}`))].sort(),
                label:  k => k,
                short:  k => k,
            },
            yearweek: {
                key:   c => { const {y,w} = isoWeek(c._date); return `${y}-W${String(w).padStart(2,'0')}`; },
                allKeys: cmts => [...new Set(cmts.filter(c=>c._date).map(c=>{ const {y,w}=isoWeek(c._date); return `${y}-W${String(w).padStart(2,'0')}`; }))].sort(),
                label:  k => k,
                short:  k => k,
            },
        };

        const RD = DIMS[rowDim], CD = DIMS[colDim];
        if (!RD || !CD) return;

        if (desc) desc.textContent = `${document.getElementById('hmRow').options[document.getElementById('hmRow').selectedIndex].text} × ${document.getElementById('hmCol').options[document.getElementById('hmCol').selectedIndex].text}`;

        // ── Build map ─────────────────────────────────────────────────────────
        const map = {};
        const dated = comments.filter(c => c._date);
        dated.forEach(c => {
            const rk = RD.key(c), ck = CD.key(c);
            if (!map[rk]) map[rk] = {};
            if (!map[rk][ck]) map[rk][ck] = {n:0, pts:0};
            map[rk][ck].n++;
            map[rk][ck].pts += c._pts;
        });

        const rowKeys = RD.allKeys(dated);
        const colKeys = CD.allKeys(dated);

        // Filter to only rows/cols that have any data (for unique-key dims this avoids empty rows)
        const isDynamic = ['year','yearmonth','yearweek'].includes(rowDim);
        const cIsDynamic = ['year','yearmonth','yearweek'].includes(colDim);
        const activeRows = isDynamic  ? rowKeys.filter(k => map[k]) : rowKeys;
        const activeCols = cIsDynamic ? colKeys.filter(k => dated.some(c => CD.key(c)===k)) : colKeys;

        function cellVal(rk, ck) {
            const cell = map[rk]?.[ck];
            if (!cell || cell.n === 0) return null;
            return metric==='count' ? cell.n : metric==='score' ? cell.pts : cell.pts/cell.n;
        }

        // ── Global min/max ────────────────────────────────────────────────────
        let gMax = -Infinity, gMin = Infinity;
        activeRows.forEach(rk => activeCols.forEach(ck => {
            const v = cellVal(rk, ck);
            if (v !== null) { if (v > gMax) gMax = v; if (v < gMin) gMin = v; }
        }));

        if (gMax === -Infinity) {
            target.innerHTML = `<div style="color:#a1a1aa;padding:20px;">No data — ${dated.length} dated comments in filter, but no overlap between the two axes chosen (e.g. same axis selected for both row and column).</div>`;
            return;
        }

        const hasNeg = gMin < 0;
        function cellBg(val) {
            if (val === null) return '#101012';
            if (hasNeg) {
                if (val < 0) { const t = gMin!==0 ? val/gMin : 0; return `rgba(239,68,68,${Math.min(.95,t*.85+.1).toFixed(2)})`; }
                const t = gMax!==0 ? val/gMax : 0; return `rgba(71,207,115,${Math.min(.95,t*.85+.1).toFixed(2)})`;
            }
            const n = gMax===gMin ? 1 : (val-gMin)/(gMax-gMin);
            return `rgba(71,207,115,${(n*.88+.08).toFixed(2)})`;
        }

        // ── Cell sizing ───────────────────────────────────────────────────────
        const wideCols  = new Set(['year','yearmonth','yearweek']);
        const narrowCols = new Set(['hour','dow']);
        const CW = wideCols.has(colDim)  ? 64
                 : narrowCols.has(colDim) ? 18
                 : 24;
        const CH = 17;

        // ── Render table ──────────────────────────────────────────────────────
        let html = `<table style="border-collapse:collapse;font-size:11px;font-family:monospace;">`;
        html += `<tr><td style="padding:2px 8px;min-width:60px;"></td>`;
        activeCols.forEach(ck => {
            html += `<td style="padding:1px 2px;color:#a1a1aa;text-align:center;min-width:${CW}px;font-size:11px;white-space:nowrap;">${CD.short(ck)}</td>`;
        });
        html += '</tr>';

        activeRows.forEach(rk => {
            html += `<tr><td style="padding:3px 8px;color:#d4d4d8;white-space:nowrap;font-size:11px;">${RD.label(rk)}</td>`;
            activeCols.forEach(ck => {
                const val  = cellVal(rk, ck);
                const disp = val===null ? '—' : metric==='avg' ? val.toFixed(1) : Math.round(val);
                const tip  = `${RD.label(rk)} / ${CD.label(ck)}: ${disp} ${metric==='count'?'comments':metric==='score'?'pts':'avg pts'}`;
                html += `<td data-tip="${tip}" style="width:${CW}px;height:${CH}px;background:${cellBg(val)};border-radius:2px;border:1px solid #09090b;cursor:default;"></td>`;
            });
            html += '</tr>';
        });
        html += '</table>';

        const fmt = v => metric==='avg' ? v.toFixed(1) : Math.round(v);
        html += `<div style="margin-top:10px;display:flex;align-items:center;gap:8px;font-size:11px;color:#a1a1aa;flex-wrap:wrap;">
            <span>${fmt(gMin)}</span>
            <div style="width:120px;height:8px;border-radius:4px;background:linear-gradient(to right,${hasNeg?'rgba(239,68,68,.9),#101012,':''}rgba(71,207,115,.08),rgba(71,207,115,.95));"></div>
            <span>${fmt(gMax)}</span>
            <span style="margin-left:8px;color:#a1a1aa;">${metric==='count'?'comments':metric==='score'?'total pts':'avg pts'} · ${dated.length.toLocaleString()} dated comments · ${activeRows.length}×${activeCols.length} grid</span>
        </div>`;

        target.innerHTML = html;

        wireTooltips(target, 'td[data-tip]', makeTooltip('hm-tip'));
    }

    // ─── TOP COMMENTS ─────────────────────────────────────────

    let topComments = [];

    function renderTopList() {
        const sortKey = document.getElementById('topSort').value;
        const limit   = parseInt(document.getElementById('topLimit').value);
        const list = document.getElementById('topList');

        const sorted = [...topComments].sort((a,b) => {
            if (sortKey === 'pts')         return b._pts - a._pts;
            if (sortKey === 'pts_asc')     return a._pts - b._pts;
            if (sortKey === 'newest')      return (b._date?.getTime()||0) - (a._date?.getTime()||0);
            if (sortKey === 'oldest')      return (a._date?.getTime()||0) - (b._date?.getTime()||0);
            if (sortKey === 'controversy') {
                const scoreOf = c => {
                    if (!c._ups || !c._downs) return 0;
                    const total = c._ups + c._downs;
                    const bal   = 1 - Math.abs(c._ups - c._downs) / total;
                    return total * bal * bal;
                };
                return scoreOf(b) - scoreOf(a);
            }
            if (sortKey === 'words')       return b._wordCount - a._wordCount;
            return 0;
        }).slice(0, limit);

        list.innerHTML = '';
        sorted.forEach(c => {
            const card = document.createElement('div');
            card.style.cssText = `background:#18181b;border:1px solid #27272a;border-radius:8px;padding:10px 12px;display:flex;gap:12px;align-items:flex-start;`;
            const pts = c._pts;
            const ptsColor = pts > 0 ? '#47cf73' : pts < 0 ? '#ef4444' : '#a1a1aa';
            const ptsStr   = (pts > 0 ? '+' : '') + pts;

            // media thumbnail
            let mediaHTML = '';
            if (c._hasMedia) {
                const m = c._mediaUrls.find(u => u.type==='img'||u.type==='gif') || c._mediaUrls[0];
                const rawSrc   = m.url.replace('.gifv', '.mp4');
                const thumbSrc = imgurThumb(rawSrc, 'lq');
                const isTVid   = thumbSrc.endsWith('.mp4');
                mediaHTML = isTVid
                    ? `<video src="${thumbSrc}" autoplay loop muted playsinline style="width:100px;min-width:100px;max-height:100px;object-fit:cover;border-radius:6px;" onerror="this.style.display='none'"></video>`
                    : `<img src="${thumbSrc}" loading="lazy" style="width:100px;min-width:100px;max-height:100px;object-fit:cover;border-radius:6px;" onerror="this.style.display='none'">`;
            }

            card.innerHTML = `
                <div style="min-width:50px;text-align:center;">
                    <div style="font-size:20px;font-weight:700;font-family:monospace;color:${ptsColor};">${ptsStr}</div>
                    <div style="font-size:11px;color:#a1a1aa;font-family:monospace;margin-top:2px;">${c._ups}↑ ${c._downs}↓</div>
                </div>
                ${mediaHTML ? `<div style="flex-shrink:0;">${mediaHTML}</div>` : ''}
                <div style="flex:1;min-width:0;">
                    <div style="color:#d4d4d8;font-size:12px;line-height:1.5;overflow:hidden;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;">${c.text.replace(/</g,'&lt;')}</div>
                    <div style="margin-top:5px;display:flex;gap:8px;font-size:11px;color:#a1a1aa;flex-wrap:wrap;">
                        <span>${c._date ? c._date.toLocaleDateString() : '?'}</span>
                        <span style="color:#52525b;">·</span>
                        <span>${c._wordCount} words</span>
                        ${c._hasMedia ? `<span style="color:#a855f7;">📎 ${c._mediaUrls.length}</span>` : ''}
                        <a href="${c.permalink}" target="_blank" style="color:#38bdf8;text-decoration:none;">↗ view</a>
                    </div>
                </div>
            `;
            list.appendChild(card);
        });
    }

    ['topSort','topLimit'].forEach(id =>
        document.getElementById(id).addEventListener('change', renderTopList)
    );

    const mediaSortEl = document.getElementById('mediaSort');
    if (mediaSortEl) {
        function applyMediaSort() {
            const sk = mediaSortEl.value;
            mediaPool.sort((a,b) => {
                if (sk==='newest')     return (b.comment._date ? b.comment._date.getTime() : 0) - (a.comment._date ? a.comment._date.getTime() : 0);
                if (sk==='oldest')     return (a.comment._date ? a.comment._date.getTime() : 0) - (b.comment._date ? b.comment._date.getTime() : 0);
                if (sk==='highest')    return b.comment._pts - a.comment._pts;
                if (sk==='lowest')     return a.comment._pts - b.comment._pts;
                if (sk==='engagement') return b.comment._totalVotes - a.comment._totalVotes;
                return 0;
            });
            // Only re-render if media was already loaded (prompt is gone)
            if (!document.getElementById('mediaLoadPrompt')) {
                document.querySelectorAll('.media-col').forEach(c => c.innerHTML = '');
                mediaIdx = 0;
                renderMediaBatch();
            }
            // Update prompt count if prompt still showing
            const prompt = document.getElementById('mediaLoadPrompt');
            if (prompt) {
                const countEl = prompt.querySelector('div');
                if (countEl) countEl.textContent = `${mediaPool.length.toLocaleString()} media items in current filter`;
            }
        }
        mediaSortEl.addEventListener('change', applyMediaSort);
        mediaSortEl.addEventListener('input',  applyMediaSort);
    }

    // ─── SYNC TAB ─────────────────────────────────────────────

    document.getElementById('syncCached').textContent = processedData.length;
    document.getElementById('dbCount').textContent    = processedData.length;

    // Scope buttons
    let scopeDays = 30;

    function updateScopeCount() {
        const cutoff = scopeDays > 0 ? Date.now() - scopeDays * 86400000 : -Infinity;
        const n = processedData.filter(c => c._date ? c._date.getTime() >= cutoff : scopeDays===0).length;
        document.getElementById('scopeCount').textContent = n.toLocaleString();
        const warn = document.getElementById('scopeWarn');
        if (warn) warn.style.display = n > 500 ? 'inline' : 'none';
    }
    document.querySelectorAll('.scope-btn').forEach(btn => {
        btn.addEventListener('click', () => {
            scopeDays = +btn.dataset.days;
            document.querySelectorAll('.scope-btn').forEach(b => {
                const on = b === btn;
                b.style.background  = on ? '#1e3a2f' : '#18181b';
                b.style.color       = on ? '#47cf73' : '#a1a1aa';
                b.style.borderColor = on ? '#166534' : '#3f3f46';
            });
            updateScopeCount();
        });
    });
    updateScopeCount();

    // Scan
    let scanCancelled = false;

    function scanLog(boxId, msg, color='#a1a1aa') {
        const box = document.getElementById(boxId);
        if (!box) return;
        const d = document.createElement('div');
        d.style.color = color;
        d.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
        box.appendChild(d);
        box.scrollTop = box.scrollHeight;
    }

    function setScanButtons(startId, cancelId, running) {
        const start  = document.getElementById(startId);
        const cancel = document.getElementById(cancelId);
        if (start)  { start.disabled = running;  start.style.opacity  = running ? '0.4' : '1'; }
        if (cancel) {
            cancel.disabled = !running;
            cancel.style.color       = running ? '#ef4444' : '#52525b';
            cancel.style.borderColor = running ? '#7f1d1d' : '#27272a';
        }
    }

    function setScanUI(r) { setScanButtons("btnScanStart","btnScanCancel",r); }

    document.getElementById('btnScanCancel').onclick = () => { scanCancelled = true; };

    document.getElementById('btnScanStart').onclick = async () => {
        scanCancelled = false;
        setScanUI(true);
        document.getElementById('scanLog').innerHTML = '';
        document.getElementById('syncNew').textContent  = '0';
        document.getElementById('syncPage').textContent = '0';
        const bar = document.getElementById('scanBar');
        bar.style.width = '5%'; bar.style.background = '#38bdf8';
        scanLog('scanLog','Starting scan…', '#38bdf8');

        const existingIds = new Set(processedData.map(c => c.id));
        const newItems = [];
        for (let page = 0; !scanCancelled; page++) {
            document.getElementById('syncPage').textContent = page;
            scanLog('scanLog',`Fetching page ${page}…`);
            try {
                const data = await fetchCommentPage(page);
                if (!data) { log('No more pages.', '#47cf73'); break; }
                let hit = false;
                for (const c of data) {
                    if (existingIds.has(c.id)) { hit = true; break; }
                    newItems.push(enrichComment(c));
                }
                document.getElementById('syncNew').textContent = newItems.length;
                bar.style.width = Math.min(95, 5 + newItems.length / 2) + '%';
                if (hit) { log(`Reached cached data at page ${page}.`, '#47cf73'); break; }
                await new Promise(r => setTimeout(r, FETCH_DELAY));
            } catch(e) { log(`Error: ${e.message}`, '#ef4444'); break; }
        }
        if (scanCancelled) log('Cancelled.', '#eab308');

        if (newItems.length) {
            scanLog('scanLog',`Saving ${newItems.length} comments…`);
            await dbPutAll(db, newItems);
            newItems.map(hydrateComment).forEach(c => {
                processedData.unshift(c);
                idMap.set(c.id, 0); // rough — rebuilding below
            });
            // Rebuild idMap
            processedData.forEach((c,i) => idMap.set(c.id, i));
            document.getElementById('syncCached').textContent = processedData.length;
            document.getElementById('dbCount').textContent    = processedData.length;
            log(`✓ Done. ${newItems.length} added.`, '#47cf73');
            updateScopeCount();
            updateDashboard();
        } else {
            scanLog('scanLog','No new comments found.', '#47cf73');
        }
        bar.style.width = '100%';
        bar.style.background = newItems.length ? '#47cf73' : '#38bdf8';
        setScanUI(false);
    };

    // Refresh scores
    let refreshCancelled = false;

    function setRefreshUI(r) { setScanButtons("btnRefreshStart","btnRefreshCancel",r); document.querySelectorAll('.scope-btn').forEach(b => b.disabled = r); }

    document.getElementById('btnRefreshCancel').onclick = () => { refreshCancelled = true; };

    document.getElementById('btnRefreshStart').onclick = async () => {
        refreshCancelled = false;
        setRefreshUI(true);

        const bar  = document.getElementById('refreshBar');
        const lbl  = document.getElementById('refreshLbl');
        const dw   = document.getElementById('diffWrap');
        const db2  = document.getElementById('diffBody');
        const ds   = document.getElementById('diffSummary');

        bar.style.width = '0%'; bar.style.background = '#47cf73';
        lbl.textContent = '';
        ['refreshChanged','refreshGained','refreshLost'].forEach(id =>
            document.getElementById(id).textContent = '…'
        );
        dw.style.display = 'none'; db2.innerHTML = '';

        // Page-scan approach: one request = 50 comments, compare with stored
        // Scope = how many pages back to scan (50 comments/page)
        const maxPages = scopeDays === 0 ? 9999 :
                         scopeDays <= 7  ? 1    :
                         scopeDays <= 30 ? 5    :
                         scopeDays <= 90 ? 20   : 9999;

        const diffs = [];
        let scanned = 0;

        for (let page = 0; page < maxPages && !refreshCancelled; page++) {
            lbl.textContent = `Fetching page ${page}… (${scanned} scanned, ${diffs.length} changed)`;
            bar.style.width = Math.min(95, page / Math.max(maxPages, 1) * 100) + '%';

            try {
                const data = await fetchCommentPage(page);
                if (!data || !data.length) { lbl.textContent += ' — no more pages'; break; }

                // Check if we've gone past the scope window
                const cutoff = scopeDays > 0 ? Date.now() - scopeDays * 86400000 : -Infinity;
                let pastWindow = false;

                data.forEach(fresh => {
                    const orig = processedData[idMap.get(fresh.id)];
                    if (!orig) return;

                    const freshDate = fresh.datetime ? fresh.datetime * 1000 : 0;
                    if (scopeDays > 0 && freshDate < cutoff) { pastWindow = true; return; }

                    scanned++;
                    const newPts   = fresh.point_count  ?? fresh.points ?? orig._pts;
                    const newUps   = fresh.upvote_count ?? fresh.ups    ?? null;
                    const newDowns = fresh.downvote_count ?? fresh.downs ?? null;

                    // Coerce to numbers to avoid type mismatch false positives
                    const ptsDelta   = +newPts   - +orig._pts;
                    const upsDelta   = newUps   !== null ? +newUps   - +orig._ups   : null;
                    const downsDelta = newDowns !== null ? +newDowns - +orig._downs : null;

                    if (ptsDelta !== 0 || (upsDelta !== null && upsDelta !== 0) || (downsDelta !== null && downsDelta !== 0)) {
                        diffs.push({ orig, r: { pts: +newPts, ups: newUps !== null ? +newUps : null, downs: newDowns !== null ? +newDowns : null } });
                    }
                });

                document.getElementById('refreshChanged').textContent = diffs.length;
                document.getElementById('refreshGained').textContent  = diffs.filter(d=>d.r.pts>d.orig._pts).length;
                document.getElementById('refreshLost').textContent    = diffs.filter(d=>d.r.pts<d.orig._pts).length;

                if (pastWindow) { lbl.textContent += ' — reached scope window'; break; }
                await new Promise(r => setTimeout(r, FETCH_DELAY));
            } catch(e) { lbl.textContent = `Error: ${e.message}`; break; }
        }

        if (diffs.length) {
            const dbRows = diffs.map(({orig, r}) => {
                const idx = idMap.get(orig.id);
                if (idx !== undefined) {
                    const c = processedData[idx];
                    c._pts=r.pts; if(r.ups!==null){c._ups=r.ups; c._downs=r.downs;}
                    c._totalVotes=(r.ups??orig._ups)+(r.downs??orig._downs);
                    c.points=r.pts; if(r.ups!==null){c.ups=r.ups; c.downs=r.downs;}
                }
                return { ...orig, _pts:r.pts,
                    _ups:r.ups??orig._ups, _downs:r.downs??orig._downs,
                    _totalVotes:(r.ups??orig._ups)+(r.downs??orig._downs),
                    points:r.pts, ups:r.ups??orig._ups, downs:r.downs??orig._downs,
                    _date: orig._date instanceof Date ? orig._date.toISOString() : orig._date,
                    _words:undefined, _cleanWords:undefined
                };
            });
            await dbPutAll(db, dbRows);

            processedData.forEach(c => {
                if (c._pts < bounds.minScore) bounds.minScore = c._pts;
                if (c._pts > bounds.maxScore) bounds.maxScore = c._pts;
            });

            const netDelta = diffs.reduce((s,d) => s+(d.r.pts-d.orig._pts), 0);
            try {
                const histKey = `imgurScoreHistory_${USERNAME}`;
                const hist = JSON.parse(localStorage.getItem(histKey) || '[]');
                hist.push({ ts: Date.now(), pool: scanned, changed: diffs.length, net: netDelta });
                if (hist.length > 50) hist.splice(0, hist.length - 50);
                localStorage.setItem(histKey, JSON.stringify(hist));
            } catch {}

            updateDashboard();

            ds.textContent = `Scanned ${scanned} • Changed ${diffs.length} • Net ${netDelta>=0?'+':''}${netDelta} • Gained ${diffs.filter(d=>d.r.pts>d.orig._pts).length} • Lost ${diffs.filter(d=>d.r.pts<d.orig._pts).length}`;
            const sorted = [...diffs].sort((a,b)=>Math.abs(b.r.pts-b.orig._pts)-Math.abs(a.r.pts-a.orig._pts));
            db2.innerHTML = sorted.map(({orig:o, r}) => {
                const ptsDelta   = r.pts - o._pts;
                const upsDelta   = r.ups   !== null ? r.ups   - o._ups   : null;
                const downsDelta = r.downs !== null ? r.downs - o._downs : null;
                const sign = v => v > 0 ? `+${v}` : String(v);
                const deltaCell = (v, oldV, newV) => {
                    if (v === null) return '<td style="padding:4px 6px;color:#52525b;font-family:monospace;font-size:11px;">—</td>';
                    const c = v > 0 ? '#47cf73' : v < 0 ? '#ef4444' : '#a1a1aa';
                    return `<td style="padding:4px 6px;font-family:monospace;font-size:11px;">
                        <span style="color:${c};font-weight:700;">${sign(v)}</span>
                        <span style="color:#52525b;font-size:10px;"> (${oldV}→${newV})</span>
                    </td>`;
                };
                const ds2 = o._date instanceof Date ? o._date.toLocaleDateString() : '?';
                const snip = (o.text||'').slice(0,55).replace(/</g,'&lt;').replace(/\n/g,' ');
                return `<tr style="border-bottom:1px solid #1a1a1f;">
                    ${deltaCell(ptsDelta,  o._pts,  r.pts)}
                    ${deltaCell(upsDelta,  o._ups,  r.ups  ?? '?')}
                    ${deltaCell(downsDelta,o._downs, r.downs ?? '?')}
                    <td style="padding:4px 6px;color:#a1a1aa;white-space:nowrap;font-size:11px;">${ds2}</td>
                    <td style="padding:4px 6px;color:#d4d4d8;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;">${snip}…</td>
                    <td style="padding:4px 6px;"><a href="${o.permalink}" target="_blank" style="color:#38bdf8;text-decoration:none;font-size:11px;">↗</a></td>
                </tr>`;
            }).join('');
            dw.style.display = 'flex';
        }

        bar.style.width = '100%';
        bar.style.background = diffs.length ? '#eab308' : '#47cf73';
        lbl.textContent = diffs.length ? `Done — ${diffs.length} updated` : `Done — all unchanged`;
        setRefreshUI(false);
    };

    // ── Post Stats Refresh — re-scans submissions pages (50 posts/request) ──
    let postRefreshCancelled = false;
    let postRefreshMaxPages  = 5;

    function setPostRefreshUI(r) { setScanButtons('btnPostRefreshStart','btnPostRefreshCancel',r); document.querySelectorAll('.post-scope-btn').forEach(b => b.disabled = r); }

    document.querySelectorAll('.post-scope-btn').forEach(btn => {
        btn.addEventListener('click', () => {
            postRefreshMaxPages = +btn.dataset.pages;
            document.querySelectorAll('.post-scope-btn').forEach(b => {
                const on = b === btn;
                b.style.background = on ? '#1e3050' : '#18181b';
                b.style.color      = on ? '#38bdf8' : '#a1a1aa';
                b.style.borderColor= on ? '#1d4ed8' : '#3f3f46';
            });
        });
    });

    document.getElementById('btnPostRefreshCancel').onclick = () => { postRefreshCancelled = true; };

    document.getElementById('btnPostRefreshStart').onclick = async () => {
        postRefreshCancelled = false;
        setPostRefreshUI(true);
        const lbl = document.getElementById('postRefreshLbl');
        const bar = document.getElementById('postRefreshBar');
        bar.style.width = '0%'; bar.style.background = '#47cf73';
        document.getElementById('postRefreshScanned').textContent = '0';
        document.getElementById('postRefreshNew').textContent     = '0';

        let updated = 0, newFound = 0;

        async function runPageScan(fetchFn, label, maxPages) {
            for (let page = 0; page < maxPages && !postRefreshCancelled; page++) {
                lbl.textContent = `[${label}] Page ${page}… (${updated} updated, ${newFound} new)`;
                bar.style.width = Math.min(90, Math.round(page / Math.max(maxPages, 1) * 100)) + '%';
                try {
                    const data = await fetchFn(page);
                    if (!data) break;
                    const enriched = data.map(enrichPost);
                    const toStore  = enriched.map(p => ({...p, _date: p._date instanceof Date ? p._date.toISOString() : p._date}));
                    await dbPutAll(db, toStore, STORE_POSTS);
                    const idxMap = new Map(processedPosts.map((p,i) => [p.id, i]));
                    enriched.map(hydratePost).forEach(p => {
                        if (idxMap.has(p.id)) { processedPosts[idxMap.get(p.id)] = p; updated++; }
                        else { processedPosts.unshift(p); newFound++; }
                    });
                    processedPosts.forEach((p,i) => postIdMap.set(p.id, i));
                    document.getElementById('postRefreshScanned').textContent = updated;
                    document.getElementById('postRefreshNew').textContent     = newFound;
                    document.getElementById('postSyncCached').textContent     = processedPosts.length;
                    document.getElementById('postDbCount').textContent        = processedPosts.length;
                    await new Promise(r => setTimeout(r, FETCH_DELAY));
                } catch(e) { lbl.textContent = `Error: ${e.message}`; return; }
            }
        }

        await runPageScan(fetchPostPage, 'Public', postRefreshMaxPages);
        if (!postRefreshCancelled && getStoredToken()) {
            await runPageScan(fetchPrivateImagePage, 'Private Images', postRefreshMaxPages);
            if (!postRefreshCancelled) await runPageScan(fetchPrivateAlbumPage, 'Private Albums', postRefreshMaxPages);
        }

        bar.style.width = '100%';
        bar.style.background = '#47cf73';
        lbl.textContent = postRefreshCancelled
            ? `Cancelled — ${updated} updated, ${newFound} new`
            : `Done — ${updated} updated, ${newFound} new`;
        setPostRefreshUI(false);
        if (updated || newFound) renderPostsTab();
    };

    document.getElementById('btnResetUser').onclick = () => {
        if (!confirm('Reset the tracked username?')) return;
        GM_setValue('imgurDashUser', '');
        alert('Username cleared. Reload to set a new one.');
    };


    document.getElementById('btnClearDB').onclick = async () => {
        if (!confirm('Clear ALL cached comments? Cannot be undone.')) return;
        await dbClear(db, STORE_COMMENTS);
        alert('Comments cache cleared. Reload to re-fetch.');
    };

    document.getElementById('btnClearPostsDB').onclick = async () => {
        if (!confirm('Clear ALL cached posts? Cannot be undone.')) return;
        await dbClear(db, STORE_POSTS);
        processedPosts.length = 0;
        postIdMap.clear();
        document.getElementById('postDbCount').textContent = '0';
        document.getElementById('postSyncCached').textContent = '0';
        alert('Posts cache cleared. Scan again to re-fetch.');
    };

    // ── OAuth UI ─────────────────────────────────────────────

    function updateOAuthUI() {
        const token = getStoredToken();
        const status    = document.getElementById('oauthStatus');
        const connectBtn    = document.getElementById('btnOAuthConnect');
        const disconnectBtn = document.getElementById('btnOAuthDisconnect');
        const privateBtn    = document.getElementById('btnPostScanPrivateStart');

        if (token) {
            status.textContent = '✓ Connected — private posts and images accessible';
            status.style.color = '#47cf73';
            connectBtn.style.display    = 'none';
            disconnectBtn.style.display = 'block';
            if (privateBtn) { privateBtn.disabled = false; privateBtn.style.opacity = '1'; }
        } else {
            status.textContent = 'Not connected — public posts only';
            status.style.color = '#52525b';
            connectBtn.style.display    = 'block';
            disconnectBtn.style.display = 'none';
            if (privateBtn) { privateBtn.disabled = true; privateBtn.style.opacity = '0.4'; }
        }
    }
    updateOAuthUI();

    document.getElementById('btnOAuthConnect').onclick = async () => {
        const btn = document.getElementById('btnOAuthConnect');
        btn.textContent = '⏳ Waiting for auth…';
        btn.disabled = true;
        try {
            await doOAuthFlow();
            updateOAuthUI();
        } catch(e) {
            document.getElementById('oauthStatus').textContent = `Auth failed: ${e.message}`;
            document.getElementById('oauthStatus').style.color = '#ef4444';
        }
        btn.textContent = '🔑 Connect Account';
        btn.disabled = false;
    };

    document.getElementById('btnOAuthDisconnect').onclick = () => {
        clearToken();
        updateOAuthUI();
    };

    // ── Post Scan ────────────────────────────────────────────

    document.getElementById('postSyncCached').textContent = processedPosts.length;

    let postScanCancelled = false;

    function postLog(msg, color='#a1a1aa') {
        const box = document.getElementById('postScanLog');
        const d = document.createElement('div');
        d.style.color = color;
        d.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
        box.appendChild(d);
        box.scrollTop = box.scrollHeight;
    }

    function setPostScanUI(r) { setScanButtons("btnPostScanStart","btnPostScanCancel",r); }

    document.getElementById('btnPostScanCancel').onclick = () => { postScanCancelled = true; };

    // Shared scan runner — used by both public and private scan
    async function runPostScan(fetchPage, label, color, forceUpdate=false) {
        postScanCancelled = false;
        setPostScanUI(true);
        document.getElementById('postScanLog').innerHTML = '';
        document.getElementById('postSyncNew').textContent  = '0';
        document.getElementById('postSyncPage').textContent = '0';
        const bar = document.getElementById('postScanBar');
        const plog = (msg, col) => scanLog('postScanLog', msg, col);
        bar.style.width = '5%'; bar.style.background = color;
        plog(`Starting ${label} scan…`, color);

        const existingIds = new Set(processedPosts.map(p => p.id));
        const newPosts = [], updatedPosts = [];

        for (let page = 0; !postScanCancelled; page++) {
            document.getElementById('postSyncPage').textContent = page;
            plog(`Fetching page ${page}…`);
            try {
                const data = await fetchPage(page);
                if (!data) { plog('No more pages — done.', '#47cf73'); break; }
                let hit = false;
                for (const p of data) {
                    if (existingIds.has(p.id)) {
                        if (forceUpdate) updatedPosts.push(enrichPost(p));
                        else { hit = true; break; }
                    } else {
                        newPosts.push(enrichPost(p));
                    }
                }
                document.getElementById('postSyncNew').textContent = newPosts.length + updatedPosts.length;
                bar.style.width = Math.min(95, 5 + (newPosts.length + updatedPosts.length) / 2) + '%';
                if (hit) { plog(`Hit cached record at page ${page}.`, '#47cf73'); break; }
                await new Promise(r => setTimeout(r, FETCH_DELAY));
            } catch(e) { plog(`Error: ${e.message}`, '#ef4444'); break; }
        }
        if (postScanCancelled) plog('Cancelled.', '#eab308');

        const allNew = [...newPosts, ...updatedPosts];
        if (allNew.length) {
            plog(`Saving ${newPosts.length} new, ${updatedPosts.length} updated…`);
            const toStore = allNew.map(p => ({...p, _date: p._date instanceof Date ? p._date.toISOString() : p._date}));
            await dbPutAll(db, toStore, STORE_POSTS);
            // Update existing records in memory
            const updateMap = new Map(updatedPosts.map(p => [p.id, hydratePost(p)]));
            for (let i = 0; i < processedPosts.length; i++) {
                if (updateMap.has(processedPosts[i].id)) processedPosts[i] = updateMap.get(processedPosts[i].id);
            }
            // Prepend new posts
            newPosts.map(hydratePost).forEach(p => processedPosts.unshift(p));
            processedPosts.forEach((p,i) => postIdMap.set(p.id, i));
            document.getElementById('postSyncCached').textContent = processedPosts.length;
            document.getElementById('postDbCount').textContent    = processedPosts.length;
            plog(`✓ ${newPosts.length} added, ${updatedPosts.length} updated.`, '#47cf73');
            renderPostsTab();
        } else {
            plog('No new posts found.', '#47cf73');
        }

        bar.style.width = '100%';
        bar.style.background = allNew.length ? '#47cf73' : color;
        setPostScanUI(false);
    }

    document.getElementById('btnPostScanStart').onclick = () =>
        runPostScan(fetchPostPage, 'public', '#f97316', true); // forceUpdate: overwrite private-only records

    document.getElementById('btnPostScanPrivateStart').onclick = async () => {
        if (!getStoredToken()) {
            scanLog('postScanLog','Not connected — click "Connect Account" first.', '#ef4444');
            return;
        }
        // Scan images and albums separately, merge
        // Run images scan first, then albums
        scanLog('postScanLog','Scanning private images…', '#38bdf8');
        await runPostScan(fetchPrivateImagePage, 'private images', '#38bdf8');
        scanLog('postScanLog','Scanning private albums…', '#38bdf8');
        await runPostScan(fetchPrivateAlbumPage, 'private albums', '#38bdf8');
    };

    // ─── EXPORT ───────────────────────────────────────────────

    function download(name, content, mime) {
        const a = Object.assign(document.createElement('a'), {
            href: URL.createObjectURL(new Blob([content], {type:mime})),
            download: name
        });
        a.click(); URL.revokeObjectURL(a.href);
    }

    // ─── POSTS TAB ────────────────────────────────────────────

    // Posts lazy loader — created lazily with correct root when tab first renders
    let postLazyObs = null;

    function setupPostLazy() {
        if (postLazyObs) postLazyObs.disconnect();
        // Use postSubGallery as root if visible, otherwise fall back to null (viewport)
        const galleryEl = document.getElementById('postSubGallery');
        const root = (galleryEl && galleryEl.style.display !== 'none') ? galleryEl : null;
        postLazyObs = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                if (!entry.isIntersecting) return;
                const ph = entry.target;
                swapPlaceholder(ph, ph.dataset.src,
                    'width:100%;height:180px;object-fit:cover;display:block;');
                postLazyObs.unobserve(ph);
            });
        }, { root, rootMargin: '50px' });
    }

    function applyPostFilters() {
        const vis  = document.getElementById('postFilterVis')?.value  || 'all';
        const type = document.getElementById('postFilterType')?.value || 'all';
        const dateFrom = document.getElementById('filterDateFrom')?.value;
        const dateTo   = document.getElementById('filterDateTo')?.value;
        const fromMs   = dateFrom ? new Date(dateFrom).getTime()           : -Infinity;
        const toMs     = dateTo   ? new Date(dateTo+'T23:59:59').getTime() :  Infinity;

        return processedPosts.filter(p => {
            if (p._date) {
                const t = p._date.getTime();
                if (t < fromMs || t > toMs) return false;
            }
            if (vis === 'gallery' && !p._inGallery) return false;
            if (vis === 'hidden'  &&  p._inGallery) return false;
            if (vis === 'nsfw'    && !p._nsfw)       return false;
            if (type === 'album'  && !p._isAlbum)    return false;
            if (type === 'image'  &&  p._isAlbum)    return false;
            return true;
        });
    }

    function renderPostsTab() {
        const POST_BATCH = 20;
        const grid = document.getElementById('postGrid');
        console.log('[Posts] grid:', grid, 'processedPosts:', processedPosts.length);
        if (!grid) return;
        const galleryHeader = document.getElementById('postGalleryHeader');
        const statBlock     = document.getElementById('postStatBlock');
        console.log('[Posts] galleryHeader:', !!galleryHeader, 'statBlock:', !!statBlock);
        if (!galleryHeader || !statBlock) return;

        // (Re)create lazy observer with correct scrolling root
        setupPostLazy();

        if (!processedPosts.length) {
            grid.innerHTML = `
                <div style="grid-column:1/-1;display:flex;flex-direction:column;align-items:center;
                    justify-content:center;gap:12px;padding:60px 20px;text-align:center;color:#a1a1aa;">
                    <div style="font-size:40px;">📸</div>
                    <div style="font-size:14px;color:#e4e4e7;">No posts cached yet</div>
                    <div style="font-size:12px;color:#52525b;">Go to the <b style="color:#f97316;">⟳ Sync</b> tab and click <b style="color:#f97316;">Scan Public</b> to fetch your submissions.</div>
                </div>`;
            statBlock.innerHTML = '';
            return;
        }

        const posts  = applyPostFilters();
        const sortKey= document.getElementById('postSort')?.value || 'newest';

        const sorted = [...posts].sort((a,b) => {
            if (sortKey==='newest')   return (b._date?.getTime()||0)-(a._date?.getTime()||0);
            if (sortKey==='oldest')   return (a._date?.getTime()||0)-(b._date?.getTime()||0);
            if (sortKey==='score')    return b._pts - a._pts;
            if (sortKey==='views')    return b._views - a._views;
            if (sortKey==='comments') return b._commentCt - a._commentCt;
            return 0;
        });

        // ── Stat pills ──────────────────────────────────────────
        const totalViews  = posts.reduce((s,p)=>s+p._views,0);
        const totalPts    = posts.reduce((s,p)=>s+p._pts,0);
        const avgPts      = posts.length ? (totalPts/posts.length).toFixed(1) : 0;
        const galleryPct  = posts.length ? Math.round(posts.filter(p=>p._inGallery).length/posts.length*100) : 0;
        const peakViews   = posts.length ? Math.max(...posts.map(p=>p._views)) : 0;
        const peakScore   = posts.length ? Math.max(...posts.map(p=>p._pts)) : 0;

        statBlock.innerHTML = [
            ['Showing',     `${Math.min(POST_BATCH, posts.length)} of ${posts.length}`],
            ['Total Cached', processedPosts.length],
            ['Views',       totalViews >= 1000 ? (totalViews/1000).toFixed(1)+'k' : totalViews],
            ['Net Score',   totalPts],
            ['Avg Score',   avgPts],
            ['Peak Score',  peakScore],
            ['Peak Views',  peakViews >= 1000 ? (peakViews/1000).toFixed(1)+'k' : peakViews],
            ['Gallery',     `${galleryPct}%`],
            ['Albums',      posts.filter(p=>p._isAlbum).length],
        ].map(([k,v]) => `
            <div style="background:#09090b;padding:4px 8px;border-radius:4px;border:1px solid #27272a;white-space:nowrap;flex-shrink:0;">
                <span style="color:#71717a;font-size:10px;text-transform:uppercase;letter-spacing:0.04em;">${k} </span>
                <span style="color:#e4e4e7;font-size:11px;font-weight:600;font-family:monospace;">${v}</span>
            </div>`).join('');

        // ── Charts ──────────────────────────────────────────────
        const ym = [...new Set(posts.filter(p=>p._ymKey).map(p=>p._ymKey))].sort();
        const ymMap = {};
        posts.forEach(p => {
            if (!p._ymKey) return;
            if (!ymMap[p._ymKey]) ymMap[p._ymKey] = {n:0,pts:0,views:0,comments:0};
            ymMap[p._ymKey].n++;
            ymMap[p._ymKey].pts     += p._pts;
            ymMap[p._ymKey].views   += p._views;
            ymMap[p._ymKey].comments+= p._commentCt;
        });

        renderChart('cvPostTrend','line',{
            labels:ym,
            datasets:[
                {label:'Posts',data:ym.map(k=>ymMap[k].n),borderColor:'#f97316',tension:.15,yAxisID:'y1'},
                {label:'Avg Score',data:ym.map(k=>ymMap[k].n?+(ymMap[k].pts/ymMap[k].n).toFixed(1):0),borderColor:'#eab308',tension:.15,yAxisID:'y2'},
            ]
        }, dualAxis('Post Volume & Avg Score Over Time'));

        renderChart('cvPostViews','line',{
            labels:ym,
            datasets:[
                {label:'Total Views',data:ym.map(k=>ymMap[k].views),borderColor:'#38bdf8',tension:.15,yAxisID:'y1'},
                {label:'Total Comments',data:ym.map(k=>ymMap[k].comments),borderColor:'#a855f7',tension:.15,yAxisID:'y2'},
            ]
        }, dualAxis('Views & Comments Over Time'));

        // Visibility pie: gallery vs hidden
        const galCount    = posts.filter(p=>p._inGallery && !p._nsfw).length;
        const hiddenCount = posts.filter(p=>!p._inGallery).length;
        const nsfwCount   = posts.filter(p=>p._nsfw).length;
        renderChart('cvPostVisibility','doughnut',{
            labels:['Gallery (public)','Hidden','NSFW'],
            datasets:[{data:[galCount,hiddenCount,nsfwCount],backgroundColor:['#47cf73','#71717a','#ef4444'],borderWidth:0}]
        },{...titled('Post Visibility Split'),scales:{}});

        // Engagement rate (pts per 1k views) over time
        renderChart('cvPostEngRate','bar',{
            labels:ym,
            datasets:[{
                label:'Pts per 1k views',
                data:ym.map(k=>{
                    const m=ymMap[k];
                    return m.views>0 ? +(m.pts/m.views*1000).toFixed(2) : 0;
                }),
                backgroundColor:'#10b981'
            }]
        }, titled('Engagement Rate (pts / 1k views)'));

        // Hour of day distribution
        const hourMap = Array.from({length:24},()=>({n:0,pts:0}));
        posts.forEach(p=>{ if(p._hour!==null){hourMap[p._hour].n++;hourMap[p._hour].pts+=p._pts;}});
        renderChart('cvPostHour','bar',{
            labels:HOUR_LABELS,
            datasets:[
                {label:'Posts',data:hourMap.map(h=>h.n),backgroundColor:'#f97316',yAxisID:'y1'},
                {label:'Avg Score',data:hourMap.map(h=>h.n?+(h.pts/h.n).toFixed(1):0),backgroundColor:'#eab308',yAxisID:'y2'},
            ]
        }, dualAxis('Post Performance by Hour'));

        // Top tags
        const tagFreq={};
        posts.forEach(p=>p._tags.forEach(t=>{ tagFreq[t]=(tagFreq[t]||0)+1; }));
        const topTags = Object.entries(tagFreq).sort((a,b)=>b[1]-a[1]).slice(0,14);
        renderChart('cvPostTags','bar',{
            labels:topTags.map(([t])=>t),
            datasets:[{label:'Posts with tag',data:topTags.map(([,n])=>n),backgroundColor:'#a855f7'}]
        }, titled('Top Tags'));

        // Score distribution
        const sDist={'<0':0,'0-1':0,'2-10':0,'11-50':0,'51-200':0,'201+':0};
        posts.forEach(p=>{
            const k=p._pts<0?'<0':p._pts<=1?'0-1':p._pts<=10?'2-10':p._pts<=50?'11-50':p._pts<=200?'51-200':'201+';
            sDist[k]++;
        });
        renderChart('cvPostScoreDist','bar',{
            labels:Object.keys(sDist),
            datasets:[{label:'Posts',data:Object.values(sDist),backgroundColor:'#14b8a6'}]
        }, titled('Post Score Distribution'));

        // ── Gallery grid ────────────────────────────────────────
        galleryHeader.textContent = `${sorted.length} posts — ${sortKey}`;

        grid.innerHTML = '';

        // Render first batch, load more on scroll
        let postRenderIdx = 0;

        function renderPostBatch() {
            const slice = sorted.slice(postRenderIdx, postRenderIdx + POST_BATCH);
            if (!slice.length) return;
            slice.forEach(p => {
            const card = document.createElement('div');
            card.style.cssText = `background:#18181b;border:1px solid #27272a;border-radius:10px;
                overflow:hidden;display:flex;flex-direction:column;cursor:pointer;
                transition:border-color 0.15s;`;
            card.onmouseenter = () => card.style.borderColor = '#3f3f46';
            card.onmouseleave = () => card.style.borderColor = '#27272a';

            // Determine preview media — use data-src placeholder, load on scroll
            let previewEl;
            if (p.cover_link) {
                const isGifV = /\.(gifv|mp4|webm)(\?|$)/i.test(p.cover_link);
                const isGif  = /\.gif(\?|$)/i.test(p.cover_link);
                const src = p.cover_link.replace('.gifv', '.mp4');
                previewEl = document.createElement('div');
                previewEl.className = 'post-lazy-ph';
                previewEl.dataset.src  = src;
                previewEl.dataset.type = isGifV ? 'video' : 'img';  // .gif uses img, .gifv uses video
                previewEl.style.cssText = 'width:100%;height:180px;background:#101012;display:flex;align-items:center;justify-content:center;';
                previewEl.innerHTML = `<div style="font-size:11px;color:#52525b;">${isGifV ? '🎬' : isGif ? '🎞️' : '🖼️'}</div>`;
            } else {
                previewEl = document.createElement('div');
                previewEl.style.cssText = 'width:100%;height:180px;background:#101012;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;';
                previewEl.innerHTML = `
                    <div style="font-size:36px;">${p._isAlbum ? '📁' : '🖼️'}</div>
                    <div style="font-size:11px;color:#52525b;">${p._isAlbum ? `${p._imageCount} images` : 'No preview'}</div>`;
            }

            // Badges
            const visBadge = p._inGallery
                ? `<span style="background:rgba(71,207,115,0.15);color:#47cf73;padding:2px 6px;border-radius:3px;font-size:9px;font-weight:700;">GALLERY</span>`
                : `<span style="background:rgba(113,113,122,0.15);color:#71717a;padding:2px 6px;border-radius:3px;font-size:9px;">HIDDEN</span>`;
            const nsfwBadge = p._nsfw
                ? `<span style="background:rgba(239,68,68,0.15);color:#ef4444;padding:2px 6px;border-radius:3px;font-size:9px;font-weight:700;">NSFW</span>` : '';
            const albumBadge = p._isAlbum
                ? `<span style="background:rgba(168,85,247,0.15);color:#a855f7;padding:2px 6px;border-radius:3px;font-size:9px;">📁 ${p._imageCount}</span>` : '';

            const ptsColor = p._pts > 0 ? '#eab308' : p._pts < 0 ? '#ef4444' : '#71717a';
            const dateStr = p._date ? p._date.toLocaleDateString(undefined, {year:'2-digit',month:'short',day:'numeric'}) : '';

            card.innerHTML = `
                <div class="post-ph-wrap" style="overflow:hidden;border-radius:10px 10px 0 0;"></div>
                <div style="padding:8px 10px;display:flex;flex-direction:column;gap:5px;flex:1;">
                    <div style="display:flex;gap:4px;flex-wrap:wrap;align-items:center;">
                        ${visBadge}${nsfwBadge}${albumBadge}
                        <span style="color:#52525b;font-size:9px;margin-left:auto;">${dateStr}</span>
                    </div>
                    <a href="${p.link}" target="_blank" style="color:#e4e4e7;font-size:12px;font-weight:600;
                        text-decoration:none;overflow:hidden;display:-webkit-box;
                        -webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.4;">
                        ${(p.title || '(untitled)').replace(/</g,'&lt;')}
                    </a>
                    <div style="display:flex;gap:8px;font-size:11px;font-family:monospace;flex-wrap:wrap;align-items:center;">
                        <span style="color:${ptsColor};font-weight:700;">${p._pts > 0 ? '+' : ''}${p._pts}</span>
                        <span style="color:#a1a1aa;">👁 ${p._views >= 1000 ? (p._views/1000).toFixed(1)+'k' : p._views}</span>
                        <span style="color:#a1a1aa;">↑${p._ups} ↓${p._downs}</span>
                        <span style="color:#a1a1aa;">💬${p._commentCt}</span>
                        <span style="color:#a1a1aa;">⭐${p._favCt}</span>
                    </div>
                    ${p._tags.length ? `<div style="display:flex;gap:3px;flex-wrap:wrap;">
                        ${p._tags.slice(0,4).map(t =>
                            `<span style="background:#1a1a2e;color:#71717a;padding:1px 5px;border-radius:3px;font-size:9px;">${t}</span>`
                        ).join('')}
                    </div>` : ''}
                </div>
            `;
            card.querySelector('.post-ph-wrap').appendChild(previewEl);
            grid.appendChild(card);

            // Observe the placeholder for lazy loading
            if (previewEl.classList.contains('post-lazy-ph') && postLazyObs) {
                postLazyObs.observe(previewEl);
            }
        });
        postRenderIdx += POST_BATCH;
        }

        renderPostBatch();

        // Load more on scroll
        const postGalleryEl = document.getElementById('postSubGallery');
        if (postGalleryEl) {
            postGalleryEl.onscroll = () => {
                if (postRenderIdx >= sorted.length) return;
                const { scrollTop, scrollHeight, clientHeight } = postGalleryEl;
                if (scrollTop + clientHeight >= scrollHeight - 400) renderPostBatch();
            };
        }
    }

    // Sub-tab switching within posts tab
    document.querySelectorAll('.post-sub-btn').forEach(btn => {
        btn.addEventListener('click', () => {
            const sub = btn.dataset.sub;
            document.querySelectorAll('.post-sub-btn').forEach(b => {
                const isGallery = b.dataset.sub === 'gallery';
                const on = b === btn;
                b.style.background = on ? '#f97316' : '#18181b';
                b.style.color      = on ? '#09090b' : '#a1a1aa';
                b.style.fontWeight = on ? '700' : '600';
            });
            document.getElementById('postSubGallery')?.style.setProperty('display', sub === 'gallery' ? 'block' : 'none');
            document.getElementById('postSubCharts')?.style.setProperty('display', sub === 'charts' ? 'block' : 'none');
            if (sub === 'charts') renderPostsTab();
        });
    });

    // Wire post filters
    ['postFilterVis','postFilterType','postSort'].forEach(id => {
        document.getElementById(id)?.addEventListener('change', renderPostsTab);
    });

    // ─── MAIN UPDATE ──────────────────────────────────────────

    function getFilters() {
        return {
            minWords:  +document.getElementById('filterMinWords').value  || 0,
            minScore:  +document.getElementById('filterMinScore').value,
            maxScore:  +document.getElementById('filterMaxScore').value,
            minVotes:  +document.getElementById('filterMinVotes').value  || 0,
            minHour:   +document.getElementById('filterMinHour').value,
            maxHour:   +document.getElementById('filterMaxHour').value,
            mediaMode: document.getElementById('filterMedia').value,
            unseenOnly:document.getElementById('filterUnseen').checked,
            textQuery: document.getElementById('filterText').value.trim().toLowerCase(),
            dateFrom:  document.getElementById('filterDateFrom').value,
            dateTo:    document.getElementById('filterDateTo').value,
        };
    }

    function applyFilters(f) {
        const fromMs = f.dateFrom ? new Date(f.dateFrom).getTime()            : -Infinity;
        const toMs   = f.dateTo   ? new Date(f.dateTo+'T23:59:59').getTime()  :  Infinity;
        return processedData.filter(c => {
            if (c._wordCount < f.minWords) return false;
            if (c._pts < f.minScore || c._pts > f.maxScore) return false;
            if (c._totalVotes < f.minVotes) return false;
            if (c._hour !== null) {
                const ok = f.minHour <= f.maxHour
                    ? c._hour >= f.minHour && c._hour <= f.maxHour
                    : c._hour >= f.minHour || c._hour <= f.maxHour;
                if (!ok) return false;
            }
            if (c._date) {
                const t = c._date.getTime();
                if (t < fromMs || t > toMs) return false;
            }
            if (f.mediaMode==='media'   && !c._hasMedia) return false;
            if (f.mediaMode==='nomedia' &&  c._hasMedia) return false;
            if (f.unseenOnly && !c._isUnseen)   return false;
            if (f.textQuery  && !c.text.toLowerCase().includes(f.textQuery)) return false;
            return true;
        });
    }

    function aggregate(filtered) {
        const acc = {
            totalPts:0, totalUps:0, totalDowns:0, mediaCount:0,
            timeMap:{}, hourMap:Array.from({length:24},()=>({n:0,pts:0})),
            weekMap:Array.from({length:7},()=>({n:0,pts:0})),
            monthMap:Array.from({length:12},()=>({n:0,pts:0})),
            mediaDist:{"Plain Text":{n:0,pts:0},"Generic URL":{n:0,pts:0},
                "Static Image":{n:0,pts:0},"GIF/Video":{n:0,pts:0},"Mixed Media":{n:0,pts:0}},
            lenBuckets:{"1-5":{n:0,pts:0},"6-15":{n:0,pts:0},"16-30":{n:0,pts:0},"31-50":{n:0,pts:0},"51+":{n:0,pts:0}},
            scoreDist:{"<0":0,"0-1":0,"2-5":0,"6-20":0,"21-100":0,"101+":0},
            puncBkt:{'?':0,'!':0,'...':0,'ALL CAPS':0,'URL/Media':0,'Plain':0},
            velBins:{"<1h":0,"1-12h":0,"12-24h":0,"1-7d":0,"7d+":0},
            wordFreq:{}, controversy:[], lenScat:[],
            nextMedia:[], commentDays:new Set(),
            postMap:{},
            // syntax score sums (computed in single pass)
            synQ:{n:0,pts:0}, synEx:{n:0,pts:0}, synPlain:{n:0,pts:0}, synUrl:{n:0,pts:0},
        };

        // Sort filtered by date for velocity to be meaningful
        const sorted = [...filtered].sort((a,b) => {
            const ta = a._date ? a._date.getTime() : 0;
            const tb = b._date ? b._date.getTime() : 0;
            return ta - tb;
        });

        sorted.forEach((c, idx) => {
            acc.totalPts   += c._pts;
            acc.totalUps   += c._ups;
            acc.totalDowns += c._downs;

            // Media pool
            if (c._hasMedia) {
                acc.mediaCount++;
                c._mediaUrls.forEach(m => {
                    if (m.type==='img'||m.type==='gif') acc.nextMedia.push({comment:c, media:m});
                });
            }

            // Media bucket
            if (acc.mediaDist[c._mediaBucket]) {
                acc.mediaDist[c._mediaBucket].n++;
                acc.mediaDist[c._mediaBucket].pts += c._pts;
            }

            // Length bucket
            const lk = c._wordCount<=5?'1-5':c._wordCount<=15?'6-15':c._wordCount<=30?'16-30':c._wordCount<=50?'31-50':'51+';
            acc.lenBuckets[lk].n++; acc.lenBuckets[lk].pts += c._pts;

            // Length scatter (1-in-5 sample)
            if (idx%5===0) acc.lenScat.push({x:c._wordCount, y:c._pts});

            // Score dist
            const sk = c._pts<0?'<0':c._pts<=1?'0-1':c._pts<=5?'2-5':c._pts<=20?'6-20':c._pts<=100?'21-100':'101+';
            acc.scoreDist[sk]++;

            // Punctuation (single pass for syntax chart too)
            const txt = c.text;
            const hasQ   = txt.includes('?'), hasEx = txt.includes('!');
            const hasDot = /[?!.]/.test(txt);
            const hasUrl = c._hasMedia || /https?:\/\//.test(txt);
            if (hasQ)   { acc.puncBkt['?']++; acc.synQ.n++;   acc.synQ.pts   += c._pts; }
            if (hasEx)  { acc.puncBkt['!']++; acc.synEx.n++;  acc.synEx.pts  += c._pts; }
            if (hasUrl) { acc.puncBkt['URL/Media']++; acc.synUrl.n++; acc.synUrl.pts += c._pts; }
            if (txt.includes('...')) acc.puncBkt['...']++;
            if (c._isAllCaps) acc.puncBkt['ALL CAPS']++;
            if (!hasDot && !hasUrl) { acc.puncBkt['Plain']++; acc.synPlain.n++; acc.synPlain.pts += c._pts; }

            // Word freq — skip URL fragments and file extensions
            c._cleanWords.forEach(w => {
                if (!STOPWORDS.has(w) && w.length > 2 && !URL_NOISE.has(w))
                    acc.wordFreq[w] = (acc.wordFreq[w]||0) + 1;
            });

            // Controversy + polar
            if (c._ups > 0 && c._downs > 0) {
                const total   = c._ups + c._downs;
                const balance = 1 - Math.abs(c._ups - c._downs) / total;
                // controversy = total_votes × balance²  (rewards both high engagement AND split)
                acc.controversy.push({ x: total, y: parseFloat(balance.toFixed(3)), c });
            }
            // Post map
            if (c.post_id) {
                if (!acc.postMap[c.post_id]) acc.postMap[c.post_id]={n:0,pts:0,permalink:c.permalink};
                acc.postMap[c.post_id].n++;
                acc.postMap[c.post_id].pts += c._pts;
            }

            // Temporal
            if (c._date) {
                acc.commentDays.add(c._date.toDateString());
                const ym = c._ymKey;
                if (ym) {
                    if (!acc.timeMap[ym]) acc.timeMap[ym]={n:0,pts:0,ups:0,downs:0,uqSum:0,wSum:0};
                    const tm = acc.timeMap[ym];
                    tm.n++; tm.pts+=c._pts; tm.ups+=c._ups; tm.downs+=c._downs;
                    tm.uqSum+=c._uniqueWords; tm.wSum+=c._wordCount;
                }
                acc.hourMap[c._hour].n++; acc.hourMap[c._hour].pts+=c._pts;
                acc.weekMap[c._day].n++;  acc.weekMap[c._day].pts+=c._pts;
                acc.monthMap[c._month].n++;acc.monthMap[c._month].pts+=c._pts;

                // Velocity (against date-sorted neighbours)
                if (idx>0 && sorted[idx-1]._date) {
                    const diffH = (c._date - sorted[idx-1]._date) / 3600000;
                    if (diffH>0) {
                        const vk = diffH<1?'<1h':diffH<=12?'1-12h':diffH<=24?'12-24h':diffH<=168?'1-7d':'7d+';
                        acc.velBins[vk]++;
                    }
                }
            }
        });

        return { acc, sorted };
    }

    function computeStreak(commentDays) {
        const days = [...commentDays].map(d => new Date(d)).sort((a,b) => b-a);
        let current=0, longest=0, streak=0;
        const todayMs = new Date().setHours(0,0,0,0);
        for (let i=0; i<days.length; i++) {
            streak = i===0 ? 1
                : (days[i-1]-days[i])/86400000===1 ? streak+1 : 1;
            if (i===0 && (todayMs-days[0].getTime()) < 86400000*2) current=streak;
            if (streak>longest) longest=streak;
        }
        return { current, longest };
    }

    async function updateDashboard() {
        const f = getFilters();

        // Update label displays
        document.getElementById('valMinWords').textContent = f.minWords;
        document.getElementById('valMinVotes').textContent = f.minVotes;
        document.getElementById('valHourRange').textContent = `${f.minHour}–${f.maxHour}`;

        const filtered = applyFilters(f);
        const { acc } = aggregate(filtered);
        const streak   = computeStreak(acc.commentDays);

        // ── Media pool ──
        const mediaSortKey = document.getElementById('mediaSort')?.value || 'newest';
        acc.nextMedia.sort((a,b) => {
            if (mediaSortKey==='newest')     return (b.comment._date ? b.comment._date.getTime() : 0) - (a.comment._date ? a.comment._date.getTime() : 0);
            if (mediaSortKey==='oldest')     return (a.comment._date ? a.comment._date.getTime() : 0) - (b.comment._date ? b.comment._date.getTime() : 0);
            if (mediaSortKey==='highest')    return b.comment._pts - a.comment._pts;
            if (mediaSortKey==='lowest')     return a.comment._pts - b.comment._pts;
            if (mediaSortKey==='engagement') return b.comment._totalVotes - a.comment._totalVotes;
            return 0;
        });
        mediaPool = acc.nextMedia;
        topComments = filtered;

        // Media grid renders on demand when tab is activated (switchTab → initMediaScroll)
        // Also re-render if media tab is currently visible
        if (document.getElementById('tabMedia')?.style.display !== 'none') {
            initMediaScroll();
        }

        // ── Stat block ──
        const avg    = filtered.length ? (acc.totalPts/filtered.length).toFixed(2) : '0';
        const ptsSorted = filtered.map(c=>c._pts).sort((a,b)=>a-b);
        const mid    = Math.floor(ptsSorted.length/2);
        const median = ptsSorted.length ? (ptsSorted.length%2 ? ptsSorted[mid] : ((ptsSorted[mid-1]+ptsSorted[mid])/2).toFixed(1)) : 0;
        const peak   = filtered.length ? Math.max(...filtered.map(c=>c._pts)) : 0;
        const posRate= filtered.length ? ((filtered.filter(c=>c._pts>0).length/filtered.length)*100).toFixed(1) : 0;
        const avgLen = filtered.length ? (filtered.reduce((a,c)=>a+c._wordCount,0)/filtered.length).toFixed(1) : 0;

        document.getElementById('statBlock').innerHTML = [
            ['Filtered',    `${filtered.length}/${processedData.length}`],
            ['Net Score',   acc.totalPts],
            ['Avg Score',   avg],
            ['Median',      median],
            ['Peak',        peak],
            ['Positive',    `${posRate}%`],
            ['Media',       acc.mediaCount],
            ['Avg Words',   `${avgLen}w`],
            ['Upvotes',     acc.totalUps],
            ['Downvotes',   acc.totalDowns],
            ['Streak',      `${streak.current}d / ${streak.longest}d`],
            ['Active Days', acc.commentDays.size],
        ].map(([k,v]) => `
            <div style="background:#09090b;padding:5px 6px;border-radius:4px;border:1px solid #27272a;">
                <div style="color:#a1a1aa;font-size:11px;text-transform:uppercase;letter-spacing:0.05em;">${k}</div>
                <div style="color:#e4e4e7;font-size:13px;font-weight:600;font-family:monospace;">${v}</div>
            </div>`).join('');

        // ── Personal Records ──
        if (filtered.length) {
            const byPts  = [...filtered].sort((a,b)=>b._pts-a._pts);
            const contrScore = c => {
                if (!c._ups || !c._downs) return 0;
                const total = c._ups + c._downs;
                const bal   = 1 - Math.abs(c._ups - c._downs) / total;
                return total * bal * bal;
            };
            const byContr= [...filtered].sort((a,b)=>contrScore(b)-contrScore(a));
            const byWords= [...filtered].sort((a,b)=>b._wordCount-a._wordCount);
            const worst  = [...filtered].sort((a,b)=>a._pts-b._pts)[0];

            const records = [
                ['🏆 Best', byPts[0], c=>`+${c._pts}pts`],
                ['💀 Worst', worst, c=>`${c._pts}pts`],
                ['🔥 Most Controversial', byContr[0], c=>`${c._ups}↑ ${c._downs}↓`],
                ['📝 Longest', byWords[0], c=>`${c._wordCount}w`],
            ];

            document.getElementById('recordsBlock').style.display = 'flex';
            document.getElementById('recordsList').innerHTML = records.map(([label, c, fmt]) =>
                c ? `<div style="border-left:2px solid #27272a;padding-left:6px;">
                    <div style="color:#a1a1aa;font-size:11px;">${label} <b style="color:#e4e4e7;">${fmt(c)}</b></div>
                    <div style="color:#d4d4d8;font-size:11px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:240px;">${c.text.slice(0,80).replace(/</g,'&lt;')}…</div>
                    <a href="${c.permalink}" target="_blank" style="color:#38bdf8;font-size:10px;text-decoration:none;">↗ view</a>
                </div>` : ''
            ).join('');
        }

        // ── Heatmap ──
        renderHeatmap(filtered);

        // ── Top comments ──
        renderTopList();

        // ── Charts ──
        const ym     = Object.keys(acc.timeMap).sort();
        const topWds = Object.keys(acc.wordFreq).sort((a,b)=>acc.wordFreq[b]-acc.wordFreq[a]).slice(0,14);
        // 1. Media performance
        renderChart('cvMediaPerf','bar',{
            labels:Object.keys(acc.mediaDist),
            datasets:[
                {label:'Count',data:Object.values(acc.mediaDist).map(v=>v.n),backgroundColor:'#a855f7',yAxisID:'y1'},
                {label:'Avg Score',data:Object.values(acc.mediaDist).map(v=>v.n?+(v.pts/v.n).toFixed(2):0),backgroundColor:'#eab308',yAxisID:'y2'}
            ]
        }, dualAxis('Media Format Performance'));

        // 2. Word length vs performance
        renderChart('cvWordLen','bar',{
            labels:Object.keys(acc.lenBuckets),
            datasets:[
                {label:'Count',data:Object.values(acc.lenBuckets).map(v=>v.n),backgroundColor:'#4f46e5',yAxisID:'y1'},
                {label:'Avg Score',data:Object.values(acc.lenBuckets).map(v=>v.n?+(v.pts/v.n).toFixed(2):0),backgroundColor:'#10b981',yAxisID:'y2'}
            ]
        }, dualAxis('Comment Length vs Score'));

        // 3. Hourly
        renderChart('cvHourly','line',{
            labels:HOUR_LABELS,
            datasets:[
                {label:'Total Score',data:acc.hourMap.map(h=>h.pts),borderColor:'#ff7c43',tension:.15,yAxisID:'y1'},
                {label:'Avg Score',data:acc.hourMap.map(h=>h.n?+(h.pts/h.n).toFixed(2):0),borderColor:'#00f2fe',tension:.15,yAxisID:'y2'}
            ]
        }, dualAxis('Score by Hour of Day'));

        // 4. Weekday
        renderChart('cvWeekday','bar',{
            labels:DAY_LABELS,
            datasets:[
                {label:'Volume',data:acc.weekMap.map(d=>d.n),backgroundColor:'#fc5a8d',yAxisID:'y1'},
                {label:'Total Score',data:acc.weekMap.map(d=>d.pts),backgroundColor:'#eab308',yAxisID:'y2'}
            ]
        }, dualAxis('Activity by Day of Week'));

        // 5. Monthly
        renderChart('cvMonthly','bar',{
            labels:MONTH_LABELS,
            datasets:[
                {label:'Volume',data:acc.monthMap.map(m=>m.n),backgroundColor:'#ec4899',yAxisID:'y1'},
                {label:'Net Score',data:acc.monthMap.map(m=>m.pts),backgroundColor:'#3b82f6',yAxisID:'y2'}
            ]
        }, dualAxis('Seasonal Activity'));

        // 6. Timeline trend
        renderChart('cvTrend','line',{
            labels:ym,
            datasets:[
                {label:'Volume',data:ym.map(k=>acc.timeMap[k].n),borderColor:'#47cf73',tension:.1,yAxisID:'y1'},
                {label:'Net Score',data:ym.map(k=>acc.timeMap[k].pts),borderColor:'#38bdf8',tension:.1,yAxisID:'y2'}
            ]
        }, dualAxis('Monthly Volume & Score Trend'));

        // 7. Vote stack
        renderChart('cvVoteStack','bar',{
            labels:ym,
            datasets:[
                {label:'Upvotes',data:ym.map(k=>acc.timeMap[k].ups),backgroundColor:'#10b981'},
                {label:'Downvotes',data:ym.map(k=>-acc.timeMap[k].downs),backgroundColor:'#ef4444'}
            ]
        }, {...titled('Vote Split Over Time'), scales:{x:{...chartBase.scales.x,stacked:true},y:{...chartBase.scales.y,stacked:true}}});

        // 8. Score distribution
        renderChart('cvScoreDist','bar',{
            labels:Object.keys(acc.scoreDist),
            datasets:[{label:'Comments',data:Object.values(acc.scoreDist),backgroundColor:'#14b8a6'}]
        }, titled('Score Distribution'));

        // 9. Controversy — 2D density heatmap (vote bins × split bins) + top controversial list
        (() => {
            const wrap = document.getElementById('cvControversyWrap');
            if (!wrap) return;

            // Build density grid: X = vote count buckets, Y = split index buckets
            const VOTE_EDGES  = [0, 2, 5, 10, 20, 50, 100, 250, Infinity];
            const SPLIT_EDGES = [0, 0.2, 0.4, 0.6, 0.8, 1.0];
            const VOTE_LABELS = ['1','2-4','5-9','10-19','20-49','50-99','100-249','250+'];
            const SPLIT_LABELS= ['0-20%','20-40%','40-60%','60-80%','80-100%'];

            const grid = Array.from({length: SPLIT_LABELS.length}, () => Array(VOTE_LABELS.length).fill(0));

            acc.controversy.forEach(({x, y}) => {
                const xi = VOTE_EDGES.findIndex((e,i) => x >= VOTE_EDGES[i] && x < VOTE_EDGES[i+1]);
                const yi = SPLIT_EDGES.findIndex((e,i) => y >= SPLIT_EDGES[i] && y < SPLIT_EDGES[i+1]);
                if (xi >= 0 && xi < VOTE_LABELS.length && yi >= 0 && yi < SPLIT_LABELS.length)
                    grid[yi][xi]++;
            });

            const maxCell = Math.max(1, ...grid.flat());

            // Top 5 most controversial (ups*downs product)
            const topContr = [...filtered]
                .filter(c => c._ups > 0 && c._downs > 0)
                .sort((a,b) => {
                    const scoreOf = c => {
                        const total = c._ups + c._downs;
                        const bal   = 1 - Math.abs(c._ups - c._downs) / total;
                        return total * bal * bal;
                    };
                    return scoreOf(b) - scoreOf(a);
                })
                .slice(0, 5);

            const CELL = 28, CH = 18;
            let html = `<div style="font-size:11px;color:#e4e4e7;font-weight:700;margin-bottom:4px;">Controversy Map</div>`;
            html += `<div style="display:flex;gap:12px;height:calc(100% - 22px);">`;

            // Heatmap
            html += `<div style="flex-shrink:0;">`;
            html += `<table style="border-collapse:collapse;font-size:10px;font-family:monospace;">`;
            html += `<tr><td style="padding:1px 4px;"></td>${VOTE_LABELS.map(l=>`<td style="padding:1px 3px;color:#a1a1aa;text-align:center;width:${CELL}px;">${l}</td>`).join('')}</tr>`;
            [...SPLIT_LABELS].reverse().forEach((sl, rri) => {
                const ri = SPLIT_LABELS.length - 1 - rri;
                html += `<tr><td style="padding:1px 4px;color:#a1a1aa;white-space:nowrap;font-size:10px;">${sl}</td>`;
                grid[ri].forEach((count, ci) => {
                    const intensity = count / maxCell;
                    const r = Math.round(239 * intensity + 71 * (1-intensity));
                    const g = Math.round(68  * intensity + 207 * (1-intensity));
                    const b2= Math.round(68  * intensity + 115 * (1-intensity));
                    const bg = count === 0 ? '#101012' : `rgb(${r},${g},${b2})`;
                    const tip = `${sl} split, ${VOTE_LABELS[ci]} votes: ${count} comments`;
                    html += `<td data-tip="${tip}" style="width:${CELL}px;height:${CH}px;background:${bg};border-radius:2px;border:1px solid #09090b;cursor:default;"></td>`;
                });
                html += '</tr>';
            });
            html += `</table>`;
            html += `<div style="margin-top:4px;font-size:10px;color:#a1a1aa;">← consensus &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; split →&nbsp; (split index)</div>`;
            html += `</div>`;

            // Top controversial comments
            html += `<div style="flex:1;min-width:0;overflow-y:auto;display:flex;flex-direction:column;gap:4px;">`;
            html += `<div style="font-size:10px;color:#a1a1aa;font-weight:600;margin-bottom:2px;">Most Controversial</div>`;
            topContr.forEach(c => {
                const snip = c.text.slice(0,80).replace(/</g,'&lt;');
                html += `<div style="background:#09090b;border-radius:4px;padding:4px 6px;font-size:10px;">
                    <div style="color:#f59e0b;font-family:monospace;margin-bottom:2px;">${c._ups}↑ ${c._downs}↓
                        <a href="${c.permalink}" target="_blank" style="color:#38bdf8;text-decoration:none;float:right;">↗</a>
                    </div>
                    <div style="color:#d4d4d8;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${snip}</div>
                </div>`;
            });
            html += `</div></div>`;

            wrap.innerHTML = html;

            wireTooltips(wrap, 'td[data-tip]', makeTooltip('contr-tip'));
        })();

        // 10. Length scatter
        renderChart('cvLenScatter','scatter',{
            datasets:[{label:'Words vs Score',data:acc.lenScat,backgroundColor:'rgba(56,189,248,.5)'}]
        }, {...titled('Comment Length vs Score'),scales:{
            x:{...chartBase.scales.x,title:{display:true,text:'Word Count',color:C}},
            y:{...chartBase.scales.y,title:{display:true,text:'Score',color:C}}
        }});

        // 11. Vocabulary
        renderChart('cvVocab','bar',{
            labels:topWds,
            datasets:[{label:'Frequency',data:topWds.map(w=>acc.wordFreq[w]),backgroundColor:'#f43f5e'}]
        }, titled('Top Words (stopwords removed)'));

        // 12. Word stats — avg total words vs avg unique words per comment over time
        renderChart('cvWordStats','line',{
            labels:ym,
            datasets:[
                {
                    label:'Avg words/comment',
                    data:ym.map(k => acc.timeMap[k].n ? +(acc.timeMap[k].wSum/acc.timeMap[k].n).toFixed(1) : 0),
                    borderColor:'#a855f7', tension:.15, yAxisID:'y1', fill:false,
                },
                {
                    label:'Avg unique words/comment',
                    data:ym.map(k => acc.timeMap[k].n ? +(acc.timeMap[k].uqSum/acc.timeMap[k].n).toFixed(1) : 0),
                    borderColor:'#14b8a6', tension:.15, yAxisID:'y1', fill:false,
                }
            ]
        }, titled('Comment Vocabulary Over Time'));

        // 13. Punctuation radar
        renderChart('cvPunc','radar',{
            labels:Object.keys(acc.puncBkt),
            datasets:[{label:'Syntax Fingerprint',data:Object.values(acc.puncBkt),backgroundColor:'rgba(99,102,241,.15)',borderColor:'#6366f1'}]
        }, {
            responsive:true, maintainAspectRatio:false,
            layout:{ padding:0 },
            plugins:{
                legend:{ labels:{ color:'#e4e4e7', font:{size:10}, boxWidth:8, padding:4 } },
                title:{ display:true, text:'Syntax Fingerprint', color:'#e4e4e7', font:{size:11}, padding:{top:0,bottom:2} }
            },
            scales:{r:{grid:{color:G},angleLines:{color:G},pointLabels:{color:C,font:{size:10}},ticks:{display:false}}}
        });

        // 14. Syntax avg score — with URL/media as own category
        renderChart('cvSyntax','bar',{
            labels:['Questions (?)', 'Exclamations (!)', 'URL/Media', 'Plain Text'],
            datasets:[{
                label:'Avg Score',
                data:[
                    acc.synQ.n    ? +(acc.synQ.pts/acc.synQ.n).toFixed(2)       : 0,
                    acc.synEx.n   ? +(acc.synEx.pts/acc.synEx.n).toFixed(2)     : 0,
                    acc.synUrl.n  ? +(acc.synUrl.pts/acc.synUrl.n).toFixed(2)   : 0,
                    acc.synPlain.n? +(acc.synPlain.pts/acc.synPlain.n).toFixed(2): 0,
                ],
                backgroundColor:['#f59e0b','#f43f5e','#a855f7','#14b8a6']
            }]
        }, titled('Avg Score by Comment Type'));

        // 15. Velocity
        renderChart('cvVelocity','bar',{
            labels:Object.keys(acc.velBins),
            datasets:[{label:'Intervals',data:Object.values(acc.velBins),backgroundColor:'#06b6d4'}]
        }, titled('Commenting Cadence'));

        // 16. Comments-per-post distribution (how often you have back-and-forth vs one-and-done)
        const postDist = {'1':{n:0,pts:0},'2':{n:0,pts:0},'3-5':{n:0,pts:0},'6-10':{n:0,pts:0},'11+':{n:0,pts:0}};
        Object.values(acc.postMap).forEach(v => {
            const k = v.n===1?'1':v.n===2?'2':v.n<=5?'3-5':v.n<=10?'6-10':'11+';
            postDist[k].n++;
            postDist[k].pts += v.pts;
        });
        renderChart('cvThread','bar',{
            labels: Object.keys(postDist),
            datasets:[
                {label:'Posts',     data:Object.values(postDist).map(v=>v.n),   backgroundColor:'#f59e0b', yAxisID:'y1', minBarLength:4, barPercentage:0.8, categoryPercentage:0.7},
                {label:'Avg Score', data:Object.values(postDist).map(v=>v.n?+(v.pts/v.n).toFixed(1):0), backgroundColor:'#10b981', yAxisID:'y2', minBarLength:4, barPercentage:0.8, categoryPercentage:0.7},
            ]
        }, {...dualAxis('Comments per Post (1 = one-and-done, 11+ = deep thread)'),
            interaction: { mode:'index', intersect:false },
            scales:{...dualAxis('').scales,
                x:{...chartBase.scales.x, title:{display:true,text:'Your comments on that post',color:C,font:{size:10}}}
            }
        });

        // 17. (cvAvgLen merged into cvWordStats above)

        // 18. Engagement rate over time (avg votes per comment per month)
        renderChart('cvEngagement','line',{
            labels: ym,
            datasets:[
                {
                    label:'Avg votes/comment',
                    data: ym.map(k => {
                        const m = acc.timeMap[k];
                        return m.n ? +((m.ups + m.downs) / m.n).toFixed(2) : 0;
                    }),
                    borderColor:'#f97316', tension:.15, yAxisID:'y1', fill:false
                },
                {
                    label:'Upvote ratio %',
                    data: ym.map(k => {
                        const m = acc.timeMap[k];
                        const total = m.ups + m.downs;
                        return total ? +(m.ups / total * 100).toFixed(1) : 0;
                    }),
                    borderColor:'#47cf73', tension:.15, yAxisID:'y2', fill:false
                }
            ]
        }, dualAxis('Engagement Rate Over Time'));

        // 19. Activity calendar (GitHub-style SVG)
        const dayCount={}, dayScore={};
        filtered.forEach(c => {
            if (!c._date) return;
            const k = c._date.toISOString().slice(0,10);
            dayCount[k] = (dayCount[k]||0)+1;
            dayScore[k] = (dayScore[k]||0)+c._pts;
        });

        const wrap = document.getElementById('cvStreakWrap');
        if (!wrap) return;
        wrap.innerHTML='';

        if (!Object.keys(dayCount).length) {
            wrap.innerHTML='<div style="color:#a1a1aa;padding:12px;">No dated comments.</div>';
        } else {
            const today = new Date(); today.setHours(0,0,0,0);
            const start = new Date(today); start.setDate(start.getDate()-363-start.getDay());
            const CELL=11, GAP=2, STEP=CELL+GAP;
            const maxN = Math.max(...Object.values(dayCount));
            const weeks=[]; let wk=[];
            const cur=new Date(start);
            while(cur<=today){ wk.push(new Date(cur)); if(wk.length===7){weeks.push(wk);wk=[];} cur.setDate(cur.getDate()+1); }
            if(wk.length) weeks.push(wk);

            const W=weeks.length*STEP, H=7*STEP+24;
            const mPos=[];
            weeks.forEach((w,wi)=>{ if(w[0].getDate()<=7) mPos.push({x:wi*STEP,l:MONTH_LABELS[w[0].getMonth()]}); });

            function dcol(k){ const n=dayCount[k]||0; if(!n) return '#1a1a1f';
                const t=n/maxN, g=Math.round(80+t*127), a=(0.3+t*0.7).toFixed(2);
                return `rgba(71,${g},115,${a})`; }

            let svg=`<svg xmlns="http://www.w3.org/2000/svg" width="${W+30}" height="${H}" style="font-family:monospace;overflow:visible;">`;
            ['S','M','T','W','T','F','S'].forEach((l,i)=>{ if(i%2===1) svg+=`<text x="0" y="${18+i*STEP}" font-size="10" fill="#a1a1aa">${l}</text>`; });
            mPos.forEach(({x,l})=>svg+=`<text x="${30+x}" y="8" font-size="10" fill="#a1a1aa">${l}</text>`);
            weeks.forEach((wk,wi)=>wk.forEach((day,di)=>{
                const k=day.toISOString().slice(0,10);
                const n=dayCount[k]||0, pts=dayScore[k]||0;
                const fut=day>today;
                const tip=fut?'':`${k}: ${n} comment${n!==1?'s':''}, ${pts>0?'+':''}${pts}pts`;
                svg+=`<rect x="${30+wi*STEP}" y="${12+di*STEP}" width="${CELL}" height="${CELL}" rx="2" fill="${fut?'transparent':dcol(k)}" stroke="#09090b" stroke-width="0.5" ${tip?`data-tip="${tip}"`:''} style="cursor:${tip?'pointer':'default'};"></rect>`;
            }));
            svg+='</svg>';

            wrap.innerHTML=`
                <div style="font-size:12px;color:#47cf73;font-weight:600;margin-bottom:6px;font-family:monospace;">
                    Streak: current ${streak.current}d &nbsp;|&nbsp; longest ${streak.longest}d &nbsp;|&nbsp; active ${acc.commentDays.size} days
                </div>
                <div style="overflow-x:auto;">${svg}</div>
                <div style="margin-top:6px;display:flex;align-items:center;gap:5px;font-size:11px;color:#a1a1aa;">
                    <span>Less</span>
                    ${[.1,.3,.5,.75,1].map(t=>`<div style="width:10px;height:10px;border-radius:2px;background:rgba(71,${Math.round(80+t*127)},115,${(0.3+t*0.7).toFixed(2)});"></div>`).join('')}
                    <span>More</span>
                </div>`;
        }

        const calWrap = document.getElementById('cvStreakWrap');
        if (calWrap) wireTooltips(calWrap, 'rect[data-tip]', makeTooltip('cal-tip'));
    }

    // ─── WIRE CONTROLS ────────────────────────────────────────

    // Export — reads current filters at click time, no stale closure
    document.getElementById('btnExportCSV').onclick = () => {
        const filtered = applyFilters(getFilters());
        const cols = ['id','post_id','text','points','ups','downs','created_at','permalink'];
        const csv = [cols.join(','), ...filtered.map(c=>cols.map(k=>JSON.stringify(c[k]??'')).join(','))].join('\n');
        download(`imgur-${USERNAME}.csv`, csv, 'text/csv');
    };
    document.getElementById('btnExportJSON').onclick = () => {
        const filtered = applyFilters(getFilters());
        const clean = filtered.map(({_words,_cleanWords,...r})=>r);
        download(`imgur-${USERNAME}.json`, JSON.stringify(clean,null,2), 'application/json');
    };

    const FILTER_IDS = [
        'filterMinWords','filterMinScore','filterMaxScore','filterMinVotes',
        'filterMinHour','filterMaxHour','filterMedia','filterUnseen',
        'filterText','filterDateFrom','filterDateTo'
    ];
    FILTER_IDS.forEach(id => {
        const el = document.getElementById(id);
        if (el) el.addEventListener('input', updateDashboard);
    });

    // Date preset buttons
    document.querySelectorAll('.date-preset-btn').forEach(btn => {
        btn.addEventListener('click', () => {
            const days = +btn.dataset.days;
            const from = document.getElementById('filterDateFrom');
            const to   = document.getElementById('filterDateTo');
            if (days === 0) {
                from.value = '';
                to.value   = '';
            } else {
                const now   = new Date();
                const start = new Date(now - days * 86400000);
                from.value = start.toISOString().slice(0,10);
                to.value   = now.toISOString().slice(0,10);
            }
            // Highlight active preset
            document.querySelectorAll('.date-preset-btn').forEach(b => {
                b.style.background  = b === btn ? '#1e3a2f' : '#18181b';
                b.style.color       = b === btn ? '#47cf73' : '#a1a1aa';
                b.style.borderColor = b === btn ? '#166534' : '#3f3f46';
            });
            updateDashboard();
            if (document.getElementById('tabPosts')?.style.display !== 'none') renderPostsTab();
        });
    });

    // Initial render
    updateDashboard();
};

})();