Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ require (
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.19.0
google.golang.org/api v0.263.0
google.golang.org/api v0.264.0
)

require (
Expand All @@ -40,7 +40,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 h1:jP1RStw811EvUDzsUQ9oESqw2e4RqCjSAD9qIL8eMns=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5/go.mod h1:WXNBZ64q3+ZUemCMXD9kYnr56H7CgZxDBHCVwstfl3s=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
Expand Down Expand Up @@ -132,8 +132,8 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.263.0 h1:UFs7qn8gInIdtk1ZA6eXRXp5JDAnS4x9VRsRVCeKdbk=
google.golang.org/api v0.263.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
Expand Down
12 changes: 9 additions & 3 deletions handlers/stars.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,11 +388,17 @@ func RecentStarsByHourHandler(
log.Printf("[REQUEST] %s: lastDays=%d, requestedStart=%v, now=%v", repo, lastDays, requestedStartTime.Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
var starsPerHour []stats.StarsPerHour

// Use a background context with timeout for the GitHub API call
// This prevents the fetch from being cancelled if the HTTP request times out (e.g. 504 Gateway Timeout)
// allowing the cache to be populated for subsequent requests.
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()

if len(cachedHourly) == 0 {
// No cache - fetch full requested range
log.Printf("[FETCH FULL] %s: fetching %d days from %v to now", repo, lastDays, requestedStartTime.Format(time.RFC3339))
starsPerHour, err = client.GetRecentStarsHistoryByHourRange(
c.Context(),
bgCtx,
repo,
requestedStartTime,
// Workaround: Pass now+1h to ensure the current partial hour is included.
Expand Down Expand Up @@ -462,7 +468,7 @@ func RecentStarsByHourHandler(
log.Printf("[FETCH OLDER] %s: from %v to %v",
repo, requestedStartTime.Format(time.RFC3339), oldestCachedTime.Format(time.RFC3339))
olderData, err = client.GetRecentStarsHistoryByHourRange(
c.Context(),
bgCtx,
repo,
requestedStartTime,
oldestCachedTime,
Expand All @@ -486,7 +492,7 @@ func RecentStarsByHourHandler(
// The library truncates endTime to the hour and uses strict inequality (< truncatedTime),
// which excludes stars in the current hour if we just pass 'now'.
newerData, err = client.GetRecentStarsHistoryByHourRange(
c.Context(),
bgCtx,
repo,
fetchFrom,
now.Add(time.Hour),
Expand Down
143 changes: 143 additions & 0 deletions handlers/trending.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package handlers

import (
"sort"
"time"

cache "github.com/Code-Hex/go-generics-cache"
"github.com/emanuelef/gh-repo-stats-server/types"
"github.com/gofiber/fiber/v2"
)

// TrendingRepo represents a repository with velocity metrics
type TrendingRepo struct {
Repo string `json:"repo"`
Stars1h int `json:"stars1h"`
Stars24h int `json:"stars24h"`
Stars7d int `json:"stars7d"`
TotalStars int `json:"totalStars"`
VelocityPerHour float64 `json:"velocityPerHour"`
GrowthPercent float64 `json:"growthPercent"`
LastUpdated string `json:"lastUpdated"`
}

// TrendingHandler returns repos sorted by star velocity
func TrendingHandler(
cacheRecentStarsByHour *cache.Cache[string, []types.HourlyStars],
cacheStars *cache.Cache[string, types.StarsWithStatsResponse],
) fiber.Handler {
return func(c *fiber.Ctx) error {
now := time.Now().UTC()

// Time boundaries
oneHourAgo := now.Add(-1 * time.Hour)
oneDayAgo := now.Add(-24 * time.Hour)
sevenDaysAgo := now.Add(-7 * 24 * time.Hour)

// Get all repos from hourly cache
repoKeys := cacheRecentStarsByHour.Keys()

trending := make([]TrendingRepo, 0, len(repoKeys))

for _, repo := range repoKeys {
hourlyData, found := cacheRecentStarsByHour.Get(repo)
if !found || len(hourlyData) == 0 {
continue
}

var stars1h, stars24h, stars7d int
var lastTotalStars int
var lastUpdated string

// Calculate stars in different time windows
for _, h := range hourlyData {
t, err := time.Parse(time.RFC3339, h.Hour)
if err != nil {
continue
}

if t.After(oneHourAgo) {
stars1h += h.Stars
}
if t.After(oneDayAgo) {
stars24h += h.Stars
}
if t.After(sevenDaysAgo) {
stars7d += h.Stars
}

// Track the most recent data
if h.TotalStars > lastTotalStars {
lastTotalStars = h.TotalStars
lastUpdated = h.Hour
}
}

// If we don't have hourly total, try to get from daily cache
if lastTotalStars == 0 {
if dailyData, found := cacheStars.Get(repo); found && len(dailyData.Stars) > 0 {
lastStar := dailyData.Stars[len(dailyData.Stars)-1]
lastTotalStars = lastStar.TotalStars
}
}

// Skip repos with no recent activity
if stars24h == 0 && stars7d == 0 {
continue
}

// Calculate velocity (stars per hour over last 24h)
velocityPerHour := float64(stars24h) / 24.0

// Calculate growth percentage (24h stars / total stars * 100)
growthPercent := 0.0
if lastTotalStars > 0 {
growthPercent = (float64(stars24h) / float64(lastTotalStars)) * 100
}

trending = append(trending, TrendingRepo{
Repo: repo,
Stars1h: stars1h,
Stars24h: stars24h,
Stars7d: stars7d,
TotalStars: lastTotalStars,
VelocityPerHour: velocityPerHour,
GrowthPercent: growthPercent,
LastUpdated: lastUpdated,
})
}

// Sort by 24h stars (descending) by default
sortBy := c.Query("sort", "stars24h")
switch sortBy {
case "stars1h":
sort.Slice(trending, func(i, j int) bool {
return trending[i].Stars1h > trending[j].Stars1h
})
case "stars24h":
sort.Slice(trending, func(i, j int) bool {
return trending[i].Stars24h > trending[j].Stars24h
})
case "stars7d":
sort.Slice(trending, func(i, j int) bool {
return trending[i].Stars7d > trending[j].Stars7d
})
case "velocity":
sort.Slice(trending, func(i, j int) bool {
return trending[i].VelocityPerHour > trending[j].VelocityPerHour
})
case "growth":
sort.Slice(trending, func(i, j int) bool {
return trending[i].GrowthPercent > trending[j].GrowthPercent
})
}

// Limit results
limit := c.QueryInt("limit", 50)
if limit > 0 && len(trending) > limit {
trending = trending[:limit]
}

return c.JSON(trending)
}
}
4 changes: 4 additions & 0 deletions routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ func RegisterStarsRoutes(
ghStatClients,
caches.RecentStarsByHour,
))
app.Get("/trending", handlers.TrendingHandler(
caches.RecentStarsByHour,
caches.Stars,
))
}

// RegisterRepoActivityRoutes registers repository activity routes
Expand Down
17 changes: 16 additions & 1 deletion website/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import ContributorsTimeSeriesChart from "./ContributorsTimeSeriesChart";
import NewReposTimeSeriesChart from "./NewReposTimeSeriesChart";
import InfoPage from "./InfoPage";
import FeaturedReposPage from "./FeaturedReposPage";
import ViralLeaderboard from "./ViralLeaderboard";

import { Sidebar, Menu, MenuItem } from "react-pro-sidebar";
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
Expand All @@ -30,6 +31,7 @@ import Diversity3OutlinedIcon from '@mui/icons-material/Diversity3Outlined';
import AddBoxOutlinedIcon from '@mui/icons-material/AddBoxOutlined';
import ArticleIcon from '@mui/icons-material/Article';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import WhatshotIcon from '@mui/icons-material/Whatshot';

import { ThemeProvider, createTheme } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
Expand Down Expand Up @@ -101,7 +103,8 @@ function App() {
location.pathname.includes("/contributors") ||
location.pathname.includes("/newrepos") ||
location.pathname.includes("/featured") ||
location.pathname.includes("/hourly")
location.pathname.includes("/hourly") ||
location.pathname.includes("/viral")
)
}
>
Expand All @@ -118,6 +121,17 @@ function App() {
>
Hourly Stars
</MenuItem>
<MenuItem
component={<Link to="/viral" className="link" />}
icon={
<Tooltip title="Viral Velocity Leaderboard" placement="right">
<WhatshotIcon />
</Tooltip>
}
active={location.pathname.includes("/viral")}
>
Viral
</MenuItem>
<MenuItem
component={<Link to="/compare" className="link" />}
icon={
Expand Down Expand Up @@ -257,6 +271,7 @@ function App() {
<Route path="/newrepos" element={<NewReposTimeSeriesChart />} />
<Route path="/showhn" element={<Navigate to="/featured" replace />} />
<Route path="/featured" element={<FeaturedReposPage />} />
<Route path="/viral" element={<ViralLeaderboard />} />
</Routes>
</section>
</div>
Expand Down
Loading