Merge branch 'main' into claude-azure-support

This commit is contained in:
Kevin Turcios 2025-12-22 18:26:12 -05:00 committed by GitHub
commit c97e16e12a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 890 additions and 23 deletions

View file

@ -4,6 +4,7 @@ import logging
import sentry_sdk
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
from django.db import IntegrityError
from django.db.models import F
from django.http import JsonResponse
from django.utils.decorators import async_only_middleware
from django.utils.timezone import now
@ -140,16 +141,28 @@ class TrackUsageMiddleware:
status=403,
)
# Increment usage
subscription.optimizations_used = current_used + cost
subscription.total_lifetime_optimizations += cost
await subscription.asave(update_fields=["optimizations_used", "total_lifetime_optimizations"])
# Atomically increment usage using F() expressions to prevent race conditions.
# This ensures concurrent requests each increment the counter correctly
# instead of overwriting each other's updates (lost update problem).
await Subscriptions.objects.filter(user_id=user_id).aupdate(
optimizations_used=F("optimizations_used") + cost,
total_lifetime_optimizations=F("total_lifetime_optimizations") + cost,
)
# Re-read to get the actual updated value for the response
updated_subscription = await Subscriptions.objects.filter(user_id=user_id).afirst()
new_used = updated_subscription.optimizations_used if updated_subscription else current_used + cost
logging.debug(
f"track_usage_middleware.py|__call__|Atomic update completed: "
f"user_id={user_id}, endpoint={endpoint}, cost={cost}, new_used={new_used}"
)
# Attach subscription info to request
request.subscription_info = {
"userId": user_id,
"tier": subscription.plan_type,
"used": current_used + cost,
"used": new_used,
"limit": subscription.optimizations_limit,
}

View file

@ -124,11 +124,24 @@ async def test_allows_usage_within_limit(middleware, rf, monkeypatch):
request.user = type("User", (), {"id": 1})()
fake_sub = FakeSubscription(used=50, limit=100, lifetime=10)
# After atomic update, the new used value would be 50 + 10 (optimize cost) = 60
updated_sub = FakeSubscription(used=60, limit=100, lifetime=20)
# Track whether aupdate has been called to determine which subscription to return
update_called = {"value": False}
class FakeFilter:
async def afirst(self):
# Return original sub before update, updated sub after update
if update_called["value"]:
return updated_sub
return fake_sub
async def aupdate(self, **kwargs):
# Simulate atomic update - mark that update was called
update_called["value"] = True
return 1
monkeypatch.setattr(
"aiservice.middleware.track_usage_middleware.Subscriptions.objects.filter", lambda **kwargs: FakeFilter()
)

View file

@ -26,17 +26,17 @@ export const STEP_DEFINITIONS: StepDefinition[] = [
{
id: "environment",
title: "Install Codeflash Python package",
shortTitle: "Verify Environment",
shortTitle: "Install",
},
{
id: "server",
title: "Starting Codeflash server",
shortTitle: "Starting Services",
shortTitle: "Start",
},
{
id: "init",
title: "Initializing Codeflash",
shortTitle: "Project Configuration",
shortTitle: "Activate",
},
];

View file

