Skip to content
Merged
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
30 changes: 16 additions & 14 deletions app/[locale]/developers/apps/[category]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,8 @@ import AppModalContents from "../_components/AppModalContents"
import AppModalWrapper from "../_components/AppModalWrapper"
import CategoryAppsGrid from "../_components/CategoryAppsGrid"
import HighlightsSection from "../_components/HighlightsSection"
import { DEV_APP_CATEGORIES } from "../constants"
import { DEV_APP_CATEGORIES, DEV_APP_CATEGORY_SLUGS } from "../constants"
import type { DeveloperAppCategorySlug, DeveloperAppTag } from "../types"
import {
getCachedHighlightsByCategory,
getCategoryPageHighlights,
transformDeveloperAppsData,
} from "../utils"

import DevelopersAppsCategoryJsonLD from "./page-jsonld"

Expand All @@ -40,17 +35,23 @@ const Page = async ({
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "page-developers-apps" })

const enrichedData = await getDeveloperToolsData()
if (!enrichedData) throw Error("No developer apps data available")
const dataByCategory = transformDeveloperAppsData(enrichedData)
const allCategoryData = dataByCategory[category]
const data = await getDeveloperToolsData()
if (!data) throw Error("No developer apps data available")

const { appsById, selections } = data

// Get all apps for this category (filter at runtime - trivial for few hundred apps)
const allApps = Object.values(appsById)
const allCategoryData = allApps.filter(
(app) => DEV_APP_CATEGORY_SLUGS[app.category] === category
)

// Extract unique tags from current category
const uniqueTags = Array.from(
new Set(allCategoryData.flatMap((app) => app.tags))
).sort()

const activeApp = enrichedData.find((app) => app.id === appId)
const activeApp = appId ? appsById[appId] : undefined

