Skip to content

Commit e9fb8eb

Browse files
authored
Merge pull request #27 from ArjinAlbay/claude/user-hover-card-scoring-01REMSaoZ3VK6uVAFhJsa6vM
feat: implement user hover card with open source scoring system
2 parents c5f19c3 + 986f3ed commit e9fb8eb

File tree

5 files changed

+178
-11
lines changed

5 files changed

+178
-11
lines changed

src/components/quick-wins/columns.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
44
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
55
import { ExternalLink, Calendar, ListPlus, X, MessageCircle, Star, AlertCircle } from "lucide-react";
66
import type { GitHubIssue } from "@/types/quickWins";
7+
import { UserHoverCard } from "@/components/ui/user-hover-card";
78

89
interface CreateColumnsOptions {
910
onAddToKanban?: (issue: GitHubIssue) => void;
@@ -239,17 +240,19 @@ export const createColumns = (options?: CreateColumnsOptions): ColumnDef<GitHubI
239240
cell: ({ row }) => {
240241
const author = row.original.author;
241242
return (
242-
<div className="flex items-center gap-2">
243-
<Avatar className="size-6">
244-
<AvatarImage src={author.avatar_url} alt={author.login} />
245-
<AvatarFallback className="text-xs">
246-
{author.login.charAt(0).toUpperCase()}
247-
</AvatarFallback>
248-
</Avatar>
249-
<span className="text-sm text-gray-600 truncate max-w-20">
250-
{author.login}
251-
</span>
252-
</div>
243+
<UserHoverCard username={author.login} showScore={true}>
244+
<div className="flex items-center gap-2 cursor-pointer">
245+
<Avatar className="size-6">
246+
<AvatarImage src={author.avatar_url} alt={author.login} />
247+
<AvatarFallback className="text-xs">
248+
{author.login.charAt(0).toUpperCase()}
249+
</AvatarFallback>
250+
</Avatar>
251+
<span className="text-sm text-gray-600 truncate max-w-20 hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
252+
{author.login}
253+
</span>
254+
</div>
255+
</UserHoverCard>
253256
);
254257
},
255258
enableSorting: false,

src/stores/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useDataCacheStore } from "./cache";
66
import { useAppStore } from "./app";
77
import { useFavoritesStore } from "./favorites";
88
import { useNavigationStore } from "./navigation";
9+
import { useUserScoresStore } from "./userScores";
910
export { useAuthStore } from "./auth";
1011
export { usePreferencesStore } from "./preferences";
1112
export { useSearchStore } from "./search";
@@ -15,6 +16,7 @@ export { useActionItemsStore } from "./actionItems";
1516
export { useKanbanStore } from "./kanban";
1617
export { useFavoritesStore } from "./favorites";
1718
export { useNavigationStore } from "./navigation";
19+
export { useUserScoresStore } from "./userScores";
1820

1921
// ============ HYDRATION HOOK ============
2022

@@ -33,6 +35,7 @@ const hydrateStores = async () => {
3335
useDataCacheStore.persist.rehydrate(),
3436
useFavoritesStore.persist.rehydrate(),
3537
useNavigationStore.persist.rehydrate(),
38+
useUserScoresStore.persist.rehydrate(),
3639
]);
3740
isHydrated = true;
3841
})();

