From 5021e99fbdb9a567497d977813acfe9e98b6cd74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 22:43:02 +0000 Subject: [PATCH 1/2] feat: Cache heatmap responses for 30 days (anonymous only) Cache anonymous heatmap API responses for 30 days using Next.js unstable_cache. User-specific requests are not cached to ensure fresh personal progress data. The cache key includes board configuration and filter parameters. --- .../[set_ids]/[angle]/heatmap/route.ts | 82 ++++++++++++++++++- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts b/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts index 57d61dbe..e454cd1e 100644 --- a/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts +++ b/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts @@ -1,11 +1,82 @@ -import { getHoldHeatmapData } from '@/app/lib/db/queries/climbs/holds-heatmap'; +import { getHoldHeatmapData, HoldHeatmapData } from '@/app/lib/db/queries/climbs/holds-heatmap'; import { getSession } from '@/app/lib/session'; -import { BoardRouteParameters, ErrorResponse, SearchRequestPagination } from '@/app/lib/types'; +import { BoardRouteParameters, ErrorResponse, ParsedBoardRouteParameters, SearchRequestPagination } from '@/app/lib/types'; import { urlParamsToSearchParams } from '@/app/lib/url-utils'; import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; import { cookies } from 'next/headers'; +import { unstable_cache } from 'next/cache'; import { NextResponse } from 'next/server'; +/** + * Cache duration for heatmap queries (in seconds) + * Anonymous heatmap queries are cached for 30 days since aggregate data doesn't change meaningfully + */ +const CACHE_DURATION_HEATMAP = 30 * 24 * 60 * 60; // 30 days + +/** + * Recursively sort object keys for consistent JSON serialization + */ +function sortObjectKeys(obj: T): T { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(sortObjectKeys) as T; + } + + const sortedObj: Record = {}; + const keys = Object.keys(obj as Record).sort(); + + for (const key of keys) { + sortedObj[key] = sortObjectKeys((obj as Record)[key]); + } + + return sortedObj as T; +} + +/** + * Cached version of getHoldHeatmapData + * Only used for anonymous requests - user-specific data is not cached + */ +async function cachedGetHoldHeatmapData( + params: ParsedBoardRouteParameters, + searchParams: SearchRequestPagination, +): Promise { + // Build explicit cache key with board identifiers as separate segments + // This ensures cache hits/misses are correctly differentiated by board configuration + const cacheKey = [ + 'heatmap', + params.board_name, + String(params.layout_id), + String(params.size_id), + params.set_ids.join(','), + String(params.angle), + // Include filter params as a sorted JSON string + JSON.stringify(sortObjectKeys({ + gradeAccuracy: searchParams.gradeAccuracy, + minGrade: searchParams.minGrade, + maxGrade: searchParams.maxGrade, + minAscents: searchParams.minAscents, + sortBy: searchParams.sortBy, + sortOrder: searchParams.sortOrder, + name: searchParams.name, + settername: searchParams.settername, + })), + ]; + + const cachedFn = unstable_cache( + async () => getHoldHeatmapData(params, searchParams, undefined), + cacheKey, + { + revalidate: CACHE_DURATION_HEATMAP, + tags: ['heatmap'], + } + ); + + return cachedFn(); +} + export interface HoldHeatmapResponse { holdStats: Array<{ holdId: number; @@ -60,8 +131,11 @@ export async function GET( userId = session.userId; } - // Get the heatmap data using the query function - const holdStats = await getHoldHeatmapData(parsedParams, searchParams, userId); + // Get the heatmap data - use cached version for anonymous requests only + // User-specific data is not cached to ensure fresh personal progress data + const holdStats = userId + ? await getHoldHeatmapData(parsedParams, searchParams, userId) + : await cachedGetHoldHeatmapData(parsedParams, searchParams); // Return response return NextResponse.json({ From 884f7c204c1e110919d6100f83f94093ce0fa7c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 00:55:56 +0000 Subject: [PATCH 2/2] refactor: Extract sortObjectKeys to shared cache utility - Create shared cache-utils.ts with sortObjectKeys function - Update server-cached-client.ts to use shared utility - Update heatmap route to use shared utility - Add missing filter params to heatmap cache key (minRating, onlyClassics, onlyTallClimbs, holdsFilter) --- .../[set_ids]/[angle]/heatmap/route.ts | 27 ++++--------------- packages/web/app/lib/cache-utils.ts | 23 ++++++++++++++++ .../app/lib/graphql/server-cached-client.ts | 23 +--------------- 3 files changed, 29 insertions(+), 44 deletions(-) create mode 100644 packages/web/app/lib/cache-utils.ts diff --git a/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts b/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts index e454cd1e..9a41f653 100644 --- a/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts +++ b/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts @@ -3,6 +3,7 @@ import { getSession } from '@/app/lib/session'; import { BoardRouteParameters, ErrorResponse, ParsedBoardRouteParameters, SearchRequestPagination } from '@/app/lib/types'; import { urlParamsToSearchParams } from '@/app/lib/url-utils'; import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; +import { sortObjectKeys } from '@/app/lib/cache-utils'; import { cookies } from 'next/headers'; import { unstable_cache } from 'next/cache'; import { NextResponse } from 'next/server'; @@ -13,28 +14,6 @@ import { NextResponse } from 'next/server'; */ const CACHE_DURATION_HEATMAP = 30 * 24 * 60 * 60; // 30 days -/** - * Recursively sort object keys for consistent JSON serialization - */ -function sortObjectKeys(obj: T): T { - if (obj === null || typeof obj !== 'object') { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(sortObjectKeys) as T; - } - - const sortedObj: Record = {}; - const keys = Object.keys(obj as Record).sort(); - - for (const key of keys) { - sortedObj[key] = sortObjectKeys((obj as Record)[key]); - } - - return sortedObj as T; -} - /** * Cached version of getHoldHeatmapData * Only used for anonymous requests - user-specific data is not cached @@ -58,10 +37,14 @@ async function cachedGetHoldHeatmapData( minGrade: searchParams.minGrade, maxGrade: searchParams.maxGrade, minAscents: searchParams.minAscents, + minRating: searchParams.minRating, sortBy: searchParams.sortBy, sortOrder: searchParams.sortOrder, name: searchParams.name, settername: searchParams.settername, + onlyClassics: searchParams.onlyClassics, + onlyTallClimbs: searchParams.onlyTallClimbs, + holdsFilter: searchParams.holdsFilter, })), ]; diff --git a/packages/web/app/lib/cache-utils.ts b/packages/web/app/lib/cache-utils.ts new file mode 100644 index 00000000..fb176ed8 --- /dev/null +++ b/packages/web/app/lib/cache-utils.ts @@ -0,0 +1,23 @@ +/** + * Recursively sort object keys for consistent JSON serialization in cache keys + * This ensures that objects with the same properties in different orders + * produce the same cache key string + */ +export function sortObjectKeys(obj: T): T { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(sortObjectKeys) as T; + } + + const sortedObj: Record = {}; + const keys = Object.keys(obj as Record).sort(); + + for (const key of keys) { + sortedObj[key] = sortObjectKeys((obj as Record)[key]); + } + + return sortedObj as T; +} diff --git a/packages/web/app/lib/graphql/server-cached-client.ts b/packages/web/app/lib/graphql/server-cached-client.ts index 3703f49d..ba9963cc 100644 --- a/packages/web/app/lib/graphql/server-cached-client.ts +++ b/packages/web/app/lib/graphql/server-cached-client.ts @@ -1,6 +1,7 @@ import 'server-only'; import { unstable_cache } from 'next/cache'; import { GraphQLClient, RequestDocument, Variables } from 'graphql-request'; +import { sortObjectKeys } from '@/app/lib/cache-utils'; /** * Cache durations for climb search queries (in seconds) @@ -38,28 +39,6 @@ async function executeGraphQLInternal(document, variables); } -/** - * Recursively sort object keys for consistent JSON serialization - */ -function sortObjectKeys(obj: T): T { - if (obj === null || typeof obj !== 'object') { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(sortObjectKeys) as T; - } - - const sortedObj: Record = {}; - const keys = Object.keys(obj as Record).sort(); - - for (const key of keys) { - sortedObj[key] = sortObjectKeys((obj as Record)[key]); - } - - return sortedObj as T; -} - /** * Create a stable cache key from GraphQL variables * Recursively sorts all object keys to ensure consistent key generation