diff --git a/app/globals.css b/app/globals.css index 4667aef..bc94690 100644 --- a/app/globals.css +++ b/app/globals.css @@ -27,11 +27,11 @@ body { --border: 0 0% 89.8%; --input: 0 0% 89.8%; --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; --radius: 0.5rem; } .dark { diff --git a/app/layout.tsx b/app/layout.tsx index 9eb28c2..108abd5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,39 +1,38 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -import { cn } from "@/lib/utils"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import type { FC } from "react"; +import { Header } from "./recap/components/header/header"; +import { ThemeProvider } from "./recap/components/theme-toggle/theme-provider"; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Dev Recap 2024", + description: "A recap of the latest in web development", }; -export default function RootLayout({ - children, -}: Readonly<{ +type Props = { children: React.ReactNode; -}>) { +}; + +const RootLayout: FC> = ({ children }) => { return ( - - - {children} + + + +
+
+
+ {children} +
+
+
); -} +}; + +export default RootLayout; diff --git a/app/page.tsx b/app/page.tsx index c0ee3bf..d7a8f68 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,7 @@ import { Button } from "@/components/ui/button"; import { auth } from "@/lib/auth"; -import GitHub from "@/public/github-mark-white.svg"; +import GitHubWhite from "@/public/github-mark-white.png"; +import GitHubBlack from "@/public/github-mark.png"; import Image from "next/image"; import Link from "next/link"; @@ -13,18 +14,26 @@ export default async function Home() { console.log(session); return ( -
-
-

+
+
+

Dev Recap 2024

-

Hi, @{session.user.login} !

-

- 1年間おつかれさまでした。 -

-

+ {session.user.image && ( + {session.user.login} + )} +

@{session.user.login}

+

今年もおつかれさまでした。

+

あなたの1年間を振り返りましょう🥳

@@ -34,11 +43,34 @@ export default async function Home() { href="/recap" className="flex items-center justify-center gap-4 text-base" > - GitHub + GitHub + GitHub 振り返りを見る
+ +
+ Overview +
); } diff --git a/app/recap/components/graph/languages.tsx b/app/recap/components/graph/languages.tsx new file mode 100644 index 0000000..dec3e84 --- /dev/null +++ b/app/recap/components/graph/languages.tsx @@ -0,0 +1,155 @@ +"use client"; +import type { Stats } from "@/app/recap/fetchGitHubStats"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { useMemo } from "react"; +import type { FC } from "react"; +import { Label, Pie, PieChart } from "recharts"; + +type Props = { + data: Stats["languagesByCommitCount"]; +}; + +export const LanguagesUsageGraph: FC = ({ data }) => { + const limitedData = useMemo(() => { + if (data.length <= 6) { + return data; + } + const mainItems = data.slice(0, 5); + const others = data.slice(5); + + const othersCommitSum = others.reduce( + (acc, item) => acc + item.commitCount, + 0, + ); + + return [ + ...mainItems, + { + language: "Others", + commitCount: othersCommitSum, + }, + ]; + }, [data]); + + const colorPalette = useMemo( + () => [ + "hsl(var(--chart-1))", + "hsl(var(--chart-2))", + "hsl(var(--chart-3))", + "hsl(var(--chart-4))", + "hsl(var(--chart-5))", + "hsl(var(--chart-6))", + ], + [], + ); + + const totalCommits = useMemo(() => { + return limitedData.reduce((acc, curr) => acc + curr.commitCount, 0); + }, [limitedData]); + + const chartData = useMemo(() => { + if (totalCommits === 0) { + return limitedData.map((item, index) => ({ + language: item.language, + share: 0, + fill: colorPalette[index % colorPalette.length], + })); + } + return limitedData.map((item, index) => ({ + language: item.language, + share: (item.commitCount / totalCommits) * 100, + fill: colorPalette[index % colorPalette.length], + })); + }, [limitedData, totalCommits, colorPalette]); + + const chartConfig = useMemo(() => { + const dynamicLangConfig = limitedData.reduce((acc, item, index) => { + acc[item.language] = { + label: item.language, + color: colorPalette[index % colorPalette.length], + }; + return acc; + }, {} as ChartConfig); + + return { + visitors: { + label: "Share", + }, + ...dynamicLangConfig, + }; + }, [limitedData, colorPalette]); + + return ( + + + 言語の使用率 + + あなたが最も使用した言語は {limitedData[0].language} です。 + + + + + + } + /> + + + + + + + ); +}; diff --git a/app/recap/components/graph/monthly.tsx b/app/recap/components/graph/monthly.tsx new file mode 100644 index 0000000..021ed39 --- /dev/null +++ b/app/recap/components/graph/monthly.tsx @@ -0,0 +1,103 @@ +"use client"; +import type { Stats } from "@/app/recap/fetchGitHubStats"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { type FC, useMemo } from "react"; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; + +const chartConfig = { + monthly: { + label: "Monthly", + }, +}; + +type Props = { + monthlyData: Stats["monthlyContributions"]; +}; + +export const MonthlyContributionsGraph: FC = ({ monthlyData }) => { + const chartData = useMemo(() => { + return monthlyData.map((item) => ({ + x: item.month, + y: item.contributionCount, + })); + }, [monthlyData]); + + const maxMonthData = useMemo(() => { + if (monthlyData.length === 0) { + return { month: "-", contributionCount: 0 }; + } + return monthlyData.reduce((acc, curr) => + curr.contributionCount > acc.contributionCount ? curr : acc, + ); + }, [monthlyData]); + + const dynamicConfig = useMemo(() => { + return { monthly: chartConfig.monthly }; + }, []); + + return ( + + +
+ 月別の統計 + + あなたが最も活動したのは {maxMonthData.month} で、{" "} + {maxMonthData.contributionCount}{" "} + 回のコントリビューションを行いました。 + +
+
+ + + + + + + + String(label)} + formatter={(value) => [ + + {value} + , + "コントリビューション", + ]} + /> + } + /> + + + + +
+ ); +}; diff --git a/app/recap/components/graph/weekly.tsx b/app/recap/components/graph/weekly.tsx new file mode 100644 index 0000000..ab01a8a --- /dev/null +++ b/app/recap/components/graph/weekly.tsx @@ -0,0 +1,103 @@ +"use client"; +import type { Stats } from "@/app/recap/fetchGitHubStats"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { type FC, useMemo } from "react"; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; + +const chartConfig = { + dayOfWeek: { + label: "Day of Week", + }, +}; + +type Props = { + dayOfWeekData: Stats["averageContributionsByDayOfWeek"]; +}; + +export const WeeklyContributionsGraph: FC = ({ dayOfWeekData }) => { + const chartData = useMemo(() => { + return dayOfWeekData.map((item) => ({ + x: item.dayOfWeek, + y: item.averageContributions, + })); + }, [dayOfWeekData]); + + const maxMonthData = useMemo(() => { + if (dayOfWeekData.length === 0) { + return { dayOfWeek: "-", averageContributions: 0 }; + } + return dayOfWeekData.reduce((acc, curr) => + curr.averageContributions > acc.averageContributions ? curr : acc, + ); + }, [dayOfWeekData]); + + const dynamicConfig = useMemo(() => { + return { dayOfWeek: chartConfig.dayOfWeek }; + }, []); + + return ( + + +
+ 曜日別の統計 + + あなたが最も活動したのは {maxMonthData.dayOfWeek} で、 平均{" "} + {maxMonthData.averageContributions}{" "} + 回のコントリビューションを行いました。 + +
+
+ + + + + + + + String(label)} + formatter={(value) => [ + + {value} + , + "コントリビューション", + ]} + /> + } + /> + + + + +
+ ); +}; diff --git a/app/recap/components/grass.tsx b/app/recap/components/grass.tsx new file mode 100644 index 0000000..d004121 --- /dev/null +++ b/app/recap/components/grass.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { ChevronLast } from "lucide-react"; +import type { FC } from "react"; +import { useState } from "react"; + +const MONTH_NAMES = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +function getDaySuffix(day: number) { + if (day >= 11 && day <= 13) { + return "th"; + } + switch (day % 10) { + case 1: + return "st"; + case 2: + return "nd"; + case 3: + return "rd"; + default: + return "th"; + } +} + +function formatDateToPrettyString(dateString: string): string { + const dateObj = new Date(dateString); + const month = MONTH_NAMES[dateObj.getMonth()]; + const day = dateObj.getDate(); + const suffix = getDaySuffix(day); + return `${month} ${day}${suffix}`; +} + +type Props = { + weeklyContributions: { date: string; contributionCount: number }[]; + totalContributions: number; +}; + +export const ContributionGrass: FC = ({ + weeklyContributions, + totalContributions, +}) => { + // アニメーションをスキップするか + const [skipAnimation, setSkipAnimation] = useState(false); + // 最後のセルのアニメーションが終了したか + const [hasAnimationEnded, setHasAnimationEnded] = useState(false); + + const weeks = chunkByWeek(weeklyContributions); + + // 「最後のセルの onAnimationEnd」ハンドラ + const handleLastCellAnimationEnd = () => { + setHasAnimationEnded(true); + }; + + return ( +
+ +
+ {weeks.map((daysInWeek, weekIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+ {daysInWeek.map((day, dayIndex) => { + const finalColor = getContributionColor(day.contributionCount); + + // セルごとの遅延 + const cellDelay = (weekIndex * 7 + dayIndex) * 0.03; + + // 最後のセルかどうかを判定 + const isLastCell = + weekIndex === weeks.length - 1 && + dayIndex === daysInWeek.length - 1; + + // スキップON or アニメーション終了後は最終色を即時表示 + const isFinal = skipAnimation || hasAnimationEnded; + + // スキップOFFかつアニメがまだ終わっていない場合のみアニメ適用 + const classNameWhenNotSkipped = + "w-3 h-3 rounded-[2px] animate-fadeInBg"; + const classNameWhenSkipped = "w-3 h-3 rounded-[2px]"; + + // スタイル切り替え + const styleWhenAnimating = { + ["--final-bg" as string]: finalColor, + animationDelay: `${cellDelay}s`, + }; + const styleWhenDone = { + backgroundColor: finalColor, + }; + + const isDummyCell = !day.date; + + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + +
+ + {!isDummyCell && ( + +

+ {day.contributionCount === 0 + ? `No contributions on ${formatDateToPrettyString(day.date)}.` + : `${day.contributionCount} contributions on ${formatDateToPrettyString(day.date)}.`} +

+
+ )} + + ); + })} +
+ ))} +
+ + +
+