// Clean up invalid appId by redirecting
if (appId && !activeApp) {
Expand All @@ -62,9 +63,10 @@ const Page = async ({
uniqueTags.map((tag) => [tag, t(`page-developers-apps-tag-${tag}`)])
) as Record<DeveloperAppTag, string>

// Get dynamic highlights based on stars and recent activity (cached weekly)
const highlightsByCategory = await getCachedHighlightsByCategory(enrichedData)
const highlights = getCategoryPageHighlights(highlightsByCategory, category)
// Resolve category highlight IDs to full app objects
const highlights = (selections.categoryHighlights[category] || [])
.map((id) => appsById[id])
.filter(Boolean)

// Get contributor info for JSON-LD
const commitHistoryCache: CommitHistory = {}
Expand Down
37 changes: 20 additions & 17 deletions app/[locale]/developers/apps/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,7 @@ import AppModalWrapper from "./_components/AppModalWrapper"
import HighlightsSection from "./_components/HighlightsSection"
import { DEV_APP_CATEGORIES } from "./constants"
import DevelopersAppsJsonLD from "./page-jsonld"
import {
getCachedHighlightsByCategory,
getCachedRandomPreviewsByCategory,
getMainPageHighlights,
} from "./utils"
import { transformDeveloperAppsData } from "./utils"
import type { DeveloperAppsByCategory } from "./types"

import { getDeveloperToolsData } from "@/lib/data"

Expand All @@ -46,24 +41,32 @@ const Page = async ({
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "page-developers-apps" })

const enrichedData = await getDeveloperToolsData()
if (!enrichedData) throw Error("No developer apps data available")
const dataByCategory = transformDeveloperAppsData(enrichedData)
const data = await getDeveloperToolsData()
if (!data) throw Error("No developer apps data available")

const { appsById, selections } = data

const activeApp = enrichedData.find((app) => app.id === appId)
const activeApp = appId ? appsById[appId] : undefined

// Clean up invalid appId by redirecting
if (appId && !activeApp) {
redirect("/developers/apps")
}

// Get dynamic highlights based on stars and recent activity (cached weekly)
const highlightsByCategory = await getCachedHighlightsByCategory(enrichedData)
const highlights = getMainPageHighlights(highlightsByCategory)

// Get randomized previews (5 apps per category) - cached daily
const previewsByCategory =
await getCachedRandomPreviewsByCategory(dataByCategory)
// Resolve highlight IDs to full app objects
const highlights = selections.mainPageHighlights
.map((id) => appsById[id])
.filter(Boolean)

// Resolve preview IDs per category
const previewsByCategory = Object.fromEntries(
DEV_APP_CATEGORIES.map(({ slug }) => [
slug,
(selections.categoryPreviews[slug] || [])
.map((id) => appsById[id])
.filter(Boolean),
])
) as DeveloperAppsByCategory

// Get contributor info for JSON-LD
const commitHistoryCache: CommitHistory = {}
Expand Down
265 changes: 2 additions & 263 deletions app/[locale]/developers/apps/utils.ts
Original file line number Diff line number Diff line change
@@ -1,268 +1,7 @@
import { unstable_cache } from "next/cache"

import type { TagProps } from "@/components/ui/tag"

import { getDayOfYear, getWeekNumber } from "@/lib/utils/date"
import { every } from "@/lib/utils/time"

import { DEV_APP_CATEGORIES, DEV_APP_CATEGORY_SLUGS } from "./constants"
import type {
DeveloperApp,
DeveloperAppCategorySlug,
DeveloperAppsByCategory,
} from "./types"

// Number of top apps to show in highlights section
const HIGHLIGHTS_PER_CATEGORY = 9
// Number of preview apps to show in category cards
const PREVIEWS_PER_CATEGORY = 5

/**
* Transform flat array of apps into an object grouped by category slug
*/
export const transformDeveloperAppsData = (
data: DeveloperApp[]
): DeveloperAppsByCategory => {
const initialAcc = Object.values(DEV_APP_CATEGORY_SLUGS).reduce(
(acc, slug) => {
acc[slug] = []
return acc
},
{} as DeveloperAppsByCategory
)

return data.reduce((acc, app) => {
const slug = DEV_APP_CATEGORY_SLUGS[app.category]
acc[slug].push(app)
return acc
}, initialAcc)
}

/**
* Seeded random number generator for deterministic randomization
* Uses Linear Congruential Generator algorithm
*
* Why not Math.random() or timestamp?
* - Math.random(): Non-deterministic, every user sees different highlights
* - Timestamp: Different on every page load, causes jarring UX
* - Seeded: Same seed = same "random" sequence = consistent highlights for all users
*/
function seededRandom(seed: number) {
let value = seed
return () => {
value = (value * 9301 + 49297) % 233280
return value / 233280
}
}

/**
* Shuffle array using Fisher-Yates algorithm with seeded randomization
* Ensures deterministic shuffle: same seed always produces same order
*/
function seededShuffle<T>(array: T[], seed: number): T[] {
const shuffled = [...array]
const random = seededRandom(seed)

for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}

return shuffled
}

/**
* Get maximum star count across all repos for an app
*/
function getMaxStarCount(app: DeveloperApp): number {
return Math.max(...app.repos.map((repo) => repo.stargazers || 0), 0)
}

/**
* Generate seed offset based on category name for variety across categories
*/
function getCategorySeedOffset(category: string): number {
return category.length
}

/**
* Get highlighted apps grouped by category
*
* Algorithm:
* 1. Filter to apps with GitHub repos updated in last 6 months + have banner/thumbnail images
* 2. Group by category slug
* 3. Sort each category by highest GitHub star count
* 4. Take top 9 apps per category
* 5. Shuffle each category's top 9 using weekly seed for deterministic rotation
*
* @returns Object mapping category slugs to arrays of up to 9 highlighted apps
*/
export function getHighlightsByCategory(
apps: DeveloperApp[],
now: Date = new Date()
): Record<DeveloperAppCategorySlug, DeveloperApp[]> {
const sixMonthsAgo = new Date(now)
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)

// Filter apps with GitHub repos updated in last 6 months and have required images
const recentApps = apps.filter((app) => {
// Must have both banner and thumbnail images for highlights section
if (!app.banner_url || !app.thumbnail_url) return false

const hasGitHubRepo = app.repos.some((repo) =>
repo.href.includes("github.com")
)
if (!hasGitHubRepo) return false

const latestUpdate = app.repos.reduce((latest, repo) => {
if (!repo.lastUpdated) return latest
const date = new Date(repo.lastUpdated)
return date > latest ? date : latest
}, new Date(0))

return latestUpdate > sixMonthsAgo
})

// Group by category slug
const byCategory: Record<string, DeveloperApp[]> = {}
for (const app of recentApps) {
const categorySlug = DEV_APP_CATEGORY_SLUGS[app.category]
if (!categorySlug) continue
if (!byCategory[categorySlug]) {
byCategory[categorySlug] = []
}
byCategory[categorySlug].push(app)
}

// Get weekly seed for deterministic randomization
const weekSeed = getWeekNumber(now)

// Sort by stars, take top N, randomize
const result: Record<string, DeveloperApp[]> = {}
for (const [category, categoryApps] of Object.entries(byCategory)) {
// Sort by highest star count
const sorted = [...categoryApps].sort((a, b) => {
return getMaxStarCount(b) - getMaxStarCount(a)
})

// Take top N and randomize
const topN = sorted.slice(0, HIGHLIGHTS_PER_CATEGORY)
const randomized = seededShuffle(
topN,
weekSeed + getCategorySeedOffset(category)
)

result[category] = randomized
}

return result as Record<DeveloperAppCategorySlug, DeveloperApp[]>
}

