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..483950a1 --- /dev/null +++ b/packages/web/app/components/climb-actions/actions/__tests__/tick-action.test.tsx @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } 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 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 }), +})); + +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'; + mockStandaloneLogbook = []; + }); + + 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 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'); + }); + + 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/climb-actions/actions/tick-action.tsx b/packages/web/app/components/climb-actions/actions/tick-action.tsx index 9d76bfa7..9972ea2b 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,9 @@ 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 { useLogbook } from '@/app/hooks/use-logbook'; import AuthModal from '../../auth/auth-modal'; import { LogAscentDrawer } from '../../logbook/log-ascent-drawer'; import { track } from '@vercel/analytics'; @@ -34,10 +36,17 @@ 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'); + + // Use standalone useLogbook when outside BoardProvider + // Pass empty array when provider exists to skip the fetch (enabled requires climbUuids.length > 0) + const { logbook: standaloneLogbook } = useLogbook( + boardDetails.board_name, + boardProvider ? [] : [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 new file mode 100644 index 00000000..f381e0fb --- /dev/null +++ b/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; + +// === Mock setup === + +const mockMutateAsync = vi.fn().mockResolvedValue({}); +vi.mock('@/app/hooks/use-save-tick', () => ({ + useSaveTick: () => ({ mutateAsync: mockMutateAsync }), +})); + +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 }), +})); + +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 { 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'; + +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 isAuthenticated = bp?.isAuthenticated ?? (sessionStatus === 'authenticated'); + + const saveTickMutation = useSaveTick(boardDetails.board_name); + const saveTick = bp?.saveTick ?? (async (options: SaveTickOptions) => { + await saveTickMutation.mutateAsync(options); + }); + + return { saveTick, isAuthenticated }; +} + +describe('LogAscentForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockBoardProvider = null; + mockSessionStatus = 'unauthenticated'; + }); + + 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(mockMutateAsync).not.toHaveBeenCalled(); + }); + + it('falls back to useSaveTick mutation when BoardProvider is absent', async () => { + mockBoardProvider = null; + + 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(mockMutateAsync).toHaveBeenCalledWith(tickOptions); + }); + + it('passes all SaveTickOptions fields to mutateAsync', 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 = 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 92f9a656..d3883924 100644 --- a/packages/web/app/components/logbook/logascent-form.tsx +++ b/packages/web/app/components/logbook/logascent-form.tsx @@ -18,7 +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 { useBoardProvider, TickStatus } from '../board-provider/board-provider-context'; +import { useOptionalBoardProvider, type TickStatus } from '../board-provider/board-provider-context'; +import { useSession } from 'next-auth/react'; +import { useSaveTick } from '@/app/hooks/use-save-tick'; import { TENSION_KILTER_GRADES, ANGLES } from '@/app/lib/board-data'; import dayjs from 'dayjs'; @@ -54,7 +56,14 @@ interface LogAscentFormProps { } export const LogAscentForm: React.FC = ({ currentClimb, boardDetails, onClose }) => { - const { saveTick, isAuthenticated } = useBoardProvider(); + const boardProvider = useOptionalBoardProvider(); + const { status: sessionStatus } = useSession(); + const isAuthenticated = boardProvider?.isAuthenticated ?? (sessionStatus === 'authenticated'); + + 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]; 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..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 @@ -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,32 @@ 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); + showMessage('Shared successfully!', 'success'); + } 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 +359,7 @@ export default function PlaylistDetailContent({ ); } - if (visibleClimbs.length === 0 && hiddenCount === 0 && !isFetchingClimbs) { + if (climbsWithAngle.length === 0 && !isFetchingClimbs) { return (
@@ -353,33 +367,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 +436,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 */}