Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import issueRoutes from "./modules/issues/issues.routes";
import gigRoutes from "./modules/gigs/gigs.routes";
import proposalRoutes from "./modules/proposals/proposals.routes";
import teamRoutes from "./modules/teams/teams.routes";
import dashboardRoutes from "./modules/dashboard/dashboard.routes";

const app = express();

Expand Down Expand Up @@ -82,6 +83,7 @@ app.use("/api/issues", issueRoutes);
app.use("/api/gigs", gigRoutes);
app.use("/api/gigs/:gigId/proposals", proposalRoutes);
app.use("/api/teams", teamRoutes);
app.use("/api/dashboard", dashboardRoutes);

// ── 404 Handler ──────────────────────────────────────────
app.use((_req, res) => {
Expand Down
38 changes: 38 additions & 0 deletions src/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,44 @@ export const getUserContributions = async (username: string) => {
return data;
};

export const fetchGitHubContributionCalendar = async (username: string) => {
const cacheKey = `github:calendar:${username}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;

const query = `
query($username: String!) {
user(login: $username) {
contributionsCollection {
contributionCalendar {
calendar {
dayOfMonth
month
contributionCount
}
}
}
}
}
`;

const { data } = await githubApi.post('/graphql', {
query,
variables: { username },
});

const calendar = data.data.user?.contributionsCollection?.contributionCalendar?.calendar || [];

const currentYear = new Date().getFullYear();
const formattedData = calendar.map((item: any) => ({
date: `${currentYear}-${String(item.month).padStart(2, '0')}-${String(item.dayOfMonth).padStart(2, '0')}`,
count: item.contributionCount,
}));

await cacheSet(cacheKey, formattedData, 3600); // cache 1 hour
return formattedData;
};

export const parseRepoUrl = (url: string): { owner: string; repo: string } | null => {
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
if (!match) return null;
Expand Down
111 changes: 111 additions & 0 deletions src/modules/dashboard/dashboard.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Request, Response } from 'express';
import { prisma } from '../../lib/prisma';
import { fetchGitHubContributionCalendar } from '../../lib/github';
import { AppError } from '../../middleware/errorHandler';

export const getDashboardStats = async (req: Request, res: Response) => {
const userId = req.user!.id;

// 1. Get user for GitHub username
const user = await prisma.user.findUnique({
where: { id: userId },
select: { username: true },
});
if (!user) throw new AppError('User not found', 404);

// 2. Get all issue claims for the user
const claims = await prisma.issueClaim.findMany({
where: { userId },
include: {
issue: {
include: {
project: true,
},
},
},
orderBy: { claimedAt: 'desc' },
});

// 3. "My Issues" overview
const activeClaims = claims.filter(c => c.issue.status !== 'CLOSED').length;
const recentClaims = claims.slice(0, 5).map(c => ({
issueId: c.issueId,
title: c.issue.title,
status: c.issue.status,
projectName: c.issue.project.name,
claimedAt: c.claimedAt,
}));

// 4. Projects Contributed
const projectIds = [...new Set(claims.map(c => c.issue.projectId))];
const contributedProjects = await prisma.project.findMany({
where: {
id: { in: projectIds },
},
select: {
id: true,
name: true,
logoUrl: true,
githubRepoUrl: true,
},
});

// 5. Weekly Activity Chart (last 21 days)
const activityCounts: Record<string, number> = {};
const twentyOneDaysAgo = new Date();
twentyOneDaysAgo.setDate(twentyOneDaysAgo.getDate() - 21);

claims.forEach(c => {
if (c.claimedAt >= twentyOneDaysAgo) {
const dateStr = c.claimedAt.toISOString().split('T')[0];
activityCounts[dateStr] = (activityCounts[dateStr] || 0) + 1;
}
});

// Format into weeks (last 3 weeks)
const weeklyActivity = [];
for (let i = 2; i >= 0; i--) {
const weekStart = new Date();
weekStart.setDate(weekStart.getDate() - (i * 7 + 6));
const weekEnd = new Date();
weekEnd.setDate(weekEnd.getDate() - (i * 7));

let weekCount = 0;
const curr = new Date(weekStart);
while (curr <= weekEnd) {
weekCount += activityCounts[curr.toISOString().split('T')[0]] || 0;
curr.setDate(curr.getDate() + 1);
}

weeklyActivity.push({
weekStart: weekStart.toISOString().split('T')[0],
count: weekCount,
});
}

// 6. Contribution Heatmap (DB + GitHub)
const ghCalendar = await fetchGitHubContributionCalendar(user.username);
const heatmap: Record<string, number> = {};

// Add GitHub data
ghCalendar.forEach(item => {
heatmap[item.date] = item.count;
});

// Merge DB activity
claims.forEach(c => {
const dateStr = c.claimedAt.toISOString().split('T')[0];
heatmap[dateStr] = (heatmap[dateStr] || 0) + 1;
});

res.json({
myIssues: {
totalClaimed: claims.length,
activeClaims,
recentClaims,
},
contributedProjects,
weeklyActivity,
heatmap: Object.entries(heatmap).map(([date, count]) => ({ date, count })),
});
};
71 changes: 71 additions & 0 deletions src/modules/dashboard/dashboard.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Router } from 'express';
import { getDashboardStats } from './dashboard.controller';
import { authenticate } from '../../middleware/auth';

const router = Router();

/**
* @swagger
* tags:
* name: Dashboard
* description: User dashboard analytics
*/

/**
* @swagger
* /api/dashboard/stats:
* get:
* summary: Get user dashboard statistics
* tags: [Dashboard]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: Dashboard statistics
* content:
* application/json:
* schema:
* type: object
* properties:
* myIssues:
* type: object
* properties:
* totalClaimed: { type: integer }
* activeClaims: { type: integer }
* recentClaims:
* type: array
* items:
* type: object
* properties:
* issueId: { type: string }
* title: { type: string }
* status: { type: string }
* projectName: { type: string }
* claimedAt: { type: string, format: date-time }
* contributedProjects:
* type: array
* items:
* type: object
* properties:
* id: { type: string }
* name: { type: string }
* logoUrl: { type: string }
* githubRepoUrl: { type: string }
* weeklyActivity:
* type: array
* items:
* type: object
* properties:
* weekStart: { type: string, format: date }
* count: { type: integer }
* heatmap:
* type: array
* items:
* type: object
* properties:
* date: { type: string, format: date }
* count: { type: integer }
*/
router.get('/stats', authenticate, getDashboardStats);

export default router;
27 changes: 24 additions & 3 deletions src/modules/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { z } from 'zod';
import { prisma } from '../../lib/prisma';
import { AppError } from '../../middleware/errorHandler';
import { uploadFile } from '../../lib/minio';
import { getUserContributions } from '../../lib/github';
import { getUserContributions, fetchGitHubContributionCalendar } from '../../lib/github';

const updateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
Expand Down Expand Up @@ -52,15 +52,36 @@ export const updateProfile = async (req: Request, res: Response) => {
export const getUserContributionStats = async (req: Request, res: Response) => {
const user = await prisma.user.findUnique({
where: { username: req.params.username },
select: { username: true },
select: { username: true, id: true },
});
if (!user) throw new AppError('User not found', 404);

const githubData = await getUserContributions(user.username);
const [githubData, ghCalendar] = await Promise.all([
getUserContributions(user.username),
fetchGitHubContributionCalendar(user.username),
]);

const claims = await prisma.issueClaim.findMany({
where: { userId: user.id },
select: { claimedAt: true },
});

const heatmap: Record<string, number> = {};
ghCalendar.forEach(item => {
heatmap[item.date] = item.count;
});

claims.forEach(c => {
const dateStr = c.claimedAt.toISOString().split('T')[0];
heatmap[dateStr] = (heatmap[dateStr] || 0) + 1;
});

res.json({
publicRepos: githubData.public_repos,
followers: githubData.followers,
following: githubData.following,
githubCreatedAt: githubData.created_at,
heatmap: Object.entries(heatmap).map(([date, count]) => ({ date, count })),
totalPlatformContributions: claims.length,
});
};