Imgur Comment Analytics Dashboard

Personal comment analytics dashboard — only activates on your own profile

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

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();
};

})();