Add performance optimizations and component refactors

- Cache read-heavy public API routes via redisCache (leaderboard, values,
  shop, articles, photos, guilds, teams, staff, users, home, radio, badges)
- Add single-flight and bounded-memory cache layer with unit tests
- Parallelize independent DB queries on search, rares, shop, staff, polls
  and profile pages
- Push radio points leaderboard aggregation to SQL with a LIMIT
- Split studio-client and import-furni-client into focused modules
- Clean up next.config.ts
This commit is contained in:
openhands committed 2026-08-17 22:02:21 +02:00
1 parent 54f2bc5e0c
commit f285a7cd98
42 files changed
+1616 -1205

No files matched your search

+39 -35
View File
@@ -1,6 +1,7 @@
import { inArray, sum } from "drizzle-orm";
import { desc, inArray, sum } from "drizzle-orm";
import { apiJson } from "@/lib/api";
import { db, RadioListenerPoints, User } from "@/lib/db";
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
// Radio listener-points leaderboard: the top 20 users by total points, summed
// across radio_listener_points and joined to users for username/look. Public
@@ -8,43 +9,46 @@ import { db, RadioListenerPoints, User } from "@/lib/db";
export async function GET(_req: Request) {
try {
// Sum points per user. Sort/slice in JS so we stay adapter-agnostic about
// aggregate ordering, then resolve the top 20 to usernames/looks.
const grouped = await db
.select({
userId: RadioListenerPoints.userId,
points: sum(RadioListenerPoints.points),
})
.from(RadioListenerPoints)
.groupBy(RadioListenerPoints.userId);
// Cached 60s: the leaderboard only changes while people listen, so a
// short TTL keeps it fresh without re-scanning the table per request.
const data = await redisCache(
apiCacheKey("radio:points:leaderboard"),
60,
async () => {
// Sum points per user and take the top 20 in SQL instead of
// pulling the whole table into JS to sort/slice.
const grouped = await db
.select({
userId: RadioListenerPoints.userId,
points: sum(RadioListenerPoints.points),
})
.from(RadioListenerPoints)
.groupBy(RadioListenerPoints.userId)
.orderBy(desc(sum(RadioListenerPoints.points)))
.limit(20);
const ranked = grouped
.map((g) => ({
userId: Number(g.userId),
points: Number(g.points ?? 0),
}))
.sort((a, b) => b.points - a.points)
.slice(0, 20);
if (grouped.length === 0) {
return [];
}
if (ranked.length === 0) {
return apiJson({ data: [] });
}
const userIds = grouped.map((r) => Number(r.userId));
const users = await db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(inArray(User.id, userIds));
const userById = new Map(users.map((u) => [u.id, u]));
const userIds = ranked.map((r) => r.userId);
const users = await db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(inArray(User.id, userIds));
const userById = new Map(users.map((u) => [u.id, u]));
const data = ranked.map((r) => {
const u = userById.get(r.userId);
return {
username: u?.username ?? null,
look: u?.look ?? null,
points: r.points,
};
});
return grouped.map((r) => {
const uid = Number(r.userId);
const u = userById.get(uid);
return {
username: u?.username ?? null,
look: u?.look ?? null,
points: Number(r.points ?? 0),
};
});
},
);
return apiJson({ data });
} catch {