Scratch Streak

Adds a streak counter to Scratch!

目前為 2025-10-29 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Scratch Streak
// @namespace    http://tampermonkey.net/
// @version      1.3.0
// @description  Adds a streak counter to Scratch!
// @author       alboxer2000
// @match        *://scratch.mit.edu/*
// @grant        none
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    const MAIN_STREAK_KEY = 'scratch_main_streak';
    const MAIN_LAST_VISIT_KEY = 'scratch_main_last_visit';
    const MAIN_HISTORY_KEY = 'scratch_main_history'; 
    const FORUM_STREAK_KEY = 'scratch_forum_streak';
    const FORUM_LAST_VISIT_KEY = 'scratch_forum_last_visit';
    const FORUM_HISTORY_KEY = 'scratch_forum_history'; 

    let mainCalendarDate = new Date();
    let forumCalendarDate = new Date();

    function getTodayString() {
        return new Date().toISOString().split('T')[0]; 
    }

    function getYesterdayString() {
        const yesterday = new Date();
        yesterday.setDate(yesterday.getDate() - 1);
        return yesterday.toISOString().split('T')[0];
    }

    function updateStreak(streakKey, lastVisitKey, historyKey) {
        const today = getTodayString();
        const yesterday = getYesterdayString();

        let streak = parseInt(localStorage.getItem(streakKey) || '0', 10);
        let lastVisit = localStorage.getItem(lastVisitKey);
        let history = JSON.parse(localStorage.getItem(historyKey) || '[]');

        if (lastVisit === today) {
            return { streak, history };
        }

        if (lastVisit === yesterday) {
            streak += 1; 
        } else {
            streak = 1; 
        }

        if (!history.includes(today)) {
            history.push(today);
        }

        localStorage.setItem(streakKey, streak.toString());
        localStorage.setItem(lastVisitKey, today);
        localStorage.setItem(historyKey, JSON.stringify(history));

        return { streak, history };
    }

    function getStreakDays(historyDates, year, month) {
        const monthHistory = historyDates
            .filter(d => d.startsWith(`${year}-${String(month + 1).padStart(2, '0')}`))
            .map(d => parseInt(d.split('-')[2], 10))
            .sort((a, b) => a - b); 

        const today = getTodayString();
        const todayMonthString = `${year}-${String(month + 1).padStart(2, '0')}`;
        const isCurrentMonth = today.startsWith(todayMonthString);
        
        let streakMap = {};
        let currentStreak = 0;
        let lastDay = -1;
        let activeStreakFound = false;

        
        for (let i = monthHistory.length - 1; i >= 0; i--) {
            const day = monthHistory[i];
            
            
            if (!activeStreakFound) {
                if ((isCurrentMonth && day === new Date().getDate()) || 
                    (isCurrentMonth && day === new Date().getDate() - 1)) {
                    activeStreakFound = true;
                    currentStreak = 1; 
                    lastDay = day;
                    streakMap[day] = 'streak-end';
                    continue;
                }
            }
            
            if (activeStreakFound) {
                if (day === lastDay - 1) {
                    currentStreak++;
                    streakMap[day] = (i === 0 || monthHistory[i-1] !== day - 1) ? 'streak-start' : 'streak-mid';
                    lastDay = day;
                } else {
                    activeStreakFound = false;
                }
            }
        }
        
        
        if (isCurrentMonth && monthHistory.includes(new Date().getDate())) {
            const currentDay = new Date().getDate();
            if (streakMap[currentDay] === 'streak-end' && monthHistory.length > 1 && !monthHistory.includes(currentDay - 1)) {
                streakMap[currentDay] = 'streak-solo'; 
            }
        }

        
        
        
        let finalStreakMap = {};
        for (const day of monthHistory) {
            finalStreakMap[day] = 'visited'; 

            
            const isStreakDay = monthHistory.includes(day + 1) || (isCurrentMonth && day === new Date().getDate());
            
            
            if (isStreakDay && monthHistory.includes(day + 1)) {
                 
                 finalStreakMap[day] = 'streak-start'; 
            } else if (monthHistory.includes(day) && monthHistory.includes(day - 1)) {
                 
                 finalStreakMap[day] = 'streak-end';
                 if (isStreakDay) {
                     finalStreakMap[day] = 'streak-mid';
                 }
            } else if (monthHistory.includes(day)) {
                
                finalStreakMap[day] = 'streak-solo'; 
            }
        }

        
        
        
        let dayStatus = {};
        for (const day of monthHistory) {
            
            const isFollowed = monthHistory.includes(day + 1); 
            
            const isPreceded = monthHistory.includes(day - 1);
            
            if (isFollowed && isPreceded) {
                dayStatus[day] = 'streak-mid';
            } else if (isFollowed) {
                dayStatus[day] = 'streak-start';
            } else if (isPreceded) {
                dayStatus[day] = 'streak-end';
            } else {
                dayStatus[day] = 'streak-solo';
            }
        }

        return dayStatus;
    }

    function generateCalendarHTML(historyDates, dateToShow, calendarType) {
        const year = dateToShow.getFullYear();
        const month = dateToShow.getMonth();
        const monthName = dateToShow.toLocaleString('default', { month: 'long' });

        const firstDayOfMonth = new Date(year, month, 1).getDay();
        const daysInMonth = new Date(year, month + 1, 0).getDate();

        const dayStatus = getStreakDays(historyDates, year, month);

        const today = getTodayString();
        const todayDay = (today.startsWith(`${year}-${String(month + 1).padStart(2, '0')}`)) ? new Date().getDate() : -1;
        
        const calendarTitle = calendarType === 'main' ? 'Scratch Visits' : 'Forum Visits';

        let calendarHTML = `
            <div class="calendar-header">
                <button class="nav-button prev-month" data-type="${calendarType}">&lt;</button>
                <span>${calendarTitle} - ${monthName} ${year}</span>
                <button class="nav-button next-month" data-type="${calendarType}">&gt;</button>
            </div>
            <div class="calendar-grid">
                <div>Su</div><div>Mo</div><div>Tu</div><div>We</div><div>Th</div><div>Fr</div><div>Sa</div>
        `;

        for (let i = 0; i < firstDayOfMonth; i++) {
            calendarHTML += '<div></div>';
        }

        for (let day = 1; day <= daysInMonth; day++) {
            const status = dayStatus[day] || '';
            const isToday = day === todayDay;
            let dayClass = status;

            if (isToday) {
                dayClass += ' today';
            }

            calendarHTML += `<div class="${dayClass}">${day}</div>`;
        }

        calendarHTML += '</div>';
        return calendarHTML;
    }

    function handleMonthChange(calendarType, direction) {
        let dateState;
        let history;
        let calendarElement;

        if (calendarType === 'main') {
            mainCalendarDate.setMonth(mainCalendarDate.getMonth() + direction);
            dateState = mainCalendarDate;
            history = scratchHistory;
            calendarElement = mainCalendar;
        } else {
            forumCalendarDate.setMonth(forumCalendarDate.getMonth() + direction);
            dateState = forumCalendarDate;
            history = forumHistory;
            calendarElement = forumCalendar;
        }

        renderCalendar(calendarElement, history, dateState, calendarType);
    }
    
    function renderCalendar(calendarElement, history, dateState, calendarType) {
        calendarElement.innerHTML = generateCalendarHTML(history, dateState, calendarType);
        calendarElement.style.display = 'block';

        calendarElement.querySelector('.prev-month').addEventListener('click', () => {
            handleMonthChange(calendarType, -1);
        });
        calendarElement.querySelector('.next-month').addEventListener('click', () => {
            handleMonthChange(calendarType, 1);
        });
    }

    const currentPath = window.location.pathname;

    const scratchResult = updateStreak(MAIN_STREAK_KEY, MAIN_LAST_VISIT_KEY, MAIN_HISTORY_KEY);
    const scratchStreak = scratchResult.streak;
    const scratchHistory = scratchResult.history;

    let forumStreak = parseInt(localStorage.getItem(FORUM_STREAK_KEY) || '0', 10);
    const isOnForum = currentPath.startsWith('/discuss');
    let forumHistory = JSON.parse(localStorage.getItem(FORUM_HISTORY_KEY) || '[]');

    if (isOnForum) {
        const forumResult = updateStreak(FORUM_STREAK_KEY, FORUM_LAST_VISIT_KEY, FORUM_HISTORY_KEY);
        forumStreak = forumResult.streak;
        forumHistory = forumResult.history;
    }

    const containerWrapper = document.createElement('div');
    containerWrapper.id = 'scratch-streak-wrapper';
    containerWrapper.style.cssText = `
        position: fixed;
        top: 15px;
        right: 15px;
        z-index: 10000;
        font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
    `;
    document.body.appendChild(containerWrapper);

    const streakContainer = document.createElement('div');
    streakContainer.id = 'scratch-streak-display';
    streakContainer.style.cssText = `
        display: flex;
        align-items: center;
        gap: 20px;
        padding: 10px 20px;
        background: #6A0DAD;
        color: white;
        border-radius: 8px;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
        font-size: 14px;
        font-weight: 600;
    `;
    containerWrapper.appendChild(streakContainer);

    const createStreakButton = (id, title, streak, emoji) => {
        const button = document.createElement('button');
        button.id = id;
        button.title = title;
        button.innerHTML = `${title}: <span style="color: #FFEA00; margin-left: 5px;">${streak}</span> ${emoji}`;
        button.style.cssText = `
            background: none;
            border: none;
            color: white;
            padding: 0;
            cursor: pointer;
            font-size: 14px;
            font-weight: 600;
            transition: opacity 0.2s;
        `;
        button.onmouseover = () => button.style.opacity = '0.8';
        button.onmouseout = () => button.style.opacity = '1';
        return button;
    };

    const mainButton = createStreakButton(
        'main-streak-button',
        'Scratch Streak',
        scratchStreak,
        ''
    );

    const forumButton = createStreakButton(
        'forum-streak-button',
        'Forum Streak',
        forumStreak,
        ''
    );

    streakContainer.appendChild(mainButton);
    streakContainer.appendChild(forumButton);

    const calendarStyles = `
        position: absolute;
        top: 50px;
        right: 0;
        width: 300px;
        background: white;
        color: #333;
        border-radius: 8px;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
        padding: 15px;
        margin-top: 10px;
        display: none;
        text-align: center;
        z-index: 9999;
    `;

    const mainCalendar = document.createElement('div');
    mainCalendar.id = 'main-calendar';
    mainCalendar.style.cssText = calendarStyles;
    mainCalendar.style.left = 'unset'; 
    containerWrapper.appendChild(mainCalendar);

    const forumCalendar = document.createElement('div');
    forumCalendar.id = 'forum-calendar';
    forumCalendar.style.cssText = calendarStyles;
    forumCalendar.style.left = 'unset'; 
    containerWrapper.appendChild(forumCalendar);

    const style = document.createElement('style');
    style.textContent = `
        #scratch-streak-wrapper .calendar-header {
            font-weight: 700;
            font-size: 16px;
            margin-bottom: 10px;
            color: #6A0DAD;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        #scratch-streak-wrapper .calendar-header span {
            flex-grow: 1;
            text-align: center;
        }
        #scratch-streak-wrapper .nav-button {
            background: none;
            border: none;
            color: #6A0DAD;
            font-size: 18px;
            font-weight: 700;
            cursor: pointer;
            padding: 5px;
            border-radius: 4px;
            transition: background-color 0.2s;
        }
        #scratch-streak-wrapper .nav-button:hover {
            background-color: #f0f0f0;
        }
        #scratch-streak-wrapper .calendar-grid {
            display: grid;
            grid-template-columns: repeat(7, 1fr);
            gap: 7px; 
            font-size: 12px;
        }
        #scratch-streak-wrapper .calendar-grid > div {
            padding: 5px 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 25px;
            width: 35px;
            position: relative;
            z-index: 1; 
            font-weight: 600;
            color: #333;
        }
        #scratch-streak-wrapper .calendar-grid > div:nth-child(-n+7) {
            font-weight: 600;
            color: #666;
            border-radius: 0;
            width: auto;
        }
        
        /* Streak color for days with solid background (start, mid, end) */
        #scratch-streak-wrapper .calendar-grid .streak-start,
        #scratch-streak-wrapper .calendar-grid .streak-mid,
        #scratch-streak-wrapper .calendar-grid .streak-end {
            color: white; 
            z-index: 2;
        }
        
        /* Solo streak: transparent background, purple text and border (FIXED width to make it a circle) */
        #scratch-streak-wrapper .calendar-grid .streak-solo {
            background: none; 
            border: 2px solid #6A0DAD; 
            border-radius: 50%;
            color: #6A0DAD; 
            z-index: 2;
            width: 25px; 
            margin: 0 auto; 
        }

        
        #scratch-streak-wrapper .calendar-grid .streak-start {
            background: #6A0DAD; 
            border-radius: 50% 0 0 50%; 
            margin-right: -4px; 
            width: 100%; 
            border: 2px solid #6A0DAD; 
        }

        
        #scratch-streak-wrapper .calendar-grid .streak-mid {
            background: #6A0DAD;
            border-radius: 0; 
            margin-left: -4px; 
            margin-right: -4px;
            width: calc(100% + 8px); 
            z-index: 1; 
        }

        
        #scratch-streak-wrapper .calendar-grid .streak-end {
            background: #6A0DAD;
            border-radius: 0 50% 50% 0; 
            margin-left: -4px; 
            width: 100%;
            border: 2px solid #6A0DAD; 
        }

        
        #scratch-streak-wrapper .calendar-grid .today {
            
            color: #333; 
        }
        #scratch-streak-wrapper .calendar-grid .today.streak-start,
        #scratch-streak-wrapper .calendar-grid .today.streak-mid,
        #scratch-streak-wrapper .calendar-grid .today.streak-end {
            border: 3px solid #6A0DAD; 
            z-index: 3; 
            color: white;
        }
        /* Today highlight for solo streak: purple border, purple text */
        #scratch-streak-wrapper .calendar-grid .today.streak-solo {
            border: 3px solid #6A0DAD; 
            z-index: 3; 
            color: #6A0DAD; 
        }

        
        #scratch-streak-wrapper .calendar-grid .today:not([class*="streak"]) {
            border: 2px solid #333;
        }
    `;
    document.head.appendChild(style);

    mainButton.addEventListener('click', () => {
        if (mainCalendar.style.display === 'none' || mainCalendar.style.display === '') {
            forumCalendar.style.display = 'none';
            renderCalendar(mainCalendar, scratchHistory, mainCalendarDate, 'main');
        } else {
            mainCalendar.style.display = 'none';
        }
    });

    forumButton.addEventListener('click', () => {
        if (forumCalendar.style.display === 'none' || forumCalendar.style.display === '') {
            mainCalendar.style.display = 'none';
            renderCalendar(forumCalendar, forumHistory, forumCalendarDate, 'forum');
        } else {
            forumCalendar.style.display = 'none';
        }
    });

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址