-
Notifications
You must be signed in to change notification settings - Fork 44
feat: add user reputation dashboard with XP, skills radar, and contri… #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,245 @@ | ||
| "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, isSameDay } from "date-fns"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Address lint warnings for unused symbols. Line 12 imports 🧹 Minimal cleanup patch-import { format, subDays, startOfDay, isSameDay } from "date-fns";
+import { format, subDays, startOfDay } from "date-fns";
...
- daysData.forEach((day, index) => {
+ daysData.forEach((day) => {Also applies to: 86-86 🧰 Tools🪛 GitHub Actions: CI - Build & Lint Check[warning] 12-12: ESLint ( 🪛 GitHub Check: build-and-lint (24.x)[warning] 12-12: 🤖 Prompt for AI Agents |
||
|
|
||
| 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"]; | ||
| const MONTHS = [ | ||
| "Jan", | ||
| "Feb", | ||
| "Mar", | ||
| "Apr", | ||
| "May", | ||
| "Jun", | ||
| "Jul", | ||
| "Aug", | ||
| "Sep", | ||
| "Oct", | ||
| "Nov", | ||
| "Dec", | ||
| ]; | ||
|
|
||
| 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[] = []; | ||
|
|
||
| for (let i = 364; i >= 0; i--) { | ||
| const date = subDays(today, i); | ||
| const dateStr = format(date, "yyyy-MM-dd"); | ||
| const contribution = contributions.find((c) => c.date === dateStr); | ||
| daysData.push({ | ||
| date: dateStr, | ||
| count: contribution?.count ?? 0, | ||
| }); | ||
| } | ||
|
|
||
| // 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, index) => { | ||
| 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"> | ||
| {MONTHS.map((month, index) => { | ||
| // Show month label at the start of each month | ||
| const shouldShow = | ||
| index === 0 || | ||
| (index > 0 && index % 2 === 0) || | ||
| index === MONTHS.length - 1; | ||
| if (!shouldShow) { | ||
| return <div key={month} className="w-3" />; | ||
| } | ||
| return ( | ||
| <div | ||
| key={month} | ||
| className="text-xs text-muted-foreground w-8" | ||
| > | ||
| {month} | ||
| </div> | ||
| ); | ||
| })} | ||
|
Comment on lines
+151
to
+169
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Month labels are static and can be chronologically incorrect. At Line 151–169, labels are hard-coded ( 🗓️ Suggested direction (derive labels from weeks)- <div className="flex gap-[3px] ml-6 mb-1">
- {MONTHS.map((month, index) => {
- // Show month label at the start of each month
- const shouldShow =
- index === 0 ||
- (index > 0 && index % 2 === 0) ||
- index === MONTHS.length - 1;
- if (!shouldShow) {
- return <div key={month} className="w-3" />;
- }
- return (
- <div
- key={month}
- className="text-xs text-muted-foreground w-8"
- >
- {month}
- </div>
- );
- })}
- </div>
+ <div className="flex gap-[3px] ml-6 mb-1">
+ {monthLabels.map((label, weekIndex) => (
+ <div key={`month-${weekIndex}`} className="text-xs text-muted-foreground w-3">
+ {label}
+ </div>
+ ))}
+ </div>And compute 🤖 Prompt for AI Agents |
||
| </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> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| "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; | ||
|
|
||
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unsafe cast can crash the Dashboard tab at runtime.
At Line 238,
reputationis force-cast toContributorReputationWithMetrics, but API data is typed asContributorReputation(no guaranteedmetrics). Opening this tab can throw whenmetrics.xpis accessed.💡 Suggested fix (guard + graceful partial-data handling)
<TabsContent value="dashboard" className="mt-6"> <h2 className="text-xl font-bold mb-4">Reputation Dashboard</h2> - <ReputationDashboard - reputation={reputation as ContributorReputationWithMetrics} - /> + {"metrics" in reputation && reputation.metrics ? ( + <ReputationDashboard + reputation={reputation as ContributorReputationWithMetrics} + /> + ) : ( + <div className="p-8 border rounded-lg text-center text-muted-foreground bg-secondary/5"> + Reputation metrics are not available yet. + </div> + )} </TabsContent>📝 Committable suggestion
🤖 Prompt for AI Agents