From 9c7150e589bec87a03ecbcf49d6c7246467a1acc Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 13 Feb 2026 17:04:33 +0100 Subject: [PATCH 1/4] Fix playlist viewing issues for public/non-owner playlists - Use useOptionalBoardProvider in TickAction and LogAscentForm so playlist pages without BoardProvider don't crash - Simplify LogAscentForm saveTick fallback using spread + executeGraphQL - Remove cross-layout climb filtering that incorrectly hid playlist climbs - Add Share button to playlist hero card (visible to all users) - Hide ellipsis menu for non-owners (was opening empty) - Remove unused MuiAlert import Co-Authored-By: Claude Opus 4.6 --- .../climb-actions/actions/tick-action.tsx | 11 +- .../app/components/logbook/logascent-form.tsx | 22 ++- .../playlist-detail-content.tsx | 136 +++++++++--------- 3 files changed, 95 insertions(+), 74 deletions(-) diff --git a/packages/web/app/components/climb-actions/actions/tick-action.tsx b/packages/web/app/components/climb-actions/actions/tick-action.tsx index 9d76bfa7..63c6dc1a 100644 --- a/packages/web/app/components/climb-actions/actions/tick-action.tsx +++ b/packages/web/app/components/climb-actions/actions/tick-action.tsx @@ -12,7 +12,8 @@ import CheckOutlined from '@mui/icons-material/CheckOutlined'; import LoginOutlined from '@mui/icons-material/LoginOutlined'; import AppsOutlined from '@mui/icons-material/AppsOutlined'; import { ClimbActionProps, ClimbActionResult } from '../types'; -import { useBoardProvider } from '../../board-provider/board-provider-context'; +import { useOptionalBoardProvider } from '../../board-provider/board-provider-context'; +import { useSession } from 'next-auth/react'; import AuthModal from '../../auth/auth-modal'; import { LogAscentDrawer } from '../../logbook/log-ascent-drawer'; import { track } from '@vercel/analytics'; @@ -34,10 +35,10 @@ export function TickAction({ const [drawerVisible, setDrawerVisible] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false); - const { - isAuthenticated, - logbook, - } = useBoardProvider(); + const boardProvider = useOptionalBoardProvider(); + const { status: sessionStatus } = useSession(); + const isAuthenticated = boardProvider?.isAuthenticated ?? (sessionStatus === 'authenticated'); + const logbook = boardProvider?.logbook ?? []; const { alwaysUseApp, loaded, enableAlwaysUseApp } = useAlwaysTickInApp(); diff --git a/packages/web/app/components/logbook/logascent-form.tsx b/packages/web/app/components/logbook/logascent-form.tsx index 92f9a656..c5b04be1 100644 --- a/packages/web/app/components/logbook/logascent-form.tsx +++ b/packages/web/app/components/logbook/logascent-form.tsx @@ -18,7 +18,14 @@ import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; import InfoOutlined from '@mui/icons-material/InfoOutlined'; import { track } from '@vercel/analytics'; import { Climb, BoardDetails } from '@/app/lib/types'; -import { useBoardProvider, TickStatus } from '../board-provider/board-provider-context'; +import { useOptionalBoardProvider, TickStatus, type SaveTickOptions } from '../board-provider/board-provider-context'; +import { useSession } from 'next-auth/react'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { + SAVE_TICK, + type SaveTickMutationResponse, +} from '@/app/lib/graphql/operations'; import { TENSION_KILTER_GRADES, ANGLES } from '@/app/lib/board-data'; import dayjs from 'dayjs'; @@ -54,7 +61,18 @@ interface LogAscentFormProps { } export const LogAscentForm: React.FC = ({ currentClimb, boardDetails, onClose }) => { - const { saveTick, isAuthenticated } = useBoardProvider(); + const boardProvider = useOptionalBoardProvider(); + const { status: sessionStatus } = useSession(); + const { token: wsAuthToken } = useWsAuthToken(); + const isAuthenticated = boardProvider?.isAuthenticated ?? (sessionStatus === 'authenticated'); + + const saveTick = boardProvider?.saveTick ?? (async (options: SaveTickOptions) => { + await executeGraphQL( + SAVE_TICK, + { input: { ...options, boardType: boardDetails.board_name } }, + wsAuthToken, + ); + }); const grades = TENSION_KILTER_GRADES; const angleOptions = ANGLES[boardDetails.board_name]; diff --git a/packages/web/app/my-library/playlist/[playlist_uuid]/playlist-detail-content.tsx b/packages/web/app/my-library/playlist/[playlist_uuid]/playlist-detail-content.tsx index bf4fa0d1..c4b0b10c 100644 --- a/packages/web/app/my-library/playlist/[playlist_uuid]/playlist-detail-content.tsx +++ b/packages/web/app/my-library/playlist/[playlist_uuid]/playlist-detail-content.tsx @@ -1,7 +1,6 @@ 'use client'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import MuiAlert from '@mui/material/Alert'; import MuiButton from '@mui/material/Button'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; @@ -19,6 +18,7 @@ import { ElectricBoltOutlined, EditOutlined, DeleteOutlined, + ShareOutlined, } from '@mui/icons-material'; import { useInfiniteQuery } from '@tanstack/react-query'; import { Climb, BoardDetails } from '@/app/lib/types'; @@ -212,27 +212,15 @@ export default function PlaylistDetailContent({ const allClimbs: Climb[] = climbsData?.pages.flatMap((page) => page.climbs as Climb[]) ?? []; - // Filter out cross-layout climbs - const { visibleClimbs, hiddenCount } = useMemo(() => { - const visible: Climb[] = []; - let hidden = 0; - - for (const climb of allClimbs) { - const isCrossLayout = climb.layoutId != null && climb.layoutId !== boardDetails?.layout_id; - if (isCrossLayout) { - hidden++; - } else { - visible.push({ ...climb, angle }); - } - } - - return { visibleClimbs: visible, hiddenCount: hidden }; - }, [allClimbs, boardDetails?.layout_id, angle]); + const climbsWithAngle = useMemo( + () => allClimbs.map((climb) => ({ ...climb, angle })), + [allClimbs, angle], + ); // Climb UUIDs for favorites/playlists provider const climbUuids = useMemo( - () => visibleClimbs.map((climb) => climb.uuid), - [visibleClimbs], + () => climbsWithAngle.map((climb) => climb.uuid), + [climbsWithAngle], ); // Favorites and playlists data fetching @@ -283,6 +271,31 @@ export default function PlaylistDetailContent({ const isOwner = playlist?.userRole === 'owner'; + const handleShare = useCallback(async () => { + const shareData = { + title: playlist?.name ?? 'Playlist', + url: window.location.href, + }; + + try { + if (navigator.share && navigator.canShare?.(shareData)) { + await navigator.share(shareData); + } else { + await navigator.clipboard.writeText(window.location.href); + showMessage('Link copied to clipboard!', 'success'); + } + } catch (error) { + if ((error as Error).name !== 'AbortError') { + try { + await navigator.clipboard.writeText(window.location.href); + showMessage('Link copied to clipboard!', 'success'); + } catch { + showMessage('Failed to share', 'error'); + } + } + } + }, [playlist?.name, showMessage]); + const getPlaylistColor = () => { if (playlist?.color && isValidHexColor(playlist.color)) { return playlist.color; @@ -345,7 +358,7 @@ export default function PlaylistDetailContent({ ); } - if (visibleClimbs.length === 0 && hiddenCount === 0 && !isFetchingClimbs) { + if (climbsWithAngle.length === 0 && !isFetchingClimbs) { return (
@@ -353,33 +366,18 @@ export default function PlaylistDetailContent({ ); } - // Build header with hidden-count alert and all-hidden empty state - const climbsHeader = ( - <> - {hiddenCount > 0 && ( - - {`Not showing ${hiddenCount} ${hiddenCount === 1 ? 'climb' : 'climbs'} from other layouts`} - - )} - {visibleClimbs.length === 0 && hiddenCount > 0 && !isFetchingClimbs && ( - - )} - - ); - return (
@@ -437,39 +435,43 @@ export default function PlaylistDetailContent({
- {/* Ellipsis Menu */} - ) => setMenuAnchor(e.currentTarget)} - aria-label="Playlist actions" - > - - - - setMenuAnchor(null)} - > - {isOwner && ( - { setMenuAnchor(null); setGeneratorOpen(true); }}> - - Generate - - )} - {isOwner && ( - { setMenuAnchor(null); setEditDrawerOpen(true); }}> - - Edit - - )} + {/* Action Buttons */} + + + + + {isOwner && ( - - - Delete - + <> + ) => setMenuAnchor(e.currentTarget)} + aria-label="Playlist actions" + > + + + + setMenuAnchor(null)} + > + { setMenuAnchor(null); setGeneratorOpen(true); }}> + + Generate + + { setMenuAnchor(null); setEditDrawerOpen(true); }}> + + Edit + + + + Delete + + + )} - + {/* Climbs List */} From f7c73d9d455dbfeffe2e8947860aecdfcc56af26 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 13 Feb 2026 17:18:56 +0100 Subject: [PATCH 2/4] Fix stale token closure in LogAscentForm and add tests - Use useRef for wsAuthToken so the fallback saveTick always reads the latest token value, even if it refreshes after the closure was created - Add tests for TickAction: auth fallback (with/without BoardProvider), logbook filtering by uuid+angle, badge count, action structure - Add tests for LogAscentForm saveTick logic: auth delegation, GraphQL fallback with spread, field completeness, fresh token via ref Co-Authored-By: Claude Opus 4.6 --- .../actions/__tests__/tick-action.test.tsx | 254 ++++++++++++++++ .../logbook/__tests__/logascent-form.test.tsx | 276 ++++++++++++++++++ .../app/components/logbook/logascent-form.tsx | 8 +- 3 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 packages/web/app/components/climb-actions/actions/__tests__/tick-action.test.tsx create mode 100644 packages/web/app/components/logbook/__tests__/logascent-form.test.tsx diff --git a/packages/web/app/components/climb-actions/actions/__tests__/tick-action.test.tsx b/packages/web/app/components/climb-actions/actions/__tests__/tick-action.test.tsx new file mode 100644 index 00000000..1b81465c --- /dev/null +++ b/packages/web/app/components/climb-actions/actions/__tests__/tick-action.test.tsx @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// Mock dependencies before importing the module +vi.mock('@vercel/analytics', () => ({ + track: vi.fn(), +})); + +let mockBoardProvider: { + isAuthenticated: boolean; + logbook: Array<{ climb_uuid: string; angle: number; is_ascent: boolean }>; +} | null = null; + +vi.mock('@/app/components/board-provider/board-provider-context', () => ({ + useOptionalBoardProvider: () => mockBoardProvider, +})); + +let mockSessionStatus = 'unauthenticated'; +vi.mock('next-auth/react', () => ({ + useSession: () => ({ status: mockSessionStatus }), +})); + +vi.mock('@/app/hooks/use-always-tick-in-app', () => ({ + useAlwaysTickInApp: () => ({ alwaysUseApp: false, loaded: true, enableAlwaysUseApp: vi.fn() }), +})); + +vi.mock('@/app/theme/theme-config', () => ({ + themeTokens: { + colors: { primary: '#1890ff', success: '#52c41a', error: '#ff4d4f' }, + spacing: { 4: 16 }, + typography: { fontSize: { base: '14px' } }, + }, +})); + +vi.mock('@mui/icons-material/CheckOutlined', () => ({ + default: () => 'CheckOutlinedIcon', +})); + +vi.mock('@mui/icons-material/LoginOutlined', () => ({ + default: () => 'LoginOutlinedIcon', +})); + +vi.mock('@mui/icons-material/AppsOutlined', () => ({ + default: () => 'AppsOutlinedIcon', +})); + +vi.mock('@mui/material/Button', () => ({ + default: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => ( + + ), +})); + +vi.mock('@mui/material/Badge', () => ({ + default: ({ children }: { children?: React.ReactNode }) => children, +})); + +vi.mock('@mui/material/Typography', () => ({ + default: ({ children }: { children?: React.ReactNode }) => children, +})); + +vi.mock('@mui/material/Stack', () => ({ + default: ({ children }: { children?: React.ReactNode }) => children, +})); + +vi.mock('@mui/material/Box', () => ({ + default: ({ children }: { children?: React.ReactNode }) => children, +})); + +vi.mock('../../action-tooltip', () => ({ + ActionTooltip: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('../../../swipeable-drawer/swipeable-drawer', () => ({ + default: ({ children }: { children?: React.ReactNode }) => children, +})); + +vi.mock('../../../auth/auth-modal', () => ({ + default: () => null, +})); + +vi.mock('../../../logbook/log-ascent-drawer', () => ({ + LogAscentDrawer: () => null, +})); + +vi.mock('@/app/lib/url-utils', () => ({ + constructClimbInfoUrl: () => '/test-url', +})); + +import { TickAction } from '../tick-action'; +import type { ClimbActionProps } from '../../types'; +import type { BoardDetails, Climb } from '@/app/lib/types'; + +function createTestClimb(overrides?: Partial): Climb { + return { + uuid: 'test-uuid-789', + name: 'Test Climb', + setter_username: 'testuser', + description: 'A test climb', + frames: 'p1r12p2r13', + angle: 40, + ascensionist_count: 5, + difficulty: '6a/V3', + quality_average: '3.5', + stars: 3, + difficulty_error: '0.50', + litUpHoldsMap: {}, + benchmark_difficulty: null, + ...overrides, + }; +} + +function createTestBoardDetails(overrides?: Partial): BoardDetails { + return { + board_name: 'kilter', + layout_id: 1, + size_id: 10, + set_ids: '1,2', + images_to_holds: {}, + holdsData: {}, + edge_left: 0, + edge_right: 100, + edge_bottom: 0, + edge_top: 100, + boardHeight: 100, + boardWidth: 100, + layout_name: 'Original', + size_name: '12x12', + size_description: 'Full Size', + set_names: ['Standard', 'Extended'], + ...overrides, + } as BoardDetails; +} + +function createTestProps(overrides?: Partial): ClimbActionProps { + return { + climb: createTestClimb(), + boardDetails: createTestBoardDetails(), + angle: 40, + viewMode: 'icon', + ...overrides, + }; +} + +describe('TickAction', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockBoardProvider = null; + mockSessionStatus = 'unauthenticated'; + }); + + describe('availability', () => { + it('always returns available: true', () => { + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + expect(result.current.available).toBe(true); + }); + + it('returns key: tick', () => { + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + expect(result.current.key).toBe('tick'); + }); + }); + + describe('authentication without BoardProvider', () => { + it('uses session status when BoardProvider is absent', () => { + mockBoardProvider = null; + mockSessionStatus = 'authenticated'; + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + // Authenticated users get "Tick" label (not sign-in prompt) + expect(result.current.menuItem.label).toBe('Tick'); + }); + + it('treats unauthenticated session as not authenticated', () => { + mockBoardProvider = null; + mockSessionStatus = 'unauthenticated'; + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + // Should still show Tick label (auth check happens on click) + expect(result.current.menuItem.label).toBe('Tick'); + }); + }); + + describe('authentication with BoardProvider', () => { + it('uses BoardProvider isAuthenticated when available', () => { + mockBoardProvider = { + isAuthenticated: true, + logbook: [], + }; + mockSessionStatus = 'unauthenticated'; // Session says no, but provider says yes + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + expect(result.current.available).toBe(true); + }); + }); + + describe('logbook integration', () => { + it('shows badge count from BoardProvider logbook', () => { + mockBoardProvider = { + isAuthenticated: true, + logbook: [ + { climb_uuid: 'test-uuid-789', angle: 40, is_ascent: true }, + { climb_uuid: 'test-uuid-789', angle: 40, is_ascent: false }, + ], + }; + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + expect(result.current.menuItem.label).toBe('Tick (2)'); + }); + + it('uses empty logbook when BoardProvider is absent', () => { + mockBoardProvider = null; + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + expect(result.current.menuItem.label).toBe('Tick'); + }); + + it('filters logbook by climb uuid and angle', () => { + mockBoardProvider = { + isAuthenticated: true, + logbook: [ + { climb_uuid: 'test-uuid-789', angle: 40, is_ascent: true }, + { climb_uuid: 'other-uuid', angle: 40, is_ascent: true }, + { climb_uuid: 'test-uuid-789', angle: 25, is_ascent: true }, + ], + }; + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + // Only the first entry matches both uuid and angle + expect(result.current.menuItem.label).toBe('Tick (1)'); + }); + }); + + describe('menuItem structure', () => { + it('menuItem key is tick', () => { + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + expect(result.current.menuItem.key).toBe('tick'); + }); + + it('menuItem icon is defined', () => { + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + expect(result.current.menuItem.icon).toBeDefined(); + }); + + it('menuItem onClick is a function', () => { + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + expect(typeof result.current.menuItem.onClick).toBe('function'); + }); + }); +}); diff --git a/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx b/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx new file mode 100644 index 00000000..67214ce1 --- /dev/null +++ b/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx @@ -0,0 +1,276 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useRef } from 'react'; + +// === Mock setup === + +const mockExecuteGraphQL = vi.fn().mockResolvedValue({}); +vi.mock('@/app/lib/graphql/client', () => ({ + executeGraphQL: (...args: unknown[]) => mockExecuteGraphQL(...args), +})); + +vi.mock('@/app/lib/graphql/operations', () => ({ + SAVE_TICK: 'SAVE_TICK_QUERY', +})); + +vi.mock('@vercel/analytics', () => ({ + track: vi.fn(), +})); + +let mockBoardProvider: { + isAuthenticated: boolean; + saveTick: ReturnType; +} | null = null; + +vi.mock('@/app/components/board-provider/board-provider-context', () => ({ + useOptionalBoardProvider: () => mockBoardProvider, +})); + +let mockSessionStatus = 'unauthenticated'; +vi.mock('next-auth/react', () => ({ + useSession: () => ({ status: mockSessionStatus }), +})); + +let mockWsAuthToken: string | null = 'test-token-123'; +vi.mock('@/app/hooks/use-ws-auth-token', () => ({ + useWsAuthToken: () => ({ token: mockWsAuthToken, isLoading: false }), +})); + +vi.mock('@/app/lib/board-data', () => ({ + TENSION_KILTER_GRADES: [ + { difficulty_id: 1, difficulty_name: 'V0' }, + { difficulty_id: 2, difficulty_name: 'V1' }, + ], + ANGLES: { + kilter: [0, 15, 20, 25, 30, 35, 40, 45, 50], + tension: [0, 10, 20, 30, 40], + }, +})); + +// Import the mocked modules at the top level (vitest resolves these to mocks) +import { useOptionalBoardProvider } from '@/app/components/board-provider/board-provider-context'; +import { useSession } from 'next-auth/react'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { SAVE_TICK } from '@/app/lib/graphql/operations'; + +import type { BoardDetails } from '@/app/lib/types'; +import type { SaveTickOptions } from '@/app/components/board-provider/board-provider-context'; + +function createTestBoardDetails(overrides?: Partial): BoardDetails { + return { + board_name: 'kilter', + layout_id: 1, + size_id: 10, + set_ids: [1, 2], + images_to_holds: {}, + holdsData: {}, + edge_left: 0, + edge_right: 100, + edge_bottom: 0, + edge_top: 100, + boardHeight: 100, + boardWidth: 100, + layout_name: 'Original', + size_name: '12x12', + size_description: 'Full Size', + set_names: ['Standard', 'Extended'], + supportsMirroring: true, + ...overrides, + } as BoardDetails; +} + +/** + * Custom hook that mirrors the auth + saveTick logic from LogAscentForm. + * This lets us test the delegation/fallback behavior in isolation + * without rendering the full form with all its MUI dependencies. + */ +function useSaveTickLogic(boardDetails: BoardDetails) { + const bp = useOptionalBoardProvider(); + const { status: sessionStatus } = useSession(); + const { token: wsAuthToken } = useWsAuthToken(); + const isAuthenticated = bp?.isAuthenticated ?? (sessionStatus === 'authenticated'); + + const wsAuthTokenRef = useRef(wsAuthToken); + wsAuthTokenRef.current = wsAuthToken; + + const saveTick = bp?.saveTick ?? (async (options: SaveTickOptions) => { + await executeGraphQL( + SAVE_TICK, + { input: { ...options, boardType: boardDetails.board_name } }, + wsAuthTokenRef.current, + ); + }); + + return { saveTick, isAuthenticated }; +} + +describe('LogAscentForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockBoardProvider = null; + mockSessionStatus = 'unauthenticated'; + mockWsAuthToken = 'test-token-123'; + }); + + describe('authentication fallback', () => { + it('uses BoardProvider isAuthenticated when available', () => { + mockBoardProvider = { isAuthenticated: true, saveTick: vi.fn() }; + mockSessionStatus = 'unauthenticated'; + + const { result } = renderHook(() => + useSaveTickLogic(createTestBoardDetails()), + ); + expect(result.current.isAuthenticated).toBe(true); + }); + + it('falls back to session status when BoardProvider is absent', () => { + mockBoardProvider = null; + mockSessionStatus = 'authenticated'; + + const { result } = renderHook(() => + useSaveTickLogic(createTestBoardDetails()), + ); + expect(result.current.isAuthenticated).toBe(true); + }); + + it('reports unauthenticated when both sources say no', () => { + mockBoardProvider = null; + mockSessionStatus = 'unauthenticated'; + + const { result } = renderHook(() => + useSaveTickLogic(createTestBoardDetails()), + ); + expect(result.current.isAuthenticated).toBe(false); + }); + }); + + describe('saveTick delegation', () => { + it('uses BoardProvider saveTick when available', async () => { + const mockSaveTick = vi.fn().mockResolvedValue(undefined); + mockBoardProvider = { isAuthenticated: true, saveTick: mockSaveTick }; + + const { result } = renderHook(() => + useSaveTickLogic(createTestBoardDetails()), + ); + + const tickOptions: SaveTickOptions = { + climbUuid: 'climb-uuid-123', + angle: 40, + isMirror: false, + status: 'flash', + attemptCount: 1, + isBenchmark: false, + comment: '', + climbedAt: new Date().toISOString(), + }; + + await result.current.saveTick(tickOptions); + expect(mockSaveTick).toHaveBeenCalledWith(tickOptions); + expect(mockExecuteGraphQL).not.toHaveBeenCalled(); + }); + + it('falls back to executeGraphQL when BoardProvider is absent', async () => { + mockBoardProvider = null; + mockWsAuthToken = 'fallback-token'; + + const boardDetails = createTestBoardDetails({ board_name: 'kilter' }); + const { result } = renderHook(() => useSaveTickLogic(boardDetails)); + + const tickOptions: SaveTickOptions = { + climbUuid: 'climb-uuid-123', + angle: 40, + isMirror: false, + status: 'send', + attemptCount: 3, + isBenchmark: false, + comment: 'Nice!', + climbedAt: '2025-01-01T00:00:00Z', + }; + + await result.current.saveTick(tickOptions); + + expect(mockExecuteGraphQL).toHaveBeenCalledWith( + 'SAVE_TICK_QUERY', + { input: { ...tickOptions, boardType: 'kilter' } }, + 'fallback-token', + ); + }); + + it('spreads all SaveTickOptions fields into the GraphQL input', async () => { + mockBoardProvider = null; + + const boardDetails = createTestBoardDetails({ board_name: 'tension' }); + const { result } = renderHook(() => useSaveTickLogic(boardDetails)); + + const tickOptions: SaveTickOptions = { + climbUuid: 'uuid-456', + angle: 25, + isMirror: true, + status: 'attempt', + attemptCount: 5, + quality: 4, + difficulty: 10, + isBenchmark: true, + comment: 'Hard one', + climbedAt: '2025-06-15T12:00:00Z', + sessionId: 'session-1', + layoutId: 2, + sizeId: 15, + setIds: '3,4', + }; + + await result.current.saveTick(tickOptions); + + const callArgs = mockExecuteGraphQL.mock.calls[0]; + const input = callArgs[1].input; + + expect(input.boardType).toBe('tension'); + expect(input.climbUuid).toBe('uuid-456'); + expect(input.angle).toBe(25); + expect(input.isMirror).toBe(true); + expect(input.status).toBe('attempt'); + expect(input.attemptCount).toBe(5); + expect(input.quality).toBe(4); + expect(input.difficulty).toBe(10); + expect(input.isBenchmark).toBe(true); + expect(input.comment).toBe('Hard one'); + expect(input.sessionId).toBe('session-1'); + expect(input.layoutId).toBe(2); + expect(input.sizeId).toBe(15); + expect(input.setIds).toBe('3,4'); + }); + + it('uses fresh token via ref when token changes after initial render', async () => { + mockBoardProvider = null; + mockWsAuthToken = 'old-token'; + + const boardDetails = createTestBoardDetails(); + const { result, rerender } = renderHook(() => useSaveTickLogic(boardDetails)); + + // Simulate token refresh + mockWsAuthToken = 'new-token'; + rerender(); + + const tickOptions: SaveTickOptions = { + climbUuid: 'climb-uuid-123', + angle: 40, + isMirror: false, + status: 'flash', + attemptCount: 1, + isBenchmark: false, + comment: '', + climbedAt: new Date().toISOString(), + }; + + await result.current.saveTick(tickOptions); + + // Should use the new token, not the stale one + expect(mockExecuteGraphQL).toHaveBeenCalledWith( + 'SAVE_TICK_QUERY', + expect.any(Object), + 'new-token', + ); + }); + }); +}); diff --git a/packages/web/app/components/logbook/logascent-form.tsx b/packages/web/app/components/logbook/logascent-form.tsx index c5b04be1..ea3fd3a3 100644 --- a/packages/web/app/components/logbook/logascent-form.tsx +++ b/packages/web/app/components/logbook/logascent-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import MuiRating from '@mui/material/Rating'; import Chip from '@mui/material/Chip'; import MuiTooltip from '@mui/material/Tooltip'; @@ -66,11 +66,15 @@ export const LogAscentForm: React.FC = ({ currentClimb, boar const { token: wsAuthToken } = useWsAuthToken(); const isAuthenticated = boardProvider?.isAuthenticated ?? (sessionStatus === 'authenticated'); + // Use a ref so the fallback saveTick closure always reads the latest token + const wsAuthTokenRef = useRef(wsAuthToken); + wsAuthTokenRef.current = wsAuthToken; + const saveTick = boardProvider?.saveTick ?? (async (options: SaveTickOptions) => { await executeGraphQL( SAVE_TICK, { input: { ...options, boardType: boardDetails.board_name } }, - wsAuthToken, + wsAuthTokenRef.current, ); }); const grades = TENSION_KILTER_GRADES; From 50d3b0375b6b54e00bc30fd2b63038007953cd31 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 13 Feb 2026 17:54:12 +0100 Subject: [PATCH 3/4] Adapt playlist fixes to use standalone TanStack Query hooks Replace manual executeGraphQL/useWsAuthToken fallback pattern with standalone useSaveTick and useLogbook hooks from cleanup_providers. This eliminates the stale token closure issue and leverages TanStack Query's caching/deduplication. Co-Authored-By: Claude Opus 4.6 --- .../actions/__tests__/tick-action.test.tsx | 21 +++- .../climb-actions/actions/tick-action.tsx | 10 +- .../logbook/__tests__/logascent-form.test.tsx | 109 ++++-------------- .../app/components/logbook/logascent-form.tsx | 25 +--- 4 files changed, 59 insertions(+), 106 deletions(-) diff --git a/packages/web/app/components/climb-actions/actions/__tests__/tick-action.test.tsx b/packages/web/app/components/climb-actions/actions/__tests__/tick-action.test.tsx index 1b81465c..483950a1 100644 --- a/packages/web/app/components/climb-actions/actions/__tests__/tick-action.test.tsx +++ b/packages/web/app/components/climb-actions/actions/__tests__/tick-action.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; // Mock dependencies before importing the module vi.mock('@vercel/analytics', () => ({ @@ -15,6 +15,11 @@ vi.mock('@/app/components/board-provider/board-provider-context', () => ({ useOptionalBoardProvider: () => mockBoardProvider, })); +let mockStandaloneLogbook: Array<{ climb_uuid: string; angle: number; is_ascent: boolean }> = []; +vi.mock('@/app/hooks/use-logbook', () => ({ + useLogbook: () => ({ logbook: mockStandaloneLogbook, isLoading: false, error: null }), +})); + let mockSessionStatus = 'unauthenticated'; vi.mock('next-auth/react', () => ({ useSession: () => ({ status: mockSessionStatus }), @@ -146,6 +151,7 @@ describe('TickAction', () => { vi.clearAllMocks(); mockBoardProvider = null; mockSessionStatus = 'unauthenticated'; + mockStandaloneLogbook = []; }); describe('availability', () => { @@ -209,8 +215,19 @@ describe('TickAction', () => { expect(result.current.menuItem.label).toBe('Tick (2)'); }); - it('uses empty logbook when BoardProvider is absent', () => { + it('uses standalone useLogbook when BoardProvider is absent', () => { + mockBoardProvider = null; + mockStandaloneLogbook = [ + { climb_uuid: 'test-uuid-789', angle: 40, is_ascent: true }, + ]; + const props = createTestProps(); + const { result } = renderHook(() => TickAction(props)); + expect(result.current.menuItem.label).toBe('Tick (1)'); + }); + + it('shows no badge when standalone logbook is empty', () => { mockBoardProvider = null; + mockStandaloneLogbook = []; const props = createTestProps(); const { result } = renderHook(() => TickAction(props)); expect(result.current.menuItem.label).toBe('Tick'); diff --git a/packages/web/app/components/climb-actions/actions/tick-action.tsx b/packages/web/app/components/climb-actions/actions/tick-action.tsx index 63c6dc1a..a33a9fab 100644 --- a/packages/web/app/components/climb-actions/actions/tick-action.tsx +++ b/packages/web/app/components/climb-actions/actions/tick-action.tsx @@ -14,6 +14,7 @@ import AppsOutlined from '@mui/icons-material/AppsOutlined'; import { ClimbActionProps, ClimbActionResult } from '../types'; import { useOptionalBoardProvider } from '../../board-provider/board-provider-context'; import { useSession } from 'next-auth/react'; +import { useLogbook } from '@/app/hooks/use-logbook'; import AuthModal from '../../auth/auth-modal'; import { LogAscentDrawer } from '../../logbook/log-ascent-drawer'; import { track } from '@vercel/analytics'; @@ -38,7 +39,14 @@ export function TickAction({ const boardProvider = useOptionalBoardProvider(); const { status: sessionStatus } = useSession(); const isAuthenticated = boardProvider?.isAuthenticated ?? (sessionStatus === 'authenticated'); - const logbook = boardProvider?.logbook ?? []; + + // Use standalone useLogbook when outside BoardProvider + // When inside BoardProvider, prefer its logbook (already fetched, same cache) + const { logbook: standaloneLogbook } = useLogbook( + boardDetails.board_name, + [climb.uuid], + ); + const logbook = boardProvider?.logbook ?? standaloneLogbook; const { alwaysUseApp, loaded, enableAlwaysUseApp } = useAlwaysTickInApp(); diff --git a/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx b/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx index 67214ce1..f381e0fb 100644 --- a/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx +++ b/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx @@ -1,16 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; -import { useRef } from 'react'; // === Mock setup === -const mockExecuteGraphQL = vi.fn().mockResolvedValue({}); -vi.mock('@/app/lib/graphql/client', () => ({ - executeGraphQL: (...args: unknown[]) => mockExecuteGraphQL(...args), -})); - -vi.mock('@/app/lib/graphql/operations', () => ({ - SAVE_TICK: 'SAVE_TICK_QUERY', +const mockMutateAsync = vi.fn().mockResolvedValue({}); +vi.mock('@/app/hooks/use-save-tick', () => ({ + useSaveTick: () => ({ mutateAsync: mockMutateAsync }), })); vi.mock('@vercel/analytics', () => ({ @@ -31,11 +26,6 @@ vi.mock('next-auth/react', () => ({ useSession: () => ({ status: mockSessionStatus }), })); -let mockWsAuthToken: string | null = 'test-token-123'; -vi.mock('@/app/hooks/use-ws-auth-token', () => ({ - useWsAuthToken: () => ({ token: mockWsAuthToken, isLoading: false }), -})); - vi.mock('@/app/lib/board-data', () => ({ TENSION_KILTER_GRADES: [ { difficulty_id: 1, difficulty_name: 'V0' }, @@ -50,9 +40,7 @@ vi.mock('@/app/lib/board-data', () => ({ // Import the mocked modules at the top level (vitest resolves these to mocks) import { useOptionalBoardProvider } from '@/app/components/board-provider/board-provider-context'; import { useSession } from 'next-auth/react'; -import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; -import { executeGraphQL } from '@/app/lib/graphql/client'; -import { SAVE_TICK } from '@/app/lib/graphql/operations'; +import { useSaveTick } from '@/app/hooks/use-save-tick'; import type { BoardDetails } from '@/app/lib/types'; import type { SaveTickOptions } from '@/app/components/board-provider/board-provider-context'; @@ -88,18 +76,11 @@ function createTestBoardDetails(overrides?: Partial): BoardDetails function useSaveTickLogic(boardDetails: BoardDetails) { const bp = useOptionalBoardProvider(); const { status: sessionStatus } = useSession(); - const { token: wsAuthToken } = useWsAuthToken(); const isAuthenticated = bp?.isAuthenticated ?? (sessionStatus === 'authenticated'); - const wsAuthTokenRef = useRef(wsAuthToken); - wsAuthTokenRef.current = wsAuthToken; - + const saveTickMutation = useSaveTick(boardDetails.board_name); const saveTick = bp?.saveTick ?? (async (options: SaveTickOptions) => { - await executeGraphQL( - SAVE_TICK, - { input: { ...options, boardType: boardDetails.board_name } }, - wsAuthTokenRef.current, - ); + await saveTickMutation.mutateAsync(options); }); return { saveTick, isAuthenticated }; @@ -110,7 +91,6 @@ describe('LogAscentForm', () => { vi.clearAllMocks(); mockBoardProvider = null; mockSessionStatus = 'unauthenticated'; - mockWsAuthToken = 'test-token-123'; }); describe('authentication fallback', () => { @@ -167,12 +147,11 @@ describe('LogAscentForm', () => { await result.current.saveTick(tickOptions); expect(mockSaveTick).toHaveBeenCalledWith(tickOptions); - expect(mockExecuteGraphQL).not.toHaveBeenCalled(); + expect(mockMutateAsync).not.toHaveBeenCalled(); }); - it('falls back to executeGraphQL when BoardProvider is absent', async () => { + it('falls back to useSaveTick mutation when BoardProvider is absent', async () => { mockBoardProvider = null; - mockWsAuthToken = 'fallback-token'; const boardDetails = createTestBoardDetails({ board_name: 'kilter' }); const { result } = renderHook(() => useSaveTickLogic(boardDetails)); @@ -190,14 +169,10 @@ describe('LogAscentForm', () => { await result.current.saveTick(tickOptions); - expect(mockExecuteGraphQL).toHaveBeenCalledWith( - 'SAVE_TICK_QUERY', - { input: { ...tickOptions, boardType: 'kilter' } }, - 'fallback-token', - ); + expect(mockMutateAsync).toHaveBeenCalledWith(tickOptions); }); - it('spreads all SaveTickOptions fields into the GraphQL input', async () => { + it('passes all SaveTickOptions fields to mutateAsync', async () => { mockBoardProvider = null; const boardDetails = createTestBoardDetails({ board_name: 'tension' }); @@ -222,55 +197,21 @@ describe('LogAscentForm', () => { await result.current.saveTick(tickOptions); - const callArgs = mockExecuteGraphQL.mock.calls[0]; - const input = callArgs[1].input; - - expect(input.boardType).toBe('tension'); - expect(input.climbUuid).toBe('uuid-456'); - expect(input.angle).toBe(25); - expect(input.isMirror).toBe(true); - expect(input.status).toBe('attempt'); - expect(input.attemptCount).toBe(5); - expect(input.quality).toBe(4); - expect(input.difficulty).toBe(10); - expect(input.isBenchmark).toBe(true); - expect(input.comment).toBe('Hard one'); - expect(input.sessionId).toBe('session-1'); - expect(input.layoutId).toBe(2); - expect(input.sizeId).toBe(15); - expect(input.setIds).toBe('3,4'); - }); - - it('uses fresh token via ref when token changes after initial render', async () => { - mockBoardProvider = null; - mockWsAuthToken = 'old-token'; - - const boardDetails = createTestBoardDetails(); - const { result, rerender } = renderHook(() => useSaveTickLogic(boardDetails)); - - // Simulate token refresh - mockWsAuthToken = 'new-token'; - rerender(); - - const tickOptions: SaveTickOptions = { - climbUuid: 'climb-uuid-123', - angle: 40, - isMirror: false, - status: 'flash', - attemptCount: 1, - isBenchmark: false, - comment: '', - climbedAt: new Date().toISOString(), - }; - - await result.current.saveTick(tickOptions); - - // Should use the new token, not the stale one - expect(mockExecuteGraphQL).toHaveBeenCalledWith( - 'SAVE_TICK_QUERY', - expect.any(Object), - 'new-token', - ); + const callArgs = mockMutateAsync.mock.calls[0][0]; + + expect(callArgs.climbUuid).toBe('uuid-456'); + expect(callArgs.angle).toBe(25); + expect(callArgs.isMirror).toBe(true); + expect(callArgs.status).toBe('attempt'); + expect(callArgs.attemptCount).toBe(5); + expect(callArgs.quality).toBe(4); + expect(callArgs.difficulty).toBe(10); + expect(callArgs.isBenchmark).toBe(true); + expect(callArgs.comment).toBe('Hard one'); + expect(callArgs.sessionId).toBe('session-1'); + expect(callArgs.layoutId).toBe(2); + expect(callArgs.sizeId).toBe(15); + expect(callArgs.setIds).toBe('3,4'); }); }); }); diff --git a/packages/web/app/components/logbook/logascent-form.tsx b/packages/web/app/components/logbook/logascent-form.tsx index ea3fd3a3..d3883924 100644 --- a/packages/web/app/components/logbook/logascent-form.tsx +++ b/packages/web/app/components/logbook/logascent-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import MuiRating from '@mui/material/Rating'; import Chip from '@mui/material/Chip'; import MuiTooltip from '@mui/material/Tooltip'; @@ -18,14 +18,9 @@ import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; import InfoOutlined from '@mui/icons-material/InfoOutlined'; import { track } from '@vercel/analytics'; import { Climb, BoardDetails } from '@/app/lib/types'; -import { useOptionalBoardProvider, TickStatus, type SaveTickOptions } from '../board-provider/board-provider-context'; +import { useOptionalBoardProvider, type TickStatus } from '../board-provider/board-provider-context'; import { useSession } from 'next-auth/react'; -import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; -import { executeGraphQL } from '@/app/lib/graphql/client'; -import { - SAVE_TICK, - type SaveTickMutationResponse, -} from '@/app/lib/graphql/operations'; +import { useSaveTick } from '@/app/hooks/use-save-tick'; import { TENSION_KILTER_GRADES, ANGLES } from '@/app/lib/board-data'; import dayjs from 'dayjs'; @@ -63,19 +58,11 @@ interface LogAscentFormProps { export const LogAscentForm: React.FC = ({ currentClimb, boardDetails, onClose }) => { const boardProvider = useOptionalBoardProvider(); const { status: sessionStatus } = useSession(); - const { token: wsAuthToken } = useWsAuthToken(); const isAuthenticated = boardProvider?.isAuthenticated ?? (sessionStatus === 'authenticated'); - // Use a ref so the fallback saveTick closure always reads the latest token - const wsAuthTokenRef = useRef(wsAuthToken); - wsAuthTokenRef.current = wsAuthToken; - - const saveTick = boardProvider?.saveTick ?? (async (options: SaveTickOptions) => { - await executeGraphQL( - SAVE_TICK, - { input: { ...options, boardType: boardDetails.board_name } }, - wsAuthTokenRef.current, - ); + const saveTickMutation = useSaveTick(boardDetails.board_name); + const saveTick = boardProvider?.saveTick ?? (async (options: Parameters[0]) => { + await saveTickMutation.mutateAsync(options); }); const grades = TENSION_KILTER_GRADES; const angleOptions = ANGLES[boardDetails.board_name]; From 06e5130411a632e5d8f6e08fcd11b2728cfb69bc Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 13 Feb 2026 18:00:09 +0100 Subject: [PATCH 4/4] Skip unnecessary logbook fetch and add share success feedback - Pass empty climbUuids to useLogbook when BoardProvider is present, preventing an unnecessary network request - Add success message after native navigator.share() completes Co-Authored-By: Claude Opus 4.6 --- .../web/app/components/climb-actions/actions/tick-action.tsx | 4 ++-- .../playlist/[playlist_uuid]/playlist-detail-content.tsx | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/web/app/components/climb-actions/actions/tick-action.tsx b/packages/web/app/components/climb-actions/actions/tick-action.tsx index a33a9fab..9972ea2b 100644 --- a/packages/web/app/components/climb-actions/actions/tick-action.tsx +++ b/packages/web/app/components/climb-actions/actions/tick-action.tsx @@ -41,10 +41,10 @@ export function TickAction({ const isAuthenticated = boardProvider?.isAuthenticated ?? (sessionStatus === 'authenticated'); // Use standalone useLogbook when outside BoardProvider - // When inside BoardProvider, prefer its logbook (already fetched, same cache) + // Pass empty array when provider exists to skip the fetch (enabled requires climbUuids.length > 0) const { logbook: standaloneLogbook } = useLogbook( boardDetails.board_name, - [climb.uuid], + boardProvider ? [] : [climb.uuid], ); const logbook = boardProvider?.logbook ?? standaloneLogbook; diff --git a/packages/web/app/my-library/playlist/[playlist_uuid]/playlist-detail-content.tsx b/packages/web/app/my-library/playlist/[playlist_uuid]/playlist-detail-content.tsx index c4b0b10c..ae63c196 100644 --- a/packages/web/app/my-library/playlist/[playlist_uuid]/playlist-detail-content.tsx +++ b/packages/web/app/my-library/playlist/[playlist_uuid]/playlist-detail-content.tsx @@ -280,6 +280,7 @@ export default function PlaylistDetailContent({ try { if (navigator.share && navigator.canShare?.(shareData)) { await navigator.share(shareData); + showMessage('Shared successfully!', 'success'); } else { await navigator.clipboard.writeText(window.location.href); showMessage('Link copied to clipboard!', 'success');