diff --git a/src/app.ts b/src/app.ts index f3c9ee0..b4f5a82 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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(); @@ -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) => { diff --git a/src/lib/github.ts b/src/lib/github.ts index d7c81e5..b136447 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -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; diff --git a/src/modules/dashboard/dashboard.controller.ts b/src/modules/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..c4467ac --- /dev/null +++ b/src/modules/dashboard/dashboard.controller.ts @@ -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 = {}; + 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 = {}; + + // 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 })), + }); +}; diff --git a/src/modules/dashboard/dashboard.routes.ts b/src/modules/dashboard/dashboard.routes.ts new file mode 100644 index 0000000..6c93aa3 --- /dev/null +++ b/src/modules/dashboard/dashboard.routes.ts @@ -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; diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index ba1f738..cd7bcef 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -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(), @@ -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 = {}; + 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, }); };