Tutorial Online Progress Widget

Automatically check course names, provides a structured progress for discussions and assignments, also saves progress locally.

2025/11/28のページです。最新版はこちら

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Tutorial Online Progress Widget
// @namespace    http://tampermonkey.net/
// @version      1
// @description  Automatically check course names, provides a structured progress for discussions and assignments, also saves progress locally.
// @author       deoffuscated
// @match        https://elearning.ut.ac.id/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // Config
    const APP_NAME = "Tutorial Online Progress Widget";
    const WIDGET_ICON = 'https://suopmkm.ut.ac.id/uo/statics/logo.png';
    const STORAGE_DATA_KEY = 'tuton_progress_checklist';
    const STORAGE_COURSES_KEY = 'tuton_course_cache_list';
    const STATE_KEY = 'tuton_widget_minimized_state';
    const defaultCourses = [
        "MATA KULIAH 1", "MATA KULIAH 2", "MATA KULIAH 3",
        "MATA KULIAH 4", "MATA KULIAH 5", "MATA KULIAH 6",
        "MATA KULIAH 7", "MATA KULIAH 8"// Untuk Fallback
    ];

    const colLabels = [
        "DISKUSI 1", "DISKUSI 2", "DISKUSI 3", "DISKUSI 4",
        "DISKUSI 5", "DISKUSI 6", "DISKUSI 7", "DISKUSI 8",
        "TUGAS 1",   "TUGAS 2",   "TUGAS 3"
    ];

    // Animasi Confetti
    const script = document.createElement('script');
    script.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/confetti.browser.min.js';
    document.head.appendChild(script);

    function loadCachedCourses() {
        const stored = localStorage.getItem(STORAGE_COURSES_KEY);
        return stored ? JSON.parse(stored) : defaultCourses;
    }

    function saveCachedCourses(courses) {
        localStorage.setItem(STORAGE_COURSES_KEY, JSON.stringify(courses));
    }

    function loadProgressData() { return JSON.parse(localStorage.getItem(STORAGE_DATA_KEY) || '{}'); }
    function saveProgressData(data) { localStorage.setItem(STORAGE_DATA_KEY, JSON.stringify(data)); }
    function loadWidgetState() { return localStorage.getItem(STATE_KEY) === 'true'; }
    function saveWidgetState(isMin) { localStorage.setItem(STATE_KEY, isMin); }

    // Check Daftar Mata Kuliah
    let courseList = loadCachedCourses();

    function scanForCourses() {
        const courseElements = document.querySelectorAll('.coursename .multiline');

        if (courseElements.length > 0) {
            const scannedNames = Array.from(courseElements).map(el => {
                let name = el.textContent.trim();
                return name.replace(/\s+\d+$/, '');
            });

            if (JSON.stringify(scannedNames) !== JSON.stringify(courseList)) {
                console.log(`[${APP_NAME}] Daftar mata kuliah diperbarui.`);
                courseList = scannedNames;
                saveCachedCourses(courseList);

                const existingWrapper = document.getElementById('ut-helper-wrapper');
                if (existingWrapper) existingWrapper.remove();
                initUI();
            }
        }
    }

    const observer = new MutationObserver((mutations) => {
        if (document.querySelector('.dashboard-card-deck')) {
            scanForCourses();
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // CSS
    const style = document.createElement('style');
    style.innerHTML = `
        :root {
            --glass-bg: rgba(255, 255, 255, 0.85);
            --glass-border: rgba(255, 255, 255, 0.4);
            --glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.25);

            --primary-color: #1859BC;
            --accent-tugas: #e67e22;
            --success-color: #2ecc71;

            --text-dark: #1a202c;
            --text-light: #4a5568;
            --danger-color: #c0392b;
            --row-hover: rgba(24, 89, 188, 0.1);
            --border-color: rgba(0, 0, 0, 0.1);
            --chk-border: #718096;
            --chk-bg: rgba(255, 255, 255, 0.6);
        }

        #ut-helper-wrapper {
            position: fixed; z-index: 10000;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
            transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
        }

        #ut-helper-wrapper.minimized { bottom: 30px; right: 30px; top: auto; left: auto; transform: none; }
        #ut-helper-wrapper.expanded { bottom: 30px; right: 30px; top: auto; left: auto; transform: none; }

        /* TOMBOL WIDGET BULAT */
        #ut-widget-trigger {
            width: 55px; height: 55px; border-radius: 50%;
            background: rgba(255, 255, 255, 0.9);
            backdrop-filter: blur(4px);
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
            display: flex; align-items: center; justify-content: center;
            cursor: pointer; border: 2px solid var(--primary-color);
            animation: popIn 0.4s; padding: 8px; box-sizing: border-box;
        }
        #ut-widget-trigger:hover { transform: scale(1.05); box-shadow: 0 8px 25px rgba(24, 89, 188, 0.4); }
        #ut-widget-trigger img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
        @keyframes popIn { from { transform: scale(0); } to { transform: scale(1); } }

        /* PANEL UTAMA */
        #ut-main-panel {
            background: var(--glass-bg);
            backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
            border: 1px solid var(--glass-border);
            box-shadow: var(--glass-shadow);
            border-radius: 12px;
            padding: 15px;
            display: none; flex-direction: column;
            /* Lebar dinamis agar fit dengan tab */
            min-width: 400px;
            max-width: 95vw;
            animation: slideUp 0.3s ease-out;
            transform-origin: bottom right;
        }
        @keyframes slideUp {
            from { opacity: 0; transform: translateY(20px) scale(0.95); }
            to { opacity: 1; transform: translateY(0) scale(1); }
        }

        /* HEADER */
        .ut-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
        .ut-title { font-weight: 700; color: var(--text-dark); font-size: 15px; display: flex; align-items: center; }
        .ut-title img { margin-right: 10px; height: 24px; width: auto; object-fit: contain; pointer-events: none; }
        .ut-close-icon {
            cursor: pointer; color: var(--text-dark);
            width: 28px; height: 28px; border-radius: 50%;
            display: flex; align-items: center; justify-content: center;
            transition: all 0.2s;
        }
        .ut-close-icon:hover { background: rgba(0,0,0,0.1); color: red; }

        /* TABS (NAVIGATION) */
        .ut-nav-tabs {
            display: flex;
            background: rgba(0,0,0,0.05);
            padding: 4px;
            border-radius: 8px;
            margin-bottom: 10px;
            gap: 5px;
        }
        .ut-tab-item {
            flex: 1;
            text-align: center;
            padding: 6px 10px;
            font-size: 12px;
            font-weight: 600;
            cursor: pointer;
            border-radius: 6px;
            color: var(--text-light);
            transition: all 0.2s;
            user-select: none;
        }
        .ut-tab-item:hover { background: rgba(255,255,255,0.5); }
        .ut-tab-item.active {
            background: #fff;
            color: var(--primary-color);
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }

        /* TABLE */
        .ut-table { width: 100%; border-collapse: collapse; border-spacing: 0; margin-bottom: 5px; }
        .ut-table tr { border-bottom: 1px solid var(--border-color); transition: background 0.15s ease; }
        .ut-table tr:hover:not(:first-child) { background-color: var(--row-hover); }
        .ut-table td { padding: 6px 4px; text-align: center; vertical-align: middle; }

        /* LOGIC UNTUK MENYEMBUNYIKAN KOLOM (ACCORDION) */
        .ut-table.mode-diskusi .type-tugas { display: none; }
        .ut-table.mode-tugas .type-diskusi { display: none; }

        .col-head {
            font-size: 11px; font-weight: 800; color: var(--primary-color);
            text-transform: uppercase; padding-bottom: 8px !important;
            border-bottom: 2px solid var(--primary-color) !important;
        }
        .col-head-tugas {
            font-size: 11px; font-weight: 800; color: var(--accent-tugas);
            text-transform: uppercase; padding-bottom: 8px !important;
            border-bottom: 2px solid var(--accent-tugas) !important;
        }
        .bg-tugas { background-color: rgba(230, 126, 34, 0.05); }

        .row-label {
            font-size: 11px; font-weight: 700; color: var(--text-dark);
            text-align: left !important;
            padding-left: 5px !important;
            min-width: 150px; max-width: 200px;
            text-transform: uppercase; line-height: 1.2;
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }

        .btn-reset {
            cursor: pointer; width: 50px;
            text-align: center !important; vertical-align: middle !important;
            padding: 0 !important;
            border-bottom: 2px solid var(--primary-color) !important;
            font-size: 11px; font-weight: 900; color: var(--danger-color);
        }
        .btn-reset:hover { background-color: rgba(192, 57, 43, 0.1); }

        /* CHECKBOX */
        .ut-chk-wrap {
            display: inline-block; position: relative; cursor: pointer;
            width: 16px; height: 16px; user-select: none; top: 2px;
            transition: opacity 0.3s;
        }
        .ut-chk-wrap.disabled { opacity: 0.3; pointer-events: none; filter: grayscale(1); }

        .ut-chk-wrap input { opacity: 0; cursor: pointer; height: 0; width: 0; }
        .checkmark {
            position: absolute; top: 0; left: 0; height: 16px; width: 16px;
            background-color: var(--chk-bg); border-radius: 4px;
            border: 2px solid var(--chk-border); transition: all 0.15s ease-in-out;
            box-shadow: 0 1px 2px rgba(0,0,0,0.1);
        }
        .ut-chk-wrap:hover .checkmark { border-color: var(--primary-color); background-color: #fff; }

        .ut-chk-wrap input:checked ~ .checkmark {
            background-color: var(--primary-color); border-color: var(--primary-color);
            box-shadow: 0 2px 4px rgba(24, 89, 188, 0.4);
        }
        .chk-tugas input:checked ~ .checkmark {
            background-color: var(--accent-tugas); border-color: var(--accent-tugas);
            box-shadow: 0 2px 4px rgba(230, 126, 34, 0.4);
        }
        .checkmark:after {
            content: ""; position: absolute; display: none; left: 4px; top: 1px; width: 3px; height: 8px;
            border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg);
        }
        .ut-chk-wrap input:checked ~ .checkmark:after { display: block; }

        /* PROGRESS BAR */
        .prog-cont { margin-top: 10px; }
        .prog-bg { background: rgba(0,0,0,0.1); border-radius: 20px; height: 6px; width: 100%; overflow: hidden; }

        .prog-fill {
            height: 100%;
            background: var(--success-color);
            width: 0%;
            transition: width 0.5s;
            border-radius: 20px;
        }

        .prog-text { font-size: 10px; text-align: right; margin-top: 4px; color: var(--text-dark); font-weight: 600; }

        /* MODAL KONFIRMASI */
        #ut-modal-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
            z-index: 10001; display: none; justify-content: center; align-items: center;
        }
        #ut-modal-box {
            background: rgba(255,255,255,0.95); border: 1px solid rgba(255,255,255,0.5);
            box-shadow: 0 20px 50px rgba(0,0,0,0.3); border-radius: 12px; padding: 25px; width: 300px;
            text-align: center; transform: scale(0.95); animation: modalPop 0.2s forwards;
        }
        @keyframes modalPop { to { transform: scale(1); } }

        .ut-modal-title { font-size: 16px; font-weight: 700; margin-bottom: 5px; color: var(--text-dark); }
        .ut-modal-desc { font-size: 13px; color: var(--text-light); margin-bottom: 20px; }
        .ut-modal-btns { display: flex; gap: 10px; }
        .ut-btn { flex: 1; border: none; padding: 10px; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 12px; }
        .btn-cancel { background: #edf2f7; color: var(--text-dark); }
        .btn-cancel:hover { background: #e2e8f0; }
        .btn-ok { background: var(--danger-color); color: white; }
        .btn-ok:hover { background: #a93226; }
    `;
    document.head.appendChild(style);

    // Load UI
    function initUI() {
        const wrapper = document.createElement('div');
        wrapper.id = 'ut-helper-wrapper';

        const widgetTrigger = document.createElement('div');
        widgetTrigger.id = 'ut-widget-trigger';
        widgetTrigger.innerHTML = `<img src="${WIDGET_ICON}" alt="UT Helper">`;
        widgetTrigger.title = 'Open Progress Widget';

        let tableHTML = `<table class="ut-table mode-all" id="ut-checklist-table">`;

        tableHTML += `<tr><td class="btn-reset" id="ut-btn-reset" title="Reset Semua">RESET</td>`;
        colLabels.forEach((l, index) => {
            const isTugas = index >= 8;
            const headerClass = isTugas ? 'col-head-tugas' : 'col-head';
            const typeClass = isTugas ? 'type-tugas' : 'type-diskusi';
            tableHTML += `<td class="${headerClass} ${typeClass}">${l}</td>`;
        });
        tableHTML += `</tr>`;
        courseList.forEach(row => {
            const cleanRow = row.replace(/[^a-zA-Z0-9]/g, '');

            tableHTML += `<tr><td class="row-label" title="${row}">${row}</td>`;

            for(let i=1; i<=11; i++) {
                const isTugas = i >= 9;
                const typeClass = isTugas ? 'type-tugas' : 'type-diskusi';
                const bgClass = isTugas ? 'bg-tugas' : '';
                const chkClass = isTugas ? 'ut-chk-wrap chk-tugas' : 'ut-chk-wrap';

                tableHTML += `<td class="${bgClass} ${typeClass}"><label class="${chkClass}"><input type="checkbox" data-id="${cleanRow}_${i}"><span class="checkmark"></span></label></td>`;
            }
            tableHTML += `</tr>`;
        });

        tableHTML += `</table>`;

        const mainPanel = document.createElement('div');
        mainPanel.id = 'ut-main-panel';
        mainPanel.innerHTML = `
            <div class="ut-header">
                <span class="ut-title"><img src="${WIDGET_ICON}" alt="icon"> Tutorial Online Progress Widget</span>
                <div class="ut-close-icon" id="ut-btn-minimize" title="Tutup">
                     <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
                </div>
            </div>

            <!-- TAB NAVIGASI -->
            <div class="ut-nav-tabs">
                <div class="ut-tab-item active" data-mode="mode-all">Semua</div>
                <div class="ut-tab-item" data-mode="mode-diskusi">Diskusi</div>
                <div class="ut-tab-item" data-mode="mode-tugas">Tugas</div>
            </div>

            ${tableHTML}
            <div class="prog-cont">
                <div class="prog-bg"><div class="prog-fill" id="ut-prog-bar"></div></div>
                <div class="prog-text" id="ut-prog-lbl">0% Selesai</div>
            </div>
        `;

        wrapper.appendChild(widgetTrigger);
        wrapper.appendChild(mainPanel);
        document.body.appendChild(wrapper);

        const modalOverlay = document.createElement('div');
        modalOverlay.id = 'ut-modal-overlay';
        modalOverlay.innerHTML = `
            <div id="ut-modal-box">
                <div class="ut-modal-title">Reset Checklist?</div>
                <div class="ut-modal-desc">Semua tanda progress akan dihapus.</div>
                <div class="ut-modal-btns">
                    <button class="ut-btn btn-cancel" id="ut-modal-cancel">Batal</button>
                    <button class="ut-btn btn-ok" id="ut-modal-ok">Hapus</button>
                </div>
            </div>
        `;
        document.body.appendChild(modalOverlay);

        // Logic Widget
        const checkboxes = mainPanel.querySelectorAll('input[type="checkbox"]');
        let progressData = loadProgressData();
        let isMinimized = loadWidgetState();
        const tabItems = mainPanel.querySelectorAll('.ut-tab-item');
        const dataTable = document.getElementById('ut-checklist-table');

        tabItems.forEach(tab => {
            tab.addEventListener('click', () => {
                tabItems.forEach(t => t.classList.remove('active'));
                tab.classList.add('active');
                const mode = tab.getAttribute('data-mode');
                dataTable.className = `ut-table ${mode}`;
            });
        });

        function triggerCelebration() {
            if (typeof confetti === 'function') {
                confetti({
                    particleCount: 100,
                    spread: 70,
                    origin: { y: 0.6 },
                    zIndex: 10002
                });
            }
        }

        function calculateProgress(isUserAction = false) {
            const total = checkboxes.length;
            if (total === 0) return;
            const checked = Array.from(checkboxes).filter(c => c.checked).length;
            const pct = Math.round((checked / total) * 100);

            document.getElementById('ut-prog-bar').style.width = `${pct}%`;
            document.getElementById('ut-prog-lbl').innerText = `${pct}% Selesai`;

            if (pct === 100 && isUserAction) {
                triggerCelebration();
            }
        }

        function applyRules(isUserAction = false) {
            courseList.forEach(row => {
                const cleanRow = row.replace(/[^a-zA-Z0-9]/g, '');

                // Untuk Diskusi
                for (let i = 1; i < 8; i++) {
                    const currentId = `${cleanRow}_${i}`;
                    const nextId = `${cleanRow}_${i+1}`;
                    toggleLinkedCheck(currentId, nextId);
                }

                // Untuk Tugas
                toggleLinkedCheck(`${cleanRow}_3`, `${cleanRow}_9`);  // Diskusi 3 -> Tugas 1
                toggleLinkedCheck(`${cleanRow}_5`, `${cleanRow}_10`); // Diskusi 5 -> Tugas 2
                toggleLinkedCheck(`${cleanRow}_7`, `${cleanRow}_11`); // Diskusi 7 -> Tugas 3
            });

            saveProgressData(progressData);
            calculateProgress(isUserAction);
        }

        function toggleLinkedCheck(sourceId, targetId) {
            const sourceChk = mainPanel.querySelector(`input[data-id="${sourceId}"]`);
            const targetChk = mainPanel.querySelector(`input[data-id="${targetId}"]`);

            if(sourceChk && targetChk) {
                const targetContainer = targetChk.closest('.ut-chk-wrap');
                if(sourceChk.checked) {
                    targetChk.disabled = false;
                    targetContainer.classList.remove('disabled');
                } else {
                    targetChk.disabled = true;
                    targetContainer.classList.add('disabled');
                    if(targetChk.checked) {
                        targetChk.checked = false;
                        progressData[targetId] = false;
                    }
                }
            }
        }

        checkboxes.forEach(chk => {
            const id = chk.getAttribute('data-id');
            if (progressData[id]) chk.checked = true;

            chk.addEventListener('change', (e) => {
                progressData[id] = e.target.checked;
                saveProgressData(progressData);
                applyRules(true);
            });
        });

        // Reset
        const btnReset = document.getElementById('ut-btn-reset');
        const btnCancel = document.getElementById('ut-modal-cancel');
        const btnOk = document.getElementById('ut-modal-ok');
        const closeModal = () => { modalOverlay.style.display = 'none'; };

        btnReset.addEventListener('click', () => { modalOverlay.style.display = 'flex'; });
        btnCancel.addEventListener('click', closeModal);
        modalOverlay.addEventListener('click', (e) => { if(e.target === modalOverlay) closeModal(); });

        btnOk.addEventListener('click', () => {
            progressData = {};
            saveProgressData(progressData);
            checkboxes.forEach(chk => chk.checked = false);
            applyRules(false);
            closeModal();
        });

        // Minimize/Maximize
        function updateWidgetState() {
            if (isMinimized) {
                wrapper.className = 'minimized'; mainPanel.style.display = 'none'; widgetTrigger.style.display = 'flex';
            } else {
                wrapper.className = 'expanded'; mainPanel.style.display = 'flex'; widgetTrigger.style.display = 'none';
            }
            saveWidgetState(isMinimized);
        }

        widgetTrigger.addEventListener('click', () => { isMinimized = false; updateWidgetState(); });
        document.getElementById('ut-btn-minimize').addEventListener('click', () => { isMinimized = true; updateWidgetState(); });

        updateWidgetState();
        applyRules(false);
    }

    scanForCourses();
    initUI();

})();