/**
* Get highlights for main /developers/apps page
* Returns top app from top 3 randomly-ordered categories
*
* @returns Array of 3 apps (one from each of top 3 categories)
*/
export function getMainPageHighlights(
highlightsByCategory: Record<DeveloperAppCategorySlug, DeveloperApp[]>,
now: Date = new Date()
): DeveloperApp[] {
const weekSeed = getWeekNumber(now)

// Get categories with highlights
const categoriesWithHighlights = Object.entries(highlightsByCategory).filter(
([, apps]) => apps.length > 0
)

// Randomize category order
const randomizedCategories = seededShuffle(categoriesWithHighlights, weekSeed)

// Take top app from top 3 categories
return randomizedCategories
.slice(0, 3)
.map(([, apps]) => apps[0])
.filter(Boolean)
}

/**
* Get highlights for category page
* Returns top 3 apps for the specified category
*/
export function getCategoryPageHighlights(
highlightsByCategory: Record<DeveloperAppCategorySlug, DeveloperApp[]>,
category: DeveloperAppCategorySlug
): DeveloperApp[] {
return highlightsByCategory[category]?.slice(0, 3) || []
}

/**
* Cached version of getHighlightsByCategory
*
* Uses Next.js unstable_cache to cache the computation for 1 week.
* Since the input data (apps array) is already cached via getDeveloperToolsData(),
* this primarily caches the sorting/filtering/randomization computation.
*
* Cache key includes the function name, ensuring cache invalidation when function changes.
* Tagged for manual cache invalidation if needed via revalidateTag('developer-apps-highlights').
*/
export const getCachedHighlightsByCategory = unstable_cache(
async (apps: DeveloperApp[]) => getHighlightsByCategory(apps),
["highlights-by-category"],
{
revalidate: every("week"),
tags: ["developer-apps-highlights"],
}
)

/**
* Get randomized preview apps per category for main page cards
*
* Simpler than highlights: no filtering, no star sorting - just shuffle and take N.
* Uses daily seed for rotation instead of weekly.
*
* @param dataByCategory - Apps already grouped by category
* @returns Same structure but with max N randomized apps per category
*/
export function getRandomPreviewsByCategory(
dataByCategory: DeveloperAppsByCategory,
now: Date = new Date()
): DeveloperAppsByCategory {
const daySeed = getDayOfYear(now)

const result = {} as DeveloperAppsByCategory

for (const [category, apps] of Object.entries(dataByCategory)) {
// Shuffle with daily seed + category offset for variety
const shuffled = seededShuffle(
apps,
daySeed + getCategorySeedOffset(category)
)
// Take first N after shuffle
result[category as DeveloperAppCategorySlug] = shuffled.slice(
0,
PREVIEWS_PER_CATEGORY
)
}

return result
}

/**
* Cached version of getRandomPreviewsByCategory
*
* Caches for 1 day (24 hours) to align with daily seed rotation.
* Simpler than highlights - no complex filtering, just randomization.
*/
export const getCachedRandomPreviewsByCategory = unstable_cache(
async (dataByCategory: DeveloperAppsByCategory) =>
getRandomPreviewsByCategory(dataByCategory),
["random-previews-by-category"],
{
revalidate: every("day"),
tags: ["developer-apps-previews"],
}
)
import { DEV_APP_CATEGORIES } from "./constants"
import type { DeveloperAppCategorySlug } from "./types"

/**
* Gets the tag style for a developer app category based on its slug.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { DeveloperAppsResponse } from "@/lib/types"

export async function fetchDeveloperToolsBuidlGuidl(): Promise<
DeveloperAppsResponse[]
> {
export async function fetchBuidlGuidl(): Promise<DeveloperAppsResponse[]> {
const url =
"https://raw.githubusercontent.com/BuidlGuidl/Developer-Tooling/refs/heads/main/output/results.json"

Expand Down
Loading