Top Academy Journal Auto Rater Pro

Автоматизация оценки, навигации и подсчёта посещаемости (только на странице прогресса)

// ==UserScript==
// @name         Top Academy Journal Auto Rater Pro
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  Автоматизация оценки, навигации и подсчёта посещаемости (только на странице прогресса)
// @author       Rodion
// @match        https://journal.top-academy.ru/*
// @grant        none
// ==/UserScript==

(function () {
	'use strict';

	/** Конфигурация скрипта */
	const CONFIG = {
		ZOOM_LEVEL: '80%',
		TARGET_RATING: 5,
		NEXT_BUTTON_TEXTS: ['Далее', 'Отправить'],
		OBSERVER_CONFIG: {
			childList: true,
			subtree: true,
			attributes: true,
			attributeFilter: ['style', 'class'],
		},
		CHECK_INTERVAL: 1000,
		CLICK_DELAY: 100,
		PROGRESS_PAGE_REGEX:
			/https:\/\/journal\.top-academy\.ru\/.*\/main\/progress\/.*/,
	};

	/** Основной класс автоматизатора */
	class AutoRater {
		constructor() {
			this.isProcessing = false;
			this.observer = null;
			this.intervalId = null;
			this.widget = null;
			this.isCollapsed = false;

			// Переменные для хранения границ выбранного диапазона
			this.rangeStart = null;
			this.rangeEnd = null;

			// После инициализации виджета и статистики
			this.setupRangeSelection();

			this.init();
		}

		/** Инициализация скрипта */
		init() {
			this.setPageZoom();
			this.setupObservers();
			this.initialCheck();

			// Инициализация статистики только на странице прогресса
			if (this.isProgressPage()) {
				this.initAttendanceStats();
			}

			// Отслеживание изменений URL для SPA
			this.setupNavigationObserver();
		}

		/** Настройка «кликабельного» выбора диапазона */
		setupRangeSelection() {
			document.body.addEventListener('click', e => {
				// Ищем обёртку «пары»
				const lesson = e.target.closest(
					'.lessons, .lessons.lateness, .lessons.pass'
				);
				if (!lesson) return;

				// Получаем дату пары
				const dateEl = lesson.querySelector('.date');
				if (!dateEl) return;
				const [day, month, year] = dateEl.textContent
					.trim()
					.split('.')
					.map(Number);
				const lessonDate = new Date(year, month - 1, day);

				if (e.shiftKey) {
					// Срабатывает при Shift+LKM — вторая точка
					this.rangeEnd = lessonDate;
				} else {
					// Простое LKM — первая точка
					this.rangeStart = lessonDate;
					this.rangeEnd = null;
				}

				// Если обе точки выбраны и стоят в неверном порядке — меняем местами
				if (this.rangeStart && this.rangeEnd) {
					if (this.rangeStart > this.rangeEnd) {
						[this.rangeStart, this.rangeEnd] = [this.rangeEnd, this.rangeStart];
					}
				}

				// Обновляем поля в виджете и статистику
				this.applyRangeToWidget();
				this.updateAttendanceStats();

				// Подсвечиваем выбранный диапазон на странице
				this.highlightRange();
			});
		}

		/** Заполняет date-инпуты в виджете по выбранному диапазону */
		applyRangeToWidget() {
			if (!this.widget) return;
			const format = d => {
				const y = d.getFullYear();
				const m = String(d.getMonth() + 1).padStart(2, '0');
				const day = String(d.getDate()).padStart(2, '0');
				return `${y}-${m}-${day}`;
			};
			const fromInput = this.widget.querySelector('#date-from');
			const toInput = this.widget.querySelector('#date-to');
			if (this.rangeStart) fromInput.value = format(this.rangeStart);
			if (this.rangeEnd) toInput.value = format(this.rangeEnd);
		}

		/** Добавляет/удаляет метки «start», «in», «end» у уроков */
		highlightRange() {
			// Убираем старые метки
			document.querySelectorAll('.range-badge').forEach(b => b.remove());

			if (!(this.rangeStart && this.rangeEnd)) return;

			// Собираем все уроки, попадающие в диапазон
			const lessons = Array.from(
				document.querySelectorAll('.lessons, .lessons.lateness, .lessons.pass')
			)
				.map(lesson => {
					const dateEl = lesson.querySelector('.date');
					if (!dateEl) return null;
					const [d, m, y] = dateEl.textContent.trim().split('.').map(Number);
					const dt = new Date(y, m - 1, d);
					return { lesson, dt };
				})
				.filter(
					item => item && item.dt >= this.rangeStart && item.dt <= this.rangeEnd
				);

			// Для каждого отмечаем, что это — start, in или end
			lessons.forEach(({ lesson, dt }) => {
				let label;
				if (dt.getTime() === this.rangeStart.getTime()) {
					label = 'start';
				} else if (dt.getTime() === this.rangeEnd.getTime()) {
					label = 'end';
				} else {
					label = 'in';
				}

				// Создаём бейдж
				lesson.style.position = lesson.style.position || 'relative';
				const badge = document.createElement('div');
				badge.className = 'range-badge';
				badge.textContent = label;
				Object.assign(badge.style, {
					position: 'absolute',
					bottom: '2px',
					left: '2px',
					backgroundColor: 'rgba(0, 128, 0, 0.7)',
					color: '#fff',
					padding: '2px 4px',
					fontSize: '10px',
					borderRadius: '3px',
					pointerEvents: 'none',
				});
				lesson.appendChild(badge);
			});
		}

		/** Проверка, что текущая страница - страница прогресса */
		isProgressPage() {
			return CONFIG.PROGRESS_PAGE_REGEX.test(window.location.href);
		}

		/** Инициализация статистики посещаемости */
		initAttendanceStats() {
			this.createAttendanceWidget();
			this.updateAttendanceStats = this.debounce(
				this.updateAttendanceStats.bind(this),
				300
			);
			// this.setupAttendanceObserver();
			this.updateAttendanceStats(); // Первоначальное обновление
		}

		/** Настройка наблюдателя для посещаемости */
		setupAttendanceObserver() {
			const targetNode =
				document.querySelector('#attendance-anchor') || document.body;
			new MutationObserver(() => {
				this.updateAttendanceStats(); // Обновляем статистику при изменениях
			}).observe(targetNode, { childList: true, subtree: true });
		}

		/** Проверка и обновление виджета */
		checkAndUpdateWidget() {
			if (this.isProgressPage()) {
				if (!this.widget) {
					this.createAttendanceWidget();
				}
				this.updateAttendanceStats();
			} else {
				this.removeAttendanceWidget();
			}
		}

		/** Создание виджета посещаемости */
		createAttendanceWidget() {
			if (document.getElementById('attendance-stats')) return;

			this.widget = document.createElement('div');
			this.widget.id = 'attendance-stats';
			Object.assign(this.widget.style, {
				position: 'fixed',
				top: '10%',
				right: '10px',
				backgroundColor: '#fff',
				padding: '10px',
				border: '1px solid #ccc',
				borderRadius: '5px',
				zIndex: '9999',
				boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
				fontSize: '14px',
				width: '220px', // фиксированная ширина в развернутом состоянии
				transition: 'width 0.2s, padding 0.2s',
			});

			this.widget.innerHTML = `
        <div style="display:flex; align-items:center; justify-content:space-between;">
            <strong>📊 Статистика</strong>
            <button id="toggle-stats"
                    title="Свернуть/развернуть"
                    style="background:none; border:none; cursor:pointer; font-size:14px; line-height:1; padding:0;">
                ⯆
            </button>
        </div>
        <div class="stats-body" style="margin-top:8px;">
            <label>С: <input type="date" id="date-from"></label><br>
            <label>По: <input type="date" id="date-to"></label><br>
            <button id="reset-stats" style="margin-top:6px;">Сбросить</button>
            <button id="refresh-stats" title="Обновить"
                    style="margin-top:6px; margin-left:4px; font-size:12px; line-height:1; padding:2px 4px; cursor:pointer;">
                ↻
            </button>
            <div id="stats-content" style="margin-top:8px">
                Всего занятий: 0<br>
                Присутствия: 0<br>
                Опоздания: 0<br>
                Пропуски: 0<br>
                Посещаемость: <b>0%</b>
            </div>
        </div>
    `;
			document.body.appendChild(this.widget);

			// Установка диапазона по умолчанию: с начала месяца до сегодня
			const now = new Date();
			const yyyy = now.getFullYear();
			const mm = String(now.getMonth() + 1).padStart(2, '0');
			const dd = String(now.getDate()).padStart(2, '0');
			const todayStr = `${yyyy}-${mm}-${dd}`;
			const firstOfMonthStr = `${yyyy}-${mm}-01`;

			this.widget.querySelector('#date-from').value = firstOfMonthStr;
			this.widget.querySelector('#date-to').value = todayStr;

			// Кешируем элементы для переключения состояния
			const btn = this.widget.querySelector('#toggle-stats');
			const headerLabel = this.widget.querySelector('strong');
			const body = this.widget.querySelector('.stats-body');

			// Обработчик сворачивания/разворачивания
			btn.addEventListener('click', () => {
				this.isCollapsed = !this.isCollapsed;

				if (this.isCollapsed) {
					headerLabel.style.display = 'none';
					body.style.display = 'none';
					this.widget.style.width = 'auto';
					this.widget.style.padding = '4px';
					btn.textContent = '⯈';
				} else {
					headerLabel.style.display = '';
					body.style.display = '';
					this.widget.style.width = '220px';
					this.widget.style.padding = '10px';
					btn.textContent = '⯆';
				}
			});

			// Обработчики остальной логики
			this.widget
				.querySelector('#date-from')
				.addEventListener('change', () => this.updateAttendanceStats());
			this.widget
				.querySelector('#date-to')
				.addEventListener('change', () => this.updateAttendanceStats());
			this.widget
				.querySelector('#reset-stats')
				.addEventListener('click', () => {
					this.widget.querySelector('#date-from').value = firstOfMonthStr;
					this.widget.querySelector('#date-to').value = todayStr;
					this.rangeStart = null;
					this.rangeEnd = null;
					document.querySelectorAll('.range-badge').forEach(b => b.remove());
					this.updateAttendanceStats();
				});
			this.widget
				.querySelector('#refresh-stats')
				.addEventListener('click', () => {
					this.updateAttendanceStats();
					this.highlightRange();
				});
		}

		/** Удаление виджета посещаемости */
		removeAttendanceWidget() {
			if (this.widget && this.widget.parentNode) {
				this.widget.parentNode.removeChild(this.widget);
				this.widget = null;
			}
		}

		/** Обновление статистики посещаемости */
		updateAttendanceStats() {
			if (!this.isProgressPage()) {
				this.removeAttendanceWidget();
				return;
			}

			if (!this.widget) {
				this.createAttendanceWidget();
			}

			// Получаем значения дат
			const dateFromInput = this.widget.querySelector('#date-from').value;
			const dateToInput = this.widget.querySelector('#date-to').value;
			// Парсим YYYY-MM-DD как местную полуночь
			const parseLocalDate = s => {
				const [y, m, d] = s.split('-').map(Number);
				return new Date(y, m - 1, d);
			};
			const dateFrom = dateFromInput ? parseLocalDate(dateFromInput) : null;
			const dateTo = dateToInput ? parseLocalDate(dateToInput) : null;
			if (dateTo) dateTo.setHours(23, 59, 59, 999);

			// Собираем все занятия
			let lessons = document.querySelectorAll(
				'.lessons, .lessons.lateness, .lessons.pass'
			);

			// Фильтруем по диапазону дат, если он указан
			lessons = Array.from(lessons).filter(lesson => {
				const dateText = lesson.querySelector('.date')?.textContent.trim();
				if (!dateText) return false;
				const [d, m, y] = dateText.split('.').map(Number);
				const ld = new Date(y, m - 1, d);
				return (!dateFrom || ld >= dateFrom) && (!dateTo || ld <= dateTo);
			});

			// Подсчёт статистики
			const total = lessons.length;
			const lateness = lessons.filter(lesson =>
				lesson.classList.contains('lateness')
			).length;
			const present =
				lateness +
				lessons.filter(
					lesson =>
						!lesson.classList.contains('pass') &&
						!lesson.classList.contains('lateness')
				).length;
			const absent = lessons.filter(lesson =>
				lesson.classList.contains('pass')
			).length;

			const attendancePercentage =
				total > 0 ? ((present / total) * 100).toFixed(1) : 0;

			// Обновляем содержимое виджета
			if (this.widget) {
				const statsContent = this.widget.querySelector('#stats-content');
				statsContent.innerHTML = `
                  Всего занятий: ${total}<br>
                  Присутствия: ${present}<br>
                  Опоздания: ${lateness}<br>
                  Пропуски: ${absent}<br>
                  Посещаемость: <b>${attendancePercentage}%</b>
              `;
			}
		}

		/** Дебаунс для оптимизации */
		debounce(func, wait) {
			let timeout;
			return (...args) => {
				clearTimeout(timeout);
				timeout = setTimeout(() => func.apply(this, args), wait);
			};
		}

		/** Установка масштаба страницы */
		setPageZoom() {
			document.documentElement.style.zoom = CONFIG.ZOOM_LEVEL;
		}

		/** Настройка наблюдателя за навигацией */
		setupNavigationObserver() {
			let lastUrl = window.location.href;
			new MutationObserver(() => {
				const currentUrl = window.location.href;
				if (currentUrl !== lastUrl) {
					lastUrl = currentUrl;
					this.checkAndUpdateWidget();
				}
			}).observe(document.body, { childList: true, subtree: true });

			// Отслеживание изменений через popstate
			window.addEventListener('popstate', () => {
				this.checkAndUpdateWidget();
			});
		}

		/** Настройка наблюдателей */
		setupObservers() {
			this.observer = new MutationObserver(() => this.handleModal());
			this.observer.observe(document.body, CONFIG.OBSERVER_CONFIG);
			this.intervalId = setInterval(
				() => this.handleModal(),
				CONFIG.CHECK_INTERVAL
			);
		}

		/** Первоначальная проверка */
		initialCheck() {
			this.handleModal();
		}

		/** Основной обработчик модальных окон */
		handleModal() {
			if (this.shouldSkipProcessing()) return;
			this.isProcessing = true;

			try {
				if (this.processRating()) {
					this.scheduleNextButtonClick();
				} else if (this.isRatingAlreadySet()) {
					this.clickNextButton();
				}
			} finally {
				this.isProcessing = false;
			}
		}

		/** Проверка условий для пропуска обработки */
		shouldSkipProcessing() {
			return this.isProcessing || !this.isModalVisible();
		}

		/** Проверка видимости модального окна */
		isModalVisible() {
			const modal = document.querySelector('modal-container');
			return modal && modal.style.display !== 'none';
		}

		/** Обработка рейтинга */
		processRating() {
			const ratingElement = this.findRatingElement();
			if (!ratingElement) return false;

			this.clickRating(ratingElement);
			return true;
		}

		/** Поиск элемента рейтинга */
		findRatingElement() {
			return document.querySelector(
				`span.bs-rating-star[title="${CONFIG.TARGET_RATING}"]:not(.active)`
			);
		}

		/** Клик по звёздочке рейтинга */
		clickRating(element) {
			const button = element.querySelector('button.rating-star');
			if (button) {
				button.click();
				console.log(`Нажата ${CONFIG.TARGET_RATING}-звёздочная оценка`);
			}
		}

		/** Проверка установленного рейтинга */
		isRatingAlreadySet() {
			return !!document.querySelector(
				`span.bs-rating-star[title="${CONFIG.TARGET_RATING}"].active`
			);
		}

		/** Запланировать клик по кнопке "Далее" */
		scheduleNextButtonClick() {
			setTimeout(() => this.clickNextButton(), CONFIG.CLICK_DELAY);
		}

		/** Попытка нажатия кнопки продолжения */
		clickNextButton() {
			const button = this.findNextButton();
			if (button) {
				button.click();
				console.log(`Нажата кнопка "${button.textContent.trim()}"`);
				return true;
			}
			return false;
		}

		/** Поиск кнопки продолжения */
		findNextButton() {
			return Array.from(
				document.querySelectorAll('button.btn.btn-default')
			).find(btn => CONFIG.NEXT_BUTTON_TEXTS.includes(btn.textContent.trim()));
		}

		/** Очистка ресурсов */
		cleanup() {
			if (this.observer) this.observer.disconnect();
			if (this.intervalId) clearInterval(this.intervalId);
			this.removeAttendanceWidget();
		}
	}

	// Запуск скрипта
	const autoRater = new AutoRater();

	// Очистка при выгрузке страницы
	window.addEventListener('unload', () => {
		autoRater.cleanup();
	});
})();