Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
11 changes: 11 additions & 0 deletions .qwen/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(npm test)",
"Bash(git add *)",
"Bash(git commit *)"
]
},
"$version": 3
}
7 changes: 7 additions & 0 deletions .qwen/settings.json.orig
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run *)"
]
}
}
39 changes: 39 additions & 0 deletions app/profile/[userId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
EarningsSummary,
type EarningsSummary as EarningsSummaryType,
} from "@/components/reputation/earnings-summary";
import { ReputationDashboard } from "@/components/profile/reputation-dashboard";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
Expand All @@ -17,6 +18,22 @@ import Link from "next/link";
import { useParams } from "next/navigation";
import { useMemo } from "react";
import { useCompletionHistory } from "@/hooks/use-reputation";
import { ContributorReputationWithMetrics } from "@/types/reputation";

// Mock metrics data for testing until backend supports full metrics payload
const getMockMetrics = (): ContributorReputationWithMetrics["metrics"] => ({
xp: 2500,
skills: [
{ name: "Frontend", level: 75 },
{ name: "Backend", level: 60 },
{ name: "Documentation", level: 40 },
],
contributionHistory: {
totalContributions: 0,
contributions: [],
streak: { current: 0, longest: 0 },
},
});

export default function ProfilePage() {
const params = useParams();
Expand Down Expand Up @@ -194,6 +211,12 @@ export default function ProfilePage() {
>
Bounty History
</TabsTrigger>
<TabsTrigger
value="dashboard"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3"
>
Reputation Dashboard
</TabsTrigger>
<TabsTrigger
value="analytics"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3"
Expand Down Expand Up @@ -224,6 +247,22 @@ export default function ProfilePage() {
)}
</TabsContent>

<TabsContent value="dashboard" className="mt-6">
<h2 className="text-xl font-bold mb-4">Reputation Dashboard</h2>
{"metrics" in reputation && reputation.metrics ? (
<ReputationDashboard
reputation={reputation as ContributorReputationWithMetrics}
/>
) : (
<ReputationDashboard
reputation={{
...reputation,
metrics: getMockMetrics(),
}}
/>
)}
</TabsContent>

<TabsContent value="analytics" className="mt-6">
<div className="p-8 border rounded-lg text-center text-muted-foreground bg-secondary/5">
Detailed analytics coming soon.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { Loader2, DollarSign } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Expand Down
242 changes: 242 additions & 0 deletions components/profile/contribution-heatmap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
"use client";

import { ContributionHistory } from "@/types/reputation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { format, subDays, startOfDay } from "date-fns";

interface ContributionHeatmapProps {
contributionHistory: ContributionHistory;
className?: string;
}

interface DayData {
date: string;
count: number;
}

// Color levels for different contribution intensities
const getContributionColor = (count: number, maxCount: number): string => {
if (count === 0) return "bg-muted";

const ratio = count / (maxCount || 1);

if (ratio >= 0.8) return "bg-primary";
if (ratio >= 0.6) return "bg-primary/80";
if (ratio >= 0.4) return "bg-primary/60";
if (ratio >= 0.2) return "bg-primary/40";
return "bg-primary/20";
};

const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

export function ContributionHeatmap({
contributionHistory,
className,
}: ContributionHeatmapProps) {
const { contributions, totalContributions, streak } = contributionHistory;

// Generate last 365 days (52 weeks + 1 day = 365 days)
const today = startOfDay(new Date());
const daysData: DayData[] = [];

// Build a Map for O(1) lookup instead of O(n) .find() in each iteration
const contributionMap = new Map(contributions.map((c) => [c.date, c.count]));

for (let i = 364; i >= 0; i--) {
const date = subDays(today, i);
const dateStr = format(date, "yyyy-MM-dd");
const count = contributionMap.get(dateStr) ?? 0;
daysData.push({
date: dateStr,
count,
});
}

// Organize into weeks for grid display
const weeks: DayData[][] = [];
let currentWeek: DayData[] = [];

// Find the starting day of week (Sunday)
const startDate = subDays(today, 364);
const startDayOfWeek = startDate.getDay();

// Add padding days for the first week
for (let i = 0; i < startDayOfWeek; i++) {
currentWeek.push({ date: "", count: 0 });
}

daysData.forEach((day) => {
currentWeek.push(day);
if (currentWeek.length === 7) {
weeks.push(currentWeek);
currentWeek = [];
}
});

// Push remaining days
if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push({ date: "", count: 0 });
}
weeks.push(currentWeek);
}

const maxCount = Math.max(...daysData.map((d) => d.count), 1);

