Adds a streak counter to Scratch!
// ==UserScript==
// @name Scratch Streak
// @namespace http://tampermonkey.net/
// @version 1.3.3
// @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';
const SECRET_PHRASE = 'modifystreakdata';
let mainCalendarDate = new Date();
let forumCalendarDate = new Date();
let keyBuffer = '';
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); // Not used
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}"><</button>
<span>${calendarTitle} - ${monthName} ${year}</span>
<button class="nav-button next-month" data-type="${calendarType}">></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 = JSON.parse(localStorage.getItem(MAIN_HISTORY_KEY) || '[]');
calendarElement = mainCalendar;
} else {
forumCalendarDate.setMonth(forumCalendarDate.getMonth() + direction);
dateState = forumCalendarDate;
history = JSON.parse(localStorage.getItem(FORUM_HISTORY_KEY) || '[]');
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);
});
}
function updateStreakDisplay(mainStreak, forumStreak) {
const mainButton = document.getElementById('main-streak-button');
const forumButton = document.getElementById('forum-streak-button');
if (mainButton) {
mainButton.innerHTML = `Scratch Streak: <span style="color: #FFEA00; margin-left: 5px;">${mainStreak}</span> `;
}
if (forumButton) {
forumButton.innerHTML = `Forum Streak: <span style="color: #FFEA00; margin-left: 5px;">${forumStreak}</span> `;
}
}
function handleManualUpdate(mainInput, forumInput, mainHistoryInput, forumHistoryInput, modal) {
const newMainStreak = parseInt(mainInput.value, 10);
const newForumStreak = parseInt(forumInput.value, 10);
const errorMessageElement = modal.querySelector('#error-message');
errorMessageElement.textContent = '';
let newMainHistory;
let newForumHistory;
let valid = true;
try {
newMainHistory = JSON.parse(mainHistoryInput.value);
if (!Array.isArray(newMainHistory) || newMainHistory.some(d => typeof d !== 'string' || !d.match(/^\d{4}-\d{2}-\d{2}$/))) {
throw new Error('Invalid main history format. Must be an array of YYYY-MM-DD strings.');
}
} catch (e) {
errorMessageElement.textContent = 'Error in Scratch Date History: Must be a valid JSON array of YYYY-MM-DD date strings.';
valid = false;
}
if (valid) {
try {
newForumHistory = JSON.parse(forumHistoryInput.value);
if (!Array.isArray(newForumHistory) || newForumHistory.some(d => typeof d !== 'string' || !d.match(/^\d{4}-\d{2}-\d{2}$/))) {
throw new Error('Invalid forum history format. Must be an array of YYYY-MM-DD strings.');
}
} catch (e) {
errorMessageElement.textContent = 'Error in Forum Date History: Must be a valid JSON array of YYYY-MM-DD date strings.';
valid = false;
}
}
if (valid && !isNaN(newMainStreak) && newMainStreak >= 0 && !isNaN(newForumStreak) && newForumStreak >= 0) {
localStorage.setItem(MAIN_STREAK_KEY, newMainStreak.toString());
localStorage.setItem(FORUM_STREAK_KEY, newForumStreak.toString());
localStorage.setItem(MAIN_HISTORY_KEY, JSON.stringify(newMainHistory));
localStorage.setItem(FORUM_HISTORY_KEY, JSON.stringify(newForumHistory));
const today = getTodayString();
localStorage.setItem(MAIN_LAST_VISIT_KEY, today);
localStorage.setItem(FORUM_LAST_VISIT_KEY, today);
updateStreakDisplay(newMainStreak, newForumStreak);
modal.remove();
} else if (valid) {
errorMessageElement.textContent = 'Error: Please enter valid, non-negative numbers for both streak counts.';
}
}
function showModifyStreakModal() {
const modalId = 'streak-modifier-modal';
if (document.getElementById(modalId)) return;
const currentMainStreak = parseInt(localStorage.getItem(MAIN_STREAK_KEY) || '0', 10);
const currentForumStreak = parseInt(localStorage.getItem(FORUM_STREAK_KEY) || '0', 10);
const currentMainHistory = localStorage.getItem(MAIN_HISTORY_KEY) || '[]';
const currentForumHistory = localStorage.getItem(FORUM_HISTORY_KEY) || '[]';
const modal = document.createElement('div');
modal.id = modalId;
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 10001;
display: flex;
justify-content: center;
align-items: center;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.8);
width: 450px;
max-height: 80vh;
overflow-y: auto;
font-family: 'Inter', sans-serif;
color: #333;
`;
content.innerHTML = `
<h3 style="margin-top: 0; color: #6A0DAD; border-bottom: 2px solid #6A0DAD; padding-bottom: 10px; margin-bottom: 20px;">Manual Streak Editor</h3>
<p style="font-size: 13px; margin-bottom: 20px; color: #555;">
Use this tool to manually set the **streak count** and the **date history**.
The date history must be a valid JSON array of "YYYY-MM-DD" strings.
</p>
<div style="margin-bottom: 30px; border: 1px solid #ddd; padding: 15px; border-radius: 8px;">
<h4 style="margin-top: 0; color: #6A0DAD;">Scratch Main Streak</h4>
<div style="margin-bottom: 15px;">
<label for="main-streak-input" style="display: block; font-weight: 600; margin-bottom: 5px;">Streak Count (Days):</label>
<input id="main-streak-input" type="number" value="${currentMainStreak}" min="0" style="width: 95%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px;">
</div>
<div>
<label for="main-history-input" style="display: block; font-weight: 600; margin-bottom: 5px;">Date History (JSON Array):</label>
<textarea id="main-history-input" rows="4" style="width: 95%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; font-family: monospace; font-size: 12px; resize: vertical;">${currentMainHistory}</textarea>
</div>
</div>
<div style="margin-bottom: 20px; border: 1px solid #ddd; padding: 15px; border-radius: 8px;">
<h4 style="margin-top: 0; color: #6A0DAD;">Forum Streak</h4>
<div style="margin-bottom: 15px;">
<label for="forum-streak-input" style="display: block; font-weight: 600; margin-bottom: 5px;">Streak Count (Days):</label>
<input id="forum-streak-input" type="number" value="${currentForumStreak}" min="0" style="width: 95%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px;">
</div>
<div>
<label for="forum-history-input" style="display: block; font-weight: 600; margin-bottom: 5px;">Date History (JSON Array):</label>
<textarea id="forum-history-input" rows="4" style="width: 95%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; font-family: monospace; font-size: 12px; resize: vertical;">${currentForumHistory}</textarea>
</div>
</div>
<p id="error-message" style="color: red; font-size: 13px; font-weight: 600; margin-top: -10px; margin-bottom: 15px; text-align: center;"></p>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button id="cancel-streak-update" style="padding: 10px 15px; background: #f4f4f4; border: 1px solid #ccc; border-radius: 6px; cursor: pointer; font-weight: 600;">Cancel</button>
<button id="confirm-streak-update" style="padding: 10px 15px; background: #6A0DAD; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 600;">Update Streaks</button>
</div>
`;
modal.appendChild(content);
document.body.appendChild(modal);
const mainInput = document.getElementById('main-streak-input');
const forumInput = document.getElementById('forum-streak-input');
const mainHistoryInput = document.getElementById('main-history-input');
const forumHistoryInput = document.getElementById('forum-history-input');
document.getElementById('cancel-streak-update').addEventListener('click', () => {
modal.remove();
});
document.getElementById('confirm-streak-update').addEventListener('click', () => {
handleManualUpdate(mainInput, forumInput, mainHistoryInput, forumHistoryInput, modal);
});
}
function handleKeyPress(event) {
const key = event.key.toLowerCase();
// Check if the keypress is happening inside an input field or textarea
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
return;
}
if (key.length === 1 && key.match(/[a-z0-9]/i)) {
keyBuffer += key;
} else {
keyBuffer = '';
}
if (keyBuffer.length > SECRET_PHRASE.length) {
keyBuffer = keyBuffer.substring(keyBuffer.length - SECRET_PHRASE.length);
}
if (keyBuffer === SECRET_PHRASE) {
showModifyStreakModal();
keyBuffer = '';
}
}
const currentPath = window.location.pathname;
const scratchResult = updateStreak(MAIN_STREAK_KEY, MAIN_LAST_VISIT_KEY, MAIN_HISTORY_KEY);
let scratchStreak = scratchResult.streak;
let 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: 0; /* Ensures bars connect fully */
font-size: 12px;
}
#scratch-streak-wrapper .calendar-grid > div {
padding: 5px 0;
display: flex;
justify-content: center;
align-items: center;
height: 25px;
position: relative;
z-index: 1;
font-weight: 600;
color: #333;
box-sizing: border-box;
}
/* Style for the day names (Su, Mo, etc.) */
#scratch-streak-wrapper .calendar-grid > div:nth-child(-n+7) {
font-weight: 600;
color: #666;
background: none;
border-radius: 0;
padding: 5px;
}
/* Streak general styling */
#scratch-streak-wrapper .calendar-grid .streak-start,
#scratch-streak-wrapper .calendar-grid .streak-mid,
#scratch-streak-wrapper .calendar-grid .streak-end {
background: #6A0DAD;
color: white;
z-index: 2;
box-shadow: none !important; /* Ensure no general shadow interferes */
outline: none !important; /* Ensure no general outline interferes */
}
/* Solo streak: transparent background, purple text and border */
#scratch-streak-wrapper .calendar-grid .streak-solo {
background: none;
border: 2px solid #6A0DAD;
border-radius: 50%;
color: #6A0DAD;
z-index: 2;
width: 25px;
height: 25px;
margin: 0 auto;
}
/* Streak start: rounded left edge, square right edge */
#scratch-streak-wrapper .calendar-grid .streak-start {
border-radius: 500px 0 0 500px;
margin: 0;
}
/* Streak mid: fully square */
#scratch-streak-wrapper .calendar-grid .streak-mid {
border-radius: 0;
margin: 0;
}
/* Streak end: square left edge, rounded right edge */
#scratch-streak-wrapper .calendar-grid .streak-end {
border-radius: 0 500px 500px 0;
margin: 0;
}
/* TODAY HIGHLIGHTS */
/* General styling for today (not strictly part of streak) */
#scratch-streak-wrapper .calendar-grid .today {
font-weight: 700;
}
/* NEW FIX: Highlight today's date if it's streaked using an inset box-shadow for a clean border */
#scratch-streak-wrapper .calendar-grid .today.streak-start,
#scratch-streak-wrapper .calendar-grid .today.streak-mid,
#scratch-streak-wrapper .calendar-grid .today.streak-end {
/* This creates a 3px white 'border' effect inside the purple shape, eliminating outline artifacts */
box-shadow: inset 0 0 0 3px white;
z-index: 3;
color: white;
}
/* Today highlight for solo streak: use thicker border */
#scratch-streak-wrapper .calendar-grid .today.streak-solo {
border: 3px solid #6A0DAD;
z-index: 3;
color: #6A0DAD;
}
/* Remove the generic square border for non-streaked today */
#scratch-streak-wrapper .calendar-grid .today:not([class*="streak"]) {
border: none;
color: #6A0DAD; /* Highlight the number in purple */
}
`;
document.head.appendChild(style);
mainButton.addEventListener('click', () => {
if (mainCalendar.style.display === 'none' || mainCalendar.style.display === '') {
forumCalendar.style.display = 'none';
// Re-read history just in case it was modified via the modal
const currentHistory = JSON.parse(localStorage.getItem(MAIN_HISTORY_KEY) || '[]');
renderCalendar(mainCalendar, currentHistory, mainCalendarDate, 'main');
} else {
mainCalendar.style.display = 'none';
}
});
forumButton.addEventListener('click', () => {
if (forumCalendar.style.display === 'none' || forumCalendar.style.display === '') {
mainCalendar.style.display = 'none';
// Re-read history just in case it was modified via the modal
const currentHistory = JSON.parse(localStorage.getItem(FORUM_HISTORY_KEY) || '[]');
renderCalendar(forumCalendar, currentHistory, forumCalendarDate, 'forum');
} else {
forumCalendar.style.display = 'none';
}
});
document.addEventListener('keypress', handleKeyPress);
})();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址