Widget Clarity Insighter – opisy AI nagrań sesji Microsoft Clarity

Strona główna » Skrypty » Widget Clarity Insighter – opisy AI nagrań sesji Microsoft Clarity
Instrukcja użycia

→ Ściągnij wtyczkę Tampermonkey do przeglądarki
→ W opcjach wtyczki włącz opcję „Allow User Scripts” (dzięki temu będzie można korzystać z własnych skryptów)
→ Wklej w Tampermonkey poniższy skrypt widgetu
→ Wejdź do Microsoft Clarity, do sekcji nagrań, i pobieraj opisy

Skrypt widgeta

// ==UserScript==
// @name         Clarity Insighter
// @namespace    https://pawelpiekarski.pl/
// @version      3.6
// @description  Widget do generowania opisów sesji w MS Clarity — trwały schowek opisów per ID nagrania (bez ponownego generowania, także po odświeżeniu), „Doładuj listę” generuje wszystko czego brakuje, filtry (ID nagrań, kraj, urządzenie, przeglądarka, referrer, czas/kliki/strony, URL), kopiowanie prompta (ICE + zasięg → ⭐) — opcjonalnie tylko nagrania z załadowanej listy
// @author       Paweł Piekarski
// @match        https://clarity.microsoft.com/*
// @icon         https://clarity.microsoft.com/blog/wp-content/uploads/2025/02/siteIcon.png
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    const WID = 'clarity-helper';
    const BLAD_AI_TEKST = "I'm sorry, something went wrong";
    const LS_OPTS = 'clarityHelper.ustawienia5';
    const LS_POS = 'clarityHelper.pos';
    const LS_SCHOWEK = 'clarityHelper.schowek.';   // + ID projektu Clarity
    const URZADZENIA = ['mobile', 'desktop', 'tablet'];
    const MULTI = ['kraj', 'przegladarka', 'urzadzenie', 'referrer'];
    const MAX_PROB = 3;              // ile razy klikać „Generate” dla jednego nagrania (gdy AI zwraca błąd)
    const PRZERWA_PO_BLEDZIE = 5000;
    const OCZEKIWANIE_MS = 120000;   // kliknięte nagranie bez odpowiedzi — po tym czasie wolno kliknąć ponownie
    const PROMPT_WERSJA = 2;       // podbicie wersji jednorazowo podmienia zapisany prompt na nowy domyślny

    const DOMYSLNY_PROMPT = `## ROLA
Jesteś senior analitykiem UX/CRO. Oceniasz nagrania sesji z Microsoft Clarity i zamieniasz je w priorytetyzowaną listę hipotez do wdrożenia/testów. Opierasz się wyłącznie na danych z INPUT — niczego nie dopowiadasz ani nie zmyślasz.

## DANE
INPUT to tablica JSON. Każdy obiekt = jedno nagranie:
- userId — ID nagrania (to ono trafia do kolumny „ID nagrań”)
- description — opis sesji wygenerowany przez Clarity (główne źródło obserwacji)
- entry, exit, referrer, device, browser, country, duration, clicks, pages — kontekst sesji
N = liczba obiektów w tablicy.

## ZADANIE
1. Przeczytaj wszystkie opisy i pogrupuj nagrania we wzorce zachowań / problemy. Jedno nagranie może należeć do kilku wzorców. Pomiń zachowania neutralne, z których nie wynika żaden wniosek biznesowy.
2. Każdy wzorzec oceń w 4 wymiarach (tylko do obliczeń — nie pokazuj ich w tabeli):
   - Impact 1–3 — wpływ na konwersję/przychód po usunięciu problemu (3 = blokuje zakup/lead),
   - Confidence 1–3 — na ile opisy jednoznacznie potwierdzają problem (3 = powtarzalny i jasno opisany),
   - Ease 1–3 — łatwość wdrożenia (3 = drobna zmiana treści/UI, 1 = przebudowa procesu lub integracji),
   - Zasięg 1–3 — liczba nagrań: 1 = 1 nagranie, 2 = 2–3 nagrania, 3 = co najmniej 4 nagrania lub ≥ 20% N.
3. Punkty = Impact + Confidence + Ease + Zasięg (zakres 4–12) → Score:
   - ⭐⭐⭐ = 10–12 pkt (zrobić w pierwszej kolejności)
   - ⭐⭐ = 7–9 pkt
   - ⭐ = 4–6 pkt
4. Posortuj wiersze: Score malejąco, przy równym Score — więcej nagrań wyżej.

## FORMAT ODPOWIEDZI
Zwróć WYŁĄCZNIE tabelę Markdown — bez wstępu, legendy i podsumowania — z kolumnami:
| Obserwacja | Hipoteza (Rekomendacja) | Score | ID nagrań |

- Obserwacja — co konkretnie robią użytkownicy (1–2 zdania, z kontekstem strony/urządzenia, jeśli istotny); na końcu liczba nagrań w nawiasie, np. (4 nagr.).
- Hipoteza (Rekomendacja) — w formie: „Jeśli [zmiana], to [oczekiwany efekt], ponieważ [co widać w nagraniach].”
- Score — wyłącznie gwiazdki: ⭐⭐⭐, ⭐⭐ albo ⭐.
- ID nagrań — WSZYSTKIE userId przypisane do wzorca, dokładnie tak jak w INPUT, oddzielone przecinkiem i spacją, np.: 1r41zk2, 14n3xyi, 724ia7. Bez cudzysłowów, nawiasów, formatowania kodu i skrótów typu „…” — ta kolumna jest kopiowana 1:1 do filtra, więc nie pomijaj żadnego ID.

## PRZYKŁAD ODPOWIEDZI
| Obserwacja | Hipoteza (Rekomendacja) | Score | ID nagrań |
|---|---|---|---|
| Porzucenie checkoutu na etapie Dostawa i Płatność mimo wypełnionych danych (4 nagr.) | Jeśli uprościmy formularz i dodamy pasek postępu, to więcej osób dokończy zamówienie, ponieważ użytkownicy przewijają formularz w górę i w dół, po czym wychodzą. | ⭐⭐⭐ | 4fyolj, cctsli, 1hzq5lm, 14n3xyi |
| Błędy JS przy wyborze wariantu i galerii blokują dodanie do koszyka (3 nagr.) | Jeśli naprawimy skrypty wariantów i galerii, to wzrośnie liczba dodań do koszyka, ponieważ kliknięcia w warianty nie dają reakcji, a użytkownicy klikają wielokrotnie. | ⭐⭐⭐ | 1r41zk2, 14n3xyi, 724ia7 |
| Wahanie przy wyborze rozmiaru — częste przełączanie wariantów (2 nagr.) | Jeśli umieścimy tabelę rozmiarów bezpośrednio przy wyborze wariantu, to skróci się czas decyzji, ponieważ użytkownicy przełączają rozmiary bez dodania do koszyka. | ⭐⭐ | 13amh0c, 1hzq5lm |
| Długie oczekiwanie przy logowaniu przez Google (1 nagr.) | Jeśli skrócimy czas autoryzacji Google, to mniej osób porzuci logowanie, ponieważ użytkownik czekał na ekranie logowania, po czym opuścił stronę. | ⭐ | 1ff24f8 |

## INPUT
`;

    /* ═══════════════════ THEME ═══════════════════ */
    const C = {
        bg: '#111827', hover: '#1e293b', input: '#1f2937',
        accent: '#3b82f6', accentDk: '#1e3a5f',
        green: '#22c55e', red: '#ef4444', orange: '#f97316',
        text: '#e5e7eb', dim: '#6b7280', border: '#374151',
    };
    const escHtml = s => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

    /* ═══════════════════ STAN ═══════════════════ */
    const stan = {
        aktywny: false, stop: false, przewijanie: false, watcherId: null,
        dodane: 0,                 // ile opisów trafiło do schowka w bieżącym przebiegu
        proby: new Map(),          // id → liczba kliknięć „Generate” w bieżącym przebiegu
        oczekujace: new Map(),     // id → czas kliknięcia (czeka na odpowiedź AI)
        sygnaturaSelectow: ''
    };
    let widget = null;
    let collapsed = false;

    // Domyślnie brak filtrów — widoczne wszystkie nagrania
    const DOMYSLNE = {
        minCzas: 0, minKlik: 0, minStron: 0, ids: '',
        kraj: [], przegladarka: [], urzadzenie: [], referrer: [], url: '', exitUrl: '',
        ukrywajKarty: true, tylkoZListy: true, collapsed: false, promptOpen: false,
        prompt: DOMYSLNY_PROMPT, promptWersja: PROMPT_WERSJA
    };
    let u = wczytajUstawienia();

    function wczytajUstawienia() {
        try {
            const zapisane = localStorage.getItem(LS_OPTS);
            const surowe = JSON.parse(zapisane || '{}');
            const z = { ...DOMYSLNE, ...surowe };
            delete z.limit; // usunięte w 3.5 — zawsze generujemy wszystko
            for (const k of MULTI) if (!Array.isArray(z[k])) z[k] = z[k] ? [z[k]] : [];
            if (typeof z.ids !== 'string') z.ids = '';
            // nowa wersja prompta → jednorazowa podmiana zapisanego prompta
            if (surowe.promptWersja !== PROMPT_WERSJA || !z.prompt || !z.prompt.trim()) {
                z.prompt = DOMYSLNY_PROMPT;
                z.promptWersja = PROMPT_WERSJA;
            }
            return z;
        } catch { return { ...DOMYSLNE }; }
    }
    function zapiszUstawienia() {
        try { localStorage.setItem(LS_OPTS, JSON.stringify(u)); } catch { /* ignoruj */ }
    }

    /* ═══════════════════ WYDOBYWANIE DANYCH Z KART ═══════════════════ */

    const pobierzKarty = () => Array.from(document.querySelectorAll('div[data-testid="sessionCard"]'));
    const maOpis = card => !!card.querySelector('div.insightsContent');
    function czyBladAI(card) {
        const el = card.querySelector('div.insightsContent');
        return !!el && el.textContent.includes(BLAD_AI_TEKST);
    }
    const maPoprawnyOpis = card => maOpis(card) && !czyBladAI(card);
    const przyciskGeneruj = card => card.querySelector('button[data-testid="sessionGenerateInsightsButton"]');
    const tekstOpisu = card => card.querySelector('div.insightsContent')?.textContent.trim() || '';

    function pobierzDostepneNagrania() {
        const el = document.querySelector('.sessionCount strong');
        return el ? el.textContent.replace(/\D/g, '') : '…';
    }
    function parsujCzas(str) {
        if (!str) return 0;
        const p = str.trim().split(':').map(n => parseInt(n, 10) || 0);
        if (p.length === 3) return p[0] * 3600 + p[1] * 60 + p[2];
        if (p.length === 2) return p[0] * 60 + p[1];
        return p[0] || 0;
    }
    function sciezkaUrl(url) {
        if (!url) return '';
        try { return new URL(url).pathname; } catch { return url.split('?')[0].split('#')[0]; }
    }
    const BEZPOSREDNIE = '(bezpośrednie)';
    function hostReferrera(url) {
        if (!url || !url.trim()) return BEZPOSREDNIE;
        try { return new URL(url).hostname.replace(/^www\./, ''); }
        catch { return url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0] || BEZPOSREDNIE; }
    }
    function usunParametryUrl(url) {
        if (!url) return url;
        try { const p = new URL(url); return p.origin + p.pathname; }
        catch { return url.split('?')[0].split('#')[0]; }
    }

    const metaCache = new WeakMap();
    function pobierzMetadaneSesji(card) {
        const cached = metaCache.get(card);
        if (cached && cached._opisFlag === maOpis(card)) return cached;

        const meta = { entry: '', exit: '', referrer: '', duration: '', clicks: '', pages: '', date: '',
                       userId: '', country: '', browser: '', device: '', other: [] };
        const rows = Array.from(card.querySelectorAll('div[class*="recordings_cardRow"]'));
        rows.forEach(row => {
            const strong = row.querySelector('strong');
            const span = row.querySelector('span[class*="oneLineText"]');
            if (!strong || !span) return;
            const label = strong.textContent.trim().replace(':', '');
            if (label === 'Entry') meta.entry = row.getAttribute('title') || span.textContent.trim();
            if (label === 'Exit') meta.exit = row.getAttribute('title') || span.textContent.trim();
        });
        const refRow = rows.find(r => r.querySelector('strong')?.textContent.includes('Referrer'));
        if (refRow) {
            const zTitle = refRow.hasAttribute('title') ? refRow : refRow.querySelector('[title]');
            meta.referrer = zTitle?.getAttribute('title') || refRow.querySelector('span')?.textContent.trim() || '';
        }
        const multiRow = card.querySelector('div[class*="recordings_multiValuedRow"]');
        if (multiRow) {
            const strongs = multiRow.querySelectorAll('strong');
            const spans = multiRow.querySelectorAll('span');
            strongs.forEach((s, i) => {
                const label = s.textContent.trim().replace(':', '').toLowerCase();
                const val = spans[i] ? spans[i].textContent.trim() : '';
                if (label === 'duration') meta.duration = val;
                if (label === 'clicks') meta.clicks = val;
                if (label === 'pages') meta.pages = val;
            });
        }
        meta.durationSec = parsujCzas(meta.duration);
        meta.clicksNum = parseInt(meta.clicks, 10) || 0;
        meta.pagesNum = parseInt(meta.pages, 10) || 0;

        const dateSection = card.querySelector('div[class*="recordings_cardDateSection"]');
        if (dateSection) meta.date = Array.from(dateSection.querySelectorAll('span')).map(s => s.textContent.trim()).join(' ');

        // Badge'e: wiersz 1 = [User ID, Kraj], wiersz 2 = [Przeglądarka/aplikacja, Urządzenie]
        card.querySelectorAll('div[class*="recordings_cardBadgeRow"]').forEach((row, ri) => {
            Array.from(row.querySelectorAll('div[class*="recordings_cardBadge__"]')).map(x => x.textContent.trim()).filter(Boolean).forEach(txt => {
                if (txt.startsWith('User ID:')) { meta.userId = txt.replace('User ID:', '').trim().split(/\s+/)[0]; return; }
                if (URZADZENIA.includes(txt.toLowerCase())) { meta.device = txt; return; }
                if (ri === 0 && !meta.country) meta.country = txt;
                else if (ri === 1 && !meta.browser) meta.browser = txt;
                else meta.other.push(txt);
            });
        });
        meta.badges = [meta.country, meta.browser, meta.device, ...meta.other].filter(Boolean);
        meta.entryPath = sciezkaUrl(meta.entry);
        meta.exitPath = sciezkaUrl(meta.exit);
        meta.referrerHost = hostReferrera(meta.referrer);
        meta.userIdLc = (meta.userId || '').toLowerCase();
        meta.klucz = [meta.userId, meta.date, meta.duration, meta.entryPath].join('|'); // stabilny identyfikator sesji

        meta._opisFlag = maOpis(card);
        metaCache.set(card, meta);
        return meta;
    }

    /* ═══════════════════ FILTRY ═══════════════════ */

    // "1r41zk2, 14n3xyi\n724ia7" / z backtickami, cudzysłowami, średnikami… → ['1r41zk2','14n3xyi','724ia7']
    function parsujIds(txt) {
        return [...new Set(String(txt || '').toLowerCase()
            .split(/[\s,;|`"'()[\]{}<>]+/)
            .map(s => s.replace(/^[#.:…]+|[#.:…]+$/g, ''))
            .filter(s => /^[a-z0-9_-]+$/.test(s)))];
    }

    function odczytajFiltry() {
        const ids = parsujIds(u.ids);
        return {
            ids, idsSet: new Set(ids),
            minCzas: u.minCzas, minKlik: u.minKlik, minStron: u.minStron,
            kraj: u.kraj, przegladarka: u.przegladarka, urzadzenie: u.urzadzenie, referrer: u.referrer,
            url: (u.url || '').trim().toLowerCase(),
            exitUrl: (u.exitUrl || '').trim().toLowerCase()
        };
    }
    function pasujeDoFiltrow(meta, f) {
        if (f.ids.length && !f.idsSet.has(meta.userIdLc)) return false;
        if (meta.durationSec < f.minCzas) return false;
        if (meta.clicksNum < f.minKlik) return false;
        if (meta.pagesNum < f.minStron) return false;
        if (f.kraj.length && !f.kraj.includes(meta.country)) return false;
        if (f.przegladarka.length && !f.przegladarka.includes(meta.browser)) return false;
        if (f.urzadzenie.length && !f.urzadzenie.includes(meta.device)) return false;
        if (f.referrer.length && !f.referrer.includes(meta.referrerHost)) return false;
        if (f.url && !(meta.entry || '').toLowerCase().includes(f.url)) return false;
        if (f.exitUrl && !(meta.exit || '').toLowerCase().includes(f.exitUrl)) return false;
        return true;
    }
    const czyFiltryAktywne = f => f.ids.length > 0 || f.minCzas > 0 || f.minKlik > 0 || f.minStron > 0 || f.kraj.length > 0 || f.przegladarka.length > 0 || f.urzadzenie.length > 0 || f.referrer.length > 0 || !!f.url || !!f.exitUrl;
    function opisFiltrow(f) {
        const k = [];
        if (f.ids.length) k.push(`ID nagrań: ${f.ids.length}`);
        if (f.minCzas) k.push(`czas ≥ ${f.minCzas}s`);
        if (f.minKlik) k.push(`kliknięcia ≥ ${f.minKlik}`);
        if (f.minStron) k.push(`strony ≥ ${f.minStron}`);
        if (f.kraj.length) k.push(`kraj: ${f.kraj.join('/')}`);
        if (f.przegladarka.length) k.push(`przeglądarka: ${f.przegladarka.join('/')}`);
        if (f.urzadzenie.length) k.push(`urządzenie: ${f.urzadzenie.join('/')}`);
        if (f.referrer.length) k.push(`referrer: ${f.referrer.join('/')}`);
        if (f.url) k.push(`entry URL zawiera: "${f.url}"`);
        if (f.exitUrl) k.push(`exit URL zawiera: "${f.exitUrl}"`);
        return k.join(', ');
    }

    function policz(metas, pole) {
        const m = new Map();
        metas.forEach(x => { const v = x[pole]; if (v) m.set(v, (m.get(v) || 0) + 1); });
        return [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'pl'));
    }

    /* ═══════════════════ MULTISELECT ═══════════════════ */

    const MS_MAP = { 'f-kraj': ['kraj', 'country'], 'f-przegladarka': ['przegladarka', 'browser'], 'f-urzadzenie': ['urzadzenie', 'device'], 'f-referrer': ['referrer', 'referrerHost'] };
    const msDane = {};

    function msEtykieta(id) {
        const klucz = MS_MAP[id][0];
        const wyb = u[klucz];
        const box = document.getElementById(id);
        if (!box) return;
        const lab = box.querySelector('.h-ms-label');
        const n = (msDane[id] || []).reduce((a, [, c]) => a + c, 0);
        lab.textContent = wyb.length ? `${wyb.join(', ')} (${wyb.length})` : `wszystkie (${n})`;
        lab.title = wyb.join(', ');
        box.classList.toggle('h-input--active', wyb.length > 0);
    }
    function msRenderujListe(id) {
        const box = document.getElementById(id);
        const list = box.querySelector('.h-ms-list');
        const klucz = MS_MAP[id][0];
        const wpisy = msDane[id] || [];
        const brakujace = u[klucz].filter(v => !wpisy.some(([w]) => w === v)).map(v => [v, 0]);
        list.innerHTML = [...wpisy, ...brakujace].map(([v, n]) =>
            `<div class="h-ms-item${u[klucz].includes(v) ? ' on' : ''}" data-v="${escHtml(v)}"><div class="h-chk">✓</div><span>${escHtml(v)}</span><em>${n}</em></div>`
        ).join('') || `<div class="h-ms-empty">brak danych</div>`;
    }
    function odswiezSelecty(metas) {
        const dane = {};
        for (const id of Object.keys(MS_MAP)) dane[id] = policz(metas, MS_MAP[id][1]);
        const sciezki = policz(metas, 'entryPath');
        const sciezkiExit = policz(metas, 'exitPath');
        const sygnatura = JSON.stringify([dane, sciezki, sciezkiExit]);
        if (sygnatura === stan.sygnaturaSelectow) return;
        stan.sygnaturaSelectow = sygnatura;
        for (const id of Object.keys(MS_MAP)) {
            msDane[id] = dane[id];
            const box = document.getElementById(id);
            if (!box) continue;
            if (!box.classList.contains('open')) msRenderujListe(id);
            msEtykieta(id);
        }
        const dl = document.getElementById('f-url-list');
        if (dl) dl.innerHTML = sciezki.map(([v, n]) => `<option value="${escHtml(v)}">${n} sesji</option>`).join('');
        const dlx = document.getElementById('f-exit-list');
        if (dlx) dlx.innerHTML = sciezkiExit.map(([v, n]) => `<option value="${escHtml(v)}">${n} sesji</option>`).join('');
        const max = pole => metas.reduce((a, m) => Math.max(a, m[pole] || 0), 0);
        const ph = (id, v) => { const el = document.getElementById(id); if (el) el.placeholder = `max ${v}`; };
        ph('f-czas', max('durationSec') + ' s'); ph('f-klik', max('clicksNum')); ph('f-strony', max('pagesNum'));
    }
    function msInit(id) {
        const box = document.getElementById(id);
        const klucz = MS_MAP[id][0];
        box.querySelector('.h-ms-btn').addEventListener('click', e => {
            e.stopPropagation();
            const otwarty = box.classList.contains('open');
            document.querySelectorAll(`#${WID} .h-ms.open`).forEach(b => b.classList.remove('open'));
            if (!otwarty) { msRenderujListe(id); box.classList.add('open'); }
        });
        box.querySelector('.h-ms-list').addEventListener('click', e => {
            const item = e.target.closest('.h-ms-item'); if (!item) return;
            e.stopPropagation();
            const v = item.dataset.v;
            const i = u[klucz].indexOf(v);
            if (i >= 0) u[klucz].splice(i, 1); else u[klucz].push(v);
            item.classList.toggle('on', i < 0);
            zapiszUstawienia(); msEtykieta(id); refreshLiczniki();
        });
        box.querySelector('.h-ms-clear').addEventListener('click', e => {
            e.stopPropagation();
            u[klucz] = []; zapiszUstawienia(); msRenderujListe(id); msEtykieta(id); refreshLiczniki();
        });
        msEtykieta(id);
    }

    /* ═══════════════════ SCHOWEK (trwały, per projekt, klucz = ID nagrania) ═══════════════════ */
    // Każdy poprawny opis trafia do localStorage pod ID nagrania. Nagranie, które jest w schowku,
    // nigdy nie jest generowane ponownie — także po odświeżeniu strony i w innej karcie przeglądarki.

    let schowekCache = {}, schowekProjekt = null;
    const idSesji = meta => meta.userIdLc || meta.klucz;

    function idProjektu() {
        const m = location.pathname.match(/\/projects\/view\/([^/]+)/);
        return m ? m[1] : 'default';
    }
    function schowek() {
        const p = idProjektu();
        if (schowekProjekt !== p) {
            schowekProjekt = p;
            try { schowekCache = JSON.parse(localStorage.getItem(LS_SCHOWEK + p) || '{}') || {}; }
            catch { schowekCache = {}; }
        }
        return schowekCache;
    }
    function zapiszSchowek() {
        try { localStorage.setItem(LS_SCHOWEK + schowekProjekt, JSON.stringify(schowekCache)); }
        catch { ustawStatus('Brak miejsca w localStorage — schowek nie zapisany', 'warn'); }
    }
    // inna karta zapisała schowek → wczytaj go na nowo przy następnym odczycie
    window.addEventListener('storage', e => { if (e.key && e.key.startsWith(LS_SCHOWEK)) schowekProjekt = null; });

    function wpisZMeta(meta, opis) {
        return {
            userId: meta.userId, userIdLc: meta.userIdLc, date: meta.date,
            duration: meta.duration, durationSec: meta.durationSec, clicksNum: meta.clicksNum, pagesNum: meta.pagesNum,
            country: meta.country, browser: meta.browser, device: meta.device, referrerHost: meta.referrerHost,
            entry: meta.entry, exit: meta.exit, entryPath: meta.entryPath, exitPath: meta.exitPath,
            opis, ts: Date.now()
        };
    }

    // Przepisuje do schowka wszystkie poprawne opisy widoczne na kartach. Zwraca liczbę nowych wpisów.
    function synchronizujSchowek() {
        const s = schowek();
        let nowe = 0, zmiana = false;
        for (const card of pobierzKarty()) {
            if (!maPoprawnyOpis(card)) continue;
            const meta = pobierzMetadaneSesji(card);
            const id = idSesji(meta);
            const opis = tekstOpisu(card);
            if (s[id] && s[id].opis === opis) continue;
            if (!s[id]) nowe++;
            s[id] = wpisZMeta(meta, opis);
            zmiana = true;
        }
        if (zmiana) zapiszSchowek();
        if (stan.aktywny) stan.dodane += nowe;
        return nowe;
    }

    function wyzerujSchowek() {
        if (stan.aktywny) return;
        const n = Object.keys(schowek()).length;
        if (!n) { ustawStatus('Schowek jest pusty', 'warn'); return; }
        if (!confirm(`Usunąć ${n} opisów ze schowka dla tego projektu?\n\nOpisy widoczne teraz na załadowanych kartach od razu wrócą do schowka (są już wygenerowane).`)) return;
        schowekCache = {};
        zapiszSchowek();
        stan.oczekujace.clear();
        ustawStatus(`Wyzerowano schowek (−${n})`, 'ok');
        refreshLiczniki();
    }

    /* ═══════════════════ GENEROWANIE: „Doładuj listę” (zawsze na maksa) ═══════════════════ */
    // Pętla: generuj opisy dla załadowanych nagrań spoza schowka → przewiń listę po kolejne → powtarzaj,
    // aż lista się skończy, znajdą się wszystkie ID z filtra albo użytkownik kliknie „Zatrzymaj”.

    const czekaj = ms => new Promise(r => setTimeout(r, ms));
    const liczbaOczekujacych = () => [...stan.oczekujace.values()].filter(ts => Date.now() - ts < OCZEKIWANIE_MS).length;

    function znajdzKartyPoKluczu(klucze) {
        const mapa = new Map();
        for (const card of pobierzKarty()) {
            const k = pobierzMetadaneSesji(card).klucz;
            if (klucze.has(k)) mapa.set(k, card);
        }
        return mapa;
    }

    function kandydaciDoGenerowania() {
        const f = odczytajFiltry();
        const s = schowek();
        const widziane = new Set();
        const lista = [];
        let pominiete = 0, odfiltrowane = 0;
        for (const card of pobierzKarty()) {
            const meta = pobierzMetadaneSesji(card);
            const id = idSesji(meta);
            if (s[id] || widziane.has(id) || maPoprawnyOpis(card)) continue;   // już jest — nie generujemy drugi raz
            if (!pasujeDoFiltrow(meta, f)) { odfiltrowane++; continue; }
            const btn = przyciskGeneruj(card);
            if (!btn || btn.disabled) { pominiete++; continue; }
            const ts = stan.oczekujace.get(id);
            if (ts && Date.now() - ts < OCZEKIWANIE_MS) continue;            // kliknięte, czeka na odpowiedź
            if ((stan.proby.get(id) || 0) >= MAX_PROB) continue;             // AI odmawia — odpuszczamy
            widziane.add(id);
            lista.push({ id, klucz: meta.klucz });
        }
        return { lista, pominiete, odfiltrowane };
    }

    function wszystkieIdZnalezione() {
        const f = odczytajFiltry();
        if (!f.ids.length) return false;
        const s = schowek();
        const zaladowane = new Set(pobierzKarty().map(c => pobierzMetadaneSesji(c).userIdLc));
        return f.ids.every(id => s[id] || zaladowane.has(id));
    }

    async function startProcess() {
        if (stan.aktywny) { stan.stop = true; ustawStatus('Zatrzymywanie…', 'warn'); odswiezPrzyciski(); return; }
        stan.aktywny = true; stan.stop = false; stan.dodane = 0; stan.proby = new Map();
        odswiezPrzyciski();
        let pustePrzewiniecia = 0, powod = '';
        try {
            while (!stan.stop) {
                synchronizujSchowek();
                const { lista } = kandydaciDoGenerowania();
                if (lista.length) {
                    const w = await runda(lista);
                    if (stan.stop) break;
                    if (w.klikniete > 0) {
                        if (w.bledy) { ustawStatus(`${w.bledy} błąd AI → ponawiam za ${PRZERWA_PO_BLEDZIE / 1000} s…`, 'warn'); await czekaj(PRZERWA_PO_BLEDZIE); }
                        continue;
                    }
                }
                if (wszystkieIdZnalezione()) { powod = 'znaleziono wszystkie ID z filtra'; break; }
                const nowe = await przewinListe();
                if (nowe > 0) pustePrzewiniecia = 0;
                else if (++pustePrzewiniecia >= 2) { powod = 'koniec listy'; break; }
            }
        } finally {
            zakoncz(powod);
        }
    }

    // Klika „Generate” po kolei, potem czeka na opisy. Zwraca { klikniete, bledy }.
    function runda(lista) {
        return new Promise(resolve => {
            const klikniete = [];
            let i = 0;
            const klik = () => {
                if (stan.stop || i >= lista.length) { obserwuj(klikniete, resolve); return; }
                const w = lista[i++];
                let kliknieto = false;
                if (!schowek()[w.id]) {   // ponowne sprawdzenie tuż przed kliknięciem (np. inna karta przeglądarki)
                    const card = znajdzKartyPoKluczu(new Set([w.klucz])).get(w.klucz);
                    const btn = card ? przyciskGeneruj(card) : null;
                    if (card && !maPoprawnyOpis(card) && btn && !btn.disabled) {
                        w.staryOpis = maOpis(card) ? tekstOpisu(card) : null;
                        btn.click();
                        kliknieto = true;
                        stan.oczekujace.set(w.id, Date.now());
                        stan.proby.set(w.id, (stan.proby.get(w.id) || 0) + 1);
                        klikniete.push(w);
                    }
                }
                ustawStatus(`Klikam ${i}/${lista.length} · +${stan.dodane} w schowku`);
                odswiezPrzyciski();
                setTimeout(klik, kliknieto ? 700 + Math.random() * 500 : 0);
            };
            klik();
        });
    }

    function obserwuj(klikniete, resolve) {
        if (!klikniete.length) { resolve({ klikniete: 0, bledy: 0 }); return; }
        const klucze = new Set(klikniete.map(w => w.klucz));
        const start = Date.now();
        const maxMs = Math.min(300, Math.max(60, klikniete.length * 10)) * 1000;
        let ostatniPostep = start, ostatnieGotowe = -1;

        clearInterval(stan.watcherId);
        stan.watcherId = setInterval(() => {
            synchronizujSchowek();
            const mapa = znajdzKartyPoKluczu(klucze);
            let gotowe = 0, bledy = 0, zniknelo = 0;
            for (const w of klikniete) {
                const card = mapa.get(w.klucz);
                if (!card) { zniknelo++; continue; }
                if (!maOpis(card)) continue;
                if (w.staryOpis != null && tekstOpisu(card) === w.staryOpis) continue; // stary błąd jeszcze nie podmieniony
                gotowe++;
                stan.oczekujace.delete(w.id);
                if (czyBladAI(card)) bledy++;
            }
            if (gotowe !== ostatnieGotowe) { ostatnieGotowe = gotowe; ostatniPostep = Date.now(); }
            const sek = Math.round((Date.now() - start) / 1000);
            ustawStatus(`Generuję ${gotowe}/${klikniete.length} · +${stan.dodane} w schowku · ${sek}s`);
            odswiezPrzyciski();

            const koniec = gotowe + zniknelo >= klikniete.length
                || Date.now() - start >= maxMs
                || stan.stop
                || (gotowe > 0 && Date.now() - ostatniPostep >= 45000);
            if (koniec) {
                clearInterval(stan.watcherId); stan.watcherId = null;
                resolve({ klikniete: klikniete.length, bledy });
            }
        }, 1000);
    }

    function zakoncz(powod) {
        clearInterval(stan.watcherId); stan.watcherId = null;
        const przerwane = stan.stop;
        stan.aktywny = false; stan.stop = false; stan.przewijanie = false;
        synchronizujSchowek();
        const s = schowek();
        const nieudane = [...stan.proby.entries()].filter(([id, n]) => n >= MAX_PROB && !s[id]).length;
        stan.proby = new Map();
        const { pominiete, odfiltrowane } = kandydaciDoGenerowania();

        ustawStatus(`${przerwane ? 'Zatrzymano' : 'Gotowe'} · +${stan.dodane} do schowka (razem: ${Object.keys(s).length})`, przerwane ? 'warn' : 'ok');
        const linie = [];
        if (powod) linie.push(powod);
        if (odfiltrowane) linie.push(`${odfiltrowane} odfiltrowano`);
        if (pominiete) linie.push(`${pominiete} zbyt krótkich (brak przycisku)`);
        if (nieudane) linie.push(`${nieudane} z błędem AI po ${MAX_PROB} próbach`);
        const rep = document.getElementById('h-report');
        rep.textContent = linie.join(' · ');
        rep.style.display = linie.length ? 'block' : 'none';
        refreshLiczniki();
    }

    // Przewija listę Clarity, żeby doładowała kolejne nagrania. Zwraca liczbę nowych kart.
    function przewinListe() {
        return new Promise(resolve => {
            const karty = pobierzKarty();
            if (!karty.length) { resolve(0); return; }
            stan.przewijanie = true;
            karty.forEach(c => c.classList.remove('ch-hidden')); // ukryte karty nie wyzwalają infinite scroll
            const ostatnia = karty[karty.length - 1];
            ostatnia.scrollIntoView({ block: 'end' });
            let el = ostatnia.parentElement;
            while (el && el !== document.body) {
                if (el.scrollHeight > el.clientHeight + 5) el.scrollTop = el.scrollHeight;
                el = el.parentElement;
            }
            window.scrollTo(0, document.body.scrollHeight);
            const przed = karty.length;
            ustawStatus(`Przewijam po kolejne nagrania (załadowane: ${przed}) · +${stan.dodane} w schowku`);
            setTimeout(() => {
                stan.przewijanie = false;
                const po = pobierzKarty().length;
                refreshLiczniki();
                resolve(po - przed);
            }, 2500);
        });
    }

    /* ═══════════════════ EKSPORT: PROMPT + INPUT (JSON) ═══════════════════ */

    function wykryjDomeneSerwisu(dane) {
        for (const d of dane) { if (d.entry) { try { return new URL(d.entry).hostname; } catch { /* skrócony */ } } }
        return null;
    }
    function anonimizujTekst(tekst, domena) {
        if (!domena || !tekst) return tekst;
        const bezWww = domena.replace(/^www\./, '');
        const wzorce = [...new Set(['www.' + bezWww, bezWww, domena])].sort((a, b) => b.length - a.length);
        let wynik = tekst;
        for (const w of wzorce) wynik = wynik.replace(new RegExp(w.replace(/\./g, '\\.'), 'gi'), 'firma.pl');
        return wynik;
    }

    // Wpisy ze schowka do eksportu: z opisem, zgodne z filtrami i — gdy włączona opcja
    // „tylko z załadowanej listy” — wyłącznie nagrania obecne teraz na liście Clarity
    // (bez wpisów z wcześniejszych przebiegów / innych zakresów dat).
    function wpisyDoEksportu() {
        const f = odczytajFiltry();
        const naLiscie = u.tylkoZListy
            ? new Set(pobierzKarty().map(c => idSesji(pobierzMetadaneSesji(c))))
            : null;
        return Object.entries(schowek())
            .filter(([id, e]) => e.opis && pasujeDoFiltrow(e, f) && (!naLiscie || naLiscie.has(id)))
            .map(([, e]) => e);
    }

    // Źródłem dla prompta jest schowek (przetrwa odświeżenie), zawężony filtrami
    function pobierzDaneSesji() {
        synchronizujSchowek();
        return wpisyDoEksportu();
    }

    function zbudujInputJson(dane) {
        const domena = wykryjDomeneSerwisu(dane);
        const anon = t => anonimizujTekst(t, domena);
        return dane.map(d => ({
            userId: d.userId,
            date: d.date,
            duration: d.duration,
            clicks: d.clicksNum,
            pages: d.pagesNum,
            country: d.country,
            browser: d.browser,
            device: d.device,
            referrer: d.referrerHost,
            entry: anon(usunParametryUrl(d.entry)),
            exit: anon(usunParametryUrl(d.exit)),
            description: anon(d.opis)
        }));
    }

    async function kopiujPrompt() {
        const dane = pobierzDaneSesji();
        if (!dane.length) { flashBtn('btn-copy', '✕ brak opisów do skopiowania', C.red); return; }
        const json = JSON.stringify(zbudujInputJson(dane), null, 2);
        const f = odczytajFiltry();
        const zakres = [];
        if (u.tylkoZListy) zakres.push('tylko nagrania z załadowanej listy');
        if (czyFiltryAktywne(f)) zakres.push('filtr: ' + opisFiltrow(f));
        const naglowek = `<!-- nagrań w INPUT (N): ${dane.length}${zakres.length ? '; ' + zakres.join('; ') : ''} -->\n`;
        const prompt = (u.prompt || DOMYSLNY_PROMPT).replace(/\s*$/, '') + '\n' + naglowek + json + '\n';
        try {
            await navigator.clipboard.writeText(prompt);
            flashBtn('btn-copy', `✓ skopiowano prompt + ${dane.length} opisów`, C.green);
        } catch { flashBtn('btn-copy', '✕ błąd schowka', C.red); }
    }

    function flashBtn(id, text, color) {
        const btn = document.getElementById(id);
        const orig = btn.dataset.label || btn.textContent;
        btn.dataset.label = orig;
        btn.textContent = text;
        btn.style.background = color; btn.style.borderColor = color; btn.style.color = '#fff';
        setTimeout(() => { btn.textContent = orig; btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = ''; }, 2200);
    }

    /* ═══════════════════ UI ═══════════════════ */

    function ustawStatus(msg, typ) {
        const el = document.getElementById('h-status');
        el.textContent = msg;
        el.className = 'h-status' + (typ ? ' h-status--' + typ : '');
        if (typ !== 'ok') document.getElementById('h-report').style.display = 'none';
    }
    // Przycisk „Doładuj listę (X)” / „Zatrzymaj (X)” — X = ile opisów zostało do wygenerowania
    function odswiezPrzyciski() {
        const btn = document.getElementById('btn-load');
        const rst = document.getElementById('btn-wipe');
        if (!btn) return;
        const x = kandydaciDoGenerowania().lista.length + (stan.aktywny ? liczbaOczekujacych() : 0);
        btn.textContent = stan.aktywny
            ? (stan.stop ? '⏳ Zatrzymywanie…' : `⏹ Zatrzymaj (${x})`)
            : `⟳ Doładuj listę (${x})`;
        btn.classList.toggle('h-btn--stop', stan.aktywny);
        btn.title = stan.aktywny
            ? 'Zatrzymuje generowanie (to, co już jest w schowku, zostaje)'
            : `Generuje opisy dla wszystkich nagrań pasujących do filtrów, których nie ma w schowku (${x} na załadowanej liście), i przewija listę po kolejne`;
        rst.disabled = stan.aktywny;
    }

    // Filtruje listę Clarity: karty spoza filtra dostają .ch-hidden (display:none), karty w schowku — .ch-stored
    function zastosujFiltrNaListe() {
        const karty = pobierzKarty();
        const metas = karty.map(pobierzMetadaneSesji);
        const f = odczytajFiltry();
        const aktywne = czyFiltryAktywne(f);
        const s = schowek();
        let pasuje = 0;
        karty.forEach((card, i) => {
            const ok = !aktywne || pasujeDoFiltrow(metas[i], f);
            if (ok) pasuje++;
            card.classList.toggle('ch-hidden', !stan.przewijanie && u.ukrywajKarty && aktywne && !ok);
            card.classList.toggle('ch-stored', !!s[idSesji(metas[i])]);
        });
        return { karty, metas, aktywne, pasuje, f };
    }

    function odswiezInfoIds(metas, f) {
        const el = document.getElementById('h-ids-info');
        if (!el) return;
        if (!f.ids.length) { el.style.display = 'none'; return; }
        const s = schowek();
        const zaladowane = new Set(metas.map(m => m.userIdLc));
        const wSchowku = f.ids.filter(id => s[id]).length;
        const brak = f.ids.filter(id => !zaladowane.has(id) && !s[id]);
        el.innerHTML = `znaleziono <b>${f.ids.length - brak.length}/${f.ids.length}</b> · w schowku <b>${wSchowku}</b>`
            + (brak.length ? ` · brak: <span style="color:${C.orange}">${escHtml(brak.join(', '))}</span> — „Doładuj listę” je wyszuka` : '');
        el.style.display = 'block';
    }

    function refreshLiczniki() {
        if (!widget) return;
        synchronizujSchowek();
        const { karty, metas, aktywne, pasuje, f } = zastosujFiltrNaListe();
        const wpisy = Object.values(schowek());
        const doEksportu = wpisyDoEksportu().length;
        const ukryte = u.ukrywajKarty && aktywne ? karty.length - pasuje : 0;

        document.getElementById('h-badge').textContent = karty.length ? `${karty.length} / ${pobierzDostepneNagrania()}` : '—';
        document.getElementById('h-info').innerHTML =
            `załadowane <b>${karty.length}</b> · pasuje <b style="color:${C.accent}">${pasuje}</b>${ukryte ? ` · ukryte <b>${ukryte}</b>` : ''}`
            + ` · w schowku <b style="color:${C.green}">${wpisy.length}</b>${aktywne || u.tylkoZListy ? ` (do eksportu ${doEksportu})` : ''}`;
        document.getElementById('h-filter-cnt').textContent = aktywne ? `${pasuje}/${karty.length}` : '';
        const cb = document.getElementById('btn-copy');
        if (!cb.dataset.label || cb.textContent === cb.dataset.label) {
            cb.textContent = `⎘ Kopiuj prompt + INPUT (${doEksportu})`;
            cb.dataset.label = cb.textContent;
        }
        odswiezPrzyciski();
        odswiezInfoIds(metas, f);
        // listy w filtrach budujemy z załadowanych kart + schowka (po odświeżeniu schowek ma więcej nagrań niż lista)
        const naLiscie = new Set(metas.map(idSesji));
        odswiezSelecty([...metas, ...wpisy.filter(e => !naLiscie.has(e.userIdLc))]);
    }

    function buildWidget() {
        collapsed = !!u.collapsed;
        widget = document.createElement('div');
        widget.id = WID;
        widget.innerHTML = `
<style>
#${WID}{position:fixed;bottom:16px;right:16px;z-index:2147483646;font-family:'Google Sans',system-ui,-apple-system,sans-serif;width:340px;border-radius:14px;overflow:hidden;box-shadow:0 10px 36px rgba(0,0,0,.55);border:1px solid ${C.border};background:${C.bg};color:${C.text};user-select:none;display:flex;flex-direction:column}
#${WID} *{box-sizing:border-box}
div[data-testid="sessionCard"].ch-hidden{display:none!important}
div[data-testid="sessionCard"].ch-stored{box-shadow:inset 4px 0 0 ${C.green}!important}
#${WID} .h-hdr{background:linear-gradient(135deg,${C.accentDk},${C.bg});padding:10px 14px;display:flex;align-items:center;gap:8px;cursor:move;border-bottom:1px solid ${C.border}}
#${WID} .h-logo{width:22px;height:22px;flex-shrink:0;display:flex;align-items:center;justify-content:center}
#${WID} .h-title{flex:1;font-size:13px;font-weight:600;color:#fff;letter-spacing:.3px}
#${WID} .h-badge{font-size:10px;background:${C.border};color:${C.dim};padding:2px 8px;border-radius:10px;white-space:nowrap}
#${WID} .h-hdr-btn{background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;opacity:.6}
#${WID} .h-hdr-btn:hover{opacity:1}
#${WID} .h-wrap{overflow:hidden;transition:max-height .25s ease}
#${WID} .h-wrap.open{max-height:92vh}#${WID} .h-wrap.closed{max-height:0!important}
#${WID} .h-panel{padding:10px 14px;max-height:calc(100vh - 170px);overflow-y:auto}
#${WID} .h-panel::-webkit-scrollbar{width:5px}#${WID} .h-panel::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
#${WID} .h-label{font-size:10px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin:0 0 4px;display:flex;align-items:center;gap:6px}
#${WID} .h-label .h-cnt{color:${C.accent};text-transform:none;letter-spacing:0}
#${WID} .h-link{margin-left:auto;color:${C.dim};font-weight:600;text-transform:none;letter-spacing:0;cursor:pointer;border:none;background:none;font-size:10px;font-family:inherit}
#${WID} .h-link:hover{color:${C.red}}
#${WID} .h-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-bottom:8px}
#${WID} .h-grid2{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px}
#${WID} .h-field{display:flex;flex-direction:column;gap:3px}
#${WID} .h-field label{font-size:9px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.4px}
#${WID} .h-input{width:100%;background:${C.input};border:1px solid #4b5563;color:${C.text};padding:6px 8px;border-radius:8px;font-size:11px;outline:none;font-family:inherit;min-width:0}
#${WID} .h-input:focus{border-color:${C.accent}}
#${WID} .h-input::placeholder{color:#4b5563}
#${WID} .h-input--active{border-color:${C.accent};background:${C.accentDk}}
#${WID} textarea.h-input{resize:vertical;min-height:120px;font-size:10px;line-height:1.35;user-select:text;font-family:ui-monospace,Menlo,Consolas,monospace}
#${WID} .h-ids-info{font-size:10px;color:${C.dim};line-height:1.35;word-break:break-all;display:none}
#${WID} .h-ids-info b{color:${C.text}}
#${WID} .h-ms{position:relative}
#${WID} .h-ms-btn{display:flex;align-items:center;gap:6px;cursor:pointer;text-align:left}
#${WID} .h-ms-label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#${WID} .h-ms-btn::after{content:'';width:10px;height:6px;flex-shrink:0;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E") no-repeat center}
#${WID} .h-ms-drop{display:none;position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:5;background:${C.bg};border:1px solid ${C.accent};border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}
#${WID} .h-ms.open .h-ms-drop{display:block}
#${WID} .h-ms-list{max-height:180px;overflow-y:auto;padding:4px 0}
#${WID} .h-ms-item{display:flex;align-items:center;gap:7px;padding:5px 8px;font-size:11px;color:${C.text};cursor:pointer}
#${WID} .h-ms-item:hover{background:${C.hover}}
#${WID} .h-ms-item span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#${WID} .h-ms-item em{font-style:normal;color:${C.dim};font-size:10px}
#${WID} .h-ms-item.on .h-chk{background:${C.accent};border-color:${C.accent};color:#fff}
#${WID} .h-ms-empty{padding:8px;font-size:10px;color:${C.dim};text-align:center}
#${WID} .h-ms-clear{display:block;width:100%;background:${C.hover};border:none;border-top:1px solid ${C.border};color:${C.dim};font-size:10px;padding:5px;cursor:pointer;font-family:inherit}
#${WID} .h-ms-clear:hover{color:${C.red}}
#${WID} .h-sep{border:none;border-top:1px solid ${C.border};margin:10px 0}
#${WID} .h-opt{display:flex;align-items:center;gap:7px;cursor:pointer;padding:3px 0;font-size:11px;color:${C.dim}}
#${WID} .h-opt:hover{color:${C.text}}
#${WID} .h-chk{width:14px;height:14px;border-radius:4px;border:2px solid ${C.border};display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:9px;color:transparent;transition:all .12s}
#${WID} .h-opt.on .h-chk{background:${C.accent};border-color:${C.accent};color:#fff}
#${WID} .h-info{font-size:10px;color:${C.dim};margin:6px 0 8px;line-height:1.4}
#${WID} .h-info b{color:${C.text};font-weight:600}
#${WID} .h-status{font-size:11px;font-weight:500;color:${C.text};background:${C.hover};border:1px solid ${C.border};padding:6px 10px;border-radius:8px}
#${WID} .h-status--ok{border-color:${C.green};color:${C.green}}
#${WID} .h-status--warn{border-color:${C.orange};color:${C.orange}}
#${WID} .h-report{font-size:10px;color:${C.orange};margin-top:4px;padding:0 4px;line-height:1.4;display:none}
#${WID} .h-bar{border-top:1px solid ${C.border};padding:10px 12px;background:${C.hover};display:grid;grid-template-columns:1fr 1fr;gap:6px}
#${WID} .h-btn{font-size:12px;font-weight:700;padding:9px 8px;width:100%;border-radius:8px;cursor:pointer;border:1px solid ${C.border};background:${C.bg};color:${C.text};transition:all .12s;text-align:center;white-space:nowrap;font-family:inherit;overflow:hidden;text-overflow:ellipsis}
#${WID} .h-btn:hover{border-color:${C.accent}}
#${WID} .h-btn:active{transform:scale(.98)}
#${WID} .h-btn--primary{background:${C.accent};border-color:${C.accent};color:#fff}
#${WID} .h-btn--primary:hover{background:#2563eb}
#${WID} .h-btn--stop{background:${C.red}!important;border-color:${C.red}!important}
#${WID} .h-btn--danger{background:${C.bg};border-color:#7f1d1d;color:#fca5a5}
#${WID} .h-btn--danger:hover{border-color:${C.red};color:#fff}
#${WID} .h-btn:disabled{opacity:.4;cursor:not-allowed;transform:none}
#${WID} .h-btn--dark{background:#000;border-color:#000;color:${C.text};grid-column:1 / -1}
#${WID} .h-btn--dark:hover{border-color:${C.dim}}
#${WID} details.h-prompt summary{font-size:10px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.5px;cursor:pointer;user-select:none;margin:8px 0 4px;list-style:none;display:flex;align-items:center;gap:6px}
#${WID} details.h-prompt summary::before{content:'▸';font-size:11px}
#${WID} details.h-prompt[open] summary::before{content:'▾'}
#${WID} details.h-prompt summary .h-link{margin-left:auto}
</style>

<div class="h-hdr" id="h-hdr">
  <div class="h-logo"><svg width="22" height="22" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg"><rect width="192" height="192" rx="24" fill="#1960bf"/><path d="M56 48L144 96L56 144V48Z" fill="#fff"/></svg></div>
  <span class="h-title">Clarity Insighter</span>
  <span class="h-badge" id="h-badge" title="załadowane / dostępne">—</span>
  <button class="h-hdr-btn" id="h-col">${collapsed ? '▸' : '▾'}</button>
</div>

<div class="h-wrap ${collapsed ? 'closed' : 'open'}" id="h-wrap">
  <div class="h-panel">

    <div class="h-label">Filtry nagrań <span class="h-cnt" id="h-filter-cnt"></span><button class="h-link" id="btn-reset">✕ wyczyść</button></div>
    <div class="h-field" style="margin-bottom:8px">
      <label title="Wklej kolumnę „ID nagrań” z tabeli LLM — separator: przecinek, spacja, średnik lub nowa linia">ID nagrań (po przecinku)</label>
      <input type="text" id="f-ids" class="h-input" value="${escHtml(u.ids)}" placeholder="np. 1r41zk2, 14n3xyi, 724ia7" spellcheck="false" autocomplete="off">
      <div class="h-ids-info" id="h-ids-info"></div>
    </div>
    <div class="h-grid">
      <div class="h-field"><label title="Minimalny czas trwania sesji, w sekundach (np. 60 = 1 min)">Czas ≥ (sek.)</label><input type="number" id="f-czas" class="h-input" min="0" value="${u.minCzas || ''}"></div>
      <div class="h-field"><label>Kliki ≥</label><input type="number" id="f-klik" class="h-input" min="0" value="${u.minKlik || ''}"></div>
      <div class="h-field"><label>Strony ≥</label><input type="number" id="f-strony" class="h-input" min="0" value="${u.minStron || ''}"></div>
    </div>
    <div class="h-grid2">
      ${ms('f-kraj', 'Kraj')}
      ${ms('f-urzadzenie', 'Urządzenie')}
    </div>
    <div class="h-grid2">
      ${ms('f-przegladarka', 'Przeglądarka / aplikacja')}
      ${ms('f-referrer', 'Referrer', 'Domena strony, z której użytkownik przyszedł')}
    </div>
    <div class="h-field" style="margin-bottom:8px"><label>Entry URL zawiera</label><input type="text" id="f-url" class="h-input" list="f-url-list" value="${escHtml(u.url)}" placeholder="np. /produkt/ — lista podpowiada ścieżki"><datalist id="f-url-list"></datalist></div>
    <div class="h-field"><label>Exit URL zawiera</label><input type="text" id="f-exit" class="h-input" list="f-exit-list" value="${escHtml(u.exitUrl)}" placeholder="np. /koszyk/ — lista podpowiada ścieżki"><datalist id="f-exit-list"></datalist></div>

    <hr class="h-sep">

    <div class="h-opt" data-opt="ukrywajKarty" title="Karty niepasujące do filtrów znikają z listy po lewej"><div class="h-chk">✓</div><span>Ukrywaj niepasujące nagrania na liście Clarity</span></div>
    <div class="h-opt" data-opt="tylkoZListy" title="Do prompta trafiają tylko nagrania obecne na załadowanej liście Clarity (bez wpisów ze schowka z wcześniejszych przebiegów / innych zakresów dat)"><div class="h-chk">✓</div><span>Kopiuj tylko nagrania z załadowanej listy</span></div>

    <details class="h-prompt" id="h-prompt" ${u.promptOpen ? 'open' : ''}>
      <summary>Prompt (edytowalny) <button class="h-link" id="btn-prompt-reset" title="Przywróć domyślny prompt">↺ domyślny</button></summary>
      <textarea id="f-prompt" class="h-input" spellcheck="false">${escHtml(u.prompt)}</textarea>
      <div class="h-info" style="margin-top:4px">Pod sekcją <b>## INPUT</b> zostanie doklejony JSON z opisami sesji pasujących do filtrów.</div>
    </details>

    <div class="h-info" id="h-info">…</div>
    <div class="h-status" id="h-status">Gotowy</div>
    <div class="h-report" id="h-report"></div>
  </div>

  <div class="h-bar">
    <button class="h-btn h-btn--primary" id="btn-load">⟳ Doładuj listę</button>
    <button class="h-btn h-btn--danger" id="btn-wipe" title="Usuwa wszystkie opisy ze schowka tego projektu">🗑 Wyzeruj listę</button>
    <button class="h-btn h-btn--dark" id="btn-copy" title="Kopiuje prompt z sekcją ## INPUT wypełnioną JSON-em opisów ze schowka (wg filtrów i opcji „tylko z załadowanej listy”)">⎘ Kopiuj prompt + INPUT</button>
  </div>
</div>`;
        document.body.appendChild(widget);

        widget.querySelectorAll('.h-opt').forEach(el => {
            el.classList.toggle('on', !!u[el.dataset.opt]);
            el.addEventListener('click', () => {
                u[el.dataset.opt] = !u[el.dataset.opt];
                el.classList.toggle('on', u[el.dataset.opt]);
                zapiszUstawienia(); refreshLiczniki();
            });
        });

        const powiaz = (id, klucz, parse) => {
            const el = document.getElementById(id);
            const zastosuj = () => {
                u[klucz] = parse ? parse(el.value) : el.value;
                el.classList.toggle('h-input--active', !!u[klucz] && klucz !== 'prompt');
                zapiszUstawienia(); refreshLiczniki();
            };
            el.addEventListener('input', zastosuj);
            el.addEventListener('change', zastosuj);
            el.classList.toggle('h-input--active', !!u[klucz] && klucz !== 'prompt');
        };
        const num = v => parseInt(v, 10) || 0;
        powiaz('f-ids', 'ids');
        powiaz('f-czas', 'minCzas', num); powiaz('f-klik', 'minKlik', num); powiaz('f-strony', 'minStron', num);
        powiaz('f-url', 'url');
        powiaz('f-exit', 'exitUrl');
        powiaz('f-prompt', 'prompt');
        Object.keys(MS_MAP).forEach(msInit);
        document.addEventListener('click', () => document.querySelectorAll(`#${WID} .h-ms.open`).forEach(b => b.classList.remove('open')));

        // Pole input gubi znaki nowej linii przy wklejaniu (ID sklejałyby się) — zamieniamy je na przecinki
        document.getElementById('f-ids').addEventListener('paste', e => {
            const t = e.clipboardData?.getData('text');
            if (!t) return;
            e.preventDefault();
            const el = e.target;
            el.setRangeText(t.trim().replace(/\s*[\r\n]+\s*/g, ', '), el.selectionStart, el.selectionEnd, 'end');
            el.dispatchEvent(new Event('input'));
        });

        document.getElementById('h-prompt').addEventListener('toggle', e => {
            u.promptOpen = e.target.open; zapiszUstawienia();
            setTimeout(dopasujDoEkranu, 50);
        });
        document.getElementById('btn-prompt-reset').addEventListener('click', e => {
            e.preventDefault(); e.stopPropagation();
            u.prompt = DOMYSLNY_PROMPT; u.promptWersja = PROMPT_WERSJA;
            document.getElementById('f-prompt').value = DOMYSLNY_PROMPT; zapiszUstawienia();
        });

        // „wyczyść” = zero filtrów (wszystkie nagrania widoczne)
        document.getElementById('btn-reset').addEventListener('click', () => {
            Object.assign(u, { ids: '', minCzas: 0, minKlik: 0, minStron: 0, kraj: [], przegladarka: [], urzadzenie: [], referrer: [], url: '', exitUrl: '' });
            const ustaw = (id, v) => { const el = document.getElementById(id); el.value = v || ''; el.classList.toggle('h-input--active', !!v); };
            ustaw('f-ids', ''); ustaw('f-czas', 0); ustaw('f-klik', 0); ustaw('f-strony', 0); ustaw('f-url', ''); ustaw('f-exit', '');
            Object.keys(MS_MAP).forEach(id => { msRenderujListe(id); msEtykieta(id); });
            zapiszUstawienia(); refreshLiczniki();
        });

        const colBtn = document.getElementById('h-col');
        colBtn.addEventListener('click', () => {
            zakotwicz(); // przy dolnej krawędzi zakotwicz od dołu → panel rozwija się w górę
            collapsed = !collapsed; u.collapsed = collapsed; zapiszUstawienia();
            document.getElementById('h-wrap').classList.toggle('open', !collapsed);
            document.getElementById('h-wrap').classList.toggle('closed', collapsed);
            colBtn.textContent = collapsed ? '▸' : '▾';
            setTimeout(() => { dopasujDoEkranu(); savePos(); }, 300);
        });

        document.getElementById('btn-load').addEventListener('click', startProcess);
        document.getElementById('btn-wipe').addEventListener('click', wyzerujSchowek);
        document.getElementById('btn-copy').addEventListener('click', kopiujPrompt);

        makeDraggable(widget, document.getElementById('h-hdr'));
        restorePos();
        window.addEventListener('resize', () => { zakotwicz(); dopasujDoEkranu(); });
        refreshLiczniki();
    }

    function ms(id, label, title = '') {
        const t = title ? ` title="${escHtml(title)}"` : '';
        return `<div class="h-field"><label${t}>${label}</label><div class="h-ms" id="${id}"><div class="h-input h-ms-btn"><span class="h-ms-label">wszystkie</span></div><div class="h-ms-drop"><div class="h-ms-list"></div><button class="h-ms-clear">wyczyść</button></div></div></div>`;
    }

    /* ═══════════════════ DRAG / POZYCJA ═══════════════════ */
    // Widget w dolnej połowie ekranu jest kotwiczony od dołu (bottom), w górnej — od góry (top).
    // Dzięki temu przy dolnej krawędzi panel rozwija się w górę, a przy górnej — w dół.

    const clamp = (v, min, max) => Math.min(Math.max(min, v), Math.max(min, max));
    const czyZDolu = () => !widget.style.top || widget.style.top === 'auto';

    function zakotwicz() {
        if (!widget) return;
        const r = widget.getBoundingClientRect();
        const vw = window.innerWidth, vh = window.innerHeight;
        widget.style.left = clamp(r.left, 0, vw - r.width) + 'px';
        widget.style.right = 'auto';
        if (r.top + r.height / 2 > vh / 2) {
            widget.style.top = 'auto';
            widget.style.bottom = clamp(vh - r.bottom, 0, vh - r.height) + 'px';
        } else {
            widget.style.bottom = 'auto';
            widget.style.top = clamp(r.top, 0, vh - r.height) + 'px';
        }
    }

    // Po rozwinięciu: jeśli widget wystaje poza ekran, przesuń go tak, żeby się zmieścił
    function dopasujDoEkranu() {
        if (!widget) return;
        const r = widget.getBoundingClientRect();
        const vh = window.innerHeight, m = 8;
        if (czyZDolu()) {
            if (r.top < m) {
                const b = parseFloat(getComputedStyle(widget).bottom) || 0;
                widget.style.bottom = Math.max(0, b + r.top - m) + 'px';
            }
        } else if (r.bottom > vh - m) {
            const t = parseFloat(widget.style.top) || 0;
            widget.style.top = Math.max(0, t - (r.bottom - vh + m)) + 'px';
        }
    }

    function savePos() {
        const s = widget.style;
        if (!s.left) return; // pozycja domyślna (prawy dolny róg) — nic do zapisania
        const p = czyZDolu()
            ? { left: parseFloat(s.left), bottom: parseFloat(getComputedStyle(widget).bottom) || 0 }
            : { left: parseFloat(s.left), top: parseFloat(s.top) || 0 };
        try { localStorage.setItem(LS_POS, JSON.stringify(p)); } catch { /* ignoruj */ }
    }
    function restorePos() {
        try {
            const p = JSON.parse(localStorage.getItem(LS_POS) || 'null');
            if (!p) return;
            widget.style.left = clamp(p.left, 0, window.innerWidth - 60) + 'px';
            widget.style.right = 'auto';
            if (typeof p.bottom === 'number') {
                widget.style.top = 'auto';
                widget.style.bottom = clamp(p.bottom, 0, window.innerHeight - 60) + 'px';
            } else {
                widget.style.bottom = 'auto';
                widget.style.top = clamp(p.top, 0, window.innerHeight - 60) + 'px';
            }
            zakotwicz();       // stary format {left, top} → przelicz na właściwą kotwicę
            dopasujDoEkranu();
        } catch { /* ignoruj */ }
    }
    function makeDraggable(el, handle) {
        handle.addEventListener('mousedown', e => {
            if (e.target.closest('button')) return;
            e.preventDefault();
            const sx = e.clientX, sy = e.clientY, r = el.getBoundingClientRect();
            const mv = ev => {
                el.style.left = (r.left + ev.clientX - sx) + 'px';
                el.style.top = (r.top + ev.clientY - sy) + 'px';
                el.style.right = 'auto'; el.style.bottom = 'auto';
            };
            const up = () => {
                document.removeEventListener('mousemove', mv);
                document.removeEventListener('mouseup', up);
                zakotwicz(); savePos();
            };
            document.addEventListener('mousemove', mv);
            document.addEventListener('mouseup', up);
        });
    }

    /* ═══════════════════ INIT ═══════════════════ */
    let moTimer = null;
    function obserwujListe() {
        const mo = new MutationObserver(muts => {
            // interesują nas tylko zmiany poza widgetem
            if (!muts.some(m => !widget.contains(m.target))) return;
            clearTimeout(moTimer);
            moTimer = setTimeout(() => { zastosujFiltrNaListe(); }, 60);
        });
        mo.observe(document.body, { childList: true, subtree: true });
    }

    function init() {
        buildWidget();
        obserwujListe();
        window.addEventListener('beforeunload', e => { if (stan.aktywny) { e.preventDefault(); e.returnValue = ''; } });
        setInterval(refreshLiczniki, 3000);
    }
    if (document.readyState === 'complete') setTimeout(init, 1000);
    else window.addEventListener('load', () => setTimeout(init, 1000));
})();

Dodaj komentarz

Paweł Piekarski

Paweł Piekarski

Doktorat z nauk technicznych, ponad 10 lat analizy danych na uczelni i ponad 3 lata w analityce marketingowej.

Z nauki wyniosłem jedną zasadę: wnioski są tyle warte, co dane, na których się opierają. Dlatego najpierw porządkuję dane, a potem pomagam przełożyć je na decyzje, które przynoszą zyski.

Pracuję jako freelancer. Specjalizuję się w GTM, GA4 i BigQuery. Przeprowadzam audyty, wdrożenia od zera (albo po kimś) i opiekę analityczną.

Prowadzę kanał YouTube z ponad 50 filmami o analityce internetowej i biznesowej. Dzielę się darmowymi treściami, żeby ogarnąć analitykę samodzielnie.

Napisz do mnie

Paweł Piekarski
Paweł Piekarski Digital Analyst Marketing oparty na danych

Dane kontaktowe

+48 725 473 745

poczta@pawelpiekarski.pl

linkedin.com/in/pawelpiekarskipl

    Wysyłając wiadomość, zgadzasz się na kontakt i przetwarzanie danych zgodnie z polityką prywatności.