Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fc394ce
feat(db): add Achievement and WeeklyStats models for gamification
senutpal Jan 9, 2026
e452f39
feat(shared): add gamification types (StreakInfo, LeaderboardEntry, e…
senutpal Jan 9, 2026
d0df1aa
feat(shared): add gamification constants (TTLs, Redis keys, achieveme…
senutpal Jan 9, 2026
bb3e0ed
feat(server): add GITHUB_WEBHOOK_SECRET to environment config
senutpal Jan 9, 2026
d5bb4a6
feat(server): add stats routes for streaks, sessions, and achievements
senutpal Jan 9, 2026
55faca0
feat(server): add leaderboards routes with Redis Sorted Sets
senutpal Jan 9, 2026
6825fb1
feat(server): add GitHub webhook handler with HMAC-SHA256 verification
senutpal Jan 9, 2026
2590986
feat(server): register stats, leaderboards, and webhooks routes
senutpal Jan 9, 2026
d45191e
feat(extension): add StatsProvider for streak and session display
senutpal Jan 9, 2026
771ecc7
feat(extension): add LeaderboardProvider for mini-leaderboard view
senutpal Jan 9, 2026
de0e097
feat(extension): export new StatsProvider and LeaderboardProvider
senutpal Jan 9, 2026
7660901
feat(extension): integrate gamification views and stats fetching
senutpal Jan 9, 2026
46d0b32
feat(extension): add My Stats and Leaderboard views, bump to v0.3.0
senutpal Jan 9, 2026
5db427f
docs(extension): add v0.3.0 changelog for gamification features
senutpal Jan 9, 2026
04ab46c
docs: add GITHUB_WEBHOOK_SECRET to .env.example
senutpal Jan 9, 2026
d21ddd9
fix(ci) : remove formatting check
senutpal Jan 9, 2026
4fd5b42
fix(extension): call stopStatsRefresh() in dispose() to prevent memor…
senutpal Jan 9, 2026
bed9759
fix(extension): handle 0 seconds as 'No activity', use unique header …
senutpal Jan 9, 2026
c09210f
fix(extension): use destructuring for array access to satisfy lint
senutpal Jan 9, 2026
40250b6
fix(server): update to 'Gamification' terminology, add min(8) validat…
senutpal Jan 9, 2026
03237a7
fix(server): add pipeline error handling, fire-and-forget catch, fix …
senutpal Jan 9, 2026
4a2975c
fix(server): add Zod payload validation, raw body handling, explicit …
senutpal Jan 9, 2026
f1f582c
fix: improve code quality, security & reliability across extension an…
senutpal Jan 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
# ==================================
# DevRadar Environment Configuration
# ==================================

# Node Environment
NODE_ENV=development

Expand All @@ -19,6 +15,9 @@ REDIS_URL=redis://localhost:6379
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GITHUB_CALLBACK_URL=http://localhost:3000/auth/callback
# GitHub Webhooks (for Boss Battles - achievements from GitHub events)
# Generate with: openssl rand -hex 32
GITHUB_WEBHOOK_SECRET=your_webhook_secret_for_github

# JWT
JWT_SECRET=your_super_secret_jwt_key_change_in_production
Expand Down
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Check formatting
run: pnpm format:check

- name: Generate Prisma Client
run: pnpm --filter @devradar/server db:generate
env:
Expand Down
41 changes: 41 additions & 0 deletions apps/extension/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,47 @@ All notable changes to the DevRadar extension will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2026-01-09

### Added

- **Daily Streaks**: Track your coding streak with visual indicators
- 25-hour grace period for timezone flexibility
- Streak milestones (7, 30, 100 days) unlock achievements
- "At risk" warning when you haven't coded today
- **Leaderboards**: Compete with friends on weekly coding time
- Mini-leaderboard view in sidebar with top 10 friends
- Medal icons (🥇🥈🥉) for top 3 positions
- Your rank indicator always visible
- **My Stats View**: New sidebar section showing:
- Current streak with fire emoji
- Today's coding session time
- Weekly stats with rank position
- Latest achievement
- **Achievements System**: Earn achievements for:
- Closing GitHub issues ("Bug Slayer" 🐛)
- Merging pull requests ("Merge Master" 🎉)
- Streak milestones ("Week Warrior" 🔥, "Monthly Machine" ⚡, "Century Coder" 🏆)
- **GitHub Webhook Integration**: Connect your repos for automatic achievement tracking
- Secure HMAC-SHA256 signature verification
- Real-time achievement notifications to you and your followers
- **Network Activity Heatmap**: See when your network is "🔥 active"

### Changed

- Sidebar reorganized with Stats at top, then Friends, Leaderboard, Requests, Activity
- Achievement notifications now show who earned the achievement
- Stats and leaderboard refresh every 60 seconds

### Technical

- New server routes: `/api/v1/stats`, `/api/v1/leaderboards`, `/webhooks/github`
- Redis Sorted Sets for O(log N) leaderboard operations
- Prisma schema extended with `Achievement` and `WeeklyStats` models
- Secure webhook handling with constant-time signature comparison

---

## [0.2.0] - 2026-01-08

### Added
Expand Down
12 changes: 11 additions & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "devradar",
"displayName": "DevRadar",
"description": "See what your friends are coding in real-time",
"version": "0.2.0",
"version": "0.3.0",
"publisher": "devradar",
"license": "AGPL-3.0-or-later",
"private": true,
Expand Down Expand Up @@ -41,12 +41,22 @@
},
"views": {
"devradar": [
{
"id": "devradar.stats",
"name": "My Stats",
"contextualTitle": "DevRadar Stats"
},
{
"id": "devradar.friends",
"name": "Friends",
"icon": "media/friends.svg",
"contextualTitle": "DevRadar Friends"
},
{
"id": "devradar.leaderboard",
"name": "Leaderboard",
"contextualTitle": "DevRadar Leaderboard"
},
{
"id": "devradar.friendRequests",
"name": "Friend Requests",
Expand Down
151 changes: 145 additions & 6 deletions apps/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,18 @@ import { Logger } from './utils/logger';
import { ActivityProvider } from './views/activityProvider';
import { FriendRequestsProvider } from './views/friendRequestsProvider';
import { FriendsProvider } from './views/friendsProvider';
import { LeaderboardProvider } from './views/leaderboardProvider';
import { StatsProvider } from './views/statsProvider';
import { StatusBarManager } from './views/statusBarItem';

import type { UserStatusType, FriendRequestDTO, PublicUserDTO } from '@devradar/shared';
import type {
UserStatusType,
FriendRequestDTO,
PublicUserDTO,
AchievementPayload,
UserStatsDTO,
LeaderboardEntry,
} from '@devradar/shared';

/** Coordinates all extension services and manages their lifecycle. */
class DevRadarExtension implements vscode.Disposable {
Expand All @@ -35,6 +44,10 @@ class DevRadarExtension implements vscode.Disposable {
private readonly friendRequestService: FriendRequestService;
private readonly activityProvider: ActivityProvider;
private readonly statusBar: StatusBarManager;
// Phase 2: Gamification
private readonly statsProvider: StatsProvider;
private readonly leaderboardProvider: LeaderboardProvider;
private statsRefreshInterval: NodeJS.Timeout | null = null;

constructor(context: vscode.ExtensionContext) {
this.logger = new Logger('DevRadar');
Expand All @@ -56,6 +69,9 @@ class DevRadarExtension implements vscode.Disposable {
);
this.activityProvider = new ActivityProvider(this.wsClient, this.logger);
this.statusBar = new StatusBarManager(this.wsClient, this.authService, this.logger);
// Phase 2: Gamification views
this.statsProvider = new StatsProvider(this.logger);
this.leaderboardProvider = new LeaderboardProvider(this.logger);
/* Track disposables */
this.disposables.push(
this.authService,
Expand All @@ -66,7 +82,9 @@ class DevRadarExtension implements vscode.Disposable {
this.friendRequestsProvider,
this.activityProvider,
this.statusBar,
this.configManager
this.configManager,
this.statsProvider,
this.leaderboardProvider
);
}

Expand Down Expand Up @@ -98,7 +116,10 @@ class DevRadarExtension implements vscode.Disposable {
'devradar.friendRequests',
this.friendRequestsProvider
),
vscode.window.registerTreeDataProvider('devradar.activity', this.activityProvider)
vscode.window.registerTreeDataProvider('devradar.activity', this.activityProvider),
// Phase 2: Gamification views
vscode.window.registerTreeDataProvider('devradar.stats', this.statsProvider),
vscode.window.registerTreeDataProvider('devradar.leaderboard', this.leaderboardProvider)
);
}

Expand Down Expand Up @@ -251,8 +272,11 @@ class DevRadarExtension implements vscode.Disposable {
this.logger.info('User is authenticated, connecting...');
this.wsClient.connect();
this.activityTracker.start();
// Phase 2: Start stats refresh
this.startStatsRefresh();
} else {
this.logger.info('User is not authenticated');
this.stopStatsRefresh();
}

void this.statusBar.update();
Expand All @@ -262,6 +286,109 @@ class DevRadarExtension implements vscode.Disposable {
}
}

/** Start periodic stats and leaderboard refresh. */
private startStatsRefresh(): void {
// Initial fetch
void this.fetchStats();
void this.fetchLeaderboard();

// Refresh every 60 seconds
this.statsRefreshInterval ??= setInterval(() => {
void this.fetchStats();
void this.fetchLeaderboard();
}, 60_000);
}

/** Stop stats refresh (e.g., on logout). */
private stopStatsRefresh(): void {
if (this.statsRefreshInterval) {
clearInterval(this.statsRefreshInterval);
this.statsRefreshInterval = null;
}
this.statsProvider.clear();
this.leaderboardProvider.clear();
}

/** Fetch user stats from the server. */
private async fetchStats(): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 10_000);

try {
const token = this.authService.getToken();
if (!token) return;

const serverUrl = this.configManager.get('serverUrl');
const response = await fetch(`${serverUrl}/api/v1/stats/me`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
signal: controller.signal,
});

if (response.ok) {
const json = (await response.json()) as { data: UserStatsDTO };
this.statsProvider.updateStats(json.data);
} else {
this.logger.warn('Failed to fetch stats', { status: response.status });
this.statsProvider.setLoading(false);
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
this.logger.warn('Stats fetch timed out after 10s');
} else {
this.logger.warn('Failed to fetch stats', error);
}
this.statsProvider.setLoading(false);
} finally {
clearTimeout(timeout);
}
}

/** Fetch friends leaderboard from the server. */
private async fetchLeaderboard(): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 10_000);

