(function () {
'use strict';
// ===============================
// ВХОДНЫЕ ОГРАНИЧЕНИЯ ВИДИМОСТИ
// ===============================
// Инструмент должен показываться только на странице пользователей GetCourse.
const SUPPORTED_PATHS = [
'/pl/user/user/index'
];
if (!SUPPORTED_PATHS.includes(location.pathname)) {
console.warn('[VSM] Неподдерживаемая страница:', location.pathname);
return;
}
const CONFIG = {
version: '0.9',
// Оставь пустым, чтобы кнопку видели все сотрудники, у кого загружен этот HTML-блок.
// Когда понадобится ограничить доступ, впиши ID через запятую или пробел:
// allowedViewerUserIds: '123456, 789012'
allowedViewerUserIds: '',
// Если allowedViewerUserIds заполнен, а ID текущего пользователя определить не удалось,
// кнопка будет скрыта.
hideIfViewerIdUnknown: true,
buttonText: 'Анализ новичков',
panelTitle: 'Анализ вовлечённости новичков: недели, общая активность и группы',
endpoint: '/pl/logic/rule/test',
productId: '749494',
fromNDays: 7,
toNDays: 60,
fromWeek: 1,
toWeek: 8,
ruleConcurrency: 1,
listConcurrency: 6,
batchSize: 50,
jobConcurrency: 80,
maxBaseUsers: 0,
requestDelayMs: 0,
maxListPages: 40,
stopAfterEmptyPages: 2,
activityDateType: 'this_week',
activityMaxListPages: 2,
weekThresholds: { 0: 1, 1: 4, 2: 3, 3: 3, 4: 3, 5: 3, 6: 2, 7: 2, 8: 3 }
};
const WRAP_ID = 'vivo-segment-matrix-wrap';
const BTN_ID = 'vivo-segment-matrix-open';
const STATUS_ID = 'vsm-status';
const FILTER_ID = 'vsm-filter-info';
const RESULT_ID = 'vsm-results';
const DEBUG_ID = 'vsm-debug';
const RESET_FILTER_BTN_ID = 'vsm-reset-main-table-filter';
let ORIGINAL_TABLE_HEAD_HTML = '';
let ORIGINAL_TABLE_BODY_HTML = '';
let ORIGINAL_SUMMARY_HTML = '';
let CURRENT_TABLE_FILTER = null;
let CURRENT_VIEW = 'overview';
let CURRENT_USER_FILTER = 'all';
const BEGINNER_LESSONS = [
{
"week": 0,
"weekTitle": "Стартовый урок",
"streamId": "934007244",
"lessonId": "340285310",
"title": "Первый обязательный шаг - знакомство"
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340456073",
"title": "Программа занятий для первой недели новичка"
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340551627",
"title": "Оборудуем домашний йога зал"
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340423192",
"title": "5 советов для начинающих"
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340448934",
"title": "Дыхательный тест"
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340455843",
"title": "Полное йоговское дыхание"
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340308293",
"title": "Бодрая зарядка на каждый день"
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340308294",
"title": "Занятие 1"
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340308295",
"title": "Занятие 2"
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340308296",
"title": "Занятие 3"
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340471342",
"title": "Эфир с Ларисой для новичков \"Ответы на вопросы\""
},
{
"week": 1,
"weekTitle": "Неделя №1",
"streamId": "934011387",
"lessonId": "340448368",
"title": "Анкета по результатам первой недели занятий"
},
{
"week": 2,
"weekTitle": "Неделя №2",
"streamId": "934043915",
"lessonId": "340478314",
"title": "Программа занятий для второй недели новичка"
},
{
"week": 2,
"weekTitle": "Неделя №2",
"streamId": "934043915",
"lessonId": "340483559",
"title": "Тест на гибкость"
},
{
"week": 2,
"weekTitle": "Неделя №2",
"streamId": "934043915",
"lessonId": "340478317",
"title": "Знакомство с Шавасаной"
},
{
"week": 2,
"weekTitle": "Неделя №2",
"streamId": "934043915",
"lessonId": "340478318",
"title": "Бодрая зарядка на каждый день"
},
{
"week": 2,
"weekTitle": "Неделя №2",
"streamId": "934043915",
"lessonId": "340478319",
"title": "Занятие 1"
},
{
"week": 2,
"weekTitle": "Неделя №2",
"streamId": "934043915",
"lessonId": "340478320",
"title": "Занятие 2."
},
{
"week": 2,
"weekTitle": "Неделя №2",
"streamId": "934043915",
"lessonId": "340480595",
"title": "Занятие 3."
},
{
"week": 2,
"weekTitle": "Неделя №2",
"streamId": "934043915",
"lessonId": "340478324",
"title": "Анкета по результатам второй недели занятий"
},
{
"week": 3,
"weekTitle": "Неделя №3",
"streamId": "934058425",
"lessonId": "340552849",
"title": "Программа занятий для третьей недели новичка"
},
{
"week": 3,
"weekTitle": "Неделя №3",
"streamId": "934058425",
"lessonId": "340557824",
"title": "Приветствие Солнцу с пояснениями"
},
{
"week": 3,
"weekTitle": "Неделя №3",
"streamId": "934058425",
"lessonId": "340552852",
"title": "Бодрая зарядка на каждый день"
},
{
"week": 3,
"weekTitle": "Неделя №3",
"streamId": "934058425",
"lessonId": "340552853",
"title": "Занятие 1"
},
{
"week": 3,
"weekTitle": "Неделя №3",
"streamId": "934058425",
"lessonId": "340552854",
"title": "Занятие 2."
},
{
"week": 3,
"weekTitle": "Неделя №3",
"streamId": "934058425",
"lessonId": "340552855",
"title": "Занятие 3."
},
{
"week": 3,
"weekTitle": "Неделя №3",
"streamId": "934058425",
"lessonId": "340749646",
"title": "Эфир с Ларисой для новичков \"Ответы на вопросы\""
},
{
"week": 3,
"weekTitle": "Неделя №3",
"streamId": "934058425",
"lessonId": "340552857",
"title": "Анкета по результатам третьей недели занятий"
},
{
"week": 4,
"weekTitle": "Неделя №4",
"streamId": "934068239",
"lessonId": "340605192",
"title": "Программа занятий для четвертой недели новичка"
},
{
"week": 4,
"weekTitle": "Неделя №4",
"streamId": "934068239",
"lessonId": "340605194",
"title": "Бодрая зарядка на каждый день"
},
{
"week": 4,
"weekTitle": "Неделя №4",
"streamId": "934068239",
"lessonId": "340754741",
"title": "2 минуты для красивой осанки"
},
{
"week": 4,
"weekTitle": "Неделя №4",
"streamId": "934068239",
"lessonId": "340605195",
"title": "Занятие 1"
},
{
"week": 4,
"weekTitle": "Неделя №4",
"streamId": "934068239",
"lessonId": "340605196",
"title": "Занятие 2."
},
{
"week": 4,
"weekTitle": "Неделя №4",
"streamId": "934068239",
"lessonId": "340605197",
"title": "Занятие 3."
},
{
"week": 4,
"weekTitle": "Неделя №4",
"streamId": "934068239",
"lessonId": "348239225",
"title": "Эфир с Ларисой для новичков \"Ответы на вопросы\""
},
{
"week": 4,
"weekTitle": "Неделя №4",
"streamId": "934068239",
"lessonId": "340605199",
"title": "Анкета по результатам четвертой недели занятий"
},
{
"week": 5,
"weekTitle": "Неделя №5",
"streamId": "934911404",
"lessonId": "345121820",
"title": "Программа занятий для начинающих на второй месяц"
},
{
"week": 5,
"weekTitle": "Неделя №5",
"streamId": "934911404",
"lessonId": "345314998",
"title": "Бодрая зарядка на каждый день"
},
{
"week": 5,
"weekTitle": "Неделя №5",
"streamId": "934911404",
"lessonId": "347268796",
"title": "Суставная разминка"
},
{
"week": 5,
"weekTitle": "Неделя №5",
"streamId": "934911404",
"lessonId": "344993440",
"title": "Занятие 1"
},
{
"week": 5,
"weekTitle": "Неделя №5",
"streamId": "934911404",
"lessonId": "344993457",
"title": "Занятие 2"
},
{
"week": 5,
"weekTitle": "Неделя №5",
"streamId": "934911404",
"lessonId": "344993087",
"title": "Занятие 3"
},
{
"week": 5,
"weekTitle": "Неделя №5",
"streamId": "934911404",
"lessonId": "344993086",
"title": "Бонусная медитация"
},
{
"week": 6,
"weekTitle": "Неделя №6",
"streamId": "934911405",
"lessonId": "345315034",
"title": "Бодрая зарядка на каждый день"
},
{
"week": 6,
"weekTitle": "Неделя №6",
"streamId": "934911405",
"lessonId": "344993092",
"title": "Занятие 1"
},
{
"week": 6,
"weekTitle": "Неделя №6",
"streamId": "934911405",
"lessonId": "344993090",
"title": "Занятие 2"
},
{
"week": 6,
"weekTitle": "Неделя №6",
"streamId": "934911405",
"lessonId": "344993091",
"title": "Занятие 3"
},
{
"week": 6,
"weekTitle": "Неделя №6",
"streamId": "934911405",
"lessonId": "345315042",
"title": "Бонусное занятие"
},
{
"week": 7,
"weekTitle": "Неделя №7",
"streamId": "934911407",
"lessonId": "345315086",
"title": "Бодрая зарядка на каждый день"
},
{
"week": 7,
"weekTitle": "Неделя №7",
"streamId": "934911407",
"lessonId": "344993100",
"title": "Занятие 1"
},
{
"week": 7,
"weekTitle": "Неделя №7",
"streamId": "934911407",
"lessonId": "344993103",
"title": "Занятие 2"
},
{
"week": 7,
"weekTitle": "Неделя №7",
"streamId": "934911407",
"lessonId": "344993104",
"title": "Занятие 3"
},
{
"week": 7,
"weekTitle": "Неделя №7",
"streamId": "934911407",
"lessonId": "345219817",
"title": "Бонусная медитация"
},
{
"week": 8,
"weekTitle": "Неделя №8",
"streamId": "934911410",
"lessonId": "345315100",
"title": "Бодрая зарядка на каждый день"
},
{
"week": 8,
"weekTitle": "Неделя №8",
"streamId": "934911410",
"lessonId": "344993108",
"title": "Занятие 1"
},
{
"week": 8,
"weekTitle": "Неделя №8",
"streamId": "934911410",
"lessonId": "344993148",
"title": "Занятие 2"
},
{
"week": 8,
"weekTitle": "Неделя №8",
"streamId": "934911410",
"lessonId": "344993155",
"title": "Занятие 3"
},
{
"week": 8,
"weekTitle": "Неделя №8",
"streamId": "934911410",
"lessonId": "345315112",
"title": "Бонусное занятие"
},
{
"week": 8,
"weekTitle": "Неделя №8",
"streamId": "934911410",
"lessonId": "345377445",
"title": "Анкета по результатам второго месяца занятий"
},
{
"week": 8,
"weekTitle": "Неделя №8",
"streamId": "934911410",
"lessonId": "345315578",
"title": "Что дальше?"
}
];
init();
function init() {
if (!isViewerAllowed()) {
const existing = document.getElementById(BTN_ID);
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
console.warn('[VSM] Кнопка скрыта: текущий пользователь не входит в allowedViewerUserIds. viewerId=', getCurrentViewerId() || 'не определён');
return;
}
injectStyles();
addButton();
const observer = new MutationObserver(addButton);
try { observer.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
console.log('[VSM] Кнопка добавлена, v' + CONFIG.version);
}
function parseAllowedViewerIds() {
const raw = String(CONFIG.allowedViewerUserIds || '').trim();
if (!raw) return [];
return raw
.split(/[\s,;]+/)
.map(function (id) { return String(id || '').trim(); })
.filter(Boolean);
}
function getCurrentViewerId() {
const windowKeys = [
'accountUserId',
'currentUserId',
'gcUserId',
'GC_USER_ID',
'userId',
'USER_ID'
];
for (let i = 0; i < windowKeys.length; i++) {
const key = windowKeys[i];
const value = window[key];
if (value === null || value === undefined || value === '') continue;
const match = String(value).match(/\d{3,}/);
if (match) return match[0];
}
const dataCandidates = [
document.body?.getAttribute('data-user-id'),
document.body?.getAttribute('data-current-user-id'),
document.documentElement?.getAttribute('data-user-id'),
document.documentElement?.getAttribute('data-current-user-id')
];
for (let j = 0; j < dataCandidates.length; j++) {
const value = dataCandidates[j];
if (!value) continue;
const match = String(value).match(/\d{3,}/);
if (match) return match[0];
}
const metaCandidates = Array.from(document.querySelectorAll(
'meta[name="user-id"], meta[name="current-user-id"], meta[name="gc-user-id"], meta[property="user-id"]'
));
for (let k = 0; k < metaCandidates.length; k++) {
const content = metaCandidates[k].getAttribute('content') || '';
const match = content.match(/\d{3,}/);
if (match) return match[0];
}
// Пробуем только верхнее меню/панель аккаунта, чтобы не перепутать текущего сотрудника
// с ID пользователей из самой таблицы сегмента.
const menuLink = document.querySelector(
'.gc-account-user-menu a[href*="/user/control/user/update/id/"], ' +
'.gc-user-menu a[href*="/user/control/user/update/id/"], ' +
'.user-menu a[href*="/user/control/user/update/id/"], ' +
'.navbar a[href*="/user/control/user/update/id/"], ' +
'header a[href*="/user/control/user/update/id/"]'
);
const href = menuLink?.getAttribute('href') || '';
const hrefMatch = href.match(/\/user\/control\/user\/update\/id\/(\d+)/);
if (hrefMatch && hrefMatch[1]) return hrefMatch[1];
return '';
}
function isViewerAllowed() {
const allowedIds = parseAllowedViewerIds();
if (!allowedIds.length) return true;
const viewerId = getCurrentViewerId();
if (!viewerId) return !CONFIG.hideIfViewerIdUnknown;
return allowedIds.indexOf(String(viewerId)) !== -1;
}
function addButton() {
if (!isViewerAllowed()) return;
const existing = document.getElementById(BTN_ID);
if (existing && existing.getAttribute('data-vsm-version') === CONFIG.version) return;
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
const btn = document.createElement('button');
btn.id = BTN_ID;
btn.type = 'button';
btn.className = 'btn btn-success';
btn.style.marginLeft = '10px';
btn.textContent = CONFIG.buttonText;
btn.setAttribute('data-vsm-version', CONFIG.version);
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
openPanel();
});
const container =
document.querySelector('.segment-rule .action-buttons') ||
document.querySelector('.action-buttons') ||
document.querySelector('.kv-panel-before') ||
document.body;
if (container && container !== document.body) {
container.appendChild(btn);
} else {
document.body.insertBefore(btn, document.body.firstChild);
}
}
function openPanel() {
const panel = ensurePanel();
panel.style.display = 'block';
try { panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) {}
}
function closePanel() {
const panel = document.getElementById(WRAP_ID);
if (panel) panel.style.display = 'none';
}
function ensurePanel() {
let panel = document.getElementById(WRAP_ID);
if (panel && panel.getAttribute('data-vsm-version') !== CONFIG.version && panel.parentNode) {
panel.parentNode.removeChild(panel);
panel = null;
}
if (!panel) {
panel = document.createElement('div');
panel.id = WRAP_ID;
panel.setAttribute('data-vsm-version', CONFIG.version);
panel.innerHTML = renderInitial();
insertPanel(panel);
bindEvents(panel);
renderPlanPreview();
}
return panel;
}
function insertPanel(panel) {
const kvPanel = document.querySelector('.kv-panel-before');
const table = getCurrentUsersTable(document);
if (kvPanel && kvPanel.parentNode) {
if (kvPanel.nextSibling !== panel) kvPanel.parentNode.insertBefore(panel, kvPanel.nextSibling);
return;
}
if (table && table.parentNode && table.parentNode.parentNode) {
table.parentNode.parentNode.insertBefore(panel, table.parentNode);
return;
}
document.body.insertBefore(panel, document.body.firstChild);
}
function bindEvents(panel) {
if (panel.getAttribute('data-bound') === '1') return;
panel.setAttribute('data-bound', '1');
panel.addEventListener('click', function (event) {
const actionEl = event.target && event.target.closest('[data-action]');
const action = actionEl ? actionEl.getAttribute('data-action') : '';
if (!action) return;
if (action === 'close') closePanel();
if (action === 'run') runMatrixAnalysis().catch(handleFatalError);
if (action === 'copy-csv') copyCsv();
if (action === 'download-csv') downloadCsv();
if (action === 'copy-ids') copyFilteredIds();
if (action === 'reset-main-table') resetCurrentTableFilter();
if (action === 'filter-cohort') filterMainTableByCohort(actionEl.getAttribute('data-cohort') || CURRENT_USER_FILTER);
if (action === 'copy-cohort') copyCohortIds(actionEl.getAttribute('data-cohort') || CURRENT_USER_FILTER);
if (action === 'open-cohort') openCohortAsSegment(actionEl.getAttribute('data-cohort') || CURRENT_USER_FILTER);
if (action === 'add-cohort-group') addCohortToNewGroup(actionEl.getAttribute('data-cohort') || CURRENT_USER_FILTER);
if (action === 'view') {
CURRENT_VIEW = actionEl.getAttribute('data-view') || 'overview';
updateViewButtons();
renderActiveView();
}
});
panel.addEventListener('change', function (event) {
const id = event.target && event.target.id;
if (['vsm-product', 'vsm-from', 'vsm-to', 'vsm-from-week', 'vsm-to-week', 'vsm-max-pages', 'vsm-activity-period', 'vsm-filter'].includes(id)) {
if (id === 'vsm-filter') {
CURRENT_USER_FILTER = event.target.value || 'all';
renderUserTable();
} else {
renderPlanPreview();
}
}
if (['vsm-batch-size', 'vsm-job-concurrency', 'vsm-max-base-users'].includes(id)) renderPlanPreview();
});
}
function handleFatalError(error) {
console.error('[VSM] Ошибка расчёта', error);
const status = document.getElementById('vsm-status');
if (status) status.innerHTML = 'Ошибка: ' + escapeHtml(error && error.message || String(error)) + ' ';
const runBtn = document.querySelector('[data-action="run"]');
if (runBtn) runBtn.disabled = false;
}
function renderInitial() {
return [
'
',
'',
'
',
'
',
'
' + escapeHtml(CONFIG.panelTitle) + '
',
'
База берётся из текущей выборки пользователей GetCourse. Дальше инструмент строит матрицу входов в уроки, раскладывает людей по когортам и даёт виртуальные фильтры основной таблицы.
',
'
',
'
',
'Скопировать CSV ',
'Скачать CSV ',
'Скрыть ',
'
',
'
',
'
',
'Обзор ',
'Когорты ',
'Недели ',
'Уроки ',
'Пользователи ',
'Группы / действия ',
'
',
'
',
'
Важно: откройте нужный сегмент на странице пользователей. Расчётный режим по стресс‑тесту: пачка 50 ID, параллельных задач 80. Стартовый урок исключён из расчёта; анализ идёт по неделям 1–8. Дополнительно инструмент проверяет общую активность пользователя в GetCourse, чтобы отделять людей, которые не идут по треку, но занимаются в других разделах. Клики по когортам/неделям подставляют людей в основную таблицу как виртуальный фильтр, а кнопка «В группу» создаёт группу GetCourse.
',
'
',
'Источник базы ',
'Страниц базы за раз ',
'С какой недели',
'неделя 1 ',
'неделя 2 ',
'неделя 3 ',
'неделя 4 ',
'неделя 5 ',
'неделя 6 ',
'неделя 7 ',
'неделя 8 ',
' ',
'По какую неделю',
'неделя 1 ',
'неделя 2 ',
'неделя 3 ',
'неделя 4 ',
'неделя 5 ',
'неделя 6 ',
'неделя 7 ',
'неделя 8 ',
' ',
'Размер пачки ID ',
'Параллельных задач ',
'Лимит базы, 0 = все ',
'Общая активность',
'активен на этой неделе ',
'не проверять ',
' ',
'
',
'
',
'
',
'Собрать матрицу ',
'Сбросить фильтр таблицы ',
'
',
'
Готово к запуску.
',
'
',
'
',
'
',
'
'
].join('');
}
function readSettings() {
const rawFrom = Number(document.getElementById('vsm-from-week')?.value || CONFIG.fromWeek || 1);
const rawTo = Number(document.getElementById('vsm-to-week')?.value || CONFIG.toWeek || 8);
const fromWeek = Math.max(1, Math.min(8, Math.min(rawFrom, rawTo)));
const toWeek = Math.max(1, Math.min(8, Math.max(rawFrom, rawTo)));
return {
productId: String(document.getElementById('vsm-product')?.value || CONFIG.productId).trim(),
fromNDays: String(document.getElementById('vsm-from')?.value || CONFIG.fromNDays).trim(),
toNDays: String(document.getElementById('vsm-to')?.value || CONFIG.toNDays).trim(),
fromWeek,
toWeek,
maxListPages: Math.max(1, Number(document.getElementById('vsm-max-pages')?.value || CONFIG.maxListPages)),
batchSize: Math.max(1, Number(document.getElementById('vsm-batch-size')?.value || CONFIG.batchSize)),
jobConcurrency: Math.max(1, Number(document.getElementById('vsm-job-concurrency')?.value || CONFIG.jobConcurrency)),
maxBaseUsers: Math.max(0, Number(document.getElementById('vsm-max-base-users')?.value || CONFIG.maxBaseUsers)),
activityDateType: String(document.getElementById('vsm-activity-period')?.value || CONFIG.activityDateType || 'this_week')
};
}
function selectedLessons(settingsOrDepth) {
const settings = typeof settingsOrDepth === 'object'
? settingsOrDepth
: { fromWeek: 1, toWeek: Number(settingsOrDepth || CONFIG.toWeek || 8) };
const fromWeek = Math.max(1, Number(settings.fromWeek || 1));
const toWeek = Math.min(8, Number(settings.toWeek || CONFIG.toWeek || 8));
// Стартовый урок / анкета week=0 специально исключён из матрицы.
// Его можно анализировать отдельно, но он не должен искажать вовлечённость по неделям.
return BEGINNER_LESSONS.filter(l => Number(l.week) >= fromWeek && Number(l.week) <= toWeek);
}
function groupByWeek(list) {
const map = new Map();
list.forEach(item => {
const key = Number(item.week);
if (!map.has(key)) map.set(key, { week: key, title: item.weekTitle, lessons: [] });
map.get(key).lessons.push(item);
});
return Array.from(map.values()).sort((a, b) => a.week - b.week);
}
function renderPlanPreview() {
const s = readSettings();
const lessons = selectedLessons(s);
const groups = groupByWeek(lessons);
const totalRequests = 1 + lessons.length;
const el = document.getElementById('vsm-plan');
if (!el) return;
el.innerHTML = [
'План: база берётся из текущей таблицы пользователей GetCourse с учётом открытого фильтра/сегмента и пагинации.',
' Диапазон: неделя ' + s.fromWeek + ' — неделя ' + s.toWeek + ' . Уроков: ' + lessons.length + ' , недель: ' + groups.length + ' . После загрузки базы инструмент разобьёт пользователей на пачки по ' + escapeHtml(s.batchSize) + ' ID и проверит каждую пачку по каждому уроку.' + ' Ориентир по тесту: 800 пользователей × 60 уроков при batchSize=50 и concurrency=80 ≈ 4,2 мин. Фактическое время зависит от нагрузки GetCourse.',
' Запрос урока теперь легче: idsrule пачки + вход в урок . Продуктовый фильтр повторно не добавляется, потому что база уже взята из нужного сегмента.',
' Стартовый урок не входит в расчёт. Неделя считается затронутой , если был вход хотя бы в 1 урок. Неделя считается вовлечённой , если набран порог 40–50% уроков недели.' + ' Общая активность: ' + escapeHtml(activityDateLabel(s.activityDateType)) + ' . Она нужна, чтобы разделить “не идут по треку, но активны в клубе” и “вообще не заходили”.'
].join('');
}
async function runMatrixAnalysis() {
const settings = readSettings();
const lessons = selectedLessons(settings);
const groups = groupByWeek(lessons);
const runBtn = document.querySelector('[data-action="run"]');
const copyBtn = document.getElementById('vsm-copy-csv');
const downloadBtn = document.getElementById('vsm-download-csv');
const resultsEl = document.getElementById('vsm-results');
const progressEl = document.getElementById('vsm-progress');
window.VIVO_SEGMENT_MATRIX_LAST = null;
window.VIVO_SEGMENT_MATRIX_LAST_REQUEST = null;
window.VIVO_SEGMENT_MATRIX_LAST_ERRORS = [];
if (runBtn) runBtn.disabled = true;
if (copyBtn) copyBtn.disabled = true;
if (downloadBtn) downloadBtn.disabled = true;
if (resultsEl) resultsEl.innerHTML = '';
if (progressEl) progressEl.innerHTML = '';
const startedAt = new Date().toISOString();
setStatus('Быстро собираю базу из текущей таблицы пользователей…');
const baseSource = await collectBaseUsersFromCurrentTable(settings);
let baseUsers = baseSource.users;
const baseJson = {
success: true,
count: baseSource.totalFromSummary || baseUsers.size,
countStr: formatNumber(baseSource.totalFromSummary || baseUsers.size) + ' пользователей',
objectsLink: window.location.href,
source: 'current_table_pages',
pages: baseSource.pages,
pageSize: baseSource.pageSize,
skipped: baseSource.skipped || []
};
if (!baseUsers.size) {
throw new Error('Не удалось извлечь ID пользователей из текущей таблицы. Откройте страницу /pl/user/user/index с нужным сегментом и таблицей пользователей.');
}
setStatus('База собрана из текущей таблицы: ' + baseUsers.size + ' ID, страниц: ' + baseSource.pages + '.');
if (settings.maxBaseUsers > 0 && baseUsers.size > settings.maxBaseUsers) {
const limited = new Map();
Array.from(baseUsers.entries()).slice(0, settings.maxBaseUsers).forEach(([id, user]) => limited.set(id, user));
baseUsers = limited;
setStatus('База ограничена для теста: ' + baseUsers.size + ' пользователей.');
}
const baseIds = Array.from(baseUsers.keys()).map(String);
const batches = chunkArray(baseIds, settings.batchSize);
const matrix = new Map();
baseUsers.forEach((user, id) => {
matrix.set(String(id), {
id: String(id),
name: user.name || '',
email: user.email || '',
lessonCount: 0,
lessons: new Set(),
weekCounts: {},
touchedWeeks: 0,
engagedWeeks: 0,
maxTouchedWeek: 0,
maxEngagedWeek: 0,
cohort: '',
rowHTML: user.rowHTML || '',
generalActive: false,
generalActivityLabel: ''
});
});
const lessonMap = new Map();
lessons.forEach(lesson => {
lessonMap.set(String(lesson.lessonId), Object.assign({}, lesson, {
count: 0,
loadedIds: 0,
percent: 0,
countStr: '',
objectsLink: '',
error: '',
users: new Set(),
batchCounts: []
}));
});
const jobs = [];
lessons.forEach(lesson => {
batches.forEach((ids, batchIndex) => {
jobs.push({ lesson, ids, batchIndex });
});
});
setStatus('Матрица: ' + baseIds.length + ' пользователей, ' + lessons.length + ' уроков, ' + batches.length + ' пачек, задач: ' + jobs.length + '.');
let done = 0;
await runWithConcurrency(jobs, settings.jobConcurrency, async (job) => {
const lesson = job.lesson;
const stat = lessonMap.get(String(lesson.lessonId));
const label = 'lesson ' + lesson.lessonId + ' batch ' + (job.batchIndex + 1) + '/' + batches.length;
try {
const rule = buildAndRule([buildIdsRule(job.ids), buildLessonRule(lesson)]);
const json = await runRule(rule, label);
let users = new Map();
if (json.objectsLink && Number(json.count || 0) > 0) {
users = await fetchUsersFromObjectsLink(json.objectsLink, label, Object.assign({}, settings, { maxListPages: 2 }));
}
stat.count += Number(json.count || users.size || 0);
stat.batchCounts.push(Number(json.count || users.size || 0));
users.forEach((user, id) => {
id = String(id);
if (!matrix.has(id)) return;
stat.users.add(id);
const row = matrix.get(id);
if (!row.lessons.has(String(lesson.lessonId))) {
row.lessons.add(String(lesson.lessonId));
row.lessonCount += 1;
const w = String(lesson.week);
row.weekCounts[w] = (row.weekCounts[w] || 0) + 1;
}
if (!row.name && user.name) row.name = user.name;
if (!row.email && user.email) row.email = user.email;
});
} catch (error) {
const msg = String(error && error.message || error);
console.error('[VSM] ' + label + ' ошибка', error);
stat.error = stat.error ? (stat.error + ' | ' + msg) : msg;
window.VIVO_SEGMENT_MATRIX_LAST_ERRORS.push({ label, error: msg, request: window.VIVO_SEGMENT_MATRIX_LAST_REQUEST });
}
done += 1;
const lessonStatsLive = Array.from(lessonMap.values()).map(finalizeLessonStat(baseUsers.size));
renderLiveSummary(baseJson, baseUsers, lessonStatsLive, matrix, lessons, groups, false);
updateProgress(done, jobs.length, 'Пачки уроков');
if (done % Math.max(1, settings.jobConcurrency) === 0) {
setStatus('Готово задач: ' + done + ' / ' + jobs.length + '. Ошибок: ' + window.VIVO_SEGMENT_MATRIX_LAST_ERRORS.length + '.');
}
if (CONFIG.requestDelayMs) await delay(CONFIG.requestDelayMs);
});
updateProgress(jobs.length, jobs.length, 'Пачки уроков');
let activitySummary = { checked: false, active: 0, inactive: matrix.size, errors: [] };
if (settings.activityDateType && settings.activityDateType !== 'off') {
activitySummary = await collectGeneralActivityForMatrix(matrix, batches, settings);
}
const lessonStats = Array.from(lessonMap.values()).map(finalizeLessonStat(baseUsers.size));
const analysis = buildAnalysis(baseJson, baseUsers, matrix, lessons, groups, lessonStats, settings, startedAt);
analysis.batches = { size: settings.batchSize, count: batches.length, jobs: jobs.length, concurrency: settings.jobConcurrency, errors: window.VIVO_SEGMENT_MATRIX_LAST_ERRORS || [] };
analysis.generalActivity = activitySummary;
window.VIVO_SEGMENT_MATRIX_LAST = analysis;
renderFinal(analysis);
setStatus('Готово: база ' + analysis.base.loaded + ', задач ' + jobs.length + ', ошибок ' + analysis.batches.errors.length + ', пользователей в матрице ' + analysis.users.length + '.');
if (runBtn) runBtn.disabled = false;
if (copyBtn) copyBtn.disabled = false;
if (downloadBtn) downloadBtn.disabled = false;
}
function finalizeLessonStat(baseSize) {
return function(stat) {
const loadedIds = stat.users ? stat.users.size : Number(stat.loadedIds || 0);
return Object.assign({}, stat, {
loadedIds,
count: Number(stat.count || loadedIds || 0),
percent: baseSize ? Math.round(loadedIds / baseSize * 100) : 0,
countStr: loadedIds + ' пользователей',
objectsLink: stat.objectsLink || '',
users: undefined
});
};
}
function buildAnalysis(baseJson, baseUsers, matrix, lessons, groups, lessonStats, settings, startedAt) {
const users = Array.from(matrix.values()).map(row => {
const touched = [];
const engaged = [];
groups.forEach(group => {
const count = Number(row.weekCounts[String(group.week)] || 0);
const threshold = getWeekThreshold(group.week, group.lessons.length);
if (count > 0) touched.push(group.week);
if (count >= threshold) engaged.push(group.week);
});
row.touchedWeeks = touched.length;
row.engagedWeeks = engaged.length;
row.maxTouchedWeek = touched.length ? Math.max.apply(null, touched) : 0;
row.maxEngagedWeek = engaged.length ? Math.max.apply(null, engaged) : 0;
row.cohort = getCohort(row.lessonCount, lessons.length);
row.generalActivityLabel = row.generalActive ? activityDateLabel(settings.activityDateType) : 'нет активности в выбранном периоде';
return {
id: row.id,
name: row.name,
email: row.email,
lessonCount: row.lessonCount,
touchedWeeks: row.touchedWeeks,
engagedWeeks: row.engagedWeeks,
maxTouchedWeek: row.maxTouchedWeek,
maxEngagedWeek: row.maxEngagedWeek,
cohort: row.cohort,
generalActive: Boolean(row.generalActive),
generalActivityLabel: row.generalActivityLabel || '',
rowHTML: row.rowHTML || '',
weekCounts: row.weekCounts,
lessons: Array.from(row.lessons)
};
}).sort((a, b) => b.lessonCount - a.lessonCount || String(a.id).localeCompare(String(b.id)));
const cohorts = cohortDefinitions(lessons.length).map(c => {
const list = users.filter(u => u.cohort === c.key);
return Object.assign({}, c, { count: list.length, percent: users.length ? Math.round(list.length / users.length * 100) : 0 });
});
const weekStats = groups.map(group => {
const threshold = getWeekThreshold(group.week, group.lessons.length);
const touchedUsers = users.filter(u => Number(u.weekCounts[String(group.week)] || 0) > 0);
const engagedUsers = users.filter(u => Number(u.weekCounts[String(group.week)] || 0) >= threshold);
return {
week: group.week,
title: group.title,
lessons: group.lessons.length,
threshold,
touched: touchedUsers.length,
touchedPercent: users.length ? Math.round(touchedUsers.length / users.length * 100) : 0,
engaged: engagedUsers.length,
engagedPercent: users.length ? Math.round(engagedUsers.length / users.length * 100) : 0
};
});
const activityStats = buildActivityStats(users, settings);
const dropoff = [];
groups.forEach(group => {
if (group.week === 0) return;
const current = users.filter(u => Number(u.weekCounts[String(group.week)] || 0) >= getWeekThreshold(group.week, group.lessons.length));
const nextGroup = groups.find(g => Number(g.week) === Number(group.week) + 1);
if (!nextGroup) return;
const dropped = current.filter(u => Number(u.weekCounts[String(nextGroup.week)] || 0) < getWeekThreshold(nextGroup.week, nextGroup.lessons.length));
dropoff.push({
afterWeek: group.week,
label: 'После ' + group.title,
count: dropped.length,
percentOfBase: users.length ? Math.round(dropped.length / users.length * 100) : 0
});
});
return {
version: CONFIG.version,
startedAt,
finishedAt: new Date().toISOString(),
settings,
base: {
count: Number(baseJson.count || 0),
countStr: baseJson.countStr || '',
loaded: users.length,
objectsLink: baseJson.objectsLink || '',
source: baseJson.source || '',
pages: baseJson.pages || 0,
pageSize: baseJson.pageSize || 0,
skipped: baseJson.skipped || []
},
lessons: lessonStats,
weekStats,
cohorts,
activityStats,
dropoff,
users
};
}
function getCohort(lessonCount, totalLessons) {
const n = Number(lessonCount || 0);
const total = Math.max(1, Number(totalLessons || 1));
const pct = n / total;
if (n === 0) return 'not_started';
if (pct < 0.20) return 'touched';
if (pct < 0.40) return 'tried';
if (pct < 0.60) return 'in_process';
if (pct < 0.80) return 'active';
return 'strong';
}
function cohortDefinitions(totalLessons) {
const payload = window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW;
const total = Math.max(1, Number(totalLessons || (payload && payload.lessons && payload.lessons.length) || 60));
const range = function (fromPct, toPct) {
const from = Math.max(1, Math.floor(total * fromPct));
const to = Math.max(from, Math.ceil(total * toPct) - 1);
return from + '–' + to + ' уроков';
};
return [
{ key: 'not_started', title: 'Не стартовали', range: '0 уроков', hint: 'В выбранном диапазоне недель не открывали ни одного урока' },
{ key: 'touched', title: 'Только коснулись', range: range(0.001, 0.20), hint: 'Есть 1–20% уроков выбранного диапазона' },
{ key: 'tried', title: 'Попробовали', range: range(0.20, 0.40), hint: 'Есть 20–40% уроков выбранного диапазона' },
{ key: 'in_process', title: 'Вошли в процесс', range: range(0.40, 0.60), hint: 'Есть 40–60% уроков выбранного диапазона' },
{ key: 'active', title: 'Активно занимаются', range: range(0.60, 0.80), hint: 'Есть 60–80% уроков выбранного диапазона' },
{ key: 'strong', title: 'Сильно вовлечены', range: Math.ceil(total * 0.80) + '+ уроков', hint: '80%+ уроков выбранного диапазона; лучшие кандидаты на продление/апселл/отзыв' }
];
}
function getWeekThreshold(week, lessonCount) {
if (CONFIG.weekThresholds.hasOwnProperty(Number(week))) return CONFIG.weekThresholds[Number(week)];
return Math.max(1, Math.ceil(Number(lessonCount || 0) * 0.45));
}
async function runRule(rule, label) {
const params = new URLSearchParams();
params.set('context', 'UserContext');
appendRule(params, 'rule', rule);
params.set('baseRule', '');
addGcMeta(params);
const body = params.toString();
window.VIVO_SEGMENT_MATRIX_LAST_REQUEST = { label: label || '', body, decoded: decodeURIComponent(body) };
const res = await fetch(CONFIG.endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': '*/*'
},
body
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch (e) {}
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 240));
if (!json || !json.success) throw new Error('Не success JSON: ' + text.slice(0, 500));
return json;
}
async function collectBaseUsersFromCurrentTable(settings) {
if (location.pathname !== '/pl/user/user/index') {
throw new Error('Быстрый сбор базы работает на странице пользователей /pl/user/user/index. Сейчас: ' + location.pathname);
}
const total = getCurrentListTotal(document);
const pageSize = getCurrentListPageSize(document);
const pagesByTotal = Math.max(1, Math.ceil((total || pageSize) / Math.max(1, pageSize)));
const pages = Math.min(Math.max(1, settings.maxListPages || CONFIG.maxListPages), pagesByTotal);
const pageNumbers = [];
for (let p = 1; p <= pages; p++) pageNumbers.push(p);
setStatus('База: найдено страниц ' + pagesByTotal + ', загружаю ' + pages + ' стр. текущей выборки…');
const pageDocs = await runWithConcurrency(pageNumbers, CONFIG.listConcurrency || 4, async (page) => {
const url = makeCurrentListPageUrl(page);
const html = await fetchFullPageHtml(url);
const doc = new DOMParser().parseFromString(html, 'text/html');
return { page, doc, html };
}, function(done, totalPages) {
setStatus('Загружаю страницы базы: ' + done + ' / ' + totalPages);
});
const users = new Map();
const skipped = [];
pageDocs.forEach(item => {
if (!item || item.error) {
skipped.push({ page: item && item.item ? item.item : '?', reason: item && item.error ? item.error : 'Ошибка загрузки страницы' });
return;
}
const parsed = parseUsersFromCurrentListDoc(item.doc, item.page);
parsed.users.forEach((user, id) => {
users.set(String(id), Object.assign(users.get(String(id)) || {}, user));
});
if (parsed.skipped && parsed.skipped.length) skipped.push.apply(skipped, parsed.skipped);
});
return {
users,
skipped,
totalFromSummary: total,
pageSize,
pages,
pagesByTotal
};
}
function getCurrentListTotal(doc) {
const bodyText = String(doc.body && doc.body.innerText || '').replace(/\u00a0/g, ' ');
let m = bodyText.match(/Под правило попадает:\s*([\d\s]+)\s+(пользовател|запис|заказ)/i);
if (m) return Number(m[1].replace(/\s/g, '')) || 0;
const summary = String(doc.querySelector('.summary') && doc.querySelector('.summary').innerText || '').replace(/\u00a0/g, ' ');
m = summary.match(/из\s+([\d\s]+)/i);
if (m) return Number(m[1].replace(/\s/g, '')) || 0;
return 0;
}
function getCurrentListPageSize(doc) {
const summary = String(doc.querySelector('.summary') && doc.querySelector('.summary').innerText || '').replace(/\u00a0/g, ' ');
const m = summary.match(/Показаны\s+(\d+)\s*[—\-]\s*(\d+)/i);
if (m) return Math.max(1, Number(m[2]) - Number(m[1]) + 1);
const table = getCurrentUsersTable(doc);
const rows = table ? table.querySelectorAll('tbody tr').length : 0;
return rows || 100;
}
function makeCurrentListPageUrl(page) {
const url = new URL(window.location.href);
url.searchParams.set('page', page);
url.searchParams.set('sort', 'id');
return url.toString();
}
function getCurrentUsersTable(doc) {
const tables = Array.from(doc.querySelectorAll('table.kv-grid-table, .kv-grid-container table, table'));
return tables.find(table => {
if (table.querySelector('tbody tr[data-user-id]')) return true;
if (table.querySelector('a[href*="/user/control/user/update/id/"], a[href*="/pl/user/user/update?id="], a[href*="/pl/user/user/view?id="]')) return true;
return false;
}) || null;
}
function parseUsersFromCurrentListDoc(doc, pageNumber) {
const users = new Map();
const skipped = [];
const table = getCurrentUsersTable(doc);
if (!table) {
return { users, skipped: [{ page: pageNumber, reason: 'Не найдена таблица пользователей' }] };
}
const headers = Array.from(table.querySelectorAll('thead th')).map((th, index) => ({
index,
seq: th.getAttribute('data-col-seq'),
text: normalizeText(th.innerText || th.textContent)
}));
const emailCol = headers.find(h => h.text === 'эл. почта' || h.text === 'email' || h.text.indexOf('почт') !== -1);
const nameCol = headers.find(h => h.text === 'отображаемое имя' || h.text === 'имя') || headers.find(h => h.text.indexOf('имя') !== -1);
const idCol = headers.find(h => h.text === 'id');
Array.from(table.querySelectorAll('tbody tr')).forEach((tr, rowIndex) => {
let id = tr.getAttribute('data-user-id') || '';
const link = tr.querySelector('a[href*="/user/control/user/update/id/"], a[href*="/pl/user/user/update?id="], a[href*="/pl/user/user/view?id="]');
if (!id && link) id = extractUserId(link.getAttribute('href') || '');
if (!id && idCol) id = parseIdFromTableCell(getCellByHeader(tr, idCol));
if (!id) {
skipped.push({ page: pageNumber, row: rowIndex + 1, reason: 'Не найден ID пользователя' });
return;
}
const nameCell = nameCol ? getCellByHeader(tr, nameCol) : null;
const emailCell = emailCol ? getCellByHeader(tr, emailCol) : null;
const rowText = normalizeText(tr.textContent || '');
const emailMatch = rowText.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
const name = normalizeText(nameCell ? (nameCell.innerText || nameCell.textContent) : (link ? link.textContent : '')).slice(0, 120);
const email = normalizeText(emailCell ? (emailCell.innerText || emailCell.textContent) : (emailMatch ? emailMatch[0] : ''));
users.set(String(id), { id: String(id), name, email, rowHTML: tr.outerHTML });
});
return { users, skipped };
}
function getCellByHeader(tr, header) {
if (!tr || !header) return null;
if (header.seq !== null && header.seq !== '') {
const bySeq = tr.querySelector('td[data-col-seq="' + header.seq + '"]');
if (bySeq) return bySeq;
}
return Array.from(tr.querySelectorAll('td'))[header.index] || null;
}
function parseIdFromTableCell(cell) {
const text = normalizeText(cell ? (cell.innerText || cell.textContent) : '');
const m = text.match(/\d{5,}/);
return m ? String(m[0]) : '';
}
async function fetchUsersFromObjectsLink(objectsLink, label, settings) {
const users = new Map();
const visited = new Set();
let nextUrl = absoluteUrl(objectsLink);
let pageNo = 0;
let emptyStreak = 0;
while (nextUrl && pageNo < settings.maxListPages && !visited.has(nextUrl)) {
visited.add(nextUrl);
pageNo += 1;
setStatus('Загружаю список: ' + label + ', стр. ' + pageNo + ', найдено ID: ' + users.size);
const html = await fetchHtml(nextUrl);
const parsed = parseUsersFromHtml(html);
let added = 0;
parsed.users.forEach((u, id) => {
if (!users.has(id)) added += 1;
users.set(id, Object.assign(users.get(id) || {}, u));
});
emptyStreak = added ? 0 : emptyStreak + 1;
if (emptyStreak >= CONFIG.stopAfterEmptyPages) break;
nextUrl = findNextPageUrl(html, nextUrl, visited);
if (CONFIG.requestDelayMs) await delay(CONFIG.requestDelayMs);
}
return users;
}
async function fetchFullPageHtml(url) {
const res = await fetch(url, { credentials: 'include' });
const text = await res.text();
if (!res.ok) throw new Error('Ошибка загрузки страницы HTTP ' + res.status + ': ' + text.slice(0, 180));
return text;
}
async function fetchHtml(url) {
const res = await fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const text = await res.text();
if (!res.ok) throw new Error('Ошибка загрузки списка HTTP ' + res.status + ': ' + text.slice(0, 180));
return text;
}
function parseUsersFromHtml(html) {
const users = new Map();
const patterns = [
/\/user\/control\/user\/update\/id\/(\d+)/g,
/\/pl\/user\/user\/update\?id=(\d+)/g,
/\/pl\/user\/user\/view\?id=(\d+)/g,
/data-user-id=["']?(\d+)/g,
/data-id=["']?(\d+)/g
];
patterns.forEach(re => {
let m;
while ((m = re.exec(html))) {
const id = String(m[1]);
if (id.length >= 5) users.set(id, Object.assign(users.get(id) || {}, { id }));
}
});
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('tr').forEach(tr => {
const link = tr.querySelector('a[href*="/user/control/user/update/id/"], a[href*="/pl/user/user/update?id="], a[href*="/pl/user/user/view?id="]');
if (!link) return;
const id = extractUserId(link.getAttribute('href') || '');
if (!id) return;
const text = normalizeText(tr.textContent || '');
const emailMatch = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
const name = normalizeText(link.textContent || '').slice(0, 120);
users.set(id, Object.assign(users.get(id) || {}, { id, name, email: emailMatch ? emailMatch[0] : '' }));
});
} catch (e) {}
return { users };
}
function extractUserId(href) {
let m = String(href || '').match(/\/user\/control\/user\/update\/id\/(\d+)/);
if (m) return String(m[1]);
m = String(href || '').match(/[?&]id=(\d+)/);
if (m) return String(m[1]);
return '';
}
function findNextPageUrl(html, currentUrl, visited) {
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const anchors = Array.from(doc.querySelectorAll('a[href]'));
const explicitNext = anchors.find(a => {
const t = normalizeText(a.textContent || '').toLowerCase();
const cls = String(a.className || '').toLowerCase();
return /след|next|›|»/.test(t) || cls.includes('next');
});
if (explicitNext) {
const u = absoluteUrl(explicitNext.getAttribute('href'));
if (u && !visited.has(u)) return u;
}
const cur = new URL(currentUrl, location.origin);
const curPage = Number(cur.searchParams.get('page') || cur.searchParams.get('p') || 1);
const candidates = anchors.map(a => absoluteUrl(a.getAttribute('href'))).filter(Boolean).filter(u => !visited.has(u)).map(u => {
try {
const x = new URL(u, location.origin);
const p = Number(x.searchParams.get('page') || x.searchParams.get('p') || 0);
return { url: u, page: p };
} catch (e) { return null; }
}).filter(Boolean).filter(x => x.page > curPage).sort((a, b) => a.page - b.page);
if (candidates.length) return candidates[0].url;
} catch (e) {}
return '';
}
function absoluteUrl(url) {
if (!url) return '';
try { return new URL(url, location.origin).toString(); } catch (e) { return ''; }
}
function buildAndRule(children) {
return {
type: 'andrule',
inverted: 0,
className: 'app::components::logic::rule::AndRule',
params: { children, mode: 'and' }
};
}
function buildProductRule(settings) {
return {
type: 'user_hasuserproduct',
inverted: 0,
className: 'app::modules::user::models::rule::HasUserProductRule',
params: {
linkedRule: {
type: 'andrule',
inverted: 0,
className: 'app::components::logic::rule::AndRule',
params: {
mode: 'and',
children: [
{
type: 'userproduct_productrule',
inverted: 0,
className: 'app::components::logic::rule::CustomFieldRule',
params: { value: { selected_id: [String(settings.productId)] }, valueMode: '' }
},
{
type: 'userproduct_startatrule',
inverted: 0,
className: 'app::components::logic::rule::CustomFieldRule',
params: {
value: { from: '', to: '', toNDays: String(settings.toNDays), fromNDays: String(settings.fromNDays), dateType: 'last_n_days', withTime: 'false' },
valueMode: ''
}
}
]
}
},
countCondition: ''
}
};
}
function buildLastActivityRule(dateType) {
const type = String(dateType || 'this_week');
return {
type: 'user_last_activity_at',
inverted: 0,
params: {
value: {
from: '',
to: '',
toNDays: '',
fromNDays: '',
dateType: type === 'off' ? 'this_week' : type,
withTime: 'false'
},
valueMode: ''
}
};
}
function activityDateLabel(dateType) {
const type = String(dateType || CONFIG.activityDateType || 'this_week');
if (type === 'off') return 'не проверяется';
if (type === 'this_week') return 'активен на этой неделе';
return 'активность: ' + type;
}
async function collectGeneralActivityForMatrix(matrix, batches, settings) {
const summary = { checked: true, active: 0, inactive: matrix.size, errors: [], dateType: settings.activityDateType };
setStatus('Проверяю общую активность пользователей: ' + activityDateLabel(settings.activityDateType) + '…');
const jobs = batches.map(function(ids, batchIndex) { return { ids: ids, batchIndex: batchIndex }; });
await runWithConcurrency(jobs, settings.jobConcurrency, async function(job) {
const label = 'general activity batch ' + (job.batchIndex + 1) + '/' + batches.length;
try {
const rule = buildAndRule([buildIdsRule(job.ids), buildLastActivityRule(settings.activityDateType)]);
const json = await runRule(rule, label);
let users = new Map();
if (json.objectsLink && Number(json.count || 0) > 0) {
users = await fetchUsersFromObjectsLink(json.objectsLink, label, Object.assign({}, settings, { maxListPages: CONFIG.activityMaxListPages || 2 }));
}
users.forEach(function(user, id) {
id = String(id);
if (!matrix.has(id)) return;
const row = matrix.get(id);
row.generalActive = true;
row.generalActivityLabel = activityDateLabel(settings.activityDateType);
if (!row.name && user.name) row.name = user.name;
if (!row.email && user.email) row.email = user.email;
});
} catch (error) {
const msg = String(error && error.message || error);
console.error('[VSM] ' + label + ' ошибка общей активности', error);
summary.errors.push({ label: label, error: msg });
}
}, function(done, total) {
setStatus('Проверяю общую активность: ' + done + ' / ' + total + '. Ошибок: ' + summary.errors.length + '.');
});
const rows = Array.from(matrix.values());
summary.active = rows.filter(function(row) { return row.generalActive; }).length;
summary.inactive = rows.length - summary.active;
return summary;
}
function buildActivityStats(users, settings) {
const notStarted = users.filter(function(u) { return u.cohort === 'not_started'; });
const active = users.filter(function(u) { return Boolean(u.generalActive); });
const notStartedActive = notStarted.filter(function(u) { return Boolean(u.generalActive); });
return {
checked: Boolean(settings && settings.activityDateType && settings.activityDateType !== 'off'),
dateType: settings ? settings.activityDateType : CONFIG.activityDateType,
label: activityDateLabel(settings && settings.activityDateType),
active: active.length,
inactive: users.length - active.length,
notStarted: notStarted.length,
notStartedActive: notStartedActive.length,
notStartedInactive: notStarted.length - notStartedActive.length
};
}
function buildLessonRule(lesson) {
return {
type: 'user_visitlessonrule',
inverted: 0,
className: 'app::modules::user::models::rule::VisitLessonRule',
params: {
value: { selected_id: String(lesson.streamId) },
lessonId: { selected_id: String(lesson.lessonId) },
status: { selected_id: '' },
checkerType: { selected_id: 'enter' }
}
};
}
function buildIdsRule(ids) {
return {
type: 'idsrule',
inverted: 0,
className: 'app::components::logic::rule::IdsRule',
params: {
value: ids.map(String).join(','),
valueMode: ''
}
};
}
function chunkArray(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
async function runWithConcurrency(items, concurrency, worker, onProgress) {
const results = [];
let index = 0;
let done = 0;
const workers = Array.from({ length: Math.max(1, Number(concurrency || 1)) }, async () => {
while (index < items.length) {
const currentIndex = index++;
const current = items[currentIndex];
try {
results[currentIndex] = await worker(current, currentIndex);
} catch (error) {
results[currentIndex] = { error: error && error.message || String(error), item: current };
}
done += 1;
if (onProgress) onProgress(done, items.length);
}
});
await Promise.all(workers);
return results;
}
function appendRule(params, prefix, value) {
if (Array.isArray(value)) {
value.forEach((item, index) => {
const isSelectedIdArray = /\[selected_id\]$/.test(prefix);
const key = isSelectedIdArray ? prefix + '[]' : prefix + '[' + index + ']';
appendRule(params, key, item);
});
return;
}
if (value && typeof value === 'object') {
Object.keys(value).forEach(key => appendRule(params, prefix + '[' + key + ']', value[key]));
return;
}
params.set(prefix, value == null ? '' : String(value));
}
function addGcMeta(params) {
const gcSession = window.gcSession || getCookie('gcSession');
const gcVisit = window.gcVisit || getCookie('gcVisit');
const gcVisitor = window.gcVisitor || getCookie('gcVisitor');
const gcSessionHash = window.gcSessionHash || getCookie('gcSessionHash');
if (gcSession) params.set('gcSession', typeof gcSession === 'string' ? gcSession : JSON.stringify(gcSession));
if (gcVisit) params.set('gcVisit', typeof gcVisit === 'string' ? gcVisit : JSON.stringify(gcVisit));
if (gcVisitor) params.set('gcVisitor', typeof gcVisitor === 'string' ? gcVisitor : JSON.stringify(gcVisitor));
if (gcSessionHash) params.set('gcSessionHash', String(gcSessionHash));
}
function getCookie(name) {
const safe = name.replace(/[.$?*|{}()\[\]\\/\+^]/g, '\$&');
const m = document.cookie.match(new RegExp('(?:^|; )' + safe + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : '';
}
function renderLiveSummary(baseJson, baseUsers, lessonStats, matrix, lessons, groups, final) {
const pseudo = buildAnalysis(baseJson, baseUsers, matrix, lessons, groups, lessonStats, {}, new Date().toISOString());
window.VIVO_SEGMENT_MATRIX_PREVIEW = pseudo;
renderSummary(pseudo, final);
}
function renderFinal(analysis) {
renderSummary(analysis, true);
}
function renderSummary(analysis, final) {
window.VIVO_SEGMENT_MATRIX_LAST = final ? analysis : (window.VIVO_SEGMENT_MATRIX_LAST || analysis);
renderActiveView(analysis);
}
function renderActiveView(analysisArg) {
const analysis = analysisArg || window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW;
const resultsEl = document.getElementById(RESULT_ID);
if (!resultsEl || !analysis) return;
updateViewButtons();
let html = renderTopCards(analysis);
if (CURRENT_VIEW === 'cohorts') html += renderCohortsView(analysis);
else if (CURRENT_VIEW === 'weeks') html += renderWeeksView(analysis);
else if (CURRENT_VIEW === 'lessons') html += renderLessonsView(analysis);
else if (CURRENT_VIEW === 'users') html += renderUsersView(analysis);
else if (CURRENT_VIEW === 'groups') html += renderGroupsView(analysis);
else html += renderOverviewView(analysis);
resultsEl.innerHTML = html;
if (CURRENT_VIEW === 'users') renderUserTable();
}
function updateViewButtons() {
const wrap = document.getElementById(WRAP_ID);
if (!wrap) return;
Array.from(wrap.querySelectorAll('[data-view]')).forEach(btn => {
const active = btn.getAttribute('data-view') === CURRENT_VIEW;
btn.classList.toggle('is-active', active);
});
}
function renderTopCards(analysis) {
const strong = analysis.cohorts.find(c => c.key === 'strong');
const notStarted = analysis.cohorts.find(c => c.key === 'not_started');
return [
'',
'
' + formatNumber(analysis.base.loaded) + ' пользователей загружено страниц: ' + escapeHtml(analysis.base.pages) + ', pageSize: ' + escapeHtml(analysis.base.pageSize) + '
',
'
' + formatNumber(analysis.lessons.length) + ' уроков проверено ' + (analysis.batches ? ('задач: ' + formatNumber(analysis.batches.jobs) + ', ошибки: ' + formatNumber(analysis.batches.errors.length)) : '') + '
',
'
' + formatNumber(strong ? strong.count : 0) + ' сильно вовлечены клик — показать в таблице
',
'
' + formatNumber(notStarted ? notStarted.count : 0) + ' не стартовали в треке все без входов в выбранные уроки
',
'
' + formatNumber(analysis.activityStats ? analysis.activityStats.notStartedActive : 0) + ' не в треке, но активны ' + escapeHtml(activityDateLabel(analysis.settings && analysis.settings.activityDateType)) + '
',
'
' + formatNumber(analysis.activityStats ? analysis.activityStats.notStartedInactive : 0) + ' не в треке и не заходили главный риск выпадения
',
'
'
].join('');
}
function renderOverviewView(analysis) {
return [
'',
'
Обзор ',
'
Здесь общий управленческий срез: сколько людей реально начали, где падают недели и какие когорты стоит отдельно догревать/продавать.
',
renderCohortsCards(analysis),
renderActivitySplit(analysis),
'
Недельная картина ',
renderWeeksList(analysis.weekStats),
'
'
].join('');
}
function renderCohortsView(analysis) {
return [
'',
'
Когорты вовлечённости ',
'
Клик по карточке подставляет людей этой когорты в основную таблицу GetCourse. Кнопки позволяют скопировать ID или открыть когорту отдельным сегментом.
',
renderCohortsCards(analysis),
renderActivitySplit(analysis),
'
'
].join('');
}
function renderCohortsCards(analysis) {
return '' + analysis.cohorts.map(c => [
'
',
'
' + formatNumber(c.count) + ' ',
'
' + escapeHtml(c.title) + ' ',
'
' + escapeHtml(c.range) + ' · ' + c.percent + '% ',
'
',
'ID ',
'Сегмент ',
'В группу ',
'
',
'
'
].join('')).join('') + '
';
}
function renderActivitySplit(analysis) {
const s = analysis.activityStats || {};
if (!s.checked) {
return 'Общая активность Проверка общей активности отключена.
';
}
const cards = [
{ key: 'general_active', title: 'Активны в целом', count: s.active || 0, hint: activityDateLabel(analysis.settings && analysis.settings.activityDateType) },
{ key: 'general_inactive', title: 'Нет общей активности', count: s.inactive || 0, hint: 'не было входов в выбранном периоде' },
{ key: 'not_started_active', title: 'Не в треке, но активны', count: s.notStartedActive || 0, hint: 'вероятно занимаются в основных уроках' },
{ key: 'not_started_inactive', title: 'Не в треке и не заходили', count: s.notStartedInactive || 0, hint: 'главная тревожная когорта' }
];
return [
'',
'
Общая активность вне новичкового трека ',
'
Эта проверка отделяет людей, которые не идут по выбранным неделям новичка, но при этом были активны в GetCourse. Для них проблема не обязательно в оттоке: они могли уйти сразу в основные занятия клуба.
',
'
' + cards.map(c => [
'
',
'
' + formatNumber(c.count) + ' ',
'
' + escapeHtml(c.title) + ' ',
'
' + escapeHtml(c.hint) + ' ',
'
',
'ID ',
'В группу ',
'
',
'
'
].join('')).join('') + '
',
'
'
].join('');
}
function renderWeeksView(analysis) {
return [
'',
'
Недели ',
'
Неделя считается затронутой, если был вход хотя бы в один урок. Вовлечённой — если набран порог 40–50% уроков недели.
',
renderWeeksList(analysis.weekStats),
'
'
].join('');
}
function renderWeeksList(weekStats) {
return '' + weekStats.map(w => [
'
',
'
' + escapeHtml(w.title) + ' Порог вовлечения: ' + w.threshold + ' из ' + w.lessons + '
',
'
' + formatNumber(w.touched) + ' коснулись · ' + w.touchedPercent + '%
',
'
' + formatNumber(w.engaged) + ' вовлеклись · ' + w.engagedPercent + '%
',
'
Показать вовлечённых В группу
',
'
'
].join('')).join('') + '
';
}
function renderLessonsView(analysis) {
const rows = analysis.lessons.slice().sort((a,b) => (Number(a.week)-Number(b.week)) || (Number(b.loadedIds)-Number(a.loadedIds))).map(l => [
'',
'' + escapeHtml(l.weekTitle) + ' ',
'' + escapeHtml(l.title) + '' + escapeHtml(l.lessonId) + '
',
'' + formatNumber(l.loadedIds) + ' ',
'' + escapeHtml(l.percent) + '% ',
'' + (l.error ? 'ошибка ' + escapeHtml(l.error).slice(0, 160) + '
' : 'ok ') + ' ',
'Показать В группу ',
' '
].join('')).join('');
return 'Уроки Неделя Урок ID % базы Статус Действия ' + rows + '
';
}
function renderUsersView(analysis) {
const payload = window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW;
const cohortFilters = cohortDefinitions(payload ? payload.lessons.length : undefined).map(c => '' + escapeHtml(c.title) + ' ').join('');
const activityFilters = 'Не в треке, но активны Не в треке и не заходили Активны в целом Нет общей активности ';
const filters = cohortFilters + activityFilters;
return [
'',
'
Пользователи ',
'
Фильтр ниже управляет таблицей внутри отчёта. Кнопка «Показать в GetCourse» подставляет эту когорту в основную таблицу пользователей.
',
'
',
'Фильтр Все ' + filters + ' ',
'Показать в GetCourse ',
'Скопировать ID ',
'Открыть как сегмент ',
'Добавить в группу ',
'
',
'
',
'
'
].join('');
}
function renderGroupsView(analysis) {
const activityRows = [
{ key: 'not_started_active', title: 'Не в треке, но активны', hint: 'Не входили в выбранные уроки, но были активны в GetCourse', count: analysis.activityStats ? analysis.activityStats.notStartedActive : 0, percent: analysis.base.loaded ? Math.round(((analysis.activityStats ? analysis.activityStats.notStartedActive : 0) / analysis.base.loaded) * 100) : 0 },
{ key: 'not_started_inactive', title: 'Не в треке и не заходили', hint: 'Не входили в выбранные уроки и не проявляли общей активности', count: analysis.activityStats ? analysis.activityStats.notStartedInactive : 0, percent: analysis.base.loaded ? Math.round(((analysis.activityStats ? analysis.activityStats.notStartedInactive : 0) / analysis.base.loaded) * 100) : 0 },
{ key: 'general_active', title: 'Активны в целом', hint: activityDateLabel(analysis.settings && analysis.settings.activityDateType), count: analysis.activityStats ? analysis.activityStats.active : 0, percent: analysis.base.loaded ? Math.round(((analysis.activityStats ? analysis.activityStats.active : 0) / analysis.base.loaded) * 100) : 0 }
];
const rows = analysis.cohorts.concat(activityRows).map(c => [
'',
'' + escapeHtml(c.title) + ' ' + escapeHtml(c.hint || '') + '
',
'' + formatNumber(c.count) + ' ',
'' + c.percent + '% ',
'',
'Показать ',
'Скопировать ID ',
'Открыть сегмент ',
'Добавить в группу ',
' ',
' '
].join('')).join('');
return [
'',
'
Группы / действия ',
'
Здесь можно показать когорту в таблице, скопировать ID, открыть её отдельным сегментом или создать новую группу GetCourse и добавить туда пользователей этой когорты.
',
'
Когорта Людей % Действия ' + rows + '
',
'
'
].join('');
}
function renderUserTable() {
const payload = window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW;
const el = document.getElementById('vsm-user-table');
if (!payload || !el) return;
const select = document.getElementById('vsm-filter');
const filter = select ? select.value : CURRENT_USER_FILTER || 'all';
CURRENT_USER_FILTER = filter;
const all = getUsersByCohort(filter, payload);
const users = all.slice(0, 300);
const rows = users.map(u => [
'',
'' + escapeHtml(u.id) + ' ' + escapeHtml(u.name || '') + (u.email ? ' · ' + escapeHtml(u.email) : '') + '
',
'' + escapeHtml(u.lessonCount) + ' / ' + payload.lessons.length + ' ',
'' + escapeHtml(u.touchedWeeks) + ' ',
'' + escapeHtml(u.engagedWeeks) + ' ',
'' + (u.generalActive ? 'активен ' : 'нет ') + ' ',
'' + escapeHtml(cohortTitle(u.cohort)) + ' ',
' '
].join('')).join('');
el.innerHTML = 'Показано ' + formatNumber(users.length) + ' из ' + formatNumber(all.length) + '. Полный список — в CSV или через ID.
ID / имя Уроков Недель коснулся Недель вовлечён Общая активность Когорта ' + rows + '
';
}
function cohortTitle(key) {
const c = cohortDefinitions().find(x => x.key === key);
return c ? c.title : key;
}
function makeCsv() {
const payload = window.VIVO_SEGMENT_MATRIX_LAST;
if (!payload) return '';
const lessonIds = payload.lessons.map(l => String(l.lessonId));
const headers = ['user_id','name','email','lesson_count','touched_weeks','engaged_weeks','max_touched_week','max_engaged_week','cohort','general_activity'].concat(lessonIds.map(id => 'lesson_' + id));
const lines = [headers.join(';')];
payload.users.forEach(u => {
const has = new Set(u.lessons.map(String));
const row = [u.id,u.name,u.email,u.lessonCount,u.touchedWeeks,u.engagedWeeks,u.maxTouchedWeek,u.maxEngagedWeek,cohortTitle(u.cohort),u.generalActive ? '1' : '0'].concat(lessonIds.map(id => has.has(id) ? '1' : '0'));
lines.push(row.map(csvCell).join(';'));
});
return lines.join('\\n');
}
function csvCell(v) {
const s = String(v == null ? '' : v);
if (/[;\"\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
return s;
}
function copyCsv() {
const text = makeCsv();
if (!text) return;
navigator.clipboard.writeText(text).then(() => alert('CSV скопирован'));
}
function downloadCsv() {
const text = makeCsv();
if (!text) return;
const blob = new Blob([text], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'beginner-track-matrix-' + new Date().toISOString().slice(0,10) + '.csv';
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
}
function copyFilteredIds() {
const filter = document.getElementById('vsm-filter')?.value || CURRENT_USER_FILTER || 'all';
copyCohortIds(filter);
}
function getUsersByCohort(cohortKey, payloadArg) {
const payload = payloadArg || window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW;
if (!payload) return [];
const key = String(cohortKey || 'all');
const users = payload.users || [];
if (!key || key === 'all') return users;
if (key.indexOf('single:') === 0) {
const id = key.replace('single:', '');
return users.filter(u => String(u.id) === id);
}
if (key.indexOf('week_touch:') === 0) {
const week = key.replace('week_touch:', '');
return users.filter(u => Number((u.weekCounts || {})[String(week)] || 0) > 0);
}
if (key.indexOf('week_engaged:') === 0) {
const week = key.replace('week_engaged:', '');
const stat = (payload.weekStats || []).find(w => String(w.week) === String(week));
const threshold = stat ? Number(stat.threshold || 1) : 1;
return users.filter(u => Number((u.weekCounts || {})[String(week)] || 0) >= threshold);
}
if (key.indexOf('lesson:') === 0) {
const lessonId = key.replace('lesson:', '');
return users.filter(u => (u.lessons || []).map(String).includes(String(lessonId)));
}
if (key === 'not_started_active') return users.filter(u => u.cohort === 'not_started' && Boolean(u.generalActive));
if (key === 'not_started_inactive') return users.filter(u => u.cohort === 'not_started' && !Boolean(u.generalActive));
if (key === 'general_active') return users.filter(u => Boolean(u.generalActive));
if (key === 'general_inactive') return users.filter(u => !Boolean(u.generalActive));
return users.filter(u => u.cohort === key);
}
function getCohortLabel(cohortKey) {
const payload = window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW;
const key = String(cohortKey || 'all');
if (!key || key === 'all') return 'Все пользователи';
if (key.indexOf('single:') === 0) return 'Пользователь ' + key.replace('single:', '');
if (key.indexOf('week_touch:') === 0) return 'Коснулись недели ' + key.replace('week_touch:', '');
if (key.indexOf('week_engaged:') === 0) return 'Вовлечены в неделю ' + key.replace('week_engaged:', '');
if (key.indexOf('lesson:') === 0) {
const lessonId = key.replace('lesson:', '');
const lesson = payload && (payload.lessons || []).find(l => String(l.lessonId) === String(lessonId));
return 'Входили в урок: ' + (lesson ? lesson.title : lessonId);
}
if (key === 'not_started_active') return 'Не в треке, но активны в GetCourse';
if (key === 'not_started_inactive') return 'Не в треке и нет общей активности';
if (key === 'general_active') return 'Активны в GetCourse';
if (key === 'general_inactive') return 'Нет общей активности в GetCourse';
return cohortTitle(key);
}
function saveOriginalTableState() {
const table = getCurrentUsersTable(document);
if (!table) return;
const theadRow = table.querySelector('thead tr');
const tbody = table.querySelector('tbody');
const summary = document.querySelector('.summary');
if (theadRow && !ORIGINAL_TABLE_HEAD_HTML) ORIGINAL_TABLE_HEAD_HTML = theadRow.innerHTML;
if (tbody && !ORIGINAL_TABLE_BODY_HTML) ORIGINAL_TABLE_BODY_HTML = tbody.innerHTML;
if (summary && !ORIGINAL_SUMMARY_HTML) ORIGINAL_SUMMARY_HTML = summary.innerHTML;
}
function filterMainTableByCohort(cohortKey) {
const users = getUsersByCohort(cohortKey);
const rows = users.filter(u => u.rowHTML).map(u => u.rowHTML);
if (!rows.length) {
alert('Для этой когорты не нашла сохранённые строки таблицы. Можно скопировать ID или открыть сегмент.');
return;
}
filterCurrentTableByRowHtml(rows, getCohortLabel(cohortKey) + ' — ' + formatNumber(rows.length) + ' пользователей');
}
function filterCurrentTableByRowHtml(rowHtmlList, label) {
const table = getCurrentUsersTable(document);
if (!table) return;
saveOriginalTableState();
const tbody = table.querySelector('tbody');
const summary = document.querySelector('.summary');
if (!tbody) return;
tbody.innerHTML = rowHtmlList.join('');
if (summary) summary.innerHTML = 'Показан виртуальный фильтр из матрицы новичков: ' + formatNumber(rowHtmlList.length) + ' пользователей';
CURRENT_TABLE_FILTER = { label, count: rowHtmlList.length };
setFilterInfo('' + escapeHtml(label) + ' В основную таблицу подставлены строки из собранной выборки. Это виртуальный фильтр: он не меняет сегмент GetCourse. Вернуть исходную таблицу ');
try { table.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) {}
}
function resetCurrentTableFilter() {
const table = getCurrentUsersTable(document);
if (!table) return;
const theadRow = table.querySelector('thead tr');
const tbody = table.querySelector('tbody');
const summary = document.querySelector('.summary');
if (theadRow && ORIGINAL_TABLE_HEAD_HTML) theadRow.innerHTML = ORIGINAL_TABLE_HEAD_HTML;
if (tbody && ORIGINAL_TABLE_BODY_HTML) tbody.innerHTML = ORIGINAL_TABLE_BODY_HTML;
if (summary && ORIGINAL_SUMMARY_HTML) summary.innerHTML = ORIGINAL_SUMMARY_HTML;
CURRENT_TABLE_FILTER = null;
setFilterInfo('');
}
function setFilterInfo(html) {
const el = document.getElementById(FILTER_ID);
if (!el) return;
if (!html) {
el.style.display = 'none';
el.innerHTML = '';
return;
}
el.style.display = 'block';
el.innerHTML = html;
}
function copyCohortIds(cohortKey) {
const ids = getUsersByCohort(cohortKey).map(u => u.id).filter(Boolean);
navigator.clipboard.writeText(ids.join('\n')).then(() => alert('ID скопированы: ' + ids.length));
}
function openCohortAsSegment(cohortKey) {
const ids = getUsersByCohort(cohortKey).map(u => String(u.id)).filter(Boolean);
if (!ids.length) {
alert('В этой выборке нет ID.');
return;
}
if (ids.length > 450) {
navigator.clipboard.writeText(ids.join('\n')).then(() => alert('ID слишком много для безопасной ссылки (' + ids.length + '). Я скопировал их в буфер. Лучше создать группу/сегмент через штатное массовое действие GetCourse.'));
return;
}
const rule = buildIdsRule(ids);
const url = new URL('/pl/user/user/index', location.origin);
url.searchParams.set('uc[rule_string]', JSON.stringify(rule));
window.open(url.toString(), '_blank');
}
function nowString() {
const d = new Date();
return [d.getFullYear(), pad2(d.getMonth() + 1), pad2(d.getDate())].join('-') + ' ' + [pad2(d.getHours()), pad2(d.getMinutes()), pad2(d.getSeconds())].join(':');
}
function pad2(value) {
value = Number(value);
return value < 10 ? '0' + value : String(value);
}
function getStoredSessionParam(key) {
try { return localStorage.getItem(key) || ''; } catch (err) { return ''; }
}
function appendGetCourseSessionParams(body) {
const session = getStoredSessionParam('session');
const visit = getStoredSessionParam('visit');
const visitor = getStoredSessionParam('visitor');
const hash = getStoredSessionParam('hash');
if (session) body.set('gcSession', session);
if (visit) body.set('gcVisit', visit);
if (visitor) body.set('gcVisitor', visitor);
if (hash) body.set('gcSessionHash', hash);
}
function parseGroupFromResponse(raw, groupName) {
const possibleIds = [
raw && raw.value,
raw && raw.id,
raw && raw.group_id,
raw && raw.groupId,
raw && raw.data && raw.data.id,
raw && raw.data && raw.data.value,
raw && raw.data && raw.data.group_id,
raw && raw.data && raw.data.groupId,
raw && raw.result && raw.result.id,
raw && raw.result && raw.result.value,
raw && raw.result && raw.result.group_id,
raw && raw.result && raw.result.groupId
];
let id = '';
for (const value of possibleIds) {
if (value === null || value === undefined) continue;
if (typeof value === 'object') {
const nested = value.id || value.value || value.group_id || value.groupId;
if (nested) { id = String(nested); break; }
} else {
const text = String(value);
const match = text.match(/\d+/);
if (match) { id = match[0]; break; }
}
}
return {
id,
name: (raw && (raw.name || raw.title || (raw.data && (raw.data.name || raw.data.title)))) || groupName,
raw
};
}
function createGetCourseGroup(groupName) {
return new Promise(function (resolve, reject) {
const name = normalizeText(groupName);
if (!name) {
reject(new Error('Название группы пустое'));
return;
}
if (typeof window.ajaxCall === 'function') {
window.ajaxCall('/pl/user/group/new', { 'UserGroup[name]': name }, {}, function (response) {
if (!response || response.success === false) {
reject(new Error((response && response.message) || 'GetCourse не создал группу'));
return;
}
resolve(parseGroupFromResponse(response, name));
});
return;
}
const body = new URLSearchParams();
body.set('UserGroup[name]', name);
appendGetCourseSessionParams(body);
fetch('/pl/user/group/new', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
},
body: body.toString()
})
.then(function (response) {
return response.text().then(function (text) {
let json = null;
try { json = text ? JSON.parse(text) : null; }
catch (err) { throw new Error('GetCourse вернул не JSON при создании группы: ' + text.slice(0, 300)); }
if (!response.ok || (json && json.success === false)) {
throw new Error((json && json.message) || 'GetCourse не создал группу');
}
return parseGroupFromResponse(json, name);
});
})
.then(resolve)
.catch(reject);
});
}
function getClarityUid() {
try {
const url = new URL(location.href);
const fromUrl = url.searchParams.get('formParams[clarity_uid]');
if (fromUrl) return fromUrl;
} catch (err) {}
if (window.clarity_uid) return String(window.clarity_uid);
if (window.clarityUid) return String(window.clarityUid);
return '';
}
async function addUserIdsToGroup(ids, groupId) {
if (!ids.length) throw new Error('Нет ID клиентов для добавления в группу');
if (!groupId) throw new Error('Нет ID группы');
const formData = new FormData();
formData.append('segmentId', '');
formData.append('rule', JSON.stringify(buildIdsRule(ids)));
formData.append('AddToGroupOperation[groupIds][]', String(groupId));
formData.append('UserGroup[name]', '');
formData.append('execute', '1');
const clarityUid = getClarityUid();
if (clarityUid) formData.append('formParams[clarity_uid]', clarityUid);
const response = await fetch('/pl/logic/operation/prepare?contextType=UserContext&operationType=user_addtogroup', {
method: 'POST',
credentials: 'include',
body: formData
});
const html = await response.text();
if (!response.ok) {
throw new Error('GetCourse не выполнил добавление в группу: HTTP ' + response.status);
}
const doc = new DOMParser().parseFromString(html, 'text/html');
const pageText = normalizeText(doc.body && doc.body.innerText || '');
const title = normalizeText(doc.querySelector('title') && doc.querySelector('title').textContent || '');
const hasResultPage = title.toLowerCase().includes('результат') || pageText.toLowerCase().includes('результат выполнения') || pageText.toLowerCase().includes('выполнен') || pageText.toLowerCase().includes('успешно');
const errorCount = Array.from(doc.querySelectorAll('.label-danger, .label-error, .text-danger, .alert-danger')).filter(function (el) { return normalizeText(el.textContent).length > 0; }).length;
return {
success: hasResultPage || errorCount === 0,
requested: ids.length,
errorCount,
title,
pageText: pageText.slice(0, 2000),
html
};
}
async function addCohortToNewGroup(cohortKey) {
const users = getUsersByCohort(cohortKey);
const ids = Array.from(new Set(users.map(u => String(u.id || '')).filter(Boolean)));
if (!ids.length) {
alert('В этой выборке не найдено ID клиентов.');
return;
}
const label = getCohortLabel(cohortKey);
const defaultName = 'Новички · ' + label + ' · ' + nowString();
const groupName = prompt('Введите название новой группы для выборки «' + label + '» (' + ids.length + ' клиентов):', defaultName);
if (groupName === null) return;
const cleanName = normalizeText(groupName);
if (!cleanName) {
alert('Название группы не может быть пустым.');
return;
}
const ok = confirm('Создать группу «' + cleanName + '» и добавить туда ' + ids.length + ' клиентов?');
if (!ok) return;
try {
setStatus('Создаю группу «' + cleanName + '» для ' + ids.length + ' клиентов...');
const group = await createGetCourseGroup(cleanName);
if (!group.id) {
console.warn('[VSM] Ответ создания группы без ID:', group.raw);
throw new Error('Группа создана, но GetCourse не вернул ID группы');
}
setStatus('Группа создана: «' + group.name + '». Добавляю клиентов...');
const result = await addUserIdsToGroup(ids, group.id);
if (!result.success) {
throw new Error('GetCourse вернул страницу результата, но успешное выполнение не распознано');
}
setStatus('Готово. Группа «' + group.name + '», отправлено ID: ' + ids.length + '.');
alert('Готово.\n\nГруппа: ' + group.name + '\nID группы: ' + group.id + '\nОтправлено клиентов: ' + ids.length);
} catch (err) {
console.error('[VSM] Ошибка добавления в группу:', err);
setStatus('Ошибка добавления в группу: ' + (err && err.message || String(err)));
alert('Ошибка добавления в группу: ' + (err && err.message || String(err)));
}
}
function setStatus(text) {
const el = document.getElementById('vsm-status');
if (el) el.innerHTML = escapeHtml(text);
}
function updateProgress(done, total, label) {
const el = document.getElementById('vsm-progress');
if (!el) return;
const pct = total ? Math.round(done / total * 100) : 0;
el.innerHTML = '' + escapeHtml(label) + ': ' + done + ' / ' + total + '
';
}
function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
function normalizeText(v) { return String(v == null ? '' : v).replace(/\s+/g, ' ').trim(); }
function escapeHtml(value) { return String(value == null ? '' : value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); }
function formatNumber(num) { return new Intl.NumberFormat('ru-RU').format(Math.round(Number(num) || 0)); }
function injectStyles() {
if (document.getElementById('vivo-segment-matrix-styles')) return;
const style = document.createElement('style');
style.id = 'vivo-segment-matrix-styles';
style.textContent = `
#vivo-segment-matrix-open.btn { font-weight:700; }
#vivo-segment-matrix-wrap { display:none; margin:15px 0; padding:14px; background:#fff; border:1px solid #ddd; border-radius:8px; font-family:Arial, sans-serif; }
#vivo-segment-matrix-wrap h3 { font-size:16px; margin:16px 0 8px; }
`;
document.head.appendChild(style);
}
})();