// Handle empty data
if (totalContributions === 0) {
return (
<Card className={cn("border-border/50", className)}>
<CardHeader className="pb-3 border-b border-border/50 bg-secondary/10">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-semibold">
Contribution Activity
</CardTitle>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="flex items-center justify-center h-40 text-muted-foreground">
<p>No contributions in the last year</p>
</div>
</CardContent>
</Card>
);
}

return (
<Card className={cn("border-border/50", className)}>
<CardHeader className="pb-3 border-b border-border/50 bg-secondary/10">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-semibold">
Contribution Activity
</CardTitle>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
<span className="font-semibold text-foreground">
{streak.current}
</span>{" "}
day streak
</span>
<span>
<span className="font-semibold text-foreground">
{streak.longest}
</span>{" "}
best
</span>
</div>
</div>
</CardHeader>
<CardContent className="p-4">
<TooltipProvider>
<div className="flex flex-col gap-1">
{/* Month labels */}
<div className="flex gap-[3px] ml-6 mb-1">
{weeks.map((week, weekIndex) => {
// Find the first valid date in this week to determine the month
const firstValidDay = week.find((day) => day.date);
if (!firstValidDay) {
return <div key={weekIndex} className="w-3" />;
}
const weekMonth = format(new Date(firstValidDay.date), "MMM");
const shouldShow =
weekIndex === 0 ||
weeks[weekIndex - 1]?.some(
(day) =>
day.date &&
format(new Date(day.date), "MMM") !== weekMonth,
);
if (!shouldShow) {
return <div key={weekIndex} className="w-3" />;
}
return (
<div
key={weekIndex}
className="text-xs text-muted-foreground w-8"
>
{weekMonth}
</div>
);
})}
</div>

<div className="flex gap-1">
{/* Weekday labels */}
<div className="flex flex-col gap-[3px] pr-2">
{WEEKDAYS.map((day, index) => (
<div
key={day}
className={cn(
"text-xs text-muted-foreground h-3 w-6 flex items-center justify-end",
index % 2 === 1 && "opacity-0",
)}
>
{day}
</div>
))}
</div>

{/* Heatmap grid */}
<div className="flex gap-[3px] overflow-x-auto">
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-[3px]">
{week.map((day, dayIndex) => {
const colorClass = day.date
? getContributionColor(day.count, maxCount)
: "bg-transparent";

return (
<Tooltip
key={`${weekIndex}-${dayIndex}`}
delayDuration={0}
>
<TooltipTrigger asChild>
<div
className={cn(
"h-3 w-3 rounded-sm transition-colors hover:ring-2 hover:ring-primary/50",
colorClass,
)}
/>
</TooltipTrigger>
{day.date && (
<TooltipContent side="top" className="text-xs">
<p className="font-medium">
{day.count} contribution
{day.count !== 1 ? "s" : ""}
</p>
<p className="text-muted-foreground">
{format(new Date(day.date), "MMM d, yyyy")}
</p>
</TooltipContent>
)}
</Tooltip>
);
})}
</div>
))}
</div>
</div>

{/* Legend */}
<div className="flex items-center justify-end gap-1 mt-2 text-xs text-muted-foreground">
<span>Less</span>
<div className="h-2 w-2 rounded-sm bg-muted" />
<div className="h-2 w-2 rounded-sm bg-primary/20" />
<div className="h-2 w-2 rounded-sm bg-primary/40" />
<div className="h-2 w-2 rounded-sm bg-primary/60" />
<div className="h-2 w-2 rounded-sm bg-primary/80" />
<div className="h-2 w-2 rounded-sm bg-primary" />
<span>More</span>
</div>
</div>
</TooltipProvider>
</CardContent>
</Card>
);
}
43 changes: 43 additions & 0 deletions components/profile/reputation-dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { ContributorReputationWithMetrics } from "@/types/reputation";
import { XpDisplay } from "./xp-display";
import { SkillRadarChart } from "./skill-radar-chart";
import { ContributionHeatmap } from "./contribution-heatmap";

interface ReputationDashboardProps {
reputation: ContributorReputationWithMetrics;
}

export function ReputationDashboard({ reputation }: ReputationDashboardProps) {
const { metrics } = reputation;

// Guard against missing metrics
if (!metrics) {
return (
<div className="p-8 border rounded-lg text-center text-muted-foreground">
<p>Metrics data is not available for this user.</p>
</div>
);
}

return (
<div className="space-y-6">
{/* XP Display */}
<XpDisplay xp={metrics.xp} />

{/* Charts Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Skill Radar Chart */}
<SkillRadarChart skills={metrics.skills} />

{/* Contribution Stats */}
<div className="space-y-6">
<ContributionHeatmap
contributionHistory={metrics.contributionHistory}
/>
</div>
</div>
</div>
);
}
Loading