// ==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}"><</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 = 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';
}
});
})();