/*
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 => `
| ${escapeHtml(p.label)} |
${p.clicks} |
${p.users} |
|
`).join('')}
`;
}
function renderDayTable(analysis) {
const max = getMax(analysis.byDay, 'clicks');
return `
`;
}
function renderHourTable(analysis) {
const hours = analysis.byHour.filter(h => h.clicks > 0);
const max = getMax(hours, 'clicks');
return `
`;
}
function renderWeekdayTable(analysis) {
const days = analysis.byWeekday.filter(d => d.clicks > 0);
const max = getMax(days, 'clicks');
return `
| День недели | Нажатий | Людей | График | Действия |
${days.map(day => `
| ${escapeHtml(day.label)} |
${day.clicks} |
${day.users} |
${renderBar(day.clicks, max)} |
показать |
`).join('')}
`;
}
function renderCohorts(analysis) {
const max = getMax(analysis.cohorts, 'count');
return `
| Когорта | Людей | Нажатий | Доля от выборки | График | Действия |
${analysis.cohorts.map(c => `
| ${escapeHtml(c.label)} |
${c.count} |
${c.clicks} |
${pct(c.count, analysis.usersTotal)} |
${renderBar(c.count, max)} |
|
`).join('')}
`;
}
function renderUsers(analysis) {
return `
`;
}
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();
})();