diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cdd8830 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# Claude Code Guidelines + +## Development Commands + +```bash +# Development +bun run dev # Start development server + +# Build & Lint +bun run build # Production build +bun run lint # Run ESLint +``` + +## Project Structure + +- `/app` - Next.js App Router pages and components +- `/server` - Server-side services and plugins +- `/src` - Shared utilities, hooks, and localization +- `/public` - Static assets + +## Styling Guidelines + +### Tailwind Color System + +This project uses a custom color palette defined in `tailwind.config.js`. **Always use Tailwind classes instead of hardcoded colors**. + +#### Text Colors +```tsx +// Main text - use for primary content +className="text-black dark:text-white" + +// Placeholder/secondary text +className="text-placeholder-light dark:text-placeholder-dark" + +// Link text +className="text-link-light dark:text-link-dark" +``` + +#### Background Colors +```tsx +// Paper/card backgrounds +className="bg-paper-light dark:bg-paper-dark" + +// Modal backgrounds +className="bg-modal-light dark:bg-modal-dark" + +// Gray backgrounds (1=lightest, 9=darkest) +className="bg-gray1 dark:bg-gray8" +className="hover:bg-gray2 dark:hover:bg-gray7" +``` + +#### Border Colors +```tsx +className="border-border-light dark:border-border-dark" +``` + +#### Brand/Accent Colors +```tsx +// Primary brand color (blue) - use for primary actions and highlights +className="bg-brand" // #4190EB + +// Primary buttons +className="bg-btn-primary-light dark:bg-btn-primary-dark" +className="text-btn-primary-text-light dark:text-btn-primary-text-dark" + +// Status colors +className="text-success-light dark:text-success-dark" +className="text-danger-light dark:text-danger-dark" +className="text-warning-light dark:text-warning-dark" +``` + +### Typography +Use predefined font sizes: +- `text-h1` through `text-h4` for headings +- `text-body1` through `text-body4` for body text + +### Common Patterns + +#### Dark Mode Support +Always provide both light and dark variants: +```tsx +// Correct +className="bg-paper-light dark:bg-paper-dark text-black dark:text-white" + +// Incorrect - hardcoded colors +style={{ backgroundColor: '#1a1a1a', color: '#ffffff' }} +``` + +#### Hover States +```tsx +className="hover:bg-gray1 dark:hover:bg-gray8" +``` + +## Component Guidelines + +- Use `clsx` for conditional class names +- Prefer Tailwind classes over inline styles +- Support both light and dark modes for all UI components diff --git a/app/[lang]/(common)/Header/index.tsx b/app/[lang]/(common)/Header/index.tsx index 2553b97..4023ea6 100644 --- a/app/[lang]/(common)/Header/index.tsx +++ b/app/[lang]/(common)/Header/index.tsx @@ -21,6 +21,16 @@ import Button from '../Button'; import SwitchToggle from './SwitchToggle'; const inter = Inter({subsets: ['latin']}); +const normalizePath = (path: string): string => path.replace(/\/+$/, ''); +const isActivePath = ( + pathname: string | null, + lang: string, + path: string, +): boolean => { + const target = normalizePath(`/${lang}${path}`); + const current = normalizePath(pathname ?? ''); + return current === target || current.startsWith(`${target}/`); +}; export type NavLink = { name: string; @@ -64,7 +74,9 @@ function DesktopNavMenus( href={`${link.path}`} className={clsx( 'text-body4 truncate', - pathname?.includes(link.path) ? 'opacity-100' : 'opacity-30', + isActivePath(pathname, lang, link.path) + ? 'opacity-100' + : 'opacity-30', )} >
  • designed by   - hyochan + hyochan

    diff --git a/app/[lang]/layout.tsx b/app/[lang]/layout.tsx index 5cf8550..7ea096f 100644 --- a/app/[lang]/layout.tsx +++ b/app/[lang]/layout.tsx @@ -30,9 +30,10 @@ export default async function LangLayout(props: Props): Promise { className={clsx( 'text-center flex-1 self-stretch relative', 'flex flex-col-reverse', + 'min-w-0 overflow-x-hidden', )} > -
    +
    {children}
    (null); + const [data, setData] = useState(initialData); + const [selectedTier, setSelectedTier] = useState(null); + const [tierData, setTierData] = useState([]); + const [isLoadingTier, setIsLoadingTier] = useState(false); + const [cursor, setCursor] = useState( + (initialData?.length || 0) > 0 + ? new Date(initialData?.[initialData?.length - 1]?.createdAt) + : null, + ); + + const handleTierSelect = useCallback(async (tier: Tier | null) => { + setSelectedTier(tier); + + if (!tier) { + setTierData([]); + return; + } + + setIsLoadingTier(true); + try { + const response = await fetch(`${API_USERS_BY_TIER}?tier=${tier}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const result = await response.json(); + if (result.users) { + setTierData(result.users); + } + } catch (error) { + console.error('Failed to fetch tier users:', error); + setTierData([]); + } finally { + setIsLoadingTier(false); + } + }, []); + + // Use tier data when a tier is selected, otherwise use recent data + const displayData = selectedTier ? tierData : data; + + const columnsDef: ColumnDef = useMemo( + () => [ + { + id: 'login', + headerClassName: 'w-6/12 py-[12px]', + cellClassName: 'w-6/12 h-[50px] py-[8px] text-default', + header: () => ( +
    + {t.githubUsername} +
    + ), + cell: ({login, avatarUrl}) => ( +
    + avatar +

    {login}

    +
    + ), + }, + { + id: 'tierName', + headerClassName: 'w-3/12 py-[12px]', + cellClassName: 'text-start w-3/12 h-[50px] py-[8px]', + header: () => ( +
    + {t.tier} +
    + ), + cell: ({tierName}) => , + }, + { + id: 'score', + headerClassName: 'w-3/12 py-[12px]', + cellClassName: 'text-start w-3/12 h-[50px] py-[8px]', + header: () => ( +
    + {t.score} +
    + ), + cell: ({score}) =>
    {score}
    , + }, + ], + [t.githubUsername, t.score, t.tier], + ); + + const handleScroll: UIEventHandler = async ( + e, + ): Promise => { + const hasEndReached = + Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >= + e.currentTarget.scrollHeight; + + if (hasEndReached) { + if (!cursor) { + return; + } + + try { + const {users} = await fetchRecentList({ + pluginId: 'dooboo-github', + take: 20, + cursor, + }); + + let nextCursor: Date | null = null; + setData((prevData) => { + const filteredUsers = users.filter( + (el) => !prevData.some((existing) => existing.login === el.login), + ); + if (filteredUsers.length === 0) return prevData; + nextCursor = new Date( + filteredUsers[filteredUsers.length - 1].createdAt, + ); + return [...prevData, ...filteredUsers]; + }); + if (nextCursor) { + setCursor(nextCursor); + } + } catch (error) { + console.error('Failed to fetch more users:', error); + } + } + }; + + return ( +
    + {/* Tier filter labels */} +
    + + {TIER_ORDER.map((tier) => ( + + ))} +
    + + {/* Data table */} +
    + { + const login = user.login; + window.open(`/stats/${login}`, '_blank', 'noopener'); + }} + className="p-6 max-[480px]:p-4" + classNames={{ + tHead: + 'bg-paper backdrop-blur-xl border-b border-black/10 dark:border-white/10 px-2 pb-2 -mx-6 -mt-6 px-6 pt-6 rounded-t-[20px] max-[480px]:-mx-4 max-[480px]:-mt-4 max-[480px]:px-4 max-[480px]:pt-4 max-[480px]:rounded-t-[16px]', + tBodyRow: + 'hover:bg-black/10 dark:hover:bg-white/5 transition-all duration-200 rounded-[8px] my-1', + }} + /> +
    +
    + ); +} diff --git a/app/[lang]/recent-list/TierRowItem.tsx b/app/[lang]/leaderboards/TierRowItem.tsx similarity index 100% rename from app/[lang]/recent-list/TierRowItem.tsx rename to app/[lang]/leaderboards/TierRowItem.tsx diff --git a/app/[lang]/leaderboards/TopTierUsers.tsx b/app/[lang]/leaderboards/TopTierUsers.tsx new file mode 100644 index 0000000..915b0b3 --- /dev/null +++ b/app/[lang]/leaderboards/TopTierUsers.tsx @@ -0,0 +1,135 @@ +'use client'; + +import type {ReactElement} from 'react'; +import {useEffect, useState} from 'react'; +import clsx from 'clsx'; +import Image from 'next/image'; + +import type {UserListItem} from '../../../src/fetches/recentList'; +import {getTierSvg} from '../../../src/utils/functions'; +import styles from '../styles.module.css'; +import {API_TOP_TIER_USERS} from './apiRoutes'; + +import type {Tier} from './TierRowItem'; + +type Props = { + title: string; +}; + +export default function TopTierUsers({title}: Props): ReactElement { + const [topTierUsers, setTopTierUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchTopUsers = async () => { + try { + const response = await fetch(API_TOP_TIER_USERS); + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + const data = await response.json(); + if (data.users) { + setTopTierUsers(data.users); + } + } catch (error) { + console.error('Failed to fetch top tier users:', error); + } finally { + setIsLoading(false); + } + }; + + fetchTopUsers(); + }, []); + + if (isLoading) { + return ( +
    + {title} +
    + {[...Array(5)].map((_, i) => ( +
    +
    +
    +
    + ))} +
    +
    + ); + } + + if (topTierUsers.length === 0) { + return <>; + } + + return ( +
    + {title} +
    + {topTierUsers.map((user) => ( + + {user.login} + {user.tierName} + + {user.login} + + + {user.score} + + + ))} +
    +
    + ); +} diff --git a/app/[lang]/leaderboards/apiRoutes.ts b/app/[lang]/leaderboards/apiRoutes.ts new file mode 100644 index 0000000..1c34233 --- /dev/null +++ b/app/[lang]/leaderboards/apiRoutes.ts @@ -0,0 +1,2 @@ +export const API_USERS_BY_TIER = '/api/users-by-tier'; +export const API_TOP_TIER_USERS = '/api/top-tier-users'; diff --git a/app/[lang]/recent-list/page.tsx b/app/[lang]/leaderboards/page.tsx similarity index 51% rename from app/[lang]/recent-list/page.tsx rename to app/[lang]/leaderboards/page.tsx index 9e9526b..546826f 100644 --- a/app/[lang]/recent-list/page.tsx +++ b/app/[lang]/leaderboards/page.tsx @@ -6,8 +6,10 @@ import {getSupabaseClient} from '../../../server/supabaseClient'; import type {UserListItem} from '../../../src/fetches/recentList'; import {getTranslates} from '../../../src/localization'; import {getUserPlugins} from '../../../src/utils/functions'; +import styles from '../styles.module.css'; import GithubUserList from './GithubUserList'; +import TopTierUsers from './TopTierUsers'; import {H1} from '~/components/Typography'; import type {Locale} from '~/i18n'; @@ -22,7 +24,7 @@ type Props = { export default async function Page(props: Props): Promise { const params = await props.params; const lang = params.lang as Locale; - const {recentList} = await getTranslates(lang); + const {leaderboards} = await getTranslates(lang); const supabase = getSupabaseClient(); const {data: plugin} = await supabase @@ -38,28 +40,46 @@ export default async function Page(props: Props): Promise {
    -

    +

    + {leaderboards.title} +

    +
    - {recentList.title} - - +
    +
    + +
    +
    + +
    +
    +
    ); diff --git a/app/[lang]/recent-list/GithubUserList.tsx b/app/[lang]/recent-list/GithubUserList.tsx deleted file mode 100644 index d60f37c..0000000 --- a/app/[lang]/recent-list/GithubUserList.tsx +++ /dev/null @@ -1,139 +0,0 @@ -'use client'; - -import type {ReactElement, UIEventHandler} from 'react'; -import {useMemo, useRef, useState} from 'react'; -import clsx from 'clsx'; -import Image from 'next/image'; - -import type {UserListItem} from '../../../src/fetches/recentList'; -import {fetchRecentList} from '../../../src/fetches/recentList'; -import type {Translates} from '../../../src/localization'; -import type {ColumnDef} from '../(common)/DataTable'; -import {DataTable} from '../(common)/DataTable'; -import styles from '../styles.module.css'; - -import type {Tier} from './TierRowItem'; -import TierRowItem from './TierRowItem'; - -import {H4, H5} from '~/components/Typography'; - -type Props = { - t: Translates['recentList']; - initialData: UserListItem[]; -}; - -export default function GithubUserList({t, initialData}: Props): ReactElement { - const tBodyRef = useRef(null); - const [data, setData] = useState(initialData); - const [cursor, setCursor] = useState( - (initialData?.length || 0) > 0 - ? new Date(initialData?.[initialData?.length - 1]?.createdAt) - : null, - ); - - const columnsDef: ColumnDef = useMemo( - () => [ - { - id: 'login', - headerClassName: 'w-6/12 py-[12px]', - cellClassName: 'w-6/12 h-[50px] py-[8px] text-default', - header: () => ( -
    - {t.githubUsername} -
    - ), - cell: ({login, avatarUrl}) => ( -
    - avatar -

    {login}

    -
    - ), - }, - { - id: 'tierName', - headerClassName: 'w-3/12 py-[12px]', - cellClassName: 'h-[37px] text-start w-3/12 h-[50px] py-[8px]', - header: () => ( -
    - {t.tier} -
    - ), - cell: ({tierName}) => , - }, - { - id: 'score', - headerClassName: 'w-3/12 py-[12px]', - cellClassName: 'h-[37px] text-start w-3/12 h-[50px] py-[8px]', - header: () => ( -
    - {t.score} -
    - ), - cell: ({score}) =>
    {score}
    , - }, - ], - [t.githubUsername, t.score, t.tier], - ); - - const handleScroll: UIEventHandler = async ( - e, - ): Promise => { - const hasEndReached = - Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >= - e.currentTarget.scrollHeight; - - if (hasEndReached) { - if (!cursor) { - return; - } - - const {users} = await fetchRecentList({ - pluginId: 'dooboo-github', - take: 20, - cursor, - }); - - const filteredUsers = users.filter((el) => !data.includes(el)); - - setData([...data, ...filteredUsers]); - setCursor(new Date(filteredUsers?.[filteredUsers.length - 1]?.createdAt)); - } - }; - - return ( -
    - { - const login = user.login; - window.open('http://github.com/' + login); - }} - className="p-6 max-[480px]:p-4" - classNames={{ - tHead: 'bg-paper backdrop-blur-xl border-b border-black/10 dark:border-white/10 px-2 pb-2 -mx-6 -mt-6 px-6 pt-6 rounded-t-[20px] max-[480px]:-mx-4 max-[480px]:-mt-4 max-[480px]:px-4 max-[480px]:pt-4 max-[480px]:rounded-t-[16px]', - tBodyRow: 'hover:bg-black/10 dark:hover:bg-white/5 transition-all duration-200 rounded-[8px] my-1', - }} - /> -
    - ); -} diff --git a/app/[lang]/stats/Container.tsx b/app/[lang]/stats/Container.tsx index ccf5fa3..1f48392 100644 --- a/app/[lang]/stats/Container.tsx +++ b/app/[lang]/stats/Container.tsx @@ -20,7 +20,8 @@ export default function Container({t, children, headerSearch, headerRight}: Prop return (
    void; + className?: string; + isLoading?: boolean; +}; + +const MONTHS = [ + 'Jan', 'Feb', 'Mar', 'Apr', + 'May', 'Jun', 'Jul', 'Aug', + 'Sep', 'Oct', 'Nov', 'Dec', +]; + +export default function MonthPicker({ + t, + value, + onChangeAction, + className, + isLoading, +}: Props): ReactElement { + const [isOpen, setIsOpen] = useState(false); + const [viewYear, setViewYear] = useState(() => { + if (value) { + return parseInt(value.split('-')[0]); + } + return new Date().getFullYear(); + }); + + const containerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth(); + + const selectedYear = value ? parseInt(value.split('-')[0]) : null; + const selectedMonth = value ? parseInt(value.split('-')[1]) - 1 : null; + + const handleMonthClick = (monthIndex: number) => { + const newValue = `${viewYear}-${String(monthIndex + 1).padStart(2, '0')}`; + onChangeAction(newValue); + setIsOpen(false); + }; + + const handleClear = () => { + onChangeAction(undefined); + setIsOpen(false); + }; + + const isMonthDisabled = (monthIndex: number) => { + if (viewYear > currentYear) return true; + if (viewYear === currentYear && monthIndex > currentMonth) return true; + if (viewYear < currentYear - 10) return true; + return false; + }; + + const formatDisplay = () => { + if (!value) { + return t.selectPeriod; + } + const [year, month] = value.split('-'); + const monthName = MONTHS[parseInt(month, 10) - 1]; + // Show year only if it's not the current year + if (parseInt(year) === currentYear) { + return monthName; + } + return `${monthName} ${year}`; + }; + + return ( +
    + + {value && !isLoading && ( + + )} + {isLoading && ( +
    + )} + + {isOpen && ( +
    + {/* Year navigation */} +
    + + + {viewYear} + + +
    + + {/* Month grid - 4 columns x 3 rows */} +
    + {MONTHS.map((month, monthIndex) => { + const isSelected = + selectedYear === viewYear && selectedMonth === monthIndex; + const isDisabled = isMonthDisabled(monthIndex); + + return ( + + ); + })} +
    + + {/* Description and clear button */} +
    +

    + {t.periodDescription} +

    + {value && ( + + )} +
    +
    + )} +
    + ); +} diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx index c19718f..b0b1eb0 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx @@ -1,10 +1,14 @@ +'use client'; + import type {ReactElement} from 'react'; +import {useCallback, useState, useEffect, useRef} from 'react'; +import {usePathname, useRouter} from 'next/navigation'; import clsx from 'clsx'; import {Inter} from 'next/font/google'; import Image from 'next/image'; import type {ScoreType} from '../../../../../../server/plugins/svgs/functions'; -import type {DoobooStatsResponse} from '../../../../../../server/services/githubService'; +import type {StatsWithMonthly} from '..'; import type {Translates} from '../../../../../../src/localization'; import {getTierSvg} from '../../../../../../src/utils/functions'; import {statNames} from '..'; @@ -12,11 +16,44 @@ import {statNames} from '..'; import type {TierType} from '.'; import Logo from '@/public/assets/logo.svg'; +import StatsChart from './StatsChart'; +import MonthPicker from '../../MonthPicker'; const inter = Inter({subsets: ['latin']}); -function SectionHeader({t, stats}: SectionProps): ReactElement { +function SectionHeader({t, stats, endDate}: SectionProps): ReactElement { + const pathname = usePathname(); + const router = useRouter(); const pluginStats = stats.pluginStats; + const [isLoading, setIsLoading] = useState(false); + const pendingEndDateRef = useRef(endDate); + + useEffect(() => { + const frame = requestAnimationFrame(() => { + if ( + pendingEndDateRef.current === endDate || + pendingEndDateRef.current === undefined + ) { + pendingEndDateRef.current = undefined; + setIsLoading(false); + } + }); + return () => cancelAnimationFrame(frame); + }, [endDate, stats]); + + const handleEndDateChange = useCallback( + (newDate: string | undefined) => { + if (!pathname) return; + pendingEndDateRef.current = newDate; + setIsLoading(true); + if (newDate) { + router.push(`${pathname}?endDate=${newDate}`); + } else { + router.push(pathname); + } + }, + [pathname, router], + ); const sum = +pluginStats.earth.score + @@ -40,7 +77,7 @@ function SectionHeader({t, stats}: SectionProps): ReactElement { : (tiers[tiers.length - 1].tier as ScoreType['tierName']); return ( -
    +

    {t.achievement}

    @@ -48,7 +85,7 @@ function SectionHeader({t, stats}: SectionProps): ReactElement { {t.achievementDetails}

    {/* Badges */} -
    +
    {/* Tier badge */}
    {/* AVG score badge */} -
    +
    {t.avgScore}{' '}

    {score}

    @@ -78,11 +110,17 @@ function SectionHeader({t, stats}: SectionProps): ReactElement {
    {/* Scores */} -
    +
    {statNames.map((name) => { return ( -
    - +
    + {pluginStats[name].name}
    + {/* MonthPicker - positioned between scores and chart */} +
    + +
    + {/* Stats Chart - Monthly contributions */} +
    ); } @@ -143,13 +192,22 @@ function SectionBody({t, stats}: SectionProps): ReactElement { type SectionProps = { t: Translates['stats']; - stats: DoobooStatsResponse; + stats: StatsWithMonthly; + endDate?: string; }; -export default function SectionDooboo(props: SectionProps): ReactElement { +export default function SectionDooboo({ + endDate, + ...props +}: SectionProps): ReactElement { return ( -
    - +
    +
    diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionPeople.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionPeople.tsx index 6a4b028..80c04cd 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionPeople.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionPeople.tsx @@ -4,6 +4,7 @@ import {Inter} from 'next/font/google'; import type {DoobooStatsResponse} from '../../../../../../server/services/githubService'; import type {Translates} from '../../../../../../src/localization'; +import StatsRadar from './StatsRadar'; const inter = Inter({subsets: ['latin']}); @@ -58,6 +59,8 @@ function SectionHeader({t, stats}: SectionProps): ReactElement { ); })}
    + {/* Radar Chart - Pentagon showing 5 stats */} +
    ); } diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx new file mode 100644 index 0000000..64e355f --- /dev/null +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx @@ -0,0 +1,217 @@ +'use client'; + +import type {ReactElement} from 'react'; +import {useState, useEffect, useCallback} from 'react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from 'recharts'; +import type {LegendPayload} from 'recharts'; + +import type {MonthlyContribution} from '../../../../../../server/services/githubService'; + +type LineKey = 'commits' | 'pullRequests' | 'reviews'; + +type Props = { + monthlyContributions?: MonthlyContribution[]; + isLoading?: boolean; +}; + +export default function StatsChart({ + monthlyContributions, + isLoading, +}: Props): ReactElement | null { + // Use lazy initialization to avoid hydration mismatch + const [isClient, setIsClient] = useState(false); + const [hoveredLine, setHoveredLine] = useState(null); + const [activeLines, setActiveLines] = useState>( + new Set(['commits', 'pullRequests', 'reviews']), + ); + + useEffect(() => { + // Use requestAnimationFrame to defer state update and avoid React 19 warning + const rafId = requestAnimationFrame(() => { + setIsClient(true); + }); + return () => cancelAnimationFrame(rafId); + }, []); + + const handleLegendMouseEnter = useCallback((data: LegendPayload) => { + if (data?.dataKey !== undefined && data?.dataKey !== null) { + setHoveredLine(String(data.dataKey)); + } + }, []); + + const handleLegendMouseLeave = useCallback(() => { + setHoveredLine(null); + }, []); + + const handleLegendClick = useCallback((data: LegendPayload) => { + if (data?.dataKey) { + const key = String(data.dataKey) as LineKey; + setActiveLines((prev) => { + // If clicking on the only active line, reset to show all + if (prev.size === 1 && prev.has(key)) { + return new Set(['commits', 'pullRequests', 'reviews']); + } + // Otherwise, show only the clicked line + return new Set([key]); + }); + } + }, []); + + const getLineOpacity = (dataKey: string) => { + // If line is not active, hide it + if (!activeLines.has(dataKey as LineKey)) { + return 0; + } + // Use hover effect when hovering + if (!hoveredLine) return 1; + return hoveredLine === dataKey ? 1 : 0.15; + }; + + const isLineVisible = (dataKey: LineKey) => { + return activeLines.has(dataKey); + }; + + // Format month for display (YYYY-MM -> MMM) + const formatMonth = (month: string): string => { + const [, monthNum] = month.split('-'); + const monthIndex = parseInt(monthNum, 10) - 1; + if (Number.isNaN(monthIndex) || monthIndex < 0 || monthIndex > 11) { + return month; + } + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return months[monthIndex]; + }; + + const chartData = monthlyContributions?.map((item) => ({ + month: formatMonth(item.month), + commits: item.commits, + pullRequests: item.pullRequests, + reviews: item.reviews, + })); + + // Don't render if no data or not on client + if (!isClient || !chartData || chartData.length === 0) { + return null; + } + + // Calculate max value for Y axis based on active lines only + const maxValue = Math.max( + ...chartData.flatMap((d) => { + const values: number[] = []; + if (activeLines.has('commits')) values.push(d.commits); + if (activeLines.has('pullRequests')) values.push(d.pullRequests); + if (activeLines.has('reviews')) values.push(d.reviews); + return values.length > 0 ? values : [0]; + }), + ); + const yAxisMax = Math.ceil(maxValue * 1.1) || 10; + + return ( +
    +
    +
    + + + + + + + {isLineVisible('commits') && ( + + )} + {isLineVisible('pullRequests') && ( + + )} + {isLineVisible('reviews') && ( + + )} + +
    +
    +
    + ); +} diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsRadar.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsRadar.tsx new file mode 100644 index 0000000..65e0174 --- /dev/null +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsRadar.tsx @@ -0,0 +1,60 @@ +'use client'; + +import type {ReactElement} from 'react'; +import {useState, useEffect} from 'react'; +import {RadarChart, PolarGrid, PolarAngleAxis, Radar} from 'recharts'; + +import type {PluginStats} from '../../../../../../server/plugins'; +import type {Translates} from '../../../../../../src/localization'; + +type Props = { + pluginStats: PluginStats; + t: Translates['stats']; +}; + +export default function StatsRadar({pluginStats, t}: Props): ReactElement | null { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + const rafId = requestAnimationFrame(() => { + setIsClient(true); + }); + return () => cancelAnimationFrame(rafId); + }, []); + + if (!isClient) { + return null; + } + + const radarData = [ + {stat: t.tree, value: Math.round(pluginStats.tree.score * 100)}, + {stat: t.fire, value: Math.round(pluginStats.fire.score * 100)}, + {stat: t.earth, value: Math.round(pluginStats.earth.score * 100)}, + {stat: t.gold, value: Math.round(pluginStats.gold.score * 100)}, + {stat: t.water, value: Math.round(pluginStats.water.score * 100)}, + ]; + + return ( +
    + + + + + +
    + ); +} diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx index e3c1b9c..da1224e 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx @@ -20,9 +20,10 @@ export default function StatsDetails({ t, stats, selectedStat, + endDate, }: ScouterProps & {selectedStat: StatName}): ReactElement { const map: Record = { - dooboo: , + dooboo: , tree: , fire: , earth: , @@ -32,7 +33,12 @@ export default function StatsDetails({ }; return ( -
    +
    {map[selectedStat]}
    ); diff --git a/app/[lang]/stats/[login]/Scouter/StatsHeader.tsx b/app/[lang]/stats/[login]/Scouter/StatsHeader.tsx index 6b0b32c..ead2c0d 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsHeader.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsHeader.tsx @@ -110,7 +110,8 @@ export default function StatsHeader({ 'h-14 max-[768px]:h-12 max-[480px]:h-10', 'min-h-[56px] max-[768px]:min-h-[48px] max-[480px]:min-h-[40px]', 'flex-shrink-0', - 'self-stretch items-center mb-6 rounded-[16px]', + 'self-stretch w-full min-w-0', + 'items-center mb-6 rounded-[16px]', 'px-4 max-[768px]:px-2 max-[480px]:px-1', 'bg-basic', 'border border-black/10 dark:border-white/10', diff --git a/app/[lang]/stats/[login]/Scouter/index.tsx b/app/[lang]/stats/[login]/Scouter/index.tsx index f7718c3..82aaa08 100644 --- a/app/[lang]/stats/[login]/Scouter/index.tsx +++ b/app/[lang]/stats/[login]/Scouter/index.tsx @@ -4,7 +4,10 @@ import type {ReactElement} from 'react'; import {useState} from 'react'; import clsx from 'clsx'; -import type {DoobooStatsResponse} from '../../../../../server/services/githubService'; +import type { + DoobooStatsResponse, + MonthlyContribution, +} from '../../../../../server/services/githubService'; import type {StatsInfo} from '../../../../../src/fetches/github'; import type {Translates} from '../../../../../src/localization'; import styles from '../../../styles.module.css'; @@ -21,21 +24,26 @@ export const statNames = [ 'people', ] as const; +export type StatsWithMonthly = DoobooStatsResponse & { + monthlyContributions?: MonthlyContribution[]; +}; + export type ScouterProps = { t: Translates['stats']; - stats: DoobooStatsResponse; + stats: StatsWithMonthly; + endDate?: string; }; export type StatName = keyof StatsInfo | 'dooboo'; -export default function Scouter(props: ScouterProps): ReactElement { +export default function Scouter({endDate, ...props}: ScouterProps): ReactElement { const [selectedStat, setSelectedStat] = useState('dooboo'); return (
    @@ -45,7 +53,7 @@ export default function Scouter(props: ScouterProps): ReactElement { setSelectedStat(name); }} /> - +
    ); } diff --git a/app/[lang]/stats/[login]/SearchTextInput.tsx b/app/[lang]/stats/[login]/SearchTextInput.tsx index ccd517e..ee4becd 100644 --- a/app/[lang]/stats/[login]/SearchTextInput.tsx +++ b/app/[lang]/stats/[login]/SearchTextInput.tsx @@ -1,13 +1,13 @@ 'use client'; import type {ReactElement} from 'react'; -import {useState, useRef} from 'react'; -import {useForm} from 'react-hook-form'; +import {useState, useRef, useEffect} from 'react'; import {SearchIcon} from '@primer/octicons-react'; import clsx from 'clsx'; +import {usePathname, useRouter} from 'next/navigation'; + import type {Translates} from '../../../../src/localization'; -import Button from '../../(common)/Button'; import TextInput from '../../(common)/TextInput'; import SearchHistoryDropdown from '../../(home)/Hero/SearchHistoryDropdown'; import {useSearchHistory} from '../../../../src/hooks/useSearchHistory'; @@ -23,18 +23,48 @@ export default function SearchTextInput({ }): ReactElement { const [login, setLogin] = useState(initialValue); const [showHistory, setShowHistory] = useState(false); - const {formState} = useForm(); + const [isLoading, setIsLoading] = useState(false); + const pendingLoginRef = useRef(null); const {history, addToHistory, removeFromHistory} = useSearchHistory(); const searchContainerRef = useRef(null); + const pathname = usePathname(); + const router = useRouter(); + + // Extract language from pathname (e.g., /ko/stats/hyochan -> ko) + const lang = pathname?.split('/')[1] || 'en'; + + // Keep local login input in sync with incoming value + useEffect(() => { + setLogin(initialValue); + }, [initialValue]); + + // Reset loading state when navigation completes + useEffect(() => { + const frame = requestAnimationFrame(() => { + if (pendingLoginRef.current && initialValue === pendingLoginRef.current) { + pendingLoginRef.current = null; + setIsLoading(false); + } + }); + return () => cancelAnimationFrame(frame); + }, [initialValue]); + + const navigateTo = (loginValue: string) => { + // Avoid toggling loading when navigating to the same user + if (loginValue === initialValue) { + return; + } + + pendingLoginRef.current = loginValue; + setIsLoading(true); + router.push(`/${lang}/stats/${loginValue}`); + }; const handleHistorySelect = (item: string) => { setLogin(item); setShowHistory(false); addToHistory(item); - // Trigger navigation - setTimeout(() => { - window.location.href = `/stats/${item}`; - }, 100); + navigateTo(item); }; const handleSubmit = (e: React.FormEvent) => { @@ -42,7 +72,7 @@ export default function SearchTextInput({ if (login) { addToHistory(login); setShowHistory(false); - window.location.href = `/stats/${login}`; + navigateTo(login); } }; @@ -59,7 +89,7 @@ export default function SearchTextInput({ 'bg-black/10 dark:bg-white/5', 'backdrop-blur-md', 'border border-black/20 dark:border-white/10', - 'flex flex-row-reverse items-center', + 'flex items-center gap-2', 'hover:bg-black/15 dark:hover:bg-white/8', 'transition-all duration-300', )} @@ -76,15 +106,28 @@ export default function SearchTextInput({ setTimeout(() => setShowHistory(false), 200); }} /> -
    ; + searchParams: Promise<{endDate?: string}>; }; export default async function Page(props: Props): Promise { const params = await props.params; + const searchParams = await props.searchParams; const lang = params.lang as Locale; const login = params.login; + const endDate = searchParams.endDate; const {stats: tStats} = await getTranslates(lang); - const stats = await getDoobooStats({ - login, - lang, - }); + // Calculate start date (12 months before end date) + const getStartDate = (end?: string) => { + const endDateObj = end + ? new Date(`${end}-01T00:00:00Z`) + : new Date(); + const year = endDateObj.getUTCFullYear(); + const month = endDateObj.getUTCMonth() - 11; // 12 months before + const date = new Date(Date.UTC(year, month, 1)); + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`; + }; + + const startDate = getStartDate(endDate); + + // Fetch stats and monthly contributions in parallel + const [stats, monthlyContributions] = await Promise.all([ + getDoobooStats({login, lang, startDate: endDate}), + getMonthlyContribution({login, startDate}), + ]); + + // Merge monthly contributions into stats + const statsWithMonthly = stats + ? {...stats, monthlyContributions} + : null; return ( { /> } > - {!!stats ? : null} + {!!statsWithMonthly ? ( + + ) : null} ); } diff --git a/app/[lang]/stats/page.tsx b/app/[lang]/stats/page.tsx index b58b609..ed804dc 100644 --- a/app/[lang]/stats/page.tsx +++ b/app/[lang]/stats/page.tsx @@ -19,7 +19,7 @@ export default async function Page(props: Props): Promise { return (
    -

    {t.searchUserHint}.

    +

    {t.searchUserHint}

    =18.0.0" } }, "sha512-NtXEVAXvSh78+8JAnrVjpbftzD4kPowacv4GB2Nyq9C/8ko6fSm6M/XvKWQLCaZi68i9F28b++Sp8uVThlzLyg=="], @@ -1767,6 +1826,12 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "recharts": ["recharts@3.5.1", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -1785,6 +1850,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], @@ -2017,6 +2084,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -2025,6 +2094,8 @@ "v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="], + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w=="], "vitest": ["vitest@4.0.14", "", { "dependencies": { "@vitest/expect": "4.0.14", "@vitest/mocker": "4.0.14", "@vitest/pretty-format": "4.0.14", "@vitest/runner": "4.0.14", "@vitest/snapshot": "4.0.14", "@vitest/spy": "4.0.14", "@vitest/utils": "4.0.14", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.14", "@vitest/browser-preview": "4.0.14", "@vitest/browser-webdriverio": "4.0.14", "@vitest/ui": "4.0.14", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw=="], @@ -2155,6 +2226,8 @@ "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + "@reduxjs/toolkit/immer": ["immer@11.0.0", "", {}, "sha512-XtRG4SINt4dpqlnJvs70O2j6hH7H0X8fUzFsjMn1rwnETaxwp83HLNimXBjZ78MrKl3/d3/pkzDH0o0Lkxm37Q=="], + "@rollup/plugin-babel/rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="], "@rollup/plugin-node-resolve/rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="], diff --git a/locales/en.json b/locales/en.json index b61d068..93b05f8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,6 +1,6 @@ { "nav": { - "recentList": "Recent List", + "leaderboards": "Leaderboards", "stats": "Stats", "certifiedUsers": "Certified Users", "signIn": "Sign in", @@ -84,16 +84,19 @@ "termsOfService": "Terms of Service", "privacyPolicy": "Privacy Policy" }, - "recentList": { - "title": "Recent List", + "leaderboards": { + "title": "Leaderboards", "githubUsername": "GitHub username", - "noRecentList": "No recent list", + "noRecentList": "No users found", "tier": "Tier", - "score": "Score" + "score": "Score", + "all": "All", + "topUsers": "Top Users", + "topRanked": "Top Ranked" }, "stats": { "title": "Stats", - "searchUserHint": "Search for a user in the top right corner to view the score attributes in detail", + "searchUserHint": "Check the developer's power level by searching for their GitHub username.", "githubUsername": "GitHub username", "achievement": "Achievement", "achievementDetails": "Based on Github data", @@ -105,7 +108,11 @@ "water": "Water", "people": "People", "github": "Github", - "score": "score" + "score": "score", + "selectPeriod": "Select period", + "periodDescription": "Stats for 1 year from selected month", + "resetToDefault": "Reset to default", + "search": "Search" }, "certifiedUsers": { "title": "Certified Users", @@ -119,4 +126,4 @@ "goHome": "Go Home", "tryAgain": "Try Again" } -} \ No newline at end of file +} diff --git a/locales/ko.json b/locales/ko.json index 4879834..7b39bbc 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1,6 +1,6 @@ { "nav": { - "recentList": "최근 목록", + "leaderboards": "리더보드", "stats": "스탯", "certifiedUsers": "인증 목록", "signIn": "로그인", @@ -93,16 +93,19 @@ "termsOfService": "서비스 약관", "privacyPolicy": "개인 정보 보호 정책" }, - "recentList": { - "title": "최근 리스트", + "leaderboards": { + "title": "리더보드", "githubUsername": "깃허브 유저명", - "noRecentList": "최근 리스트가 없습니다", + "noRecentList": "유저가 없습니다", "tier": "티어", - "score": "점수" + "score": "점수", + "all": "전체", + "topUsers": "탑 유저", + "topRanked": "최고 티어" }, "stats": { "title": "스탯", - "searchUserHint": "점수 속성을 자세히 보려면 ​​오른쪽 상단 모서리에서 사용자를 검색하십시오", + "searchUserHint": "개발자의 전투력을 GitHub username을 검색해서 확인하세요.", "githubUsername": "깃허브 유저명", "achievement": "성취", "achievementDetails": "깃허브 데이터에 따름", @@ -114,7 +117,11 @@ "water": "수", "people": "인", "github": "Github", - "score": "점수" + "score": "점수", + "selectPeriod": "기간 선택", + "periodDescription": "선택한 월부터 1년간 스탯", + "resetToDefault": "기본값으로 초기화", + "search": "검색" }, "certifiedUsers": { "title": "인증된 사용자", diff --git a/package.json b/package.json index 24b4917..2a49a3b 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react-hook-form": "^7.66.1", "react-hot-toast": "^2.6.0", "react-text-transition": "^3.1.0", + "recharts": "^3.5.1", "server-only": "^0.0.1", "tiny-invariant": "^1.3.3", "typescript": "5.9.3", diff --git a/pages/api/github-stats-advanced.ts b/pages/api/github-stats-advanced.ts index 734e3f7..63d58d2 100644 --- a/pages/api/github-stats-advanced.ts +++ b/pages/api/github-stats-advanced.ts @@ -22,12 +22,14 @@ export default async function handler( switch (method) { case 'GET': + const startDate = req.query.startDate; assert(login, common.badRequest); try { const stats = await getDoobooStats({ login: login.toLocaleLowerCase(), lang: locale, + startDate, }); if (!stats) { diff --git a/pages/api/github-stats.ts b/pages/api/github-stats.ts index 59e887f..77566ad 100644 --- a/pages/api/github-stats.ts +++ b/pages/api/github-stats.ts @@ -36,12 +36,14 @@ export default async function handler( switch (method) { case 'GET': { const loginParam = req.query.login as string; + const startDate = req.query.startDate as string | undefined; assert(loginParam, common.badRequest); try { const stats = await getDoobooStats({ login: loginParam.toLocaleLowerCase(), lang: locale, + startDate, }); if (!stats) { @@ -66,12 +68,14 @@ export default async function handler( } case 'POST': { const loginBody = req.body.login as string; + const startDateBody = req.body.startDate as string | undefined; assert(loginBody, common.badRequest); try { const stats = await getDoobooStats({ login: loginBody.toLocaleLowerCase(), lang: locale, + startDate: startDateBody, }); if (!stats) { diff --git a/pages/api/github-trophies.ts b/pages/api/github-trophies.ts index ace87c0..dc5a7cc 100644 --- a/pages/api/github-trophies.ts +++ b/pages/api/github-trophies.ts @@ -24,12 +24,14 @@ export default async function handler( switch (method) { case 'GET': + const startDate = req.query.startDate; assert(login, common.badRequest); try { const stats = await getDoobooStats({ login: login.toLocaleLowerCase(), lang: locale, + startDate, }); if (!stats) { diff --git a/pages/api/top-tier-users.ts b/pages/api/top-tier-users.ts new file mode 100644 index 0000000..9b07b18 --- /dev/null +++ b/pages/api/top-tier-users.ts @@ -0,0 +1,61 @@ +import type {NextApiRequest, NextApiResponse} from 'next'; +import {getSupabaseClient} from '@/server/supabaseClient'; +import type {PluginUser} from '~/utils/functions'; +import {getTierName} from '~/utils/functions'; +import type {UserPluginRow} from '~/types/types'; + +type Reply = {message: string} | {users: PluginUser[]}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +): Promise { + const {method} = req; + + if (method !== 'GET') { + res.status(405).send({message: 'Method not allowed'}); + return; + } + + const supabase = getSupabaseClient(); + + // Get plugin info for tier calculation + const {data: plugin} = await supabase + .from('plugins') + .select('*') + .eq('id', 'dooboo-github') + .single(); + + if (!plugin) { + res.status(404).send({message: 'Plugin not found'}); + return; + } + + // Fetch top 5 users by score (highest score = highest tier) + const {data: userPlugins}: {data: UserPluginRow[] | null} = await supabase + .from('user_plugins') + .select('*') + .match({plugin_id: 'dooboo-github'}) + .order('score', {ascending: false}) + .limit(5); + + const pluginTiers = (plugin.json || []) as {tier: string; score: number}[]; + + const users: PluginUser[] = (userPlugins || []) + .filter((user) => user.github_id !== null) + .map((user) => { + const tierName = getTierName(user.score || 0, pluginTiers); + + return { + login: user.login, + githubId: user.github_id, + score: user.score, + avatarUrl: user.avatar_url, + tierName, + createdAt: user.created_at || '', + }; + }); + + res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate'); + res.status(200).send({users}); +} diff --git a/pages/api/users-by-tier.ts b/pages/api/users-by-tier.ts new file mode 100644 index 0000000..2b30fb8 --- /dev/null +++ b/pages/api/users-by-tier.ts @@ -0,0 +1,89 @@ +export const revalidate = 3600; + +import type {NextApiRequest, NextApiResponse} from 'next'; +import {getSupabaseClient} from '@/server/supabaseClient'; +import type {PluginUser} from '~/utils/functions'; +import {getTierName} from '~/utils/functions'; +import type {UserPluginRow} from '~/types/types'; + +type Reply = {message: string} | {users: PluginUser[]}; + +type TierDef = {tier: string; score: number}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +): Promise { + const {method, query} = req; + + if (method !== 'GET') { + res.status(405).send({message: 'Method not allowed'}); + return; + } + + const tier = query.tier as string; + + if (!tier) { + res.status(400).send({message: 'Tier is required'}); + return; + } + + const supabase = getSupabaseClient(); + + // Get plugin info for tier calculation + const {data: plugin} = await supabase + .from('plugins') + .select('*') + .eq('id', 'dooboo-github') + .single(); + + if (!plugin) { + res.status(404).send({message: 'Plugin not found'}); + return; + } + + const pluginTiers = (plugin.json || []) as TierDef[]; + + // Sort tiers by score ascending + const sortedTiers = [...pluginTiers].sort((a, b) => a.score - b.score); + + // Find the tier index and calculate score range + const tierIndex = sortedTiers.findIndex((t) => t.tier === tier); + + if (tierIndex === -1) { + res.status(400).send({message: 'Invalid tier'}); + return; + } + + const minScore = sortedTiers[tierIndex].score; + const maxScore = tierIndex < sortedTiers.length - 1 + ? sortedTiers[tierIndex + 1].score - 1 + : 100; + + // Fetch users by score range + const {data: userPlugins}: {data: UserPluginRow[] | null} = await supabase + .from('user_plugins') + .select('*') + .match({plugin_id: 'dooboo-github'}) + .gte('score', minScore) + .lte('score', maxScore) + .order('score', {ascending: false}) + .limit(50); + + const users: PluginUser[] = (userPlugins || []) + .filter((user) => user.github_id !== null) + .map((user) => { + const tierName = getTierName(user.score || 0, pluginTiers); + + return { + login: user.login, + githubId: user.github_id, + score: user.score, + avatarUrl: user.avatar_url, + tierName, + createdAt: user.created_at || '', + }; + }); + + res.status(200).send({users}); +} diff --git a/server/plugins/stats/fire.ts b/server/plugins/stats/fire.ts index 2ed5264..ea71283 100644 --- a/server/plugins/stats/fire.ts +++ b/server/plugins/stats/fire.ts @@ -13,27 +13,35 @@ export const getGithubFireScore = (githubUser: UserGraph): PluginValue => { }; } - const repos = githubUser.myRepos.edges.map((el) => { - const node = el.node; + const repos = githubUser.myRepos.edges + .map((el) => { + const node = el.node; + const owner = node.owner?.login; - return { - owner: node.owner.login, - name: node.name, - languages: node.languages.edges.map((ele) => ele.node.name), - }; - }); + if (!owner) return null; - const repositoriesContributedTo = githubUser.collaboratedRepos.edges.map( - (el) => { + return { + owner, + name: node.name, + languages: node.languages.edges.map((ele) => ele.node.name), + }; + }) + .filter((el): el is NonNullable => Boolean(el)); + + const repositoriesContributedTo = githubUser.collaboratedRepos.edges + .map((el) => { const node = el.node; + const owner = node.owner?.login; + + if (!owner) return null; return { - owner: node.owner.login, + owner, name: node.name, languages: node.languages.edges.map((ele) => ele.node.name), }; - }, - ); + }) + .filter((el): el is NonNullable => Boolean(el)); const totalCommits = githubUser.contributionsCollection.totalCommitContributions; diff --git a/server/plugins/stats/gold.ts b/server/plugins/stats/gold.ts index 9825584..0878ed2 100644 --- a/server/plugins/stats/gold.ts +++ b/server/plugins/stats/gold.ts @@ -15,78 +15,96 @@ export const getGithubGoldScore = (githubUser: UserGraph): PluginValue => { const sponsorCount = githubUser.sponsors.totalCount; const gistCount = githubUser.gists.totalCount; - const prRepos = githubUser.pullRequests.edges.map((el) => { - if (!el) { - return; - } + const prRepos = githubUser.pullRequests.edges + .map((el) => { + if (!el) { + return null; + } - const node = el.node; + const node = el.node; + const owner = node.repository.owner?.login; - return { - name: node.title, - number: node.number, - state: node.state, - createdAt: node.createdAt, - owner: node.repository.owner.login, - repositoryName: node.repository.name, - repoStarCount: node.repository.stargazerCount, - }; - }); + if (!owner) return null; - const contribRepoPRs = githubUser.contributedRepos.edges.map((el) => { - if (!el) { - return; - } - - if ( - prRepos.find( - (pr) => - pr?.repositoryName === el.node.name && - pr.owner === el.node.owner.login && - pr.state !== 'MERGED', - ) - ) { - // NOTE: Avoid counting contribution on `PR` opened - return; - } + return { + name: node.title, + number: node.number, + state: node.state, + createdAt: node.createdAt, + owner, + repositoryName: node.repository.name, + repoStarCount: node.repository.stargazerCount, + }; + }) + .filter((el): el is NonNullable => Boolean(el)); - return { - owner: el.node.owner.login, - name: el.node.name, - starCount: el.node.stargazerCount, - languages: el.node.languages.edges.map((ele) => ele.node.name), - }; - }); + const contribRepoPRs = githubUser.contributedRepos.edges + .map((el) => { + if (!el) { + return null; + } - const collaboratedRepos = githubUser.collaboratedRepos.edges.map((el) => { - if (!el) { - return null; - } + const owner = el.node.owner?.login; + if (!owner) return null; - const node = el.node; + if ( + prRepos.find( + (pr) => + pr?.repositoryName === el.node.name && + pr.owner === owner && + pr.state !== 'MERGED', + ) + ) { + // NOTE: Avoid counting contribution on `PR` opened + return null; + } - return { - owner: node.owner.login, - name: node.name, - starCount: node.stargazerCount, - languages: node.languages.edges.map((ele) => ele.node.name), - }; - }); + return { + owner, + name: el.node.name, + starCount: el.node.stargazerCount, + languages: el.node.languages.edges.map((ele) => ele.node.name), + }; + }) + .filter((el): el is NonNullable => Boolean(el)); - const myRepos = githubUser.myRepos.edges.map((el) => { - if (!el) { - return null; - } + const collaboratedRepos = githubUser.collaboratedRepos.edges + .map((el) => { + if (!el) { + return null; + } - const node = el.node; + const node = el.node; + const owner = node.owner?.login; + if (!owner) return null; - return { - owner: node.owner.login, - name: node.name, - starCount: node.stargazerCount, - languages: node.languages.edges.map((ele) => ele.node.name), - }; - }); + return { + owner, + name: node.name, + starCount: node.stargazerCount, + languages: node.languages.edges.map((ele) => ele.node.name), + }; + }) + .filter((el): el is NonNullable => Boolean(el)); + + const myRepos = githubUser.myRepos.edges + .map((el) => { + if (!el) { + return null; + } + + const node = el.node; + const owner = node.owner?.login; + if (!owner) return null; + + return { + owner, + name: node.name, + starCount: node.stargazerCount, + languages: node.languages.edges.map((ele) => ele.node.name), + }; + }) + .filter((el): el is NonNullable => Boolean(el)); const contribReposStarCount = contribRepoPRs.reduce( (prev, current) => prev + (current?.starCount || 0), diff --git a/server/plugins/types/UserGraph.ts b/server/plugins/types/UserGraph.ts index 21f203f..061e93d 100644 --- a/server/plugins/types/UserGraph.ts +++ b/server/plugins/types/UserGraph.ts @@ -86,6 +86,22 @@ export interface ContributionsCollection { totalRepositoryContributions: number; totalPullRequestContributions: number; totalPullRequestReviewContributions: number; + contributionCalendar?: ContributionCalendar; +} + +export interface ContributionCalendar { + totalContributions: number; + weeks: ContributionWeek[]; +} + +export interface ContributionWeek { + firstDay: string; + contributionDays: ContributionDay[]; +} + +export interface ContributionDay { + contributionCount: number; + date: string; } export interface PullRequests { diff --git a/server/services/githubService.ts b/server/services/githubService.ts index ede5a79..9022fd9 100644 --- a/server/services/githubService.ts +++ b/server/services/githubService.ts @@ -48,10 +48,34 @@ export const getAccessToken = async ( export const getGithubUser = async ( login: string, + startDate?: string, // ISO date string (YYYY-MM) for the start of 1-year period ): Promise<{data: {user: UserGraph}}> => { // Note: Duration to 12 months fails intermittently for some users like `mcollina`. For that, we could try something like 6 months in the future. - const date = new Date(); - date.setMonth(date.getMonth() - 12); + // If startDate is provided (YYYY-MM format), use that as the start of 1-year period + // Otherwise, default to 12 months ago from today + // Always start from the 1st of the month (use UTC noon to avoid timezone issues) + let date: Date; + if (startDate) { + const [year, month] = startDate.split('-').map(Number); + if ( + Number.isFinite(year) && + Number.isFinite(month) && + month >= 1 && + month <= 12 + ) { + date = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0)); + } else { + const now = new Date(); + date = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 12, 1, 12, 0, 0), + ); + } + } else { + const now = new Date(); + date = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 12, 1, 12, 0, 0), + ); + } const {data} = await axios({ method: 'post', @@ -152,10 +176,10 @@ export const getGithubUser = async ( totalCount edges { node { - createdAt owner { login } + createdAt watchers { totalCount } @@ -164,9 +188,6 @@ export const getGithubUser = async ( stargazerCount id name - owner { - login - } stargazers { totalCount } @@ -202,9 +223,6 @@ export const getGithubUser = async ( stargazerCount id name - owner { - login - } stargazers { totalCount } @@ -240,9 +258,6 @@ export const getGithubUser = async ( stargazerCount id name - owner { - login - } stargazers { totalCount } @@ -269,6 +284,60 @@ export const getGithubUser = async ( return data; }; +// Fetch monthly contribution counts from GitHub API +const fetchGithubMonthlyContributions = async ( + login: string, + month: string, // YYYY-MM +): Promise<{commits: number; pullRequests: number; reviews: number}> => { + const [year, mon] = month.split('-').map(Number); + + // fromDate: 1st of month at midnight UTC + const fromDate = new Date(Date.UTC(year, mon - 1, 1, 0, 0, 0)); + + // toDate: last day of month at 23:59:59 UTC + const toDate = new Date(Date.UTC(year, mon, 0, 23, 59, 59)); + + const {data} = await axios({ + method: 'post', + url: 'https://api.github.com/graphql', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `token ${GH_TOKEN}`, + }, + data: { + query: /* GraphQL */ ` + query monthlyContributions( + $username: String! + $from: DateTime! + $to: DateTime! + ) { + user(login: $username) { + contributionsCollection(from: $from, to: $to) { + totalCommitContributions + totalPullRequestContributions + totalPullRequestReviewContributions + } + } + } + `, + variables: { + username: login, + from: fromDate.toISOString(), + to: toDate.toISOString(), + }, + }, + }); + + const contributions = data?.data?.user?.contributionsCollection; + + return { + commits: contributions?.totalCommitContributions || 0, + pullRequests: contributions?.totalPullRequestContributions || 0, + reviews: contributions?.totalPullRequestReviewContributions || 0, + }; +}; + export const getGithubLogin = async (login: string): Promise => { const {data} = await axios({ method: 'GET', @@ -305,6 +374,14 @@ type GithubStats = Omit< stat_element: any; }; +export type MonthlyContribution = { + month: string; // YYYY-MM format + commits: number; + pullRequests: number; + reviews: number; + total: number; +}; + export type DoobooStatsResponse = { plugin: Model['plugins']['Row']; pluginStats: PluginStats; @@ -322,16 +399,82 @@ export type DoobooStatsResponse = { score: number; }; +// Generate array of months from startDate for 12 months (always starting from the 1st) +// Excludes future months +const generateMonths = (startDate: string): string[] => { + const months: string[] = []; + const [year, month] = startDate.split('-').map(Number); + + // Get current year and month in UTC + const now = new Date(); + const currentYear = now.getUTCFullYear(); + const currentMonth = now.getUTCMonth() + 1; // 1-indexed + + for (let i = 0; i < 12; i++) { + // Use UTC to avoid timezone issues + const current = new Date(Date.UTC(year, month - 1 + i, 1)); + const y = current.getUTCFullYear(); + const m = current.getUTCMonth() + 1; // 1-indexed + + // Skip future months + if (y > currentYear || (y === currentYear && m > currentMonth)) { + continue; + } + + months.push(`${y}-${String(m).padStart(2, '0')}`); + } + + return months; +}; + +// Fetch monthly contribution data (commits, PRs, reviews) +const getMonthlyContributions = async ( + login: string, + startDate: string, +): Promise => { + const months = generateMonths(startDate); + const monthlyContributions: MonthlyContribution[] = []; + + // Fetch stats for each month in parallel (batch of 3 to avoid rate limiting) + const batchSize = 3; + for (let i = 0; i < months.length; i += batchSize) { + const batch = months.slice(i, i + batchSize); + const results = await Promise.all( + batch.map(async (month) => { + try { + const {commits, pullRequests, reviews} = + await fetchGithubMonthlyContributions(login, month); + + return { + month, + commits, + pullRequests, + reviews, + total: commits + pullRequests + reviews, + }; + } catch { + return {month, commits: 0, pullRequests: 0, reviews: 0, total: 0}; + } + }), + ); + monthlyContributions.push(...results); + } + + return monthlyContributions; +}; + const upsertGithubStats = async ({ login, plugin, user_plugin, lang = 'en', + startDate, }: { login: string; plugin: PluginRow; user_plugin: UserPluginRow | null; lang?: Locale; + startDate?: string; }): Promise => { try { const supabase = getSupabaseClient(); @@ -339,17 +482,20 @@ const upsertGithubStats = async ({ // NOTE: Unknown user or user without commits will gracefully fail here. const results: [{data: {user: UserGraph}}, AuthorCommits] = - await Promise.all([getGithubUser(login), getGithubCommits(login)]); + await Promise.all([ + getGithubUser(login, startDate), + getGithubCommits(login), + ]); const { data: {user: githubUser}, } = results[0]; - const githubStatus = getGithubStatus(githubUser, results[1]); - const languages = getTopLanguages(githubUser); - const trophies = getTrophies(githubUser); + const githubStatus = getGithubStatus(githubUser, results[1]); + const languages = getTopLanguages(githubUser); + const trophies = getTrophies(githubUser); - const stats: StatsInsert[] = [ + const stats: StatsInsert[] = [ { name: 'TREE', score: githubStatus.tree.score, @@ -388,19 +534,6 @@ const upsertGithubStats = async ({ }, ]; - if (user_plugin) { - const deleteStatsPromise = supabase - .from('stats') - .delete() - .match({user_plugin_login: user_plugin.login}); - - const deleteTrophiesPromise = supabase.from('trophies').delete().match({ - user_plugin_login: user_plugin.login, - }); - - await Promise.all([deleteStatsPromise, deleteTrophiesPromise]); - } - const sum = (githubStatus.tree?.score || 0) + (githubStatus.fire?.score || 0) + @@ -411,54 +544,71 @@ const upsertGithubStats = async ({ const score = Math.round((sum / 6) * 100); - const userPluginPayload: UserPluginInsert = { - login, - user_name: githubUser.name, - avatar_url: githubUser.avatarUrl, - description: githubUser.bio, - plugin_id: plugin.id, - score, - github_id: githubUser.id, - json: { - login: githubUser.login, - avatarUrl: githubUser.avatarUrl, - bio: githubUser.bio, + // Only save to database if not using custom startDate + // Custom date queries should not overwrite cached default data + if (!startDate) { + if (user_plugin) { + const deleteStatsPromise = supabase + .from('stats') + .delete() + .match({user_plugin_login: user_plugin.login}); + + const deleteTrophiesPromise = supabase.from('trophies').delete().match({ + user_plugin_login: user_plugin.login, + }); + + await Promise.all([deleteStatsPromise, deleteTrophiesPromise]); + } + + const userPluginPayload: UserPluginInsert = { + login, + user_name: githubUser.name, + avatar_url: githubUser.avatarUrl, + description: githubUser.bio, + plugin_id: plugin.id, score, - languages, - }, - }; + github_id: githubUser.id, + json: { + login: githubUser.login, + avatarUrl: githubUser.avatarUrl, + bio: githubUser.bio, + score, + languages, + }, + }; - await supabase.from('user_plugins').upsert(userPluginPayload); + await supabase.from('user_plugins').upsert(userPluginPayload); - await Promise.all( - trophies.map(async (el) => { - const trophyScore = el.score as number; + await Promise.all( + trophies.map(async (el) => { + const trophyScore = el.score as number; - const trophyPayload: TrophiesInsert = { - ...el, - score: trophyScore, - user_plugin_login: login, - }; + const trophyPayload: TrophiesInsert = { + ...el, + score: trophyScore, + user_plugin_login: login, + }; - await supabase.from('trophies').upsert(trophyPayload); - }) - ); + await supabase.from('trophies').upsert(trophyPayload); + }), + ); - await Promise.all( - stats.map(async (el) => { - const statScore = el.score as number; - const statElement = el.stat_element as Json; + await Promise.all( + stats.map(async (el) => { + const statScore = el.score as number; + const statElement = el.stat_element as Json; - const statPayload: StatsInsert = { - ...el, - score: statScore, - stat_element: statElement, - user_plugin_login: login, - }; + const statPayload: StatsInsert = { + ...el, + score: statScore, + stat_element: statElement, + user_plugin_login: login, + }; - await supabase.from('stats').upsert(statPayload); - }) - ); + await supabase.from('stats').upsert(statPayload); + }), + ); + } return { plugin, @@ -493,9 +643,11 @@ const upsertGithubStats = async ({ export const getDoobooStats = async ({ login, lang = 'en', + startDate, }: { login: string; lang?: Locale; + startDate?: string; // YYYY-MM format }): Promise => { login = login.toLowerCase(); @@ -510,7 +662,9 @@ export const getDoobooStats = async ({ try { const PLUGIN_ID = 'dooboo-github'; - const {data: plugin}: { + const { + data: plugin, + }: { data: Model['plugins']['Row'] | null; } = await supabase.from('plugins').select().eq('id', PLUGIN_ID).single(); @@ -535,7 +689,8 @@ export const getDoobooStats = async ({ }); // NOTE: Return the data when user was fetched. - if (userPlugin && stats?.length === 6) { + // Skip cache if custom startDate is provided + if (userPlugin && stats?.length === 6 && !startDate) { const ghStats: GithubStats[] = stats?.map((el) => { return { @@ -594,7 +749,9 @@ export const getDoobooStats = async ({ }, }; - const updatedAt = userPlugin?.updated_at ? new Date(userPlugin.updated_at) : null; + const updatedAt = userPlugin?.updated_at + ? new Date(userPlugin.updated_at) + : null; const today = new Date(); // When user was queried after 3 hours, update the data in background. @@ -614,6 +771,7 @@ export const getDoobooStats = async ({ user_plugin: userPlugin, login, lang, + startDate, }); isCachedResult = true; } @@ -649,6 +807,7 @@ export const getDoobooStats = async ({ user_plugin: userPlugin, login, lang, + startDate, }); } catch (e: any) { console.error('Error in getDoobooStats:', { @@ -660,3 +819,26 @@ export const getDoobooStats = async ({ return null; } }; + +// API for fetching monthly contribution data (commits, PRs, reviews) +export const getMonthlyContribution = async ({ + login, + startDate, +}: { + login: string; + startDate: string; // YYYY-MM format (required) +}): Promise => { + login = login.toLowerCase(); + + try { + return await getMonthlyContributions(login, startDate); + } catch (e: any) { + console.error('Error in getMonthlyContribution:', { + login, + startDate, + error: e.message || e, + }); + + return []; + } +}; diff --git a/styles/output.css b/styles/output.css index 346560c..7e7eb93 100644 --- a/styles/output.css +++ b/styles/output.css @@ -1130,10 +1130,18 @@ input[type='search']::-webkit-search-decoration, margin-bottom: 0.5rem; } +.-mt-2 { + margin-top: -0.5rem; +} + .-mt-6 { margin-top: -1.5rem; } +.mb-1 { + margin-bottom: 0.25rem; +} + .mb-10 { margin-bottom: 2.5rem; } @@ -1270,6 +1278,10 @@ input[type='search']::-webkit-search-decoration, margin-top: 80px; } +.block { + display: block; +} + .inline-block { display: inline-block; } @@ -1286,6 +1298,10 @@ input[type='search']::-webkit-search-decoration, display: table; } +.grid { + display: grid; +} + .hidden { display: none; } @@ -1310,6 +1326,10 @@ input[type='search']::-webkit-search-decoration, height: 5rem; } +.h-3 { + height: 0.75rem; +} + .h-4 { height: 1rem; } @@ -1334,6 +1354,10 @@ input[type='search']::-webkit-search-decoration, height: 37px; } +.h-\[40px\] { + height: 40px; +} + .h-\[50px\] { height: 50px; } @@ -1435,6 +1459,14 @@ input[type='search']::-webkit-search-decoration, width: 1px; } +.w-\[260px\] { + width: 260px; +} + +.w-\[80px\] { + width: 80px; +} + .w-full { width: 100%; } @@ -1443,6 +1475,14 @@ input[type='search']::-webkit-search-decoration, width: 100vw; } +.min-w-0 { + min-width: 0px; +} + +.min-w-\[280px\] { + min-width: 280px; +} + .min-w-\[32px\] { min-width: 32px; } @@ -1451,6 +1491,23 @@ input[type='search']::-webkit-search-decoration, min-width: 400px; } +.min-w-\[60px\] { + min-width: 60px; +} + +.min-w-\[640px\] { + min-width: 640px; +} + +.min-w-\[70px\] { + min-width: 70px; +} + +.min-w-fit { + min-width: -moz-fit-content; + min-width: fit-content; +} + .max-w-3xl { max-width: 48rem; } @@ -1475,6 +1532,10 @@ input[type='search']::-webkit-search-decoration, max-width: 480px; } +.max-w-\[500px\] { + max-width: 500px; +} + .max-w-\[600px\] { max-width: 600px; } @@ -1483,6 +1544,10 @@ input[type='search']::-webkit-search-decoration, max-width: 700px; } +.max-w-\[70px\] { + max-width: 70px; +} + .max-w-\[728px\] { max-width: 728px; } @@ -1521,6 +1586,16 @@ input[type='search']::-webkit-search-decoration, transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } +@keyframes pulse { + 50% { + opacity: .5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + @keyframes spin { to { transform: rotate(360deg); @@ -1549,6 +1624,10 @@ input[type='search']::-webkit-search-decoration, appearance: none; } +.grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + .flex-row { flex-direction: row; } @@ -1569,6 +1648,10 @@ input[type='search']::-webkit-search-decoration, flex-wrap: wrap; } +.flex-nowrap { + flex-wrap: nowrap; +} + .items-start { align-items: flex-start; } @@ -1593,6 +1676,10 @@ input[type='search']::-webkit-search-decoration, gap: 0.25rem; } +.gap-1\.5 { + gap: 0.375rem; +} + .gap-2 { gap: 0.5rem; } @@ -1609,6 +1696,15 @@ input[type='search']::-webkit-search-decoration, gap: 8px; } +.gap-x-4 { + -moz-column-gap: 1rem; + column-gap: 1rem; +} + +.gap-y-2 { + row-gap: 0.5rem; +} + .self-center { align-self: center; } @@ -1668,6 +1764,10 @@ input[type='search']::-webkit-search-decoration, overflow-wrap: break-word; } +.rounded { + border-radius: 0.25rem; +} + .rounded-2xl { border-radius: 1rem; } @@ -1724,6 +1824,10 @@ input[type='search']::-webkit-search-decoration, border-radius: 0.125rem; } +.rounded-xl { + border-radius: 0.75rem; +} + .rounded-t-\[20px\] { border-top-left-radius: 20px; border-top-right-radius: 20px; @@ -1749,6 +1853,10 @@ input[type='search']::-webkit-search-decoration, border-bottom-width: 1px; } +.border-t { + border-top-width: 1px; +} + .border-solid { border-style: solid; } @@ -1770,6 +1878,11 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(194 200 208 / var(--tw-border-opacity, 1)); } +.border-brand { + --tw-border-opacity: 1; + border-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); +} + .border-gray8 { --tw-border-opacity: 1; border-color: rgb(40 40 40 / var(--tw-border-opacity, 1)); @@ -1787,6 +1900,11 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(255 255 255 / 0.6); } +.border-t-brand { + --tw-border-opacity: 1; + border-top-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); +} + .border-t-transparent { border-top-color: transparent; } @@ -1819,6 +1937,16 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(190 190 190 / var(--tw-bg-opacity, 1)); } +.bg-gray1 { + --tw-bg-opacity: 1; + background-color: rgb(237 237 237 / var(--tw-bg-opacity, 1)); +} + +.bg-gray2 { + --tw-bg-opacity: 1; + background-color: rgb(204 204 204 / var(--tw-bg-opacity, 1)); +} + .bg-gray3 { --tw-bg-opacity: 1; background-color: rgb(190 190 190 / var(--tw-bg-opacity, 1)); @@ -1834,6 +1962,11 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(15 20 25 / var(--tw-bg-opacity, 1)); } +.bg-paper-light { + --tw-bg-opacity: 1; + background-color: rgb(240 242 245 / var(--tw-bg-opacity, 1)); +} + .bg-transparent { background-color: transparent; } @@ -1867,6 +2000,10 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(255 255 255 / 0.95); } +.bg-gradient-to-br { + background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)); +} + .bg-cover { background-size: cover; } @@ -1879,6 +2016,10 @@ input[type='search']::-webkit-search-decoration, padding: 0.25rem; } +.p-1\.5 { + padding: 0.375rem; +} + .p-12 { padding: 3rem; } @@ -2044,12 +2185,16 @@ input[type='search']::-webkit-search-decoration, padding-left: 1.5rem; } +.pr-2 { + padding-right: 0.5rem; +} + .pr-8 { padding-right: 2rem; } -.pt-2 { - padding-top: 0.5rem; +.pt-3 { + padding-top: 0.75rem; } .pt-4 { @@ -2124,6 +2269,10 @@ input[type='search']::-webkit-search-decoration, font-size: 44px; } +.text-\[9px\] { + font-size: 9px; +} + .text-body3 { font-size: 14px; } @@ -2226,6 +2375,11 @@ input[type='search']::-webkit-search-decoration, color: rgb(40 40 40 / var(--tw-text-opacity, 1)); } +.text-placeholder-light { + --tw-text-opacity: 1; + color: rgb(120 120 120 / var(--tw-text-opacity, 1)); +} + .text-success-light { --tw-text-opacity: 1; color: rgb(15 199 12 / var(--tw-text-opacity, 1)); @@ -2316,6 +2470,17 @@ input[type='search']::-webkit-search-decoration, outline-offset: 2px; } +.ring-2 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + .filter { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } @@ -2364,6 +2529,12 @@ input[type='search']::-webkit-search-decoration, transition-duration: 150ms; } +.transition-transform { + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + .duration-150 { transition-duration: 150ms; } @@ -2376,6 +2547,16 @@ input[type='search']::-webkit-search-decoration, transition-duration: 300ms; } +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + @font-face { font-family: 'doobooui'; @@ -2475,6 +2656,25 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(65 144 235 / 0.9); } +.hover\:bg-gray1:hover { + --tw-bg-opacity: 1; + background-color: rgb(237 237 237 / var(--tw-bg-opacity, 1)); +} + +.hover\:bg-gray2:hover { + --tw-bg-opacity: 1; + background-color: rgb(204 204 204 / var(--tw-bg-opacity, 1)); +} + +.hover\:bg-gray3:hover { + --tw-bg-opacity: 1; + background-color: rgb(190 190 190 / var(--tw-bg-opacity, 1)); +} + +.hover\:bg-transparent:hover { + background-color: transparent; +} + .hover\:bg-white\/60:hover { background-color: rgb(255 255 255 / 0.6); } @@ -2525,6 +2725,16 @@ input[type='search']::-webkit-search-decoration, box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } +.focus\:ring-transparent:focus { + --tw-ring-color: transparent; +} + +.focus-visible\:ring-0:focus-visible { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + .active\:opacity-100:active { opacity: 1; } @@ -2533,6 +2743,30 @@ input[type='search']::-webkit-search-decoration, opacity: 0.5; } +.disabled\:cursor-not-allowed:disabled { + cursor: not-allowed; +} + +.disabled\:opacity-30:disabled { + opacity: .3; +} + +.disabled\:opacity-50:disabled { + opacity: 0.5; +} + +.group:hover .group-hover\:scale-105 { + --tw-scale-x: 1.05; + --tw-scale-y: 1.05; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.group:hover .group-hover\:scale-110 { + --tw-scale-x: 1.1; + --tw-scale-y: 1.1; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .group:hover .group-hover\:text-black { --tw-text-opacity: 1; color: rgb(0 0 0 / var(--tw-text-opacity, 1)); @@ -2581,6 +2815,11 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(255 255 255 / .3); } +.dark\:border-t-brand:is(.dark *) { + --tw-border-opacity: 1; + border-top-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); +} + .dark\:bg-\[\#232323\]:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(35 35 35 / var(--tw-bg-opacity, 1)); @@ -2612,6 +2851,21 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(71 71 71 / var(--tw-bg-opacity, 1)); } +.dark\:bg-gray7:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(71 71 71 / var(--tw-bg-opacity, 1)); +} + +.dark\:bg-gray8:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(40 40 40 / var(--tw-bg-opacity, 1)); +} + +.dark\:bg-paper-dark:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(15 20 25 / var(--tw-bg-opacity, 1)); +} + .dark\:bg-white\/10:is(.dark *) { background-color: rgb(255 255 255 / 0.1); } @@ -2655,6 +2909,11 @@ input[type='search']::-webkit-search-decoration, color: rgb(240 242 245 / var(--tw-text-opacity, 1)); } +.dark\:text-placeholder-dark:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(144 144 144 / var(--tw-text-opacity, 1)); +} + .dark\:text-success-dark:is(.dark *) { --tw-text-opacity: 1; color: rgb(47 250 134 / var(--tw-text-opacity, 1)); @@ -2688,6 +2947,21 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(0 0 0 / 0.8); } +.dark\:hover\:bg-gray6:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(77 77 77 / var(--tw-bg-opacity, 1)); +} + +.dark\:hover\:bg-gray7:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(71 71 71 / var(--tw-bg-opacity, 1)); +} + +.dark\:hover\:bg-gray8:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(40 40 40 / var(--tw-bg-opacity, 1)); +} + .dark\:hover\:bg-white\/10:hover:is(.dark *) { background-color: rgb(255 255 255 / 0.1); } @@ -2726,6 +3000,10 @@ input[type='search']::-webkit-search-decoration, } @media (max-width: 768px) { + .max-\[768px\]\:hidden { + display: none; + } + .max-\[768px\]\:h-12 { height: 3rem; } @@ -2746,6 +3024,10 @@ input[type='search']::-webkit-search-decoration, min-width: 300px; } + .max-\[768px\]\:flex-col { + flex-direction: column; + } + .max-\[768px\]\:p-2 { padding: 0.5rem; } @@ -2846,16 +3128,20 @@ input[type='search']::-webkit-search-decoration, width: 1rem; } - .max-\[480px\]\:max-w-full { - max-width: 100%; + .max-\[480px\]\:min-w-\[44px\] { + min-width: 44px; } - .max-\[480px\]\:flex-col { - flex-direction: column; + .max-\[480px\]\:max-w-\[60px\] { + max-width: 60px; } - .max-\[480px\]\:items-start { - align-items: flex-start; + .max-\[480px\]\:max-w-full { + max-width: 100%; + } + + .max-\[480px\]\:gap-0 { + gap: 0px; } .max-\[480px\]\:rounded-\[16px\] { @@ -2895,6 +3181,11 @@ input[type='search']::-webkit-search-decoration, padding-right: 1.5rem; } + .max-\[480px\]\:py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; + } + .max-\[480px\]\:pt-4 { padding-top: 1rem; } diff --git a/styles/root.css b/styles/root.css index 22975c6..57d039d 100644 --- a/styles/root.css +++ b/styles/root.css @@ -2,6 +2,15 @@ @tailwind components; @tailwind utilities; +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + @font-face { font-family: 'doobooui'; src: url('/fonts/doobooui.ttf?ixpxfq') format('truetype');