From 896ae26cb54f06d7488e3e2dee1c195b978d5869 Mon Sep 17 00:00:00 2001 From: Adnan9-63 Date: Sun, 25 Oct 2026 01:50:45 +0530 Subject: [PATCH] feat(streak): add configurable weekend streak protection Fixes #31 --- CHANGELOG.md | 1 + package.json | 45 +++++++++++++++++++++++++++----- src/stats.js | 62 ++++++++++++++++++++++++++++++++++++++++----- tests/stats.test.js | 42 ++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cebf9..d672e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix double-counting of session coding time in `StatsEngine.getSnapshot()` (Fixes #22). - Automatically recalculate and reset inactive streaks to 0 after 2 or more days without activity (Fixes #24). - Dispose previous Sidebar webview message listener on view reopen to prevent memory leaks (Fixes #30). +- Add configurable Weekend Streak Protection (codecritter.weekendStreakProtection) to protect streaks over Saturday and Sunday (Fixes #31). --- diff --git a/package.json b/package.json index c742d90..d9a6417 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,25 @@ "vscode": "^1.85.0", "node": ">=20.0.0" }, - "categories": ["Other", "Visualization"], - "keywords": ["pet", "fun", "gamification", "status bar", "productivity", "tamagotchi", "companion", "critter", "motivation", "streak"], - "activationEvents": ["onStartupFinished"], + "categories": [ + "Other", + "Visualization" + ], + "keywords": [ + "pet", + "fun", + "gamification", + "status bar", + "productivity", + "tamagotchi", + "companion", + "critter", + "motivation", + "streak" + ], + "activationEvents": [ + "onStartupFinished" + ], "main": "./extension.js", "scripts": { "test": "node --test" @@ -128,13 +144,21 @@ }, "codecritter.statusBarAlignment": { "type": "string", - "enum": ["left", "right"], + "enum": [ + "left", + "right" + ], "default": "right", "description": "Which side of the status bar CodeCritter appears on (requires restart)." }, "codecritter.petTheme": { "type": "string", - "enum": ["default", "fire", "galaxy", "nature"], + "enum": [ + "default", + "fire", + "galaxy", + "nature" + ], "enumDescriptions": [ "Teal — the classic CodeCritter look", "Fire — warm orange and red tones", @@ -146,7 +170,11 @@ }, "codecritter.petSpecies": { "type": "string", - "enum": ["alien", "cat", "robot"], + "enum": [ + "alien", + "cat", + "robot" + ], "enumDescriptions": [ "Alien — The classic CodeCritter", "Cat — A purrfect coding companion", @@ -171,6 +199,11 @@ "type": "boolean", "default": true, "description": "Show a weekly coding summary when you open VS Code on Monday." + }, + "codecritter.weekendStreakProtection": { + "type": "boolean", + "default": true, + "description": "Protect your coding streak over the weekend (Saturday & Sunday) so taking weekends off won't break your streak." } } } diff --git a/src/stats.js b/src/stats.js index 2853162..61d461f 100644 --- a/src/stats.js +++ b/src/stats.js @@ -26,7 +26,11 @@ class StatsEngine { /** * @param {import('vscode').ExtensionContext} context */ - constructor(context) { + constructor(context, options = {}) { + if (options.today) this._todayDateOverride = options.today; + if (options.weekendStreakProtection !== undefined) { + this._overrideWeekendProtection = options.weekendStreakProtection; + } this.context = context; this.sessionStart = Date.now(); this._load(); @@ -70,6 +74,7 @@ class StatsEngine { /** Returns today's ISO date string (YYYY-MM-DD) in local time. */ _today() { + if (this._todayDateOverride) return this._todayDateOverride; const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } @@ -87,7 +92,47 @@ class StatsEngine { } /** - * Resets active streak to 0 if 2 or more days have elapsed since last activity. + * Returns whether weekend streak protection is enabled in settings. + */ + get weekendStreakProtection() { + if (this._overrideWeekendProtection !== undefined) { + return this._overrideWeekendProtection; + } + try { + const vscode = require('vscode'); + return vscode.workspace.getConfiguration('codecritter').get('weekendStreakProtection', true); + } catch { + return true; + } + } + + setWeekendStreakProtection(enabled) { + this._overrideWeekendProtection = enabled; + } + + /** + * Determines if the span between lastDate and todayDate falls under weekend grace. + */ + _isWeekendGrace(lastDate, todayDate) { + const lastDay = lastDate.getDay(); // 0 = Sun, 5 = Fri, 6 = Sat + const diffDays = Math.round((todayDate - lastDate) / 86400000); + + // If last active was Friday: Sat (diff 1), Sun (diff 2), Mon (diff 3) + if (lastDay === 5 && (diffDays === 1 || diffDays === 2 || diffDays === 3)) { + return true; + } + + // If last active was Saturday: Sun (diff 1), Mon (diff 2) + if (lastDay === 6 && (diffDays === 1 || diffDays === 2)) { + return true; + } + + return false; + } + + /** + * Resets active streak to 0 if 2 or more days have elapsed since last activity, + * taking weekend protection into account if enabled. */ _checkStreakExpiry() { if (!this.lastActiveDay) return; @@ -98,7 +143,9 @@ class StatsEngine { const now = new Date(today + 'T00:00:00'); const diffDays = Math.round((now - last) / 86400000); - if (diffDays >= 2 && this.currentStreak > 0) { + const weekendGrace = this.weekendStreakProtection && this._isWeekendGrace(last, now); + + if (diffDays >= 2 && !weekendGrace && this.currentStreak > 0) { this.currentStreak = 0; this._save(); } @@ -106,7 +153,7 @@ class StatsEngine { /** * Updates the coding streak based on the last active day. - * Increments streak if yesterday, resets if 2+ days ago. + * Increments streak if yesterday (or via weekend grace), resets if broken. */ _updateStreak() { const today = this._today(); @@ -116,8 +163,11 @@ class StatsEngine { const last = new Date(this.lastActiveDay + 'T00:00:00'); const now = new Date(today + 'T00:00:00'); const diffDays = Math.round((now - last) / 86400000); - if (diffDays === 1) { - this.currentStreak++; // consecutive day + + const weekendGrace = this.weekendStreakProtection && this._isWeekendGrace(last, now); + + if (diffDays === 1 || weekendGrace) { + this.currentStreak++; // consecutive day or protected weekend bridge } else { this.currentStreak = 1; // streak broken — reset } diff --git a/tests/stats.test.js b/tests/stats.test.js index f741b3e..ef65645 100644 --- a/tests/stats.test.js +++ b/tests/stats.test.js @@ -80,3 +80,45 @@ test('Issue #24: streak continues when active on consecutive days (yesterday -> assert.equal(snapshot.currentStreak, 5, 'consecutive day activity increments streak'); assert.equal(snapshot.longestStreak, 5, 'longest streak updates when current exceeds it'); }); + +test('Issue #31: weekend streak protection preserves streak from Friday to Monday', () => { + const context = createMockContext({ + 'codecritter.currentStreak': 5, + 'codecritter.longestStreak': 5, + 'codecritter.lastActiveDay': '2026-10-16' // Friday + }); + + // Today is Monday (2026-10-19) + const stats = new StatsEngine(context, { + today: '2026-10-19', + weekendStreakProtection: true + }); + + const snapshot = stats.getSnapshot(); + assert.equal(snapshot.currentStreak, 5, 'Friday streak must be protected on Monday before coding'); + + // Activity on Monday increments the streak to 6 + stats.recordSave(); + assert.equal(stats.getSnapshot().currentStreak, 6, 'Friday to Monday coding activity increments streak'); +}); + +test('Issue #31: streak resets from Friday to Monday if weekendStreakProtection is disabled', () => { + const context = createMockContext({ + 'codecritter.currentStreak': 5, + 'codecritter.longestStreak': 5, + 'codecritter.lastActiveDay': '2026-10-16' // Friday + }); + + // Today is Monday (2026-10-19) with protection disabled + const stats = new StatsEngine(context, { + today: '2026-10-19', + weekendStreakProtection: false + }); + + const snapshot = stats.getSnapshot(); + assert.equal(snapshot.currentStreak, 0, 'Streak resets to 0 on Monday when weekend protection is disabled'); + + // Coding on Monday starts streak at 1 + stats.recordSave(); + assert.equal(stats.getSnapshot().currentStreak, 1, 'Streak restarts at 1 when protection disabled'); +});