src/stores/userScores.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { create } from "zustand";
2+
import { persist, subscribeWithSelector } from "zustand/middleware";
3+
import type { UserScore, UserScoreCacheEntry } from "@/types/github";
4+
import { githubAPIClient } from "@/lib/api/github-api-client";
5+
6+
interface UserScoresState {
7+
scores: Map<string, UserScoreCacheEntry>;
8+
isHydrated: boolean;
9+
10+
getScore: (username: string) => UserScore | null;
11+
fetchScore: (username: string) => Promise<UserScore | null>;
12+
clearScore: (username: string) => void;
13+
clearAllScores: () => void;
14+
hydrate: () => void;
15+
}
16+
17+
const CACHE_DURATION = 30 * 60 * 1000;
18+
19+
function calculateScore(commits: number, prs: number, stars: number): number {
20+
return commits * 2 + prs * 5 + stars;
21+
}
22+
23+
function calculateLevel(score: number): "beginner" | "intermediate" | "advanced" | "expert" | "master" {
24+
if (score >= 1000) return "master";
25+
if (score >= 500) return "expert";
26+
if (score >= 250) return "advanced";
27+
if (score >= 100) return "intermediate";
28+
return "beginner";
29+
}
30+
31+
export const useUserScoresStore = create<UserScoresState>()(
32+
subscribeWithSelector(
33+
persist(
34+
(set, get) => ({
35+
scores: new Map(),
36+
isHydrated: false,
37+
38+
getScore: (username: string) => {
39+
const cached = get().scores.get(username);
40+
if (!cached) return null;
41+
42+
const now = Date.now();
43+
if (now - cached.fetchedAt > CACHE_DURATION) {
44+
return null;
45+
}
46+
47+
return cached.score;
48+
},
49+
50+
fetchScore: async (username: string) => {
51+
const cached = get().getScore(username);
52+
if (cached) return cached;
53+
54+
try {
55+
const contributions = await githubAPIClient.getUserContributions(username);
56+
const score = calculateScore(
57+
contributions.commits,
58+
contributions.prs,
59+
contributions.stars
60+
);
61+
const level = calculateLevel(score);
62+
63+
const userScore: UserScore = {
64+
username,
65+
score,
66+
contributions,
67+
calculatedAt: new Date().toISOString(),
68+
level,
69+
};
70+
71+
const cacheEntry: UserScoreCacheEntry = {
72+
username,
73+
score: userScore,
74+
fetchedAt: Date.now(),
75+
};
76+
77+
set((state) => {
78+
const newScores = new Map(state.scores);
79+
newScores.set(username, cacheEntry);
80+
return { scores: newScores };
81+
});
82+
83+
return userScore;
84+
} catch (error) {
85+
console.error(`Failed to fetch score for ${username}:`, error);
86+
return null;
87+
}
88+
},
89+
90+
clearScore: (username: string) => {
91+
set((state) => {
92+
const newScores = new Map(state.scores);
93+
newScores.delete(username);
94+
return { scores: newScores };
95+
});
96+
},
97+
98+
clearAllScores: () => {
99+
set({ scores: new Map() });
100+
},
101+
102+
hydrate: () => {
103+
set({ isHydrated: true });
104+
},
105+
}),
106+
{
107+
name: "user-scores-storage",
108+
onRehydrateStorage: () => (state) => {
109+
state?.hydrate();
110+
},
111+
storage: {
112+
getItem: (name) => {
113+
const str = localStorage.getItem(name);
114+
if (!str) return null;
115+
const data = JSON.parse(str);
116+
return {
117+
state: {
118+
...data.state,
119+
scores: new Map(Object.entries(data.state.scores || {})),
120+
},
121+
};
122+
},
123+
setItem: (name, value) => {
124+
const scoresObj = Object.fromEntries(value.state.scores || new Map());
125+
const data = {
126+
state: {
127+
...value.state,
128+
scores: scoresObj,
129+
},
130+
};
131+
localStorage.setItem(name, JSON.stringify(data));
132+
},
133+
removeItem: (name) => localStorage.removeItem(name),
134+
},
135+
}
136+
)
137+
)
138+
);

src/types/github.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,3 +346,23 @@ export type GitHubUserType = "User" | "Organization";
346346
export type GitHubRepoVisibility = "public" | "private";
347347
export type GitHubIssueState = "open" | "closed";
348348
export type GitHubPRState = "open" | "closed" | "merged";
349+
350+
export interface UserContributions {
351+
commits: number;
352+
prs: number;
353+
stars: number;
354+
}
355+
356+
export interface UserScore {
357+
username: string;
358+
score: number;
359+
contributions: UserContributions;
360+
calculatedAt: string;
361+
level: "beginner" | "intermediate" | "advanced" | "expert" | "master";
362+
}
363+
364+
export interface UserScoreCacheEntry {
365+
username: string;
366+
score: UserScore;
367+
fetchedAt: number;
368+
}

src/types/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export type {
6161
GitHubCommit,
6262
ContributorWithRepos,
6363
GitHubIssue,
64+
UserContributions,
65+
UserScore,
66+
UserScoreCacheEntry,
6467
} from "./github";
6568

6669
export type ValueOf<T> = T[keyof T];

0 commit comments

Comments
 (0)