try {
const token = this.authService.getToken();
if (!token) return;

const serverUrl = this.configManager.get('serverUrl');
const response = await fetch(`${serverUrl}/api/v1/leaderboards/friends`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
signal: controller.signal,
});

if (response.ok) {
const json = (await response.json()) as {
data: { leaderboard: LeaderboardEntry[]; myRank: number | null };
};
this.leaderboardProvider.updateLeaderboard(json.data.leaderboard, json.data.myRank);
} else {
this.logger.warn('Failed to fetch leaderboard', { status: response.status });
this.leaderboardProvider.setLoading(false);
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
this.logger.warn('Leaderboard fetch timed out after 10s');
} else {
this.logger.warn('Failed to fetch leaderboard', error);
}
this.leaderboardProvider.setLoading(false);
} finally {
clearTimeout(timeout);
}
}

private async handleLogin(): Promise<void> {
try {
this.logger.info('Starting login flow...');
Expand Down Expand Up @@ -447,12 +574,21 @@ class DevRadarExtension implements vscode.Disposable {
return;
}

if (typeof payload === 'object' && payload !== null && 'title' in payload) {
const achievement = payload as { title: string; description?: string };
if (typeof payload === 'object' && payload !== null && 'achievement' in payload) {
const data = payload as AchievementPayload;
const currentUserId = this.authService.getUser()?.id;
const isMyAchievement = data.userId === currentUserId;

const prefix = isMyAchievement ? 'You earned' : `${data.username} earned`;

void vscode.window.showInformationMessage(
`DevRadar: 🏆 Achievement Unlocked - ${achievement.title}!`
`DevRadar: 🏆 ${prefix} - ${data.achievement.title}!`
);

// Refresh stats to show new achievement
if (isMyAchievement) {
void this.fetchStats();
}
}
}

Expand Down Expand Up @@ -623,6 +759,9 @@ class DevRadarExtension implements vscode.Disposable {
dispose(): void {
this.logger.info('Disposing DevRadar extension...');

// Stop stats refresh interval before disposing other resources
this.stopStatsRefresh();

for (const disposable of this.disposables) {
disposable.dispose();
}
Expand Down
2 changes: 2 additions & 0 deletions apps/extension/src/views/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export { FriendsProvider, type FriendInfo } from './friendsProvider';
export { FriendRequestsProvider } from './friendRequestsProvider';
export { ActivityProvider, type ActivityEvent } from './activityProvider';
export { StatusBarManager } from './statusBarItem';
export { StatsProvider } from './statsProvider';
export { LeaderboardProvider } from './leaderboardProvider';
Loading