@ -356,9 +356,17 @@ export function buildResultTestReport(
testType.includes("Concolic"))
) {
// Add a heading for the test type details
reportTableMd += `<details>\n`
reportTableMd += `<summary>${testType} and Runtime</summary>\n\n`
// Extract emoji if present at the start, then format as "[emoji] Click to see [name]"
const emojiMatch = testType.match(/^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F?)/u)
if (emojiMatch) {
const emoji = emojiMatch[0]
const testName = testType.slice(emoji.length).trim()
reportTableMd += `<summary>${emoji} Click to see ${testName}</summary>\n\n`
} else {
reportTableMd += `<summary>Click to see ${testType}</summary>\n\n`
}
// Include the relevant test code
if (testType.includes("Existing")) {

View file

@ -10,6 +10,7 @@ import {
} from "./actions"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useSearchParams, useRouter } from "next/navigation"
import { OptimizationUsageCard } from "@/components/dashboard/OptimizationUsageCard"
// @ts-expect-error - ToDo fix the type error
export function BillingView({ userId, subscription: initialSubscription, plans }) {
@ -137,6 +138,14 @@ export function BillingView({ userId, subscription: initialSubscription, plans }
<div className="max-w-2xl p-6 space-y-6">
<h1 className="text-2xl font-bold">Billing & Subscription</h1>
{/* Optimization Usage Card */}
<OptimizationUsageCard
optimizationsUsed={subscription.optimizations_used || 0}
optimizationsLimit={subscription.optimizations_limit || 0}
currentPeriodEnd={subscription.current_period_end}
planType={subscription.plan_type}
/>
<Card>
<CardHeader>
<CardTitle>

View file

@ -2,9 +2,9 @@
import React, { useState, useEffect, useCallback } from "react"
import { Users, UserPlus, RefreshCw, AlertCircle, Building2 } from "lucide-react"
import { getUserIdAndUsername } from "@/app/utils/auth"
import { Loading } from "@/components/ui/loading"
import { DashboardErrorBoundary } from "@/components/dashboard/DashboardErrorBoundary"
import { ConfirmDialog } from "@/components/confirm-dialog"
import { MembersSkeleton } from "@/components/members/MembersSkeleton"
import { GitHubUserSearchResult, Member } from "@/lib/types"
import {
addOrganizationMember,
@ -171,7 +171,7 @@ function OrganizationMembers() {
}
if (loading) {
return <Loading />
return <MembersSkeleton count={6} />
}
if (!currentOrg?.id) {

View file

@ -15,11 +15,11 @@ import {
} from "lucide-react"
import { getUserIdAndUsername } from "@/app/utils/auth"
import { format, subDays } from "date-fns"
import { Loading } from "@/components/ui/loading"
import { ActiveUsersLeaderboard } from "@/components/dashboard/ActiveUsersLeaderboard"
import { CompactPullRequestActivityCard } from "@/components/dashboard/CompactPullRequestActivityCard"
import { DashboardErrorBoundary } from "@/components/dashboard/DashboardErrorBoundary"
import { MetricCard } from "@/components/dashboard/MetricCard"
import { RepositoryDetailSkeleton } from "@/components/repositories/RepositoryDetailSkeleton"
import Image from "next/image"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import {
@ -679,7 +679,7 @@ function RepositoryDetail() {
}, [last30DaysStart, now])
if (loading) {
return <Loading />
return <RepositoryDetailSkeleton showTabNavigation={!currentOrg} />
}
if (error) {

View file

@ -246,14 +246,16 @@ const RepositoryCard = ({ repo }: { repo: RepositoryWithUsage }) => (
</Link>
)
// Loading State Component
// Import skeleton loaders
import { RepositoriesSkeleton, RepositoriesRefreshingSkeleton } from "@/components/repositories/RepositoriesSkeleton"
// Loading State Component (now using skeleton loaders)
const RepositoriesLoading = ({ isRefreshing = false }: { isRefreshing?: boolean }) => (
<div className="flex flex-col items-center justify-center h-[70vh]">
<div className="animate-spin rounded-full h-10 w-10 sm:h-12 sm:w-12 border-t-2 border-b-2 border-primary mb-4"></div>
<p className="text-muted-foreground animate-pulse">
{isRefreshing ? "Refreshing repositories..." : "Loading repositories..."}
</p>
</div>
isRefreshing ? (
<RepositoriesRefreshingSkeleton />
) : (
<RepositoriesSkeleton message="Loading repositories..." />
)
)
// Page Header Component

View file

@ -10,6 +10,7 @@ import {
getActiveRepoIdsLast30DaysByRepoIds,
getOptimizationsTimeSeriesDataByRepoIds,
getOptimizationEventsByRepoIds,
checkAndResetSubscriptionPeriod,
} from "@codeflash-ai/common"
import { eachDayOfInterval, startOfDay } from "date-fns"
@ -354,3 +355,35 @@ export async function statistics(payload: AccountPayload, year: number) {
)
}
}
export async function getSubscriptionData(userId: string) {
try {
const subscription = await checkAndResetSubscriptionPeriod(userId)
if (!subscription) {
return null
}
return {
optimizations_used: subscription.optimizations_used || 0,
optimizations_limit: subscription.optimizations_limit || 0,
current_period_end: subscription.current_period_end,
plan_type: subscription.plan_type || "free",
}
} catch (error) {
console.error("Failed to fetch subscription data:", error)
return null
}
}
export async function getCurrentUserSubscriptionData() {
try {
const { getUserIdAndUsername } = await import("@/app/utils/auth")
const currentUser = await getUserIdAndUsername()
if (!currentUser?.userId) {
return null
}
return await getSubscriptionData(currentUser.userId)
} catch (error) {
console.error("Failed to fetch current user subscription data:", error)
return null
}
}

View file

@ -15,11 +15,11 @@ import {
import { getAllRepositories, RepositoryWithUsage, statistics } from "./action"
import { getUserIdAndUsername } from "@/app/utils/auth"
import { format, subDays } from "date-fns"
import { Loading } from "@/components/ui/loading"
import { ActiveUsersLeaderboard } from "@/components/dashboard/ActiveUsersLeaderboard"
import { CompactPullRequestActivityCard } from "@/components/dashboard/CompactPullRequestActivityCard"
import { DashboardErrorBoundary } from "@/components/dashboard/DashboardErrorBoundary"
import { MetricCard } from "@/components/dashboard/MetricCard"
import { DashboardSkeleton } from "@/components/dashboard/DashboardSkeleton"
import { useViewMode } from "../app/ViewModeContext"
import { useOutsideClick } from "@/components/hooks/useOutsideClick"
@ -204,7 +204,7 @@ function Dashboard() {
setIsYearDropdownOpen(false)
}, [])
if (loading) return <Loading />
if (loading) return <DashboardSkeleton />
if (error) return <ErrorDisplay error={error} onRetry={fetchDashboardData} />
return (

View file

@ -0,0 +1,144 @@
"use client"
import React from "react"
import { Skeleton } from "@/components/ui/skeleton"
/**
* Skeleton loader for MetricCard component
* Mimics the structure of the actual MetricCard with icon, title, value, and optional chart
*/
const MetricCardSkeleton: React.FC<{ showChart?: boolean }> = ({ showChart = true }) => (
<div className="bg-card rounded-xl border border-border p-4 h-full">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
{/* Title skeleton */}
<Skeleton className="h-4 w-32 mb-2" />
{/* Value skeleton */}
<Skeleton className="h-8 w-20" />
</div>
{/* Icon skeleton */}
<Skeleton className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl" />
</div>
{showChart ? (
/* Chart skeleton */
<div className="mt-2">
<Skeleton className="h-[60px] w-full rounded-md" />
</div>
) : (
/* Time text skeleton for cards without charts */
<div className="mt-2">
<Skeleton className="h-3 w-24" />
</div>
)}
</div>
)
/**
* Skeleton loader for Pull Request Activity Card
* Mimics the PR activity chart card structure
*/
const PullRequestActivityCardSkeleton: React.FC = () => (
<div className="bg-card rounded-xl border border-border p-4 h-full flex flex-col">
<div className="flex items-center justify-between mb-3">
<div>
<Skeleton className="h-5 w-40 mb-1" />
<Skeleton className="h-3 w-28" />
</div>
<Skeleton className="h-6 w-20 rounded-md" />
</div>
{/* Stats row skeleton */}
<div className="flex justify-between text-xs mb-3">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-1">
<Skeleton className="w-5 h-5 rounded-md" />
<div>
<Skeleton className="h-3 w-12 mb-1" />
<Skeleton className="h-4 w-8" />
</div>
</div>
))}
</div>
{/* Chart skeleton */}
<div className="flex-1 min-h-0">
<Skeleton className="h-full w-full rounded-md" />
</div>
</div>
)
/**
* Skeleton loader for Active Users Leaderboard
* Mimics the leaderboard structure with user rows
*/
const ActiveUsersLeaderboardSkeleton: React.FC = () => (
<div className="bg-card rounded-xl border border-border p-4 h-full flex flex-col">
<div className="mb-3">
<Skeleton className="h-5 w-40 mb-1" />
<Skeleton className="h-3 w-28" />
</div>
{/* User rows skeleton */}
<div className="space-y-3 flex-1">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="flex items-center gap-3 pb-3 border-b border-border last:border-0">
{/* Rank */}
<Skeleton className="h-6 w-6 rounded-full" />
{/* Avatar */}
<Skeleton className="h-9 w-9 rounded-full" />
{/* Username and count */}
<div className="flex-1">
<Skeleton className="h-4 w-24 mb-1" />
<Skeleton className="h-3 w-16" />
</div>
{/* Activity indicator */}
<Skeleton className="h-6 w-12 rounded-full" />
</div>
))}
</div>
</div>
)
/**
* Complete Dashboard Skeleton Loader
* Displays skeleton placeholders matching the full dashboard layout
* Used while dashboard data is being fetched
*/
export const DashboardSkeleton: React.FC = () => {
return (
<div className="h-screen py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
{/* Header skeleton */}
<div className="mb-6 sm:mb-8">
<div className="flex items-center justify-between mb-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-6 w-24 rounded-md" />
</div>
</div>
{/* Main metrics grid */}
<div className="grid grid-cols-1 gap-3 sm:gap-5 mb-6 sm:mb-8">
{/* Top 2 large metric cards with charts */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-5">
<MetricCardSkeleton showChart={true} />
<MetricCardSkeleton showChart={true} />
</div>
{/* Bottom 4 smaller metric cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 sm:gap-5">
<MetricCardSkeleton showChart={false} />
<MetricCardSkeleton showChart={false} />
<MetricCardSkeleton showChart={false} />
<MetricCardSkeleton showChart={false} />
</div>
</div>
{/* Activity cards grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-5 mb-6 sm:mb-8 h-96 md:h-[500px]">
<PullRequestActivityCardSkeleton />
<ActiveUsersLeaderboardSkeleton />
</div>
</div>
)
}

View file

@ -0,0 +1,131 @@
"use client"
import React, { useMemo } from "react"
import { Zap, HelpCircle } from "lucide-react"
import { Progress } from "@/components/ui/progress"
import { format } from "date-fns"
import {
formatCredits,
calculateCreditsPercentage,
getProgressBarClassName,
roundCredits,
} from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
interface OptimizationUsageCardProps {
optimizationsUsed: number
optimizationsLimit: number
currentPeriodEnd?: Date | null
planType?: string
}
export const OptimizationUsageCard: React.FC<OptimizationUsageCardProps> = ({
optimizationsUsed,
optimizationsLimit,
currentPeriodEnd,
planType,
}) => {
const percentage = useMemo(() => {
return calculateCreditsPercentage(optimizationsUsed, optimizationsLimit)
}, [optimizationsUsed, optimizationsLimit])
const remaining = useMemo(() => {
const roundedUsed = roundCredits(optimizationsUsed)
const roundedLimit = roundCredits(optimizationsLimit)
return Math.max(0, roundedLimit - roundedUsed)
}, [optimizationsUsed, optimizationsLimit])
const progressBarClassName = useMemo(() => {
return getProgressBarClassName(percentage)
}, [percentage])
const formattedUsed = useMemo(() => {
return formatCredits(optimizationsUsed)
}, [optimizationsUsed])
const formattedLimit = useMemo(() => {
return formatCredits(optimizationsLimit)
}, [optimizationsLimit])
const formattedRemaining = useMemo(() => {
return new Intl.NumberFormat("en-US").format(remaining)
}, [remaining])
const periodEndText = useMemo(() => {
if (!currentPeriodEnd) return null
try {
return format(new Date(currentPeriodEnd), "MMM d, yyyy")
} catch {
return null
}
}, [currentPeriodEnd])
const isMobile = typeof window !== "undefined" && window.innerWidth < 640
return (
<div className="bg-card rounded-xl border border-border p-4 hover:shadow-md transition-all duration-300 hover:border-primary/20 group h-full">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-1.5 mb-1">
<p className="text-muted-foreground text-xs sm:text-sm font-medium">
Optimization Attempts Usage
</p>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
aria-label="Optimization attempts calculation info"
>
<HelpCircle size={14} className="shrink-0" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">
Confirmed performance gains use 1 attempt.
<br />
Search runs with no gains use just 0.5 attempt.
<br />
<br />
Your optimization attempts reset at the start of each billing cycle.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-end gap-1 sm:gap-2 mt-1">
<h3 className="text-xl sm:text-3xl font-bold text-foreground">{formattedUsed}</h3>
<span className="text-muted-foreground text-sm sm:text-base mb-0.5 sm:mb-1">/</span>
<span className="text-muted-foreground text-sm sm:text-base mb-0.5 sm:mb-1">
{formattedLimit}
</span>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
{formattedRemaining} remaining
</p>
</div>
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/20 flex items-center justify-center text-blue-500">
<Zap size={isMobile ? 16 : 20} />
</div>
</div>
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{percentage}% used</span>
{planType && (
<span className="capitalize text-xs px-2 py-0.5 rounded-md bg-muted">
{planType} plan
</span>
)}
</div>
<div className="relative">
<Progress value={percentage} className={`h-3 ${progressBarClassName}`} />
</div>
{periodEndText && (
<p className="text-xs text-muted-foreground italic">Resets on {periodEndText}</p>
)}
</div>
</div>
)
}

View file

@ -22,12 +22,16 @@ import {
Check,
UserCircle,
Menu,
Zap,
} from "lucide-react"
import { UserProfile } from "@auth0/nextjs-auth0/client"
import { SignOut } from "../ui/SignOut"
import { useViewMode } from "@/app/app/ViewModeContext"
import { Breadcrumb } from "./bread-crumb"
import { SIDEBAR_ANNOUNCEMENT } from "@/config/announcements"
import { getCurrentUserSubscriptionData } from "@/app/dashboard/action"
import { Progress } from "@/components/ui/progress"
import { formatCredits, calculateCreditsPercentage, getProgressBarClassName } from "@/lib/utils"
interface SidebarProps {
className: string
@ -43,6 +47,10 @@ export function Sidebar({ className, user, isLoading, error }: SidebarProps): JS
const dropdownRef = useRef<HTMLDivElement>(null)
const { loading: loadingOrgs, orgs, switchToMode, currentOrg, mode } = useViewMode()
const [isMobileOpen, setIsMobileOpen] = useState(false)
const [subscription, setSubscription] = useState<{
optimizations_used: number
optimizations_limit: number
} | null>(null)
const onMobileClose = () => {
setIsMobileOpen(false)
@ -74,6 +82,32 @@ export function Sidebar({ className, user, isLoading, error }: SidebarProps): JS
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
// Fetch subscription data for personal accounts
useEffect(() => {
const fetchSubscription = async () => {
if (mode === "personal") {
try {
const subscriptionData = await getCurrentUserSubscriptionData()
if (subscriptionData) {
setSubscription({
optimizations_used: subscriptionData.optimizations_used || 0,
optimizations_limit: subscriptionData.optimizations_limit || 0,
})
} else {
setSubscription(null)
}
} catch (error) {
console.error("Failed to fetch subscription data:", error)
setSubscription(null)
}
} else {
setSubscription(null)
}
}
fetchSubscription()
}, [mode])
const toggleTheme = () => {
const newMode = !isDarkMode
setIsDarkMode(newMode)
@ -334,6 +368,40 @@ export function Sidebar({ className, user, isLoading, error }: SidebarProps): JS
{isDarkMode ? "Light mode" : "Dark mode"}
</Button>
{/* Optimization Attempts Usage - Only for Personal Accounts */}
{mode === "personal" &&
subscription &&
(() => {
const percentage = calculateCreditsPercentage(
subscription.optimizations_used,
subscription.optimizations_limit,
)
const progressBarClassName = getProgressBarClassName(percentage)
return (
<div className="px-2 py-1.5 rounded-lg bg-muted/50 border border-border/50 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<Zap size={14} className="text-primary shrink-0" />
<span className="text-xs text-muted-foreground truncate">Optimization Attempts</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs font-medium text-foreground">
{formatCredits(subscription.optimizations_used)}
</span>
<span className="text-xs text-muted-foreground">/</span>
<span className="text-xs text-muted-foreground">
{formatCredits(subscription.optimizations_limit)}
</span>
</div>
</div>
<div className="relative">
<Progress value={percentage} className={`h-1.5 ${progressBarClassName}`} />
</div>
</div>
)
})()}
{/* Profile with Organization Switcher */}
<div className="relative" ref={dropdownRef}>
{profileButton()}

View file

@ -0,0 +1,83 @@
"use client"
import React from "react"
import { Skeleton } from "@/components/ui/skeleton"
/**
* Skeleton loader for individual member row
* Mimics the member list item structure
*/
const MemberRowSkeleton: React.FC = () => (
<div className="flex items-center justify-between p-4 bg-card rounded-xl border border-border">
<div className="flex items-center gap-3 flex-1">
{/* Avatar skeleton */}
<Skeleton className="h-10 w-10 rounded-full" />
{/* Member info skeleton */}
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-1.5" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<div className="flex items-center gap-3">
{/* Role badge skeleton */}
<Skeleton className="h-6 w-16 rounded-full" />
{/* Action button skeleton */}
<Skeleton className="h-8 w-8 rounded-md" />
</div>
</div>
)
/**
* Complete Members Page Skeleton Loader
* Displays skeleton placeholders for the members page or tab
* Used while member data is being fetched
*/
export const MembersSkeleton: React.FC<{ count?: number }> = ({ count = 5 }) => {
return (
<div className="space-y-4">
{/* Header skeleton */}
<div className="flex items-center justify-between mb-6">
<div>
<Skeleton className="h-7 w-32 sm:w-40 mb-2" />
<Skeleton className="h-4 w-48 sm:w-64" />
</div>
<Skeleton className="h-9 w-28 rounded-lg" />
</div>
{/* Search and filter skeleton */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<Skeleton className="h-10 flex-1 rounded-lg" />
<Skeleton className="h-10 w-32 rounded-lg" />
</div>
{/* Member rows skeleton */}
<div className="space-y-3">
{[...Array(count)].map((_, index) => (
<MemberRowSkeleton key={index} />
))}
</div>
</div>
)
}
/**
* Compact members skeleton for smaller sections
* Useful for loading states in tabs or smaller containers
*/
export const CompactMembersSkeleton: React.FC = () => {
return (
<div className="space-y-3">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-6 w-28" />
<Skeleton className="h-8 w-24 rounded-lg" />
</div>
{[...Array(3)].map((_, index) => (
<MemberRowSkeleton key={index} />
))}
</div>
)
}

View file

@ -0,0 +1,101 @@
"use client"
import React from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { Card } from "@/components/ui/card"
/**
* Skeleton loader for individual Repository Card
* Mimics the structure of the RepositoryCard component
*/
const RepositoryCardSkeleton: React.FC = () => (
<Card className="bg-card bg-muted/5 rounded-xl border border-border overflow-hidden">
<div className="p-5">
<div className="flex items-start">
{/* Avatar skeleton */}
<div className="mr-3 flex-shrink-0">
<Skeleton className="w-9 h-9 sm:w-11 sm:h-11 rounded-full" />
</div>
<div className="flex-1 min-w-0">
{/* Repository name and badge skeleton */}
<div className="flex items-center flex-wrap gap-1 mb-2">
<Skeleton className="h-5 w-40 sm:w-48" />
<Skeleton className="h-5 w-14 rounded-full" />
</div>
{/* Organization/full name skeleton */}
<Skeleton className="h-4 w-32 sm:w-40 mb-2" />
{/* Repository stats skeleton */}
<div className="flex items-center flex-wrap gap-1.5 sm:gap-2">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-14 rounded-full" />
<Skeleton className="h-6 w-12 rounded-full" />
</div>
</div>
</div>
{/* Last optimized date skeleton */}
<div className="mt-3 sm:mt-4">
<Skeleton className="h-3 w-36" />
</div>
</div>
</Card>
)
/**
* Complete Repositories Page Skeleton Loader
* Displays skeleton placeholders for the repositories page
* Used while repository data is being fetched
*/
export const RepositoriesSkeleton: React.FC<{ message?: string }> = ({
message = "Loading repositories..."
}) => {
return (
<div className="space-y-4 sm:space-y-6">
{/* Header skeleton */}
<div className="flex items-center justify-between mb-4 sm:mb-6">
<div>
<Skeleton className="h-8 w-32 sm:w-40 mb-2" />
<Skeleton className="h-4 w-48 sm:w-64" />
</div>
<Skeleton className="h-9 w-20 sm:w-24 rounded-lg" />
</div>
{/* Optional loading message */}
{message && (
<div className="text-center mb-4">
<p className="text-sm text-muted-foreground animate-pulse">{message}</p>
</div>
)}
{/* Repository cards grid skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{[...Array(6)].map((_, index) => (
<RepositoryCardSkeleton key={index} />
))}
</div>
</div>
)
}
/**
* Compact loading state for refreshing repositories
* Shows fewer skeleton cards with a subtle animation
*/
export const RepositoriesRefreshingSkeleton: React.FC = () => {
return (
<div className="space-y-4">
<div className="text-center py-2">
<p className="text-sm text-muted-foreground animate-pulse">Refreshing repositories...</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{[...Array(3)].map((_, index) => (
<RepositoryCardSkeleton key={index} />
))}
</div>
</div>
)
}

View file

@ -0,0 +1,134 @@
"use client"
import React from "react"
import { Skeleton } from "@/components/ui/skeleton"
/**
* Skeleton loader for Repository Header
* Mimics the repository header structure with avatar, name, and badges
*/
const RepositoryHeaderSkeleton: React.FC = () => (
<div className="mb-6 sm:mb-8">
<div className="flex items-start">
<div className="flex items-start gap-4 w-full">
{/* Avatar skeleton */}
<div className="flex-shrink-0">
<Skeleton className="w-12 h-12 sm:w-16 sm:h-16 rounded-full" />
</div>
{/* Repository info skeleton */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-2">
<Skeleton className="h-7 w-48 sm:w-64" />
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-36 sm:w-48 mb-2" />
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-32" />
</div>
</div>
</div>
</div>
</div>
)
/**
* Skeleton loader for Tab Navigation
* Mimics the tab navigation buttons
*/
const TabNavigationSkeleton: React.FC = () => (
<div className="mb-6 sm:mb-8">
<div className="flex gap-2">
<Skeleton className="h-10 w-28 rounded-lg" />
<Skeleton className="h-10 w-28 rounded-lg" />
</div>
</div>
)
/**
* Skeleton loader for Metric Card
* Reusable component for metric card placeholders
*/
const MetricCardSkeleton: React.FC<{ showChart?: boolean }> = ({ showChart = true }) => (
<div className="bg-card rounded-xl border border-border p-4 h-full">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-2" />
<Skeleton className="h-8 w-20" />
</div>
<Skeleton className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl" />
</div>
{showChart && <Skeleton className="h-[60px] w-full rounded-md mt-2" />}
</div>
)
/**
* Skeleton loader for Statistics Tab
* Mimics the complete statistics section with metrics and charts
*/
const StatisticsTabSkeleton: React.FC = () => (
<div className="grid grid-cols-1 gap-3 sm:gap-5">
{/* Top 2 large metric cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-5">
<MetricCardSkeleton showChart={true} />
<MetricCardSkeleton showChart={true} />
</div>
{/* Activity cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-5 h-96 md:h-[500px]">
<div className="bg-card rounded-xl border border-border p-4 h-full flex flex-col">
<div className="flex items-center justify-between mb-3">
<div>
<Skeleton className="h-5 w-40 mb-1" />
<Skeleton className="h-3 w-28" />
</div>
<Skeleton className="h-6 w-20 rounded-md" />
</div>
<div className="flex-1">
<Skeleton className="h-full w-full rounded-md" />
</div>
</div>
<div className="bg-card rounded-xl border border-border p-4 h-full flex flex-col">
<div className="mb-3">
<Skeleton className="h-5 w-40 mb-1" />
<Skeleton className="h-3 w-28" />
</div>
<div className="space-y-3 flex-1">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="flex items-center gap-3 pb-3 border-b border-border last:border-0">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-24 mb-1" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="h-6 w-12 rounded-full" />
</div>
))}
</div>
</div>
</div>
</div>
)
/**
* Complete Repository Detail Skeleton Loader
* Displays skeleton placeholders for the repository detail page
* Used while repository data is being fetched
*/
export const RepositoryDetailSkeleton: React.FC<{ showTabNavigation?: boolean }> = ({
showTabNavigation = true
}) => {
return (
<div className="flex-1 bg-background">
<div className="h-screen py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<RepositoryHeaderSkeleton />
{showTabNavigation && <TabNavigationSkeleton />}
<StatisticsTabSkeleton />
</div>
</div>
)
}

View file

@ -0,0 +1,44 @@
import * as React from "react"
import { cn } from "@/lib/utils"
/**
* Reusable Skeleton component for loading states
* Provides a shimmer animation effect for placeholder content
*/
const Skeleton = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"animate-pulse rounded-md bg-muted/50",
className
)}
{...props}
/>
)
)
Skeleton.displayName = "Skeleton"
/**
* Shimmer effect skeleton with gradient animation
* Provides a more dynamic loading appearance
*/
const SkeletonShimmer = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative overflow-hidden rounded-md bg-muted/50",
"before:absolute before:inset-0",
"before:-translate-x-full before:animate-shimmer",
"before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent",
className
)}
{...props}
/>
)
)
SkeletonShimmer.displayName = "SkeletonShimmer"
export { Skeleton, SkeletonShimmer }

View file

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -4,3 +4,49 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
/**
* Round optimization attempts to nearest 0.5 by dividing by 100 and rounding to half increments
* (e.g., 4000 -> 40, 450 -> 4.5, 550 -> 5.5)
*/
export function roundCredits(credits: number): number {
const value = credits / 100
return Math.round(value * 2) / 2
}
/**
* Format optimization attempts for display (rounds to 0.5 increments and formats with commas)
*/
export function formatCredits(credits: number): string {
const rounded = roundCredits(credits)
// Format with up to 1 decimal place (for 0.5 increments)
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: rounded % 1 === 0 ? 0 : 1,
maximumFractionDigits: 1,
}).format(rounded)
}
/**
* Calculate usage percentage based on rounded optimization attempts
* Ensures percentage is calculated correctly after rounding to 0.5 increments
*/
export function calculateCreditsPercentage(used: number, limit: number): number {
if (limit === 0) return 0
const roundedUsed = roundCredits(used)
const roundedLimit = roundCredits(limit)
if (roundedLimit === 0) return 0
return Math.min(100, Math.round((roundedUsed / roundedLimit) * 100))
}
/**
* Get progress bar className based on percentage
*/
export function getProgressBarClassName(percentage: number): string {
if (percentage < 80) {
return "[&>div]:bg-gradient-to-r [&>div]:from-emerald-500 [&>div]:to-primary"
}
if (percentage < 95) {
return "[&>div]:bg-primary"
}
return "[&>div]:bg-gradient-to-r [&>div]:from-orange-500 [&>div]:to-red-500"
}

View file

@ -16,6 +16,14 @@ const config: Config = {
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
keyframes: {
shimmer: {
"100%": { transform: "translateX(100%)" },
},
},
animation: {
shimmer: "shimmer 2s infinite",
},
},
},
plugins: [],