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
64 changes: 64 additions & 0 deletions packages/backend/src/graphql/resolvers/playlists/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
GetUserPlaylistsInputSchema,
GetAllUserPlaylistsInputSchema,
GetPlaylistsForClimbInputSchema,
GetPlaylistMembershipsForClimbsInputSchema,
GetPlaylistClimbsInputSchema,
DiscoverPlaylistsInputSchema,
GetPlaylistCreatorsInputSchema,
Expand Down Expand Up @@ -309,6 +310,69 @@ export const playlistQueries = {
return results.map(r => r.playlistUuid);
},

/**
* Batch-fetch playlist memberships for multiple climbs in a single query.
* Replaces N separate playlistsForClimb calls with one batched query.
*/
playlistMembershipsForClimbs: async (
_: unknown,
{ input }: { input: { boardType: string; layoutId: number; climbUuids: string[] } },
ctx: ConnectionContext
): Promise<Array<{ climbUuid: string; playlistIds: string[] }>> => {
requireAuthenticated(ctx);
validateInput(GetPlaylistMembershipsForClimbsInputSchema, input, 'input');

const userId = ctx.userId!;

if (input.climbUuids.length === 0) {
return [];
}

// Single query to get all playlist memberships for all requested climbs
const results = await db
.select({
climbUuid: dbSchema.playlistClimbs.climbUuid,
playlistUuid: dbSchema.playlists.uuid,
})
.from(dbSchema.playlistClimbs)
.innerJoin(
dbSchema.playlists,
eq(dbSchema.playlists.id, dbSchema.playlistClimbs.playlistId)
)
.innerJoin(
dbSchema.playlistOwnership,
eq(dbSchema.playlistOwnership.playlistId, dbSchema.playlists.id)
)
.where(
and(
inArray(dbSchema.playlistClimbs.climbUuid, input.climbUuids),
eq(dbSchema.playlists.boardType, input.boardType),
or(
eq(dbSchema.playlists.layoutId, input.layoutId),
isNull(dbSchema.playlists.layoutId)
),
eq(dbSchema.playlistOwnership.userId, userId)
)
);

// Group results by climbUuid
const membershipsMap = new Map<string, string[]>();
for (const row of results) {
const existing = membershipsMap.get(row.climbUuid);
if (existing) {
existing.push(row.playlistUuid);
} else {
membershipsMap.set(row.climbUuid, [row.playlistUuid]);
}
}

// Return entries for all requested climbUuids (empty array for those with no memberships)
return input.climbUuids.map(uuid => ({
climbUuid: uuid,
playlistIds: membershipsMap.get(uuid) ?? [],
}));
},

/**
* Get climbs in a playlist with full climb data
*/
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,12 @@ export const GetPlaylistsForClimbInputSchema = z.object({
climbUuid: ExternalUUIDSchema,
});

export const GetPlaylistMembershipsForClimbsInputSchema = z.object({
boardType: BoardNameSchema,
layoutId: z.number().int().positive(),
climbUuids: z.array(ExternalUUIDSchema).min(1).max(200),
});

