diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b7d0d1..f8d1d80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,5 +28,8 @@ jobs: - name: Install dependencies run: npm ci + - name: Run tests + run: npm test + - name: Package Extension (Dry Run) run: npx @vscode/vsce package diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e3f74..0a233e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Query diagnostics only for visible documents and count split editors once per document, avoiding a workspace-wide scan on every diagnostics event. +- 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). ## [2.0.0] - 2026-09-25 diff --git a/extension.js b/extension.js index 02cecaf..9b03722 100644 --- a/extension.js +++ b/extension.js @@ -269,6 +269,26 @@ function activate(context) { } }); + /** Feed Pet */ + const cmdFeedPet = vscode.commands.registerCommand('codecritter.feedPet', () => { + vscode.window.showInformationMessage(`🍕 You fed ${petName()}! Nom nom nom.`); + setMood('excited', true); + updateDashboardIfOpen(stats, petName(), 'excited'); + sidebarProvider.update(); + if (comboTimeout) clearTimeout(comboTimeout); + comboTimeout = setTimeout(() => { comboTimeout = null; }, 5000); // stay excited for 5s + }); + + /** Play Pet */ + const cmdPlayPet = vscode.commands.registerCommand('codecritter.playPet', () => { + vscode.window.showInformationMessage(`🎾 You played with ${petName()}! So much fun!`); + setMood('celebrating', true); + updateDashboardIfOpen(stats, petName(), 'celebrating'); + sidebarProvider.update(); + if (comboTimeout) clearTimeout(comboTimeout); + comboTimeout = setTimeout(() => { comboTimeout = null; }, 5000); + }); + // ── Event: File Saved ── const onSave = vscode.workspace.onDidSaveTextDocument(() => { lastActivity = Date.now(); @@ -433,6 +453,8 @@ function activate(context) { cmdChooseTheme, cmdExportStats, cmdImportStats, + cmdFeedPet, + cmdPlayPet, onSave, onChange, onDiagnostics, diff --git a/package.json b/package.json index 0adc507..11b1a19 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "keywords": ["pet", "fun", "gamification", "status bar", "productivity", "tamagotchi", "companion", "critter", "motivation", "streak"], "activationEvents": ["onStartupFinished"], "main": "./extension.js", + "scripts": { + "test": "node --test" + }, "contributes": { "commands": [ { diff --git a/src/stats.js b/src/stats.js index 92c48db..2853162 100644 --- a/src/stats.js +++ b/src/stats.js @@ -31,6 +31,7 @@ class StatsEngine { this.sessionStart = Date.now(); this._load(); this._checkDayRollover(); + this._checkStreakExpiry(); } // ─── Private: Load & Save ──────────────────────────────────────────────── @@ -80,6 +81,25 @@ class StatsEngine { this.dailyLinesTyped = 0; this.dailyGoalHit = false; this.dailyDate = today; + this._checkStreakExpiry(); + this._save(); + } + } + + /** + * Resets active streak to 0 if 2 or more days have elapsed since last activity. + */ + _checkStreakExpiry() { + if (!this.lastActiveDay) return; + const today = this._today(); + if (this.lastActiveDay === today) return; + + 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 >= 2 && this.currentStreak > 0) { + this.currentStreak = 0; this._save(); } } @@ -210,11 +230,13 @@ class StatsEngine { /** Returns a plain-object snapshot of all stats (safe to pass to webview). */ getSnapshot() { + this._checkDayRollover(); + this._checkStreakExpiry(); return { saveCount: this.saveCount, linesTyped: this.linesTyped, errorsFixed: this.errorsFixed, - totalCodingMins: this.totalCodingMins + this.getSessionMinutes(), + totalCodingMins: this.totalCodingMins, currentStreak: this.currentStreak, longestStreak: this.longestStreak, lastActiveDay: this.lastActiveDay, diff --git a/tests/stats.test.js b/tests/stats.test.js new file mode 100644 index 0000000..f741b3e --- /dev/null +++ b/tests/stats.test.js @@ -0,0 +1,82 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { StatsEngine } = require('../src/stats'); + +function createMockContext(initialState = {}) { + const store = new Map(Object.entries(initialState)); + return { + globalState: { + get: (key, defaultValue) => store.has(key) ? store.get(key) : defaultValue, + update: (key, value) => { + if (value === undefined) store.delete(key); + else store.set(key, value); + return Promise.resolve(); + } + } + }; +} + +function formatDate(date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +test('Issue #22: totalCodingMins does not double-count session coding time in snapshot', () => { + const context = createMockContext({ + 'codecritter.totalCodingMinutes': 30 + }); + + const stats = new StatsEngine(context); + + // Record 15 additional minutes of coding time + stats.recordCodingTime(15); + + const snapshot = stats.getSnapshot(); + assert.equal(snapshot.totalCodingMins, 45, 'total coding minutes must equal accumulated time without session inflation'); +}); + +test('Issue #24: streak resets to 0 if user has been inactive for 2 or more days', () => { + const threeDaysAgo = new Date(); + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); + + const context = createMockContext({ + 'codecritter.currentStreak': 10, + 'codecritter.longestStreak': 10, + 'codecritter.lastActiveDay': formatDate(threeDaysAgo) + }); + + const stats = new StatsEngine(context); + + const snapshot = stats.getSnapshot(); + assert.equal(snapshot.currentStreak, 0, 'inactive streak must be 0 after 2+ days without coding'); + assert.equal(snapshot.longestStreak, 10, 'longest streak record must be preserved'); + + // Saving a file today starts a new streak of 1 + stats.recordSave(); + const updatedSnapshot = stats.getSnapshot(); + assert.equal(updatedSnapshot.currentStreak, 1, 'streak restarts at 1 on fresh activity'); + assert.equal(updatedSnapshot.longestStreak, 10, 'longest streak record remains unchanged'); +}); + +test('Issue #24: streak continues when active on consecutive days (yesterday -> today)', () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + const context = createMockContext({ + 'codecritter.currentStreak': 4, + 'codecritter.longestStreak': 4, + 'codecritter.lastActiveDay': formatDate(yesterday) + }); + + const stats = new StatsEngine(context); + + // Before activity today, current streak is still intact + assert.equal(stats.getSnapshot().currentStreak, 4, 'yesterdays streak is preserved pending activity today'); + + // Activity today increments streak to 5 + stats.recordLines(10); + const snapshot = stats.getSnapshot(); + assert.equal(snapshot.currentStreak, 5, 'consecutive day activity increments streak'); + assert.equal(snapshot.longestStreak, 5, 'longest streak updates when current exceeds it'); +});