{totalContributions} contributions

+
+ + +
+
+
+ ); +}; + +const ActivityLevelIndicator = () => { + return ( +
+

Less

+ {contributionColors.map(({ min, max, color }) => ( +
+ ))} +

More

+
+ ); +}; + +const contributionColors = [ + { min: 0, max: 0, color: "#ebedf0" }, + { min: 1, max: 11, color: "#9be9a8" }, + { min: 12, max: 22, color: "#40c463" }, + { min: 23, max: 34, color: "#30a14e" }, + { min: 35, max: Number.POSITIVE_INFINITY, color: "#216e39" }, +]; + +const getContributionColor = (count: number) => { + const colorObj = contributionColors.find( + ({ min, max }) => count >= min && count <= max, + ); + return colorObj ? colorObj.color : "#ebedf0"; +}; + +/** + * 連続した日付配列を「日曜始まり」に揃えてから、7日(1週間)ずつに区切る関数 + */ +const chunkByWeek = ( + contributions: { date: string; contributionCount: number }[], +) => { + // 1. 日付昇順にソート + const sorted = [...contributions].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ); + + // 2. 先頭日の曜日を取得(0=日, 1=月, ... 6=土) + const firstDay = new Date(sorted[0].date).getDay(); + + // 3. 先頭日が日曜日でなければ、日曜スタートに合わせるためのダミーを追加 + const placeholders = []; + for (let i = 0; i < firstDay; i++) { + placeholders.push({ + date: "", + contributionCount: 0, + }); + } + + // 4. ダミーを先頭に連結 + const aligned = [...placeholders, ...sorted]; + + // 5. 7日ごとに分割 + const chunked: { date: string; contributionCount: number }[][] = []; + for (let i = 0; i < aligned.length; i += 7) { + chunked.push(aligned.slice(i, i + 7)); + } + + return chunked; +}; diff --git a/app/recap/components/header/breadcrumbs.tsx b/app/recap/components/header/breadcrumbs.tsx new file mode 100644 index 0000000..0554919 --- /dev/null +++ b/app/recap/components/header/breadcrumbs.tsx @@ -0,0 +1,46 @@ +"use client"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { cn } from "@/lib/utils"; +import { Slash } from "lucide-react"; +import { useSession } from "next-auth/react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { FC } from "react"; + +export const Breadcrumbs: FC = () => { + const { data: session } = useSession(); + const pathname = usePathname(); + const isOnRecap = pathname.startsWith("/recap"); + + return ( + + + + + Dev Recap 2024 + + + {isOnRecap && session?.user && ( + <> + + + + + {session.user.login}'s contributions + + + )} + + + ); +}; diff --git a/app/recap/components/header/header.tsx b/app/recap/components/header/header.tsx new file mode 100644 index 0000000..4d23676 --- /dev/null +++ b/app/recap/components/header/header.tsx @@ -0,0 +1,54 @@ +"use client"; +import { cn } from "@/lib/utils"; +import GitHubWhite from "@/public/github-mark-white.png"; +import GitHubBlack from "@/public/github-mark.png"; +import { SessionProvider } from "next-auth/react"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import React, { type FC } from "react"; +import { ModeToggle } from "../theme-toggle/theme-toggle"; +import { Breadcrumbs } from "./breadcrumbs"; + +export const Header: FC = () => { + const pathname = usePathname(); + const isOnTop = pathname === "/"; + + return ( + +
+
+ +
+
+ + GitHub + GitHub + + +
+
+
+ ); +}; diff --git a/app/recap/components/overview.tsx b/app/recap/components/overview.tsx new file mode 100644 index 0000000..6d8cc70 --- /dev/null +++ b/app/recap/components/overview.tsx @@ -0,0 +1,218 @@ +import { ContributionGrass } from "@/app/recap/components/grass"; +import type { Stats } from "@/app/recap/fetchGitHubStats"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { auth } from "@/lib/auth"; +import { format } from "date-fns"; +import { + GitCommitHorizontal, + GitPullRequestCreate, + GitPullRequestCreateArrow, + SquareKanban, + UsersRound, +} from "lucide-react"; +import type { FC } from "react"; +import { LanguagesUsageGraph } from "./graph/languages"; +import { MonthlyContributionsGraph } from "./graph/monthly"; +import { WeeklyContributionsGraph } from "./graph/weekly"; +import { ReposContributions } from "./repos-contributions"; + +type Props = { + data: Stats; +}; + +export const OverView: FC = async ({ data }) => { + const session = await auth(); + if (!session) return null; + + return ( +
+
+ + + + {session.user?.login} + + + + + {session.user.login} + +

+ Joined on{" "} + {format(new Date(data.userProfile.joinedDate), "MMMM dd, yyyy")} +

+
+
+ +
+ "{data.userProfile.bio}" +
+
+ + {data.userProfile.followersCount.toLocaleString()}{" "} + followers ·{" "} + {data.userProfile.followingCount.toLocaleString()}{" "} + following +
+
+
+ +
+
+ +
+
+
+
+
+ + + + 総コミット + + + + + +
+ {data.totalCommitCount.toLocaleString()} +
+

+ 前年と比較して + + {( + ((data.totalCommitCount - + data.previousYearStats.totalCommitCount) / + data.previousYearStats.totalCommitCount) * + 100 + ).toFixed(2)} + + % 増加しました。 +

+
+
+ + + + 完了したIssue + + + + +
+ {data.closedIssuesAssigned.toLocaleString()} +
+

+ 前年と比較して + + {( + ((data.closedIssuesAssigned - + data.previousYearStats.closedIssuesAssignedCount) / + data.previousYearStats.closedIssuesAssignedCount) * + 100 + ).toFixed(2)} + + % 増加しました。 +

+
+
+ + + + 作成したPR + + + + +
+ {data.openedPullRequests.toLocaleString()} +
+

+ 前年と比較して + + {( + ((data.openedPullRequests - + data.previousYearStats.openedPullRequests) / + data.previousYearStats.openedPullRequests) * + 100 + ).toFixed(2)} + + % 増加しました。 +

+
+
+ + + + レビューしたPR + + + + +
+ {data.reviewedPullRequests.toLocaleString()} +
+

+ 前年と比較して + + {( + ((data.reviewedPullRequests - + data.previousYearStats.reviewedPullRequests) / + data.previousYearStats.reviewedPullRequests) * + 100 + ).toFixed(2)} + + % 増加しました。 +

+
+
+
+
+
+ +
+ + + リポジトリごとの統計 + + あなたが最もコミットしたリポジトリは{" "} + {data.repositoriesByCommitCount[0].nameWithOwner} です。 + + + + + + +
+ +
+
+ +
+
+
+
+ ); +}; diff --git a/app/recap/components/repos-contributions.tsx b/app/recap/components/repos-contributions.tsx new file mode 100644 index 0000000..81affc9 --- /dev/null +++ b/app/recap/components/repos-contributions.tsx @@ -0,0 +1,44 @@ +import type { Stats } from "@/app/recap/fetchGitHubStats"; +import { MapPinHouse } from "lucide-react"; +import type { FC } from "react"; + +type Props = { + data: Stats["repositoriesByCommitCount"]; +}; + +export const ReposContributions: FC = ({ data }) => { + return ( +
+ {data.map((repos) => { + const [owner, repoName] = repos.nameWithOwner.split("/"); + + return ( +
+ + +
+ +{repos.commitCount.toLocaleString()} +
+
+ ); + })} +
+ ); +}; diff --git a/app/recap/components/theme-toggle/theme-provider.tsx b/app/recap/components/theme-toggle/theme-provider.tsx new file mode 100644 index 0000000..5c7f8d6 --- /dev/null +++ b/app/recap/components/theme-toggle/theme-provider.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import type { ComponentProps, FC } from "react"; + +export const ThemeProvider: FC> = ({ + children, + ...props +}) => { + return {children}; +}; diff --git a/app/recap/components/theme-toggle/theme-toggle.tsx b/app/recap/components/theme-toggle/theme-toggle.tsx new file mode 100644 index 0000000..26da895 --- /dev/null +++ b/app/recap/components/theme-toggle/theme-toggle.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import type { FC } from "react"; + +export const ModeToggle: FC = () => { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ); +}; diff --git a/app/recap/fetchGitHubStats.ts b/app/recap/fetchGitHubStats.ts index 99d7679..9494319 100644 --- a/app/recap/fetchGitHubStats.ts +++ b/app/recap/fetchGitHubStats.ts @@ -1,22 +1,62 @@ +import "server-only"; import { graphql } from "@octokit/graphql"; +/** + * 日ごとの貢献数 + */ type ContributionDay = { date: string; contributionCount: number; }; +/** + * 週ごとの貢献数 + */ type WeeklyContribution = { date: string; contributionCount: number; }; +/** + * リポジトリ情報 + */ type Repository = { language: string | null; stargazerCount: number; + nameWithOwner: string; + createdAt: string; }; +/** + * リポジトリ別コミット数集計用 + */ +type RepositoryCommitStats = { + nameWithOwner: string; + commitCount: number; +}; + +/** + * 言語別コミット数集計用 + */ +type LanguageCommitStats = { + language: string; + commitCount: number; +}; + +/** + * GraphQL から受け取るレスポンス型 (新規フィールドを含む) + */ type GitHubStatsQueryResponse = { user: { + createdAt: string; + bio: string | null; + followers: { + totalCount: number; + }; + following: { + totalCount: number; + }; + contributionsCollection: { contributionCalendar: { totalContributions: number; @@ -24,88 +64,389 @@ type GitHubStatsQueryResponse = { contributionDays: ContributionDay[]; }[]; }; + commitContributionsByRepository: Array<{ + repository: { + nameWithOwner: string; + }; + contributions: { + totalCount: number; + }; + }>; + pullRequestContributions: { + totalCount: number; + }; + pullRequestReviewContributions: { + totalCount: number; + }; }; repositories: { - nodes: { + nodes: Array<{ + nameWithOwner: string; stargazerCount: number; primaryLanguage: { name: string; } | null; - }[]; + createdAt: string; + }>; }; }; + closedIssuesAssigned: { + issueCount: number; + }; + reviewedPRs: { + issueCount: number; + }; }; -type Stats = { - totalContributions: number; - weeklyContributions: WeeklyContribution[]; - repositories: Repository[]; +export type Stats = { + userProfile: { + joinedDate: string; // 登録日時 + bio: string | null; // bio + followingCount: number; // フォロー数 + followersCount: number; // フォロワー数 + }; + + totalContributions: number; // 年間の総貢献数 + weeklyContributions: WeeklyContribution[]; // 日別(週単位配列経由)の貢献数 + repositories: Repository[]; // リポジトリ一覧 + + repositoriesByCommitCount: RepositoryCommitStats[]; // リポジトリ別のコミット数ランキング + + languagesByCommitCount: LanguageCommitStats[]; // 言語別のコミット数ランキング + + totalCommitCount: number; // 総コミット数 + openedPullRequests: number; // 自分がオープンした PRの数 + reviewedPullRequests: number; // 自分がレビューした PRの数 + closedIssuesAssigned: number; // 自分がアサインされてクローズしたISSUE 数 + newlyCreatedRepositoryCount: number; // 2024年内に作成したリポジトリ数 + + monthlyContributions: { + month: string; // "1月", "2月", ... + contributionCount: number; // 月ごとの貢献数 + }[]; + + averageContributionsByDayOfWeek: { + dayOfWeek: string; // "日曜日", "月曜日", ... + averageContributions: number; // 曜日ごとの平均貢献数 + }[]; + + // 前年比較用のデータ + previousYearStats: { + totalCommitCount: number; // 前年の総コミット数 + openedPullRequests: number; // 前年のオープンしたPR数 + reviewedPullRequests: number; // 前年のレビューしたPR数 + closedIssuesAssignedCount: number; // 前年のアサインされてクローズしたISSUE数 + }; }; export const fetchGitHubStats = async ({ token, login, -}: { token: string; login: string }): Promise => { +}: { + token: string; + login: string; +}): Promise => { if (!login) { throw new Error("GitHub username (login) is required"); } + // 2024年の開始日時・終了日時 + const from = "2024-01-01T00:00:00Z"; + const to = "2024-12-31T23:59:59Z"; + + // 2023年の開始日時・終了日時 + const fromPrevYear = "2023-01-01T00:00:00Z"; + const toPrevYear = "2023-12-31T23:59:59Z"; + + // graphqlクライアントの作成 const graphqlWithAuth = graphql.defaults({ headers: { authorization: `token ${token}`, }, }); + // 2024年の検索クエリ + const closedIssuesByAssigneeQueryThisYear = `assignee:${login} is:issue is:closed closed:2024-01-01..2024-12-31`; + const reviewedPRsSearchQueryThisYear = `reviewed-by:${login} is:pr updated:2024-01-01..2024-12-31`; + + // 2023年の検索クエリ + const closedIssuesByAssigneeQueryPrevYear = `assignee:${login} is:issue is:closed closed:2023-01-01..2023-12-31`; + const reviewedPRsSearchQueryPrevYear = `reviewed-by:${login} is:pr updated:2023-01-01..2023-12-31`; + const query = ` - query ($login: String!) { - user(login: $login) { - contributionsCollection(from: "2024-01-01T00:00:00Z", to: "2024-12-31T23:59:59Z") { - contributionCalendar { - totalContributions - weeks { - contributionDays { - date - contributionCount - } - } - } - } - repositories(first: 100, orderBy: { field: STARGAZERS, direction: DESC }) { - nodes { - stargazerCount - primaryLanguage { - name - } - } - } - } - } - `; + query ( + $login: String!, + $from: DateTime!, + $to: DateTime!, + $closedIssuesByAssigneeQuery: String!, + $reviewedPRsSearchQuery: String! + ) { + user(login: $login) { + createdAt + bio + followers { + totalCount + } + following { + totalCount + } + + contributionsCollection(from: $from, to: $to) { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date + contributionCount + } + } + } + commitContributionsByRepository(maxRepositories: 100) { + repository { + nameWithOwner + } + contributions { + totalCount + } + } + pullRequestContributions(first: 1) { + totalCount + } + pullRequestReviewContributions(first: 1) { + totalCount + } + } + repositories(first: 100, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { + nameWithOwner + stargazerCount + primaryLanguage { + name + } + createdAt + } + } + } + closedIssuesAssigned: search( + query: $closedIssuesByAssigneeQuery + type: ISSUE + first: 1 + ) { + issueCount + } + reviewedPRs: search( + query: $reviewedPRsSearchQuery + type: ISSUE + first: 1 + ) { + issueCount + } + } + `; + + // 2024年のデータ取得 + const { user, closedIssuesAssigned, reviewedPRs } = + await graphqlWithAuth({ + query, + login, + from, + to, + closedIssuesByAssigneeQuery: closedIssuesByAssigneeQueryThisYear, + reviewedPRsSearchQuery: reviewedPRsSearchQueryThisYear, + }); - const { user } = await graphqlWithAuth({ + // 2023年のデータ取得 + const { + user: userPrevYear, + closedIssuesAssigned: closedIssuesAssignedPrevYear, + reviewedPRs: reviewedPRsPrevYear, + } = await graphqlWithAuth({ query, login, + from: fromPrevYear, + to: toPrevYear, + closedIssuesByAssigneeQuery: closedIssuesByAssigneeQueryPrevYear, + reviewedPRsSearchQuery: reviewedPRsSearchQueryPrevYear, }); + // 2023年のデータから前年の統計を取得 + const prevYearCommitContributions = + userPrevYear.contributionsCollection.commitContributionsByRepository; + // 前年の総コミット数 + const previousYearTotalCommitCount = prevYearCommitContributions.reduce( + (sum, item) => sum + item.contributions.totalCount, + 0, + ); + // 2023年のオープンしたPR数 + const previousYearOpenedPullRequests = + userPrevYear.contributionsCollection.pullRequestContributions.totalCount; + // 2023年のレビューしたPR数 + const previousYearReviewedPullRequests = reviewedPRsPrevYear.issueCount; + // 2023年のアサインされてクローズしたISSUE数 + const previousYearClosedIssuesAssignedCount = + closedIssuesAssignedPrevYear.issueCount; + + // 総貢献数 const totalContributions = user.contributionsCollection.contributionCalendar.totalContributions; - - const weeklyContributions = + // 日別の貢献数を配列として取得 + const dailyContributions = user.contributionsCollection.contributionCalendar.weeks .flatMap((week) => week.contributionDays) .map((day) => ({ date: day.date, contributionCount: day.contributionCount, })); + // 週ごとの貢献数として扱う + const weeklyContributions = dailyContributions; + // リポジトリ一覧 const repositories = user.repositories.nodes.map((repo) => ({ + nameWithOwner: repo.nameWithOwner, language: repo.primaryLanguage ? repo.primaryLanguage.name : null, stargazerCount: repo.stargazerCount, + createdAt: repo.createdAt, + })); + // リポジトリ別コミット数 + const commitContributions = + user.contributionsCollection.commitContributionsByRepository; + // コミット数の多いリポジトリをコミット数順でソートした配列を取得 + const repositoriesByCommitCount: RepositoryCommitStats[] = commitContributions + .map((item) => ({ + nameWithOwner: item.repository.nameWithOwner, + commitCount: item.contributions.totalCount, + })) + .sort((a, b) => b.commitCount - a.commitCount) + .slice(0, 5); + + // 言語ごとにコミット数を集計 + // リポジトリ名 -> 言語 のマップを作成 + const repoLanguageMap = repositories.reduce>( + (acc, repo) => { + acc[repo.nameWithOwner] = repo.language; + return acc; + }, + {}, + ); + // 言語 -> コミット数 を集計 + const languageCommitCountMap = commitContributions.reduce< + Record + >((acc, item) => { + const repoName = item.repository.nameWithOwner; + const language = repoLanguageMap[repoName]; + if (language) { + acc[language] = (acc[language] || 0) + item.contributions.totalCount; + } + return acc; + }, {}); + // コミット数の多い言語をコミット数順でソートした配列を取得 + const languagesByCommitCount: LanguageCommitStats[] = Object.entries( + languageCommitCountMap, + ) + .map(([language, commitCount]) => ({ language, commitCount })) + .sort((a, b) => b.commitCount - a.commitCount); + + // 総コミット数 + const totalCommitCount = commitContributions.reduce( + (sum, item) => sum + item.contributions.totalCount, + 0, + ); + + // 自分がオープンしたPR数 + const openedPullRequests = + user.contributionsCollection.pullRequestContributions.totalCount; + + // 自分がレビューしたPR数 + const reviewedPullRequests = reviewedPRs.issueCount; + + // 自分がアサインされていてクローズしたISSUE + const closedIssuesAssignedCount = closedIssuesAssigned.issueCount; + + // 作成したリポジトリ数 + const newlyCreatedRepositoryCount = repositories.filter((repo) => { + const created = new Date(repo.createdAt); + return created >= new Date(from) && created <= new Date(to); + }).length; + + // 月ごとの貢献数を集計 + type MonthlyStats = { + [yearMonthKey: string]: number; // "YYYY-3" のように year + monthIndex をキーに + }; + const monthlyStats: MonthlyStats = {}; + // 日別の貢献数を集計 + for (const day of dailyContributions) { + const d = new Date(day.date); + const key = `${d.getFullYear()}-${d.getMonth()}`; // 例: "2024-3" + monthlyStats[key] = (monthlyStats[key] || 0) + day.contributionCount; + } + // 配列に変換 + const monthlyContributions = Object.entries(monthlyStats).map( + ([key, total]) => { + const [_, monthIndexStr] = key.split("-"); + const idx = Number.parseInt(monthIndexStr, 10); + return { + // month: monthNames[idx], + month: `${idx + 1}月`, + contributionCount: total, + }; + }, + ); + + // 曜日ごとの平均貢献数を集計 + const dayOfWeekMap = [ + "日曜日", + "月曜日", + "火曜日", + "水曜日", + "木曜日", + "金曜日", + "土曜日", + ]; + type DayOfWeekStats = { total: number; count: number }; + // 曜日ごとの合計・件数を初期化 + const dayOfWeekStats: DayOfWeekStats[] = Array.from({ length: 7 }, () => ({ + total: 0, + count: 0, })); + // 曜日ごとに合計・件数を集計 + for (const day of dailyContributions) { + const w = new Date(day.date).getDay(); // 0=日曜日, 1=月曜日, ... + dayOfWeekStats[w].total += day.contributionCount; + dayOfWeekStats[w].count += 1; + } + // 平均値を出して配列化 + const averageContributionsByDayOfWeek = dayOfWeekStats.map((stats, idx) => { + const avg = stats.count > 0 ? stats.total / stats.count : 0; + return { + dayOfWeek: dayOfWeekMap[idx], + averageContributions: Math.round(avg * 100) / 100, + }; + }); return { + userProfile: { + joinedDate: user.createdAt, + bio: user.bio, + followingCount: user.following.totalCount, + followersCount: user.followers.totalCount, + }, totalContributions, weeklyContributions, repositories, + repositoriesByCommitCount, + languagesByCommitCount, + totalCommitCount, + openedPullRequests, + reviewedPullRequests, + closedIssuesAssigned: closedIssuesAssignedCount, + newlyCreatedRepositoryCount, + monthlyContributions, + averageContributionsByDayOfWeek, + previousYearStats: { + totalCommitCount: previousYearTotalCommitCount, + openedPullRequests: previousYearOpenedPullRequests, + reviewedPullRequests: previousYearReviewedPullRequests, + closedIssuesAssignedCount: previousYearClosedIssuesAssignedCount, + }, }; }; diff --git a/app/recap/github-grass.tsx b/app/recap/github-grass.tsx deleted file mode 100644 index 6c6bfdf..0000000 --- a/app/recap/github-grass.tsx +++ /dev/null @@ -1,168 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { SkipForward } from "lucide-react"; -import type { FC } from "react"; -import { useState } from "react"; - -type Props = { - weeklyContributions: { date: string; contributionCount: number }[]; -}; - -export const GitHubGrass: FC = ({ weeklyContributions }) => { - // アニメーションをスキップするか - const [skipAnimation, setSkipAnimation] = useState(false); - // 最後のセルのアニメーションが終了したか - const [hasAnimationEnded, setHasAnimationEnded] = useState(false); - - const weeks = chunkByWeek(weeklyContributions); - - // 「最後のセルの onAnimationEnd」ハンドラ - const handleLastCellAnimationEnd = () => { - setHasAnimationEnded(true); - }; - - return ( -
-
- {weeks.map((daysInWeek, weekIndex) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
- {daysInWeek.map((day, dayIndex) => { - const finalColor = getContributionColor(day.contributionCount); - - // セルごとの遅延 - const cellDelay = (weekIndex * 7 + dayIndex) * 0.03; - - // 最後のセルかどうかを判定 - const isLastCell = - weekIndex === weeks.length - 1 && - dayIndex === daysInWeek.length - 1; - - // スキップON or アニメーション終了後は最終色を即時表示 - const isFinal = skipAnimation || hasAnimationEnded; - - // スキップOFFかつアニメーションがまだ終わっていない場合のみ、animation適用 - const classNameWhenNotSkipped = - "w-3 h-3 rounded-[2px] animate-fadeInBg"; - const classNameWhenSkipped = "w-3 h-3 rounded-[2px]"; - - // スタイル切り替え - const styleWhenAnimating = { - ["--final-bg" as string]: finalColor, - animationDelay: `${cellDelay}s`, - }; - const styleWhenDone = { - backgroundColor: finalColor, - }; - - return ( -
- key={dayIndex} - className={cn( - isFinal ? classNameWhenSkipped : classNameWhenNotSkipped, - )} - style={isFinal ? styleWhenDone : styleWhenAnimating} - title={`${day.date}: ${day.contributionCount} contributions`} - // 最後のセルだけアニメーション終了をキャッチして hasAnimationEnded = true にする - onAnimationEnd={ - // スキップOFFかつまだアニメが終わっていなくて、かつ最後のセル - !skipAnimation && !hasAnimationEnded && isLastCell - ? handleLastCellAnimationEnd - : undefined - } - /> - ); - })} -
- ))} -
-
- - -
-
- ); -}; - -/** - * アクティビティレベルのインジケータ - */ -const ActivityLevelIndicator = () => { - return ( -
-

Less

- {contributionColors.map(({ min, max, color }) => ( -
- ))} -

More

-
- ); -}; - -const contributionColors = [ - { min: 0, max: 0, color: "#ebedf0" }, - { min: 1, max: 11, color: "#9be9a8" }, - { min: 12, max: 22, color: "#40c463" }, - { min: 23, max: 34, color: "#30a14e" }, - { min: 35, max: Number.POSITIVE_INFINITY, color: "#216e39" }, -]; - -const getContributionColor = (count: number) => { - const colorObj = contributionColors.find( - ({ min, max }) => count >= min && count <= max, - ); - return colorObj ? colorObj.color : "#ebedf0"; -}; - -/** - * 連続した日付配列を「日曜始まり」に揃えてから、7日(1週間)ずつに区切る関数 - * ・最初の日の曜日を取得し、もし日曜でなければダミー要素を先頭に追加 - * ・7日ごとにチャンク - */ -const chunkByWeek = ( - contributions: { date: string; contributionCount: number }[], -) => { - // 1. 日付昇順にソート - const sorted = [...contributions].sort( - (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), - ); - - // 2. 先頭日の曜日を取得(0=日, 1=月, ... 6=土) - const firstDay = new Date(sorted[0].date).getDay(); - - // 3. 先頭日が日曜日でなければ、日曜スタートに合わせるためのダミーを追加 - const placeholders = []; - for (let i = 0; i < firstDay; i++) { - placeholders.push({ - date: "", // 日付なし - contributionCount: 0, - }); - } - - // 4. ダミーを先頭に連結 - const aligned = [...placeholders, ...sorted]; - - // 5. 7日ごとに分割 - const chunked: { date: string; contributionCount: number }[][] = []; - for (let i = 0; i < aligned.length; i += 7) { - chunked.push(aligned.slice(i, i + 7)); - } - - return chunked; -}; diff --git a/app/recap/loading.tsx b/app/recap/loading.tsx index 6634547..31e25f9 100644 --- a/app/recap/loading.tsx +++ b/app/recap/loading.tsx @@ -1,7 +1,12 @@ export const runtime = "edge"; -export default function Loading() { +export default function Loader() { return ( -
loading...
+
+
+
); } diff --git a/app/recap/page.tsx b/app/recap/page.tsx index 9c9f8de..39e8aa8 100644 --- a/app/recap/page.tsx +++ b/app/recap/page.tsx @@ -1,10 +1,10 @@ import { auth } from "@/lib/auth"; +import { OverView } from "./components/overview"; import { fetchGitHubStats } from "./fetchGitHubStats"; -import { GitHubGrass } from "./github-grass"; export const runtime = "edge"; -export default async function Page() { +export default async function page() { const session = await auth(); if (!session?.user) return null; const token = process.env.GITHUB_TOKEN ?? session.accessToken; @@ -14,12 +14,5 @@ export default async function Page() { login: session.user.login, }); - return ( -
-

Contributions for @{session.user.login}

-
- -
-
- ); + return ; } diff --git a/bun.lockb b/bun.lockb index e10d9d3..2cdcaf2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..d3b48e6 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client"; + +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..cb84fd1 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,113 @@ +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>