/* GC Progress New Click Analytics v1.0 Назначение: анализирует JSON-колонку progress_new в текущей таблице пользователей GetCourse. Где запускать: /pl/user/user/index Как использовать: вставить этот код в HTML-блок/скрипт GetCourse или запустить в консоли на странице списка пользователей. */ (function () { 'use strict'; const CONFIG = { toolId: 'gc-progress-new-analytics', supportedPaths: ['/pl/user/user/index'], progressColumnNames: ['progress_new', 'прогресс_new', 'progress new'], idColumnNames: ['ID', 'Id', 'id'], nameColumnNames: ['Отображаемое имя', 'Имя', 'ФИО'], emailColumnNames: ['Эл. почта', 'Email', 'E-mail'], lastActivityColumnNames: ['Последняя активность'], timezoneOffsetHours: 3, // МСК. Для Тбилиси можно поставить 4. pageFetchConcurrency: 6, allowedViewerUserIds: '', // например: '166004188,123456'. Пусто = показывать всем. hideIfViewerIdUnknown: false }; const STATE = { rows: [], analysis: null, originalTable: null, filter: null }; function log(...args) { console.log('[progress_new analytics]', ...args); } function normalizeText(value) { return String(value || '') .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .trim(); } function normalizeHeader(value) { return normalizeText(value).toLowerCase(); } function isSupportedPath() { return CONFIG.supportedPaths.includes(location.pathname); } function getViewerId() { if (window.accountUserId) return String(window.accountUserId); if (window.userInfo && window.userInfo.id) return String(window.userInfo.id); const bodyText = document.body ? document.body.innerHTML : ''; const match = bodyText.match(/accountUserId\s*=\s*["']?(\d+)/); return match ? match[1] : ''; } function canShowTool() { const allowed = String(CONFIG.allowedViewerUserIds || '') .split(',') .map(x => x.trim()) .filter(Boolean); if (!allowed.length) return true; const viewerId = getViewerId(); if (!viewerId && CONFIG.hideIfViewerIdUnknown) return false; return allowed.includes(viewerId); } function injectStyles() { if (document.getElementById(CONFIG.toolId + '-styles')) return; const style = document.createElement('style'); style.id = CONFIG.toolId + '-styles'; style.textContent = ` #${CONFIG.toolId}-panel { margin: 16px 0; padding: 16px; border: 1px solid #d9e2ec; border-radius: 10px; background: #fff; box-shadow: 0 2px 10px rgba(0,0,0,.04); font-family: Arial, sans-serif; } #${CONFIG.toolId}-panel * { box-sizing: border-box; } #${CONFIG.toolId}-panel h3 { margin: 0 0 10px; font-size: 20px; } #${CONFIG.toolId}-panel .gcpa-muted { color: #667085; font-size: 13px; } #${CONFIG.toolId}-panel .gcpa-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin: 10px 0; } #${CONFIG.toolId}-panel .gcpa-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin: 12px 0; } #${CONFIG.toolId}-panel .gcpa-card { border: 1px solid #e4e7ec; border-radius: 10px; padding: 12px; background: #f9fafb; } #${CONFIG.toolId}-panel .gcpa-card b { display: block; font-size: 24px; margin-bottom: 3px; color: #101828; } #${CONFIG.toolId}-panel .gcpa-card span { color: #667085; font-size: 13px; } #${CONFIG.toolId}-panel .gcpa-tabs { display: flex; gap: 8px; flex-wrap: wrap; border-bottom: 1px solid #e4e7ec; margin-top: 12px; } #${CONFIG.toolId}-panel .gcpa-tab { border: 1px solid #e4e7ec; border-bottom: none; border-radius: 8px 8px 0 0; padding: 8px 12px; cursor: pointer; background: #f9fafb; } #${CONFIG.toolId}-panel .gcpa-tab.active { background: #fff; font-weight: 700; } #${CONFIG.toolId}-panel .gcpa-tab-content { display: none; padding-top: 12px; } #${CONFIG.toolId}-panel .gcpa-tab-content.active { display: block; } #${CONFIG.toolId}-panel table.gcpa-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; } #${CONFIG.toolId}-panel table.gcpa-table th, #${CONFIG.toolId}-panel table.gcpa-table td { border-bottom: 1px solid #e4e7ec; padding: 7px 8px; text-align: left; vertical-align: top; } #${CONFIG.toolId}-panel table.gcpa-table th { background: #f9fafb; font-weight: 700; } #${CONFIG.toolId}-panel .gcpa-clickable { cursor: pointer; } #${CONFIG.toolId}-panel .gcpa-clickable:hover { background: #f2f4f7; } #${CONFIG.toolId}-panel .gcpa-btn { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #cfd8e3; border-radius: 8px; padding: 7px 10px; background: #fff; cursor: pointer; font-size: 13px; line-height: 1.2; color: #344054; } #${CONFIG.toolId}-panel .gcpa-btn:hover { background: #f9fafb; } #${CONFIG.toolId}-panel .gcpa-btn.primary { background: #2563eb; color: #fff; border-color: #2563eb; } #${CONFIG.toolId}-panel .gcpa-btn.danger { background: #fff; color: #b42318; border-color: #fda29b; } #${CONFIG.toolId}-panel .gcpa-control { border: 1px solid #cfd8e3; border-radius: 8px; padding: 7px 10px; font-size: 13px; } #${CONFIG.toolId}-panel .gcpa-bar-wrap { min-width: 120px; height: 8px; background: #eef2f6; border-radius: 999px; overflow: hidden; } #${CONFIG.toolId}-panel .gcpa-bar { height: 8px; background: #2563eb; border-radius: 999px; } #${CONFIG.toolId}-panel .gcpa-pill { display: inline-block; border-radius: 999px; padding: 2px 8px; font-size: 12px; background: #eef2f6; color: #344054; } #${CONFIG.toolId}-panel .gcpa-filter { padding: 8px 10px; border: 1px solid #fedf89; background: #fffaeb; border-radius: 8px; } #${CONFIG.toolId}-panel .gcpa-scroll { overflow: auto; max-height: 520px; border: 1px solid #e4e7ec; border-radius: 8px; } #${CONFIG.toolId}-status { margin-left: 8px; font-size: 13px; color: #667085; } `; document.head.appendChild(style); } function findToolbarContainer() { return document.querySelector('.page-actions') || document.querySelector('.standard-page-actions') || document.querySelector('.gc-main-content .pull-right') || document.querySelector('.gc-main-content h1')?.parentElement || document.querySelector('h1')?.parentElement || document.body; } function installButton() { if (!isSupportedPath()) { log('Неподдерживаемая страница:', location.pathname); return; } if (!canShowTool()) return; injectStyles(); if (document.getElementById(CONFIG.toolId + '-button')) return; const btn = document.createElement('button'); btn.id = CONFIG.toolId + '-button'; btn.type = 'button'; btn.className = 'btn btn-default'; btn.style.marginLeft = '8px'; btn.textContent = 'Аналитика progress_new'; btn.addEventListener('click', runTool); const status = document.createElement('span'); status.id = CONFIG.toolId + '-status'; const holder = findToolbarContainer(); holder.appendChild(btn); holder.appendChild(status); } function setStatus(message) { const el = document.getElementById(CONFIG.toolId + '-status'); if (el) el.textContent = message || ''; } function getMainTable(doc) { return doc.querySelector('table.kv-grid-table') || doc.querySelector('table.table'); } function getHeaders(doc) { const table = getMainTable(doc); if (!table) return []; return Array.from(table.querySelectorAll('thead th')).map(th => normalizeText(th.textContent)); } function findColumnIndex(headers, names) { const normNames = names.map(normalizeHeader); let idx = headers.findIndex(h => normNames.includes(normalizeHeader(h))); if (idx >= 0) return idx; idx = headers.findIndex(h => normNames.some(name => normalizeHeader(h).includes(name))); return idx; } function getSummaryTotal(doc) { const text = normalizeText( Array.from(doc.querySelectorAll('.summary, .grid-summary')) .map(el => el.textContent) .join(' ') ); const match = text.match(/из\s+([\d\s]+)\s+зап/i) || text.match(/Всего\s+([\d\s]+)/i); if (!match) return 0; return Number(match[1].replace(/\s+/g, '')) || 0; } function getCurrentPageSize(doc) { const table = getMainTable(doc); return table ? table.querySelectorAll('tbody tr').length : 0; } function buildPageUrl(pageNumber) { const url = new URL(location.href); url.searchParams.set('page', String(pageNumber)); return url.toString(); } async function fetchPageDoc(pageNumber) { const html = await fetch(buildPageUrl(pageNumber), { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }).then(r => { if (!r.ok) throw new Error('Не удалось загрузить страницу ' + pageNumber + ': HTTP ' + r.status); return r.text(); }); return new DOMParser().parseFromString(html, 'text/html'); } async function runQueue(items, worker, concurrency, onProgress) { const result = []; let cursor = 0; let done = 0; const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (cursor < items.length) { const index = cursor++; result[index] = await worker(items[index], index); done++; if (onProgress) onProgress(done, items.length); } }); await Promise.all(workers); return result; } function parseProgressValue(raw) { let s = String(raw || '').trim(); if (!s || s === '-') return []; s = s.replace(/"/g, '"').replace(/\u00a0/g, ' '); const candidates = []; candidates.push(s); candidates.push(s.replace(/^\\+"/, '').replace(/\\+"$/, '').replace(/\\"/g, '"')); candidates.push(s.replace(/^"/, '').replace(/"$/, '').replace(/\\"/g, '"')); function extractFromParsed(value) { if (Array.isArray(value)) return value; if (typeof value === 'string') { try { return extractFromParsed(JSON.parse(value)); } catch (e) { return value.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z/g) || []; } } if (value && typeof value === 'object') { const found = []; Object.keys(value).forEach(key => found.push(...extractFromParsed(value[key]))); return found; } return []; } for (const candidate of candidates) { try { const parsed = JSON.parse(candidate); const arr = extractFromParsed(parsed); if (arr.length) return normalizeTimestamps(arr); } catch (e) {} } return normalizeTimestamps(s.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z/g) || []); } function normalizeTimestamps(values) { const seen = new Set(); return values .map(x => String(x || '').trim()) .filter(Boolean) .filter(x => !Number.isNaN(new Date(x).getTime())) .filter(x => { if (seen.has(x)) return false; seen.add(x); return true; }) .sort((a, b) => new Date(a) - new Date(b)); } function extractEmailFromCell(cell) { if (!cell) return ''; const text = normalizeText(cell.textContent); const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); return match ? match[0] : text; } function extractUserIdFromRow(tr, cells, idIndex) { if (tr.dataset && tr.dataset.userId) return String(tr.dataset.userId); if (tr.dataset && tr.dataset.key) return String(tr.dataset.key); if (idIndex >= 0 && cells[idIndex]) { const id = normalizeText(cells[idIndex].textContent).match(/\d+/); if (id) return id[0]; } const link = tr.querySelector('a[href*="/user/control/user/update/id/"]'); if (link) { const match = link.getAttribute('href').match(/id\/(\d+)/); if (match) return match[1]; } return ''; } function parseRowsFromDoc(doc, pageNumber) { const table = getMainTable(doc); if (!table) throw new Error('Не нашёл таблицу пользователей на странице'); const headers = getHeaders(doc); const progressIndex = findColumnIndex(headers, CONFIG.progressColumnNames); if (progressIndex < 0) { throw new Error('Не нашёл колонку progress_new. Добавьте её в отображаемые поля таблицы.'); } const idIndex = findColumnIndex(headers, CONFIG.idColumnNames); const nameIndex = findColumnIndex(headers, CONFIG.nameColumnNames); const emailIndex = findColumnIndex(headers, CONFIG.emailColumnNames); const lastActivityIndex = findColumnIndex(headers, CONFIG.lastActivityColumnNames); return Array.from(table.querySelectorAll('tbody tr')).map((tr, rowIndex) => { const cells = Array.from(tr.querySelectorAll('td')); const userId = extractUserIdFromRow(tr, cells, idIndex); const rawProgress = cells[progressIndex] ? cells[progressIndex].textContent : ''; const timestamps = parseProgressValue(rawProgress); const name = nameIndex >= 0 && cells[nameIndex] ? normalizeText(cells[nameIndex].textContent) : ''; const email = emailIndex >= 0 && cells[emailIndex] ? extractEmailFromCell(cells[emailIndex]) : ''; const lastActivity = lastActivityIndex >= 0 && cells[lastActivityIndex] ? normalizeText(cells[lastActivityIndex].textContent) : ''; return { userId, rowKey: userId || `${pageNumber}:${rowIndex}`, page: pageNumber, rowIndex, name, email, lastActivity, rawProgress: normalizeText(rawProgress), timestamps, rowHTML: tr.outerHTML }; }); } async function collectRowsFromAllPages() { const currentDoc = document; const total = getSummaryTotal(currentDoc); const pageSize = getCurrentPageSize(currentDoc); if (!pageSize) throw new Error('В таблице нет строк для анализа'); const pages = total > pageSize ? Math.ceil(total / pageSize) : 1; setStatus(`Загружаю страницы: 1/${pages}`); const docs = pages <= 1 ? [currentDoc] : await runQueue( Array.from({ length: pages }, (_, i) => i + 1), async page => page === 1 ? currentDoc : fetchPageDoc(page), CONFIG.pageFetchConcurrency, (done, all) => setStatus(`Загружаю страницы: ${done}/${all}`) ); const map = new Map(); docs.forEach((doc, index) => { const pageRows = parseRowsFromDoc(doc, index + 1); pageRows.forEach(row => { const key = row.userId || row.rowKey; if (!map.has(key)) map.set(key, row); }); }); return Array.from(map.values()); } function eventDateWithOffset(date, offsetHours) { return new Date(date.getTime() + offsetHours * 3600000); } function pad2(n) { return String(n).padStart(2, '0'); } function dateKeyWithOffset(date, offsetHours) { const shifted = eventDateWithOffset(date, offsetHours); return `${shifted.getUTCFullYear()}-${pad2(shifted.getUTCMonth() + 1)}-${pad2(shifted.getUTCDate())}`; } function hourWithOffset(date, offsetHours) { return eventDateWithOffset(date, offsetHours).getUTCHours(); } function weekdayWithOffset(date, offsetHours) { const shifted = eventDateWithOffset(date, offsetHours); return shifted.getUTCDay(); // 0 = Sunday } function formatDateTimeWithOffset(date, offsetHours) { if (!date) return '-'; const shifted = eventDateWithOffset(date, offsetHours); return `${pad2(shifted.getUTCDate())}.${pad2(shifted.getUTCMonth() + 1)}.${shifted.getUTCFullYear()} ${pad2(shifted.getUTCHours())}:${pad2(shifted.getUTCMinutes())}`; } function formatDateKeyRu(key) { if (!key) return '-'; const [y, m, d] = key.split('-'); return `${d}.${m}.${y}`; } function startOfLocalDayMs(date, offsetHours) { const shifted = eventDateWithOffset(date, offsetHours); const y = shifted.getUTCFullYear(); const m = shifted.getUTCMonth(); const d = shifted.getUTCDate(); return Date.UTC(y, m, d) - offsetHours * 3600000; } function inRange(date, startMs, endMs) { const ms = date.getTime(); return ms >= startMs && ms < endMs; } function analyzeRows(rows) { const offset = Number(CONFIG.timezoneOffsetHours) || 3; const now = new Date(); const todayStart = startOfLocalDayMs(now, offset); const tomorrowStart = todayStart + 86400000; const yesterdayStart = todayStart - 86400000; const last7Start = todayStart - 6 * 86400000; const last30Start = todayStart - 29 * 86400000; const userStats = rows.map(row => { const dates = row.timestamps.map(ts => new Date(ts)).filter(d => !Number.isNaN(d.getTime())).sort((a, b) => a - b); const days = Array.from(new Set(dates.map(d => dateKeyWithOffset(d, offset)))).sort(); return { ...row, dates, clickCount: dates.length, activeDaysCount: days.length, firstClick: dates[0] || null, lastClick: dates[dates.length - 1] || null, days }; }); const events = []; userStats.forEach(user => { user.dates.forEach(date => { events.push({ userId: user.userId, rowKey: user.rowKey, name: user.name, email: user.email, date, dateKey: dateKeyWithOffset(date, offset), hour: hourWithOffset(date, offset), weekday: weekdayWithOffset(date, offset) }); }); }); events.sort((a, b) => a.date - b.date); function periodStats(label, startMs, endMs) { const ev = events.filter(e => inRange(e.date, startMs, endMs)); return { label, clicks: ev.length, users: new Set(ev.map(e => e.userId || e.rowKey)).size, ids: Array.from(new Set(ev.map(e => e.userId).filter(Boolean))) }; } const periods = { today: periodStats('Сегодня', todayStart, tomorrowStart), yesterday: periodStats('Вчера', yesterdayStart, todayStart), last7: periodStats('7 календарных дней', last7Start, tomorrowStart), last30: periodStats('30 календарных дней', last30Start, tomorrowStart), all: { label: 'За всё время', clicks: events.length, users: new Set(events.map(e => e.userId || e.rowKey)).size, ids: Array.from(new Set(events.map(e => e.userId).filter(Boolean))) } }; const activeUsers = userStats.filter(u => u.clickCount > 0); const inactiveUsers = userStats.filter(u => u.clickCount === 0); const repeatUsers = userStats.filter(u => u.clickCount >= 2); const threePlusUsers = userStats.filter(u => u.clickCount >= 3); const counts = activeUsers.map(u => u.clickCount).sort((a, b) => a - b); const median = counts.length ? counts[Math.floor((counts.length - 1) / 2)] : 0; const average = activeUsers.length ? events.length / activeUsers.length : 0; const byDayMap = new Map(); events.forEach(e => { if (!byDayMap.has(e.dateKey)) byDayMap.set(e.dateKey, []); byDayMap.get(e.dateKey).push(e); }); const byDay = Array.from(byDayMap.entries()).map(([dateKey, ev]) => ({ dateKey, label: formatDateKeyRu(dateKey), clicks: ev.length, users: new Set(ev.map(e => e.userId || e.rowKey)).size, ids: Array.from(new Set(ev.map(e => e.userId).filter(Boolean))) })).sort((a, b) => a.dateKey.localeCompare(b.dateKey)); const byHour = Array.from({ length: 24 }, (_, hour) => { const ev = events.filter(e => e.hour === hour); return { hour, label: `${pad2(hour)}:00–${pad2(hour)}:59`, clicks: ev.length, users: new Set(ev.map(e => e.userId || e.rowKey)).size, ids: Array.from(new Set(ev.map(e => e.userId).filter(Boolean))) }; }); const weekdayNames = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']; const byWeekday = Array.from({ length: 7 }, (_, weekday) => { const ev = events.filter(e => e.weekday === weekday); return { weekday, label: weekdayNames[weekday], clicks: ev.length, users: new Set(ev.map(e => e.userId || e.rowKey)).size, ids: Array.from(new Set(ev.map(e => e.userId).filter(Boolean))) }; }); const cohorts = [ { key: 'zero', label: '0 нажатий', users: inactiveUsers }, { key: 'one', label: '1 нажатие', users: userStats.filter(u => u.clickCount === 1) }, { key: 'two', label: '2 нажатия', users: userStats.filter(u => u.clickCount === 2) }, { key: 'threePlus', label: '3+ нажатия', users: threePlusUsers }, { key: 'repeat', label: 'Повторно нажали 2+', users: repeatUsers }, { key: 'today', label: 'Нажали сегодня', users: userStats.filter(u => u.dates.some(d => inRange(d, todayStart, tomorrowStart))) }, { key: 'last7', label: 'Нажали за 7 дней', users: userStats.filter(u => u.dates.some(d => inRange(d, last7Start, tomorrowStart))) } ].map(c => ({ ...c, count: c.users.length, ids: c.users.map(u => u.userId).filter(Boolean), clicks: c.users.reduce((sum, u) => sum + u.clickCount, 0) })); const topUsers = userStats .slice() .sort((a, b) => (b.clickCount - a.clickCount) || ((b.lastClick ? b.lastClick.getTime() : 0) - (a.lastClick ? a.lastClick.getTime() : 0))); return { offset, rowsTotal: rows.length, usersTotal: rows.length, activeUsersCount: activeUsers.length, inactiveUsersCount: inactiveUsers.length, repeatUsersCount: repeatUsers.length, threePlusUsersCount: threePlusUsers.length, totalClicks: events.length, averageClicksPerActiveUser: average, medianClicksPerActiveUser: median, firstEvent: events[0] || null, lastEvent: events[events.length - 1] || null, periods, byDay, byHour, byWeekday, cohorts, userStats, topUsers, events }; } function pct(part, total) { if (!total) return '0%'; return `${Math.round((part / total) * 1000) / 10}%`; } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function getMax(items, key) { return Math.max(1, ...items.map(x => Number(x[key]) || 0)); } function renderBar(value, max) { const width = max ? Math.round((value / max) * 100) : 0; return `
`; } function idsAttr(ids) { return escapeHtml((ids || []).join(',')); } function makeActionButtons(analysis) { return `
`; } function renderCards(analysis) { const activeRate = pct(analysis.activeUsersCount, analysis.usersTotal); const repeatRate = pct(analysis.repeatUsersCount, analysis.activeUsersCount); return `
${analysis.usersTotal}Пользователей в выборке
${analysis.activeUsersCount}Хотя бы 1 нажатие · ${activeRate}
${analysis.totalClicks}Всего нажатий
${analysis.repeatUsersCount}Повторно нажали 2+ · ${repeatRate}
${analysis.periods.today.clicks}Нажатий сегодня · ${analysis.periods.today.users} чел.
${analysis.periods.last7.clicks}Нажатий за 7 календарных дней · ${analysis.periods.last7.users} чел.
${analysis.averageClicksPerActiveUser.toFixed(2)}Среднее на активного
${analysis.medianClicksPerActiveUser}Медиана на активного
Периоды считаются по часовому поясу UTC+${analysis.offset}. Первый клик: ${analysis.firstEvent ? formatDateTimeWithOffset(analysis.firstEvent.date, analysis.offset) : '-'}, последний клик: ${analysis.lastEvent ? formatDateTimeWithOffset(analysis.lastEvent.date, analysis.offset) : '-'}.
`; } function renderPeriodTable(analysis) { const periods = Object.values(analysis.periods); return ` ${periods.map(p => ` `).join('')}
ПериодНажатийУникальных людейДействия
${escapeHtml(p.label)} ${p.clicks} ${p.users}
`; } function renderDayTable(analysis) { const max = getMax(analysis.byDay, 'clicks'); return `
${analysis.byDay.slice().reverse().map(day => ` `).join('')}
ДатаНажатийЛюдейГрафикДействия
${escapeHtml(day.label)} ${day.clicks} ${day.users} ${renderBar(day.clicks, max)} показать
`; } function renderHourTable(analysis) { const hours = analysis.byHour.filter(h => h.clicks > 0); const max = getMax(hours, 'clicks'); return `
${hours.map(hour => ` `).join('')}
ЧасНажатийЛюдейГрафикДействия
${escapeHtml(hour.label)} ${hour.clicks} ${hour.users} ${renderBar(hour.clicks, max)} показать
`; } function renderWeekdayTable(analysis) { const days = analysis.byWeekday.filter(d => d.clicks > 0); const max = getMax(days, 'clicks'); return ` ${days.map(day => ` `).join('')}
День неделиНажатийЛюдейГрафикДействия
${escapeHtml(day.label)} ${day.clicks} ${day.users} ${renderBar(day.clicks, max)} показать
`; } function renderCohorts(analysis) { const max = getMax(analysis.cohorts, 'count'); return ` ${analysis.cohorts.map(c => ` `).join('')}
КогортаЛюдейНажатийДоля от выборкиГрафикДействия
${escapeHtml(c.label)} ${c.count} ${c.clicks} ${pct(c.count, analysis.usersTotal)} ${renderBar(c.count, max)}
`; } function renderUsers(analysis) { return `
${analysis.topUsers.map(u => ` `).join('')}
IDИмяEmailНажатийАктивных днейПервый кликПоследний кликПоследняя активность GC
${escapeHtml(u.userId)} ${escapeHtml(u.name)} ${escapeHtml(u.email)} ${u.clickCount} ${u.activeDaysCount} ${formatDateTimeWithOffset(u.firstClick, analysis.offset)} ${formatDateTimeWithOffset(u.lastClick, analysis.offset)} ${escapeHtml(u.lastActivity)}
`; } function renderPanel(analysis) { let panel = document.getElementById(CONFIG.toolId + '-panel'); if (!panel) { panel = document.createElement('div'); panel.id = CONFIG.toolId + '-panel'; const table = getMainTable(document); if (table) table.parentElement.insertBefore(panel, table); else document.body.prepend(panel); } panel.innerHTML = `

Аналитика кнопки «Я позанималась» / progress_new

Данные читаются из текущей таблицы пользователей и её страниц.
${renderCards(analysis)} ${makeActionButtons(analysis)}
${renderPeriodTable(analysis)}
${renderDayTable(analysis)}
${renderHourTable(analysis)}
${renderWeekdayTable(analysis)}
${renderCohorts(analysis)}
${renderUsers(analysis)}
`; panel.onclick = onPanelClick; document.getElementById(CONFIG.toolId + '-tz')?.addEventListener('change', event => { CONFIG.timezoneOffsetHours = Number(event.target.value) || 3; }); panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function onPanelClick(event) { const tab = event.target.closest('[data-gcpa-tab]'); if (tab) { const name = tab.dataset.gcpaTab; const panel = document.getElementById(CONFIG.toolId + '-panel'); panel.querySelectorAll('.gcpa-tab').forEach(el => el.classList.toggle('active', el === tab)); panel.querySelectorAll('.gcpa-tab-content').forEach(el => el.classList.toggle('active', el.dataset.gcpaContent === name)); return; } const actionEl = event.target.closest('[data-gcpa-action]'); if (!actionEl) return; const action = actionEl.dataset.gcpaAction; if (action === 'rerun') { runTool(); } else if (action === 'filter-ids') { const ids = (actionEl.dataset.ids || '').split(',').map(x => x.trim()).filter(Boolean); applyVirtualFilter(ids, actionEl.dataset.label || 'фильтр'); } else if (action === 'reset-filter') { resetVirtualFilter(); } else if (action === 'copy-active-ids') { const ids = STATE.analysis.userStats.filter(u => u.clickCount > 0).map(u => u.userId).filter(Boolean); copyText(ids.join('\n'), `Скопировано ID: ${ids.length}`); } else if (action === 'copy-json') { copyText(JSON.stringify(buildExportJSON(), null, 2), 'JSON скопирован'); } else if (action === 'copy-csv') { copyText(buildCSV(), 'CSV скопирован'); } else if (action === 'download-csv') { downloadText(buildCSV(), 'progress_new_analytics.csv', 'text/csv;charset=utf-8'); } } function saveOriginalTableState() { if (STATE.originalTable) return; const table = getMainTable(document); if (!table) return; const tbody = table.querySelector('tbody'); const summary = document.querySelector('.summary, .grid-summary'); STATE.originalTable = { tbodyHTML: tbody ? tbody.innerHTML : '', summaryText: summary ? summary.textContent : '' }; } function applyVirtualFilter(ids, label) { saveOriginalTableState(); const table = getMainTable(document); if (!table) return; const tbody = table.querySelector('tbody'); if (!tbody) return; const idSet = new Set(ids.map(String)); const rows = STATE.rows.filter(row => idSet.has(String(row.userId))); tbody.innerHTML = rows.map(row => row.rowHTML).join(''); const summary = document.querySelector('.summary, .grid-summary'); if (summary) summary.textContent = `Показаны ${rows.length} строк из виртуального фильтра: ${label}`; STATE.filter = { label, ids }; const filterEl = document.getElementById(CONFIG.toolId + '-filter'); if (filterEl) { filterEl.style.display = 'block'; filterEl.innerHTML = `Активен виртуальный фильтр: ${escapeHtml(label)}, людей: ${rows.length}. Это не меняет реальный сегмент GetCourse. `; } } function resetVirtualFilter() { if (!STATE.originalTable) return; const table = getMainTable(document); const tbody = table ? table.querySelector('tbody') : null; if (tbody) tbody.innerHTML = STATE.originalTable.tbodyHTML; const summary = document.querySelector('.summary, .grid-summary'); if (summary) summary.textContent = STATE.originalTable.summaryText; STATE.filter = null; const filterEl = document.getElementById(CONFIG.toolId + '-filter'); if (filterEl) filterEl.style.display = 'none'; } function buildExportJSON() { const analysis = STATE.analysis; return { generatedAt: new Date().toISOString(), timezoneOffsetHours: analysis.offset, summary: { usersTotal: analysis.usersTotal, activeUsersCount: analysis.activeUsersCount, inactiveUsersCount: analysis.inactiveUsersCount, totalClicks: analysis.totalClicks, repeatUsersCount: analysis.repeatUsersCount, threePlusUsersCount: analysis.threePlusUsersCount, averageClicksPerActiveUser: analysis.averageClicksPerActiveUser, medianClicksPerActiveUser: analysis.medianClicksPerActiveUser }, periods: analysis.periods, byDay: analysis.byDay, byHour: analysis.byHour, byWeekday: analysis.byWeekday, cohorts: analysis.cohorts.map(c => ({ key: c.key, label: c.label, count: c.count, clicks: c.clicks, ids: c.ids })), users: analysis.userStats.map(u => ({ userId: u.userId, name: u.name, email: u.email, clickCount: u.clickCount, activeDaysCount: u.activeDaysCount, firstClick: u.firstClick ? u.firstClick.toISOString() : null, lastClick: u.lastClick ? u.lastClick.toISOString() : null, timestamps: u.timestamps, lastActivity: u.lastActivity })) }; } function csvEscape(value) { const s = String(value ?? ''); return /[";\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } function buildCSV() { const analysis = STATE.analysis; const head = ['user_id', 'name', 'email', 'click_count', 'active_days', 'first_click_local', 'last_click_local', 'timestamps_utc', 'last_activity_gc']; const lines = [head.join(';')]; analysis.userStats.forEach(u => { lines.push([ u.userId, u.name, u.email, u.clickCount, u.activeDaysCount, formatDateTimeWithOffset(u.firstClick, analysis.offset), formatDateTimeWithOffset(u.lastClick, analysis.offset), u.timestamps.join('|'), u.lastActivity ].map(csvEscape).join(';')); }); return '\ufeff' + lines.join('\n'); } async function copyText(text, successMessage) { try { await navigator.clipboard.writeText(text); setStatus(successMessage || 'Скопировано'); } catch (e) { const textarea = document.createElement('textarea'); textarea.value = text; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); textarea.remove(); setStatus(successMessage || 'Скопировано'); } } function downloadText(text, filename, mimeType) { const blob = new Blob([text], { type: mimeType || 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } async function runTool() { const btn = document.getElementById(CONFIG.toolId + '-button'); try { if (btn) btn.disabled = true; setStatus('Собираю строки таблицы...'); resetVirtualFilter(); const rows = await collectRowsFromAllPages(); STATE.rows = rows; setStatus(`Анализирую ${rows.length} пользователей...`); STATE.analysis = analyzeRows(rows); renderPanel(STATE.analysis); setStatus(`Готово: ${STATE.analysis.activeUsersCount} пользователей, ${STATE.analysis.totalClicks} нажатий`); } catch (error) { console.error(error); setStatus('Ошибка: ' + error.message); alert('Ошибка аналитики progress_new: ' + error.message); } finally { if (btn) btn.disabled = false; } } installButton(); })();