export const GetPlaylistClimbsInputSchema = z.object({
playlistId: z.string().min(1),
boardName: BoardNameSchema,
Expand Down
28 changes: 28 additions & 0 deletions packages/shared-schema/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,28 @@ export const typeDefs = /* GraphQL */ `
climbUuid: String!
}

"""
Input for batch-fetching playlist memberships for multiple climbs.
"""
input GetPlaylistMembershipsForClimbsInput {
"Board type"
boardType: String!
"Layout ID"
layoutId: Int!
"Climb UUIDs to check memberships for"
climbUuids: [String!]!
}

"""
A single climb's playlist membership entry.
"""
type PlaylistMembershipEntry {
"The climb UUID"
climbUuid: String!
"Playlist UUIDs containing this climb"
playlistIds: [ID!]!
}

"""
Input for getting climbs in a playlist with full data.
"""
Expand Down Expand Up @@ -2625,6 +2647,12 @@ export const typeDefs = /* GraphQL */ `
"""
playlistsForClimb(input: GetPlaylistsForClimbInput!): [ID!]!

"""
Batch-fetch playlist memberships for multiple climbs in a single query.
Returns which playlists each climb belongs to.
"""
playlistMembershipsForClimbs(input: GetPlaylistMembershipsForClimbsInput!): [PlaylistMembershipEntry!]!

"""
Get climbs in a playlist with full climb data.
"""
Expand Down
139 changes: 68 additions & 71 deletions packages/web/app/hooks/use-climb-actions-data.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useCallback, useState, useEffect, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token';
import { useSnackbar } from '@/app/components/providers/snackbar-provider';
Expand All @@ -13,12 +13,12 @@ import {
} from '@/app/lib/graphql/operations/favorites';
import {
GET_USER_PLAYLISTS,
GET_PLAYLISTS_FOR_CLIMB,
GET_PLAYLIST_MEMBERSHIPS_FOR_CLIMBS,
ADD_CLIMB_TO_PLAYLIST,
REMOVE_CLIMB_FROM_PLAYLIST,
CREATE_PLAYLIST,
type GetUserPlaylistsQueryResponse,
type GetPlaylistsForClimbQueryResponse,
type GetPlaylistMembershipsForClimbsQueryResponse,
type AddClimbToPlaylistMutationResponse,
type RemoveClimbFromPlaylistMutationResponse,
type CreatePlaylistMutationResponse,
Expand Down Expand Up @@ -131,67 +131,70 @@ export function useClimbActionsData({

// === Playlists ===

const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [playlistMemberships, setPlaylistMemberships] = useState<Map<string, Set<string>>>(
new Map(),
);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const playlistsEnabled =
isAuthenticated && !isAuthLoading && boardName !== 'moonboard';

// Fetch user's playlists
useEffect(() => {
if (!token || !isAuthenticated || boardName === 'moonboard') return;
// Fetch user's playlists using useQuery for consistency and automatic caching
const playlistsQueryKey = useMemo(
() => ['userPlaylists', boardName, layoutId] as const,
[boardName, layoutId],
);

const fetchPlaylists = async () => {
const { data: playlistsData, isLoading: playlistsLoading } = useQuery({
queryKey: playlistsQueryKey,
queryFn: async (): Promise<Playlist[]> => {
const client = createGraphQLHttpClient(token);
try {
setPlaylistsLoading(true);
const client = createGraphQLHttpClient(token);
const response = await client.request<GetUserPlaylistsQueryResponse>(GET_USER_PLAYLISTS, {
input: { boardType: boardName, layoutId },
});
setPlaylists(response.userPlaylists);
return response.userPlaylists;
} catch (error) {
console.error('Failed to fetch playlists:', error);
setPlaylists([]);
} finally {
setPlaylistsLoading(false);
return [];
}
};
},
enabled: playlistsEnabled,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});

fetchPlaylists();
}, [token, isAuthenticated, boardName, layoutId]);
const playlists = playlistsData ?? [];

// Fetch playlist memberships for visible climbs
// Fetch playlist memberships for visible climbs using a single batched query
const climbUuidsKey = useMemo(() => sortedClimbUuids.join(','), [sortedClimbUuids]);

const membershipsQueryKey = useMemo(
() => ['playlistMemberships', boardName, layoutId, climbUuidsKey] as const,
[boardName, layoutId, climbUuidsKey],
);

const { data: membershipsData } = useQuery({
queryKey: ['playlistMemberships', boardName, layoutId, climbUuidsKey],
queryKey: membershipsQueryKey,
queryFn: async (): Promise<Map<string, Set<string>>> => {
if (sortedClimbUuids.length === 0) return new Map();
const client = createGraphQLHttpClient(token);
const memberships = new Map<string, Set<string>>();

await Promise.all(
sortedClimbUuids.map(async (uuid) => {
const response = await client.request<GetPlaylistsForClimbQueryResponse>(
GET_PLAYLISTS_FOR_CLIMB,
{ input: { boardType: boardName, layoutId, climbUuid: uuid } },
);
memberships.set(uuid, new Set(response.playlistsForClimb));
}),
);

return memberships;
try {
const response = await client.request<GetPlaylistMembershipsForClimbsQueryResponse>(
GET_PLAYLIST_MEMBERSHIPS_FOR_CLIMBS,
{ input: { boardType: boardName, layoutId, climbUuids: sortedClimbUuids } },
);
const memberships = new Map<string, Set<string>>();
for (const entry of response.playlistMembershipsForClimbs) {
memberships.set(entry.climbUuid, new Set(entry.playlistIds));
}
return memberships;
} catch (error) {
console.error('Failed to fetch playlist memberships:', error);
throw error;
}
},
enabled:
isAuthenticated && !isAuthLoading && sortedClimbUuids.length > 0 && boardName !== 'moonboard',
enabled: playlistsEnabled && sortedClimbUuids.length > 0,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});

// Merge query data with local optimistic state
const effectiveMemberships = membershipsData
? new Map([...membershipsData, ...playlistMemberships])
: playlistMemberships;
const effectiveMemberships = membershipsData ?? new Map<string, Set<string>>();

const addToPlaylist = useCallback(
async (playlistId: string, climbUuid: string, climbAngle: number) => {
Expand All @@ -200,18 +203,19 @@ export function useClimbActionsData({
await client.request<AddClimbToPlaylistMutationResponse>(ADD_CLIMB_TO_PLAYLIST, {
input: { playlistId, climbUuid, angle: climbAngle },
});
setPlaylistMemberships((prev) => {
// Optimistically update memberships cache with new Set to avoid stale closure mutation
queryClient.setQueryData<Map<string, Set<string>>>(membershipsQueryKey, (prev) => {
const updated = new Map(prev);
const current = updated.get(climbUuid) || new Set<string>();
current.add(playlistId);
updated.set(climbUuid, current);
const current = updated.get(climbUuid) ?? new Set<string>();
updated.set(climbUuid, new Set([...current, playlistId]));
return updated;
});
setPlaylists((prev) =>
prev.map((p) => (p.uuid === playlistId ? { ...p, climbCount: p.climbCount + 1 } : p)),
// Update playlist climb count
queryClient.setQueryData<Playlist[]>(playlistsQueryKey, (prev) =>
prev?.map((p) => (p.uuid === playlistId ? { ...p, climbCount: p.climbCount + 1 } : p)),
);
},
[token],
[token, membershipsQueryKey, playlistsQueryKey, queryClient],
);

const removeFromPlaylist = useCallback(
Expand All @@ -221,22 +225,25 @@ export function useClimbActionsData({
await client.request<RemoveClimbFromPlaylistMutationResponse>(REMOVE_CLIMB_FROM_PLAYLIST, {
input: { playlistId, climbUuid },
});
setPlaylistMemberships((prev) => {
// Optimistically update memberships cache with new Set to avoid stale closure mutation
queryClient.setQueryData<Map<string, Set<string>>>(membershipsQueryKey, (prev) => {
const updated = new Map(prev);
const current = updated.get(climbUuid);
if (current) {
current.delete(playlistId);
updated.set(climbUuid, current);
const next = new Set(current);
next.delete(playlistId);
updated.set(climbUuid, next);
}
return updated;
});
setPlaylists((prev) =>
prev.map((p) =>
// Update playlist climb count
queryClient.setQueryData<Playlist[]>(playlistsQueryKey, (prev) =>
prev?.map((p) =>
p.uuid === playlistId ? { ...p, climbCount: Math.max(0, p.climbCount - 1) } : p,
),
);
},
[token],
[token, membershipsQueryKey, playlistsQueryKey, queryClient],
);

const createPlaylist = useCallback(
Expand All @@ -251,27 +258,17 @@ export function useClimbActionsData({
const response = await client.request<CreatePlaylistMutationResponse>(CREATE_PLAYLIST, {
input: { boardType: boardName, layoutId, name, description, color, icon },
});
setPlaylists((prev) => [response.createPlaylist, ...prev]);
queryClient.setQueryData<Playlist[]>(playlistsQueryKey, (prev) =>
[response.createPlaylist, ...(prev ?? [])],
);
return response.createPlaylist;
},
[token, boardName, layoutId],
[token, boardName, layoutId, playlistsQueryKey, queryClient],
);

const refreshPlaylists = useCallback(async () => {
if (!token) return;
try {
setPlaylistsLoading(true);
const client = createGraphQLHttpClient(token);
const response = await client.request<GetUserPlaylistsQueryResponse>(GET_USER_PLAYLISTS, {
input: { boardType: boardName, layoutId },
});
setPlaylists(response.userPlaylists);
} catch (error) {
console.error('Failed to refresh playlists:', error);
} finally {
setPlaylistsLoading(false);
}
}, [token, boardName, layoutId]);
await queryClient.invalidateQueries({ queryKey: playlistsQueryKey });
}, [queryClient, playlistsQueryKey]);

return {
favoritesProviderProps: {
Expand Down
Loading
Loading