sync: Redis caching, progress bar rewrite, sentry filtering, UI improvements

- Add Redis caching to all pages and uncached API routes
- Rewrite progress bar with direct DOM manipulation for search/filters/navigation
- Filter generic network errors from Sentry client reporting
- Fix non-null assertion for repo.owner in popular-repos strategy
- Remove next-nprogress-bar dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-22 12:58:16 +03:30
parent 0713607693
commit d19113795e
16 changed files with 353 additions and 151 deletions

View File

@@ -6,6 +6,7 @@ import { BrowseFilters, SearchBar, Pagination, ActiveFilters, EmptyState } from
import { SkillCard } from '@/components/SkillCard';
import { toPersianNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
@@ -108,7 +109,7 @@ export default async function BrowsePage({ params, searchParams }: BrowsePagePro
{ id: 'rating', name: t('filters.sortOptions.rating') },
];
// Fetch categories hierarchically for filter dropdown with translations
// Fetch categories hierarchically for filter dropdown with translations (cached 12h)
const tCategories = await getTranslations('categories');
type HierarchicalCategory = {
id: string;
@@ -119,8 +120,14 @@ export default async function BrowsePage({ params, searchParams }: BrowsePagePro
};
let categories: HierarchicalCategory[] = [];
try {
const db = createDb();
const rawCategories = await categoryQueries.getHierarchical(db);
const rawCategories = await getOrSetCache(
cacheKeys.categoriesHierarchical(),
cacheTTL.categories,
async () => {
const db = createDb();
return await categoryQueries.getHierarchical(db);
}
);
categories = rawCategories.map(parent => ({
id: parent.id,
name: tCategories(`parents.${parent.slug}`) || parent.name,

View File

@@ -32,6 +32,7 @@ import {
import { createDb, categoryQueries } from '@skillhub/db';
import { formatNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
@@ -87,11 +88,13 @@ const parentColorMap: Record<string, string> = {
'specialized': 'bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
};
// Get categories hierarchically from database
// Get categories hierarchically with Redis caching (12 hour TTL)
async function getHierarchicalCategories() {
try {
const db = createDb();
return await categoryQueries.getHierarchical(db);
return await getOrSetCache(cacheKeys.categoriesHierarchical(), cacheTTL.categories, async () => {
const db = createDb();
return await categoryQueries.getHierarchical(db);
});
} catch (error) {
console.error('Error fetching categories:', error);
return [];

View File

@@ -18,6 +18,7 @@ import { Footer } from '@/components/Footer';
import { EarlyAccessForm } from '@/components/EarlyAccessForm';
import { createDb, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@@ -42,11 +43,13 @@ export async function generateMetadata({
async function getStats() {
try {
const db = createDb();
const skillsResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills);
return skillsResult[0]?.count ?? 0;
return await getOrSetCache(cacheKeys.pageCount('claude-plugin'), cacheTTL.pageCount, async () => {
const db = createDb();
const skillsResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills);
return skillsResult[0]?.count ?? 0;
});
} catch {
return 119000;
}

View File

@@ -7,18 +7,21 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
import { createDb, skills, sql } from '@skillhub/db';
import { formatPromptSkillCount } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
export const dynamic = 'force-dynamic';
async function getSkillCount(): Promise<string> {
try {
const db = createDb();
const result = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.isDuplicate} = false`);
return formatPromptSkillCount(result[0]?.count ?? 16000);
return await getOrSetCache(cacheKeys.pageCount('getting-started'), cacheTTL.pageCount, async () => {
const db = createDb();
const result = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.isDuplicate} = false`);
return formatPromptSkillCount(result[0]?.count ?? 16000);
});
} catch {
return '16,000+';
}

View File

@@ -6,6 +6,7 @@ import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters';
import { SkillCard } from '@/components/SkillCard';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
@@ -16,23 +17,25 @@ interface FeaturedPageProps {
searchParams: Promise<{ page?: string }>;
}
// Get featured skills with pagination
// Get featured skills with pagination and Redis caching (2 hour TTL)
async function getFeaturedSkills(page: number, limit: number) {
try {
const db = createDb();
const offset = (page - 1) * limit;
return await getOrSetCache(cacheKeys.featuredPage(page), cacheTTL.featured, async () => {
const db = createDb();
const offset = (page - 1) * limit;
// Try featured first, fall back to combined popularity score
let featuredSkills = await skillQueries.getFeatured(db, limit, offset);
let total = await skillQueries.countFeatured(db);
// Try featured first, fall back to combined popularity score
let featuredSkills = await skillQueries.getFeatured(db, limit, offset);
let total = await skillQueries.countFeatured(db);
// If no featured skills, use adaptive popularity with owner/repo diversity
if (total === 0) {
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3);
total = await skillQueries.countAll(db);
}
// If no featured skills, use adaptive popularity with owner/repo diversity
if (total === 0) {
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3);
total = await skillQueries.countAll(db);
}
return { skills: featuredSkills, total };
return { skills: featuredSkills, total };
});
} catch (error) {
console.error('Error fetching featured skills:', error);
return { skills: [], total: 0 };

View File

@@ -8,6 +8,7 @@ import { createDb, skillQueries } from '@skillhub/db';
import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
@@ -41,36 +42,40 @@ function formatTimeAgo(date: Date | null, locale: string): string {
return 'Just now';
}
// Get skills based on tab with pagination
// Get skills based on tab with pagination and Redis caching (30 min TTL)
async function getSkillsForTab(tab: 'new' | 'updated', page: number, limit: number) {
try {
const db = createDb();
const offset = (page - 1) * limit;
return await getOrSetCache(cacheKeys.newSkills(tab, page), cacheTTL.newSkills, async () => {
const db = createDb();
const offset = (page - 1) * limit;
if (tab === 'new') {
const skills = await skillQueries.getNewSkills(db, limit, offset);
const total = await skillQueries.countNewSkills(db);
return { skills, total };
} else {
const skills = await skillQueries.getUpdatedSkills(db, limit, offset);
const total = await skillQueries.countUpdatedSkills(db);
return { skills, total };
}
if (tab === 'new') {
const skills = await skillQueries.getNewSkills(db, limit, offset);
const total = await skillQueries.countNewSkills(db);
return { skills, total };
} else {
const skills = await skillQueries.getUpdatedSkills(db, limit, offset);
const total = await skillQueries.countUpdatedSkills(db);
return { skills, total };
}
});
} catch (error) {
console.error('Error fetching skills:', error);
return { skills: [], total: 0 };
}
}
// Get counts for both tabs
// Get counts for both tabs with Redis caching (30 min TTL)
async function getTabCounts() {
try {
const db = createDb();
const [newCount, updatedCount] = await Promise.all([
skillQueries.countNewSkills(db),
skillQueries.countUpdatedSkills(db),
]);
return { newCount, updatedCount };
return await getOrSetCache(cacheKeys.newSkillsCounts(), cacheTTL.newSkills, async () => {
const db = createDb();
const [newCount, updatedCount] = await Promise.all([
skillQueries.countNewSkills(db),
skillQueries.countUpdatedSkills(db),
]);
return { newCount, updatedCount };
});
} catch (error) {
console.error('Error fetching counts:', error);
return { newCount: 0, updatedCount: 0 };

View File

@@ -9,6 +9,7 @@ import { ExternalLink, Download, Package, Eye, GitFork, ArrowUpDown, FolderGit2
import Link from 'next/link';
import type { Metadata } from 'next';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@@ -49,11 +50,15 @@ export default async function OwnerPage({ params, searchParams }: OwnerPageProps
const db = createDb();
// Fetch stats, count, and repo list in parallel
// Fetch stats and repo list with caching (30 min TTL), count is dynamic per filter
const [stats, totalSkills, ownerRepos] = await Promise.all([
skillQueries.getOwnerStats(db, username),
getOrSetCache(cacheKeys.ownerStats(username), cacheTTL.owner, () =>
skillQueries.getOwnerStats(db, username)
),
skillQueries.countByOwner(db, username, activeRepo || undefined),
skillQueries.getOwnerRepos(db, username),
getOrSetCache(cacheKeys.ownerRepos(username), cacheTTL.owner, () =>
skillQueries.getOwnerRepos(db, username)
),
]);
if (stats.totalSkills === 0) {

View File

@@ -8,75 +8,70 @@ import { SkillCard } from '@/components/SkillCard';
import { createDb, categoryQueries, skillQueries, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
// Get stats directly from database
// Get stats with Redis caching (1 hour TTL)
async function getStats() {
try {
const db = createDb();
return await getOrSetCache(cacheKeys.homeStats(), cacheTTL.stats, async () => {
const db = createDb();
// Browse-ready filter: exclude duplicates (matches browseReadyFilter in queries.ts)
const browseReady = sql`${skills.isDuplicate} = false`;
// Browse-ready filter: exclude duplicates (matches browseReadyFilter in queries.ts)
const browseReady = sql`${skills.isDuplicate} = false`;
// Get total skills count (browse-ready, SKILL.md only)
const skillsResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false AND ${browseReady}`);
const totalSkills = skillsResult[0]?.count ?? 0;
// Run all independent count queries in parallel
const [skillsResult, downloadsResult, categories, contributorsResult, totalIndexedResult] = await Promise.all([
// Get total skills count (browse-ready, SKILL.md only)
db.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false AND ${browseReady}`),
// Get total downloads (ALL skills — downloads are real user actions)
db.select({ sum: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int` })
.from(skills),
// Get total categories
categoryQueries.getAll(db),
// Get unique contributors (browse-ready skills only)
db.select({ count: sql<number>`count(distinct ${skills.githubOwner})::int` })
.from(skills)
.where(browseReady),
// Get total indexed skills (all, before curation) for curation note
db.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.isBlocked} = false`),
]);
// Get total downloads (ALL skills — downloads are real user actions)
const downloadsResult = await db
.select({ sum: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int` })
.from(skills);
const totalDownloads = downloadsResult[0]?.sum ?? 0;
// Get total categories
const categories = await categoryQueries.getAll(db);
const totalCategories = categories.length;
// Get unique contributors (browse-ready skills only)
const contributorsResult = await db
.select({ count: sql<number>`count(distinct ${skills.githubOwner})::int` })
.from(skills)
.where(browseReady);
const totalContributors = contributorsResult[0]?.count ?? 0;
// Get total indexed skills (all, before curation) for curation note
const totalIndexedResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.isBlocked} = false`);
const totalIndexed = totalIndexedResult[0]?.count ?? 0;
return {
totalSkills,
totalDownloads,
totalCategories,
totalContributors,
totalIndexed,
platforms: 5,
};
return {
totalSkills: skillsResult[0]?.count ?? 0,
totalDownloads: downloadsResult[0]?.sum ?? 0,
totalCategories: categories.length,
totalContributors: contributorsResult[0]?.count ?? 0,
totalIndexed: totalIndexedResult[0]?.count ?? 0,
platforms: 5,
};
});
} catch (error) {
console.error('Error fetching stats:', error);
return null;
}
}
// Get featured skills directly from database
// Get featured skills with Redis caching (2 hour TTL)
async function getFeaturedSkills() {
try {
const db = createDb();
// Get featured skills, or top skills by popularity if none are featured
let featuredSkills = await skillQueries.getFeatured(db, 6);
if (featuredSkills.length === 0) {
// Fallback to adaptive popularity with owner/repo diversity
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, 6, 2, 3);
}
return featuredSkills;
return await getOrSetCache(cacheKeys.homeFeatured(), cacheTTL.featured, async () => {
const db = createDb();
// Get featured skills, or top skills by popularity if none are featured
let featuredSkills = await skillQueries.getFeatured(db, 6);
if (featuredSkills.length === 0) {
// Fallback to adaptive popularity with owner/repo diversity
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, 6, 2, 3);
}
return featuredSkills;
});
} catch (error) {
console.error('Error fetching featured skills:', error);
return [];

View File

@@ -15,7 +15,7 @@ import { ShareButton } from '@/components/ShareButton';
import { createDb, skillQueries } from '@skillhub/db';
import { FORMAT_LABELS } from 'skillhub-core';
import { formatCompactNumber } from '@/lib/format-number';
import { shouldCountView } from '@/lib/cache';
import { shouldCountView, getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
import type { Metadata } from 'next';
import { getPageAlternates } from '@/lib/seo';
@@ -46,11 +46,13 @@ interface SkillPageProps {
params: Promise<{ locale: string; id: string[] }>;
}
// Get skill directly from database
// Get skill with Redis caching (1 hour TTL)
async function getSkill(skillId: string) {
try {
const db = createDb();
return await skillQueries.getById(db, skillId);
return await getOrSetCache(cacheKeys.skillDetail(skillId), cacheTTL.skill, async () => {
const db = createDb();
return await skillQueries.getById(db, skillId);
});
} catch (error) {
console.error('Error fetching skill:', error);
return null;