diff --git a/.zed/settings.json b/.zed/settings.json index 9da6164..b08b74a 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,39 +1,57 @@ { - "language_servers": ["!eslint", "..."], + "language_servers": [ + "...", + "!eslint" + ], "code_actions_on_format": { "source.action.useSortedAttributes.biome": true, "source.action.useSortedKeys.biome": true, "source.fixAll.biome": true, }, + "prettier": { + "allowed": false, + }, "languages": { "JavaScript": { "formatter": { - "language_server": { "name": "biome" }, + "language_server": { + "name": "biome" + }, }, }, "TypeScript": { "formatter": { - "language_server": { "name": "biome" }, + "language_server": { + "name": "biome" + }, }, }, "TSX": { "formatter": { - "language_server": { "name": "biome" }, + "language_server": { + "name": "biome" + }, }, }, "JSON": { "formatter": { - "language_server": { "name": "biome" }, + "language_server": { + "name": "biome" + }, }, }, "CSS": { "formatter": { - "language_server": { "name": "biome" }, + "language_server": { + "name": "biome" + }, }, }, "YAML": { "formatter": { - "language_server": { "name": "biome" }, + "language_server": { + "name": "biome" + }, }, }, }, diff --git a/CLAUDE.md b/CLAUDE.md index 6af560f..5b29c4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,3 +84,39 @@ Validators use `zod` (v4) via `convex-helpers/server/zod4` (`zodToConvex`, `zid` **Daily puzzle timing:** `generateUseQueryHookWithTimestampArg` passes a stable UTC midnight timestamp so the daily puzzle query doesn't re-fire during a session when the date rolls over. Use anytime a query requires a timestamp. **Icons:** use `lucide-react-native` for all icons. Always import using the `Icon`-suffixed export (e.g. `InfoIcon`, `SettingsIcon`, `Share2Icon`) — both `Info` and `InfoIcon` resolve to the same component, but the suffixed form makes it clear the import is an icon. + +**Date formatting:** always use `dayjs` for formatting dates. The `sl` locale is configured globally in `src/app/_layout.tsx`, so Slovenian month names work out of the box (e.g. `dayjs(timestamp).format('MMMM YYYY')`). Never use `toLocaleDateString`. + +## Testing + +**Screen tests** live in `src/__tests__/` mirroring the app's file structure (e.g. `src/__tests__/leaderboards/weekly-leaderboard.test.tsx`). **Component, hook, and utility tests** are co-located with the file they test (e.g. `src/components/ui/Button/Button.test.tsx`). Use `@testing-library/react-native` (`render`, `screen`, `fireEvent`). + +**Mocking hooks:** mock the module with `jest.requireActual` spread, then override only the hooks used by the screen: +```ts +jest.mock('@/hooks/queries', () => ({ + ...jest.requireActual('@/hooks/queries'), + useUserQuery: jest.fn(), +})); +// Then cast to spy: const useUserQuerySpy = useUserQuery as jest.Mock; +``` + +**Mocking expo-router:** same spread pattern. To capture `Stack.Screen` options: +```ts +jest.mock('expo-router', () => ({ + ...jest.requireActual('expo-router'), + Stack: { Screen: jest.fn().mockReturnValue(null) }, + useLocalSearchParams: jest.fn(), +})); +// Stack.Screen is called with (props, undefined) — not (props, {}) +``` + +**Fixtures:** reuse shared test data from `src/tests/fixtures/` (`testUser1`, `testPuzzleStatistics1`, etc.). Add new fixtures there when a new model is introduced. + +**Disambiguating text:** numbers often appear in multiple places (e.g. stat values vs. distribution graph labels). Add `testID` to the container and assert with `toHaveTextContent`: +```ts +expect(screen.getByTestId('stat-total-played')).toHaveTextContent(/Odigranih5/); +``` + +**Locale:** `jest.setup.tsx` sets `dayjs.locale('sl')` globally — date assertions must use Slovenian month names (e.g. `"julij 2025"`, `"01. jul. 2025"`). + +**beforeEach/afterEach:** set up spy return values in `beforeEach`, call `jest.clearAllMocks()` in `afterEach`. diff --git a/biome.json b/biome.json index 5740a11..c377cb8 100644 --- a/biome.json +++ b/biome.json @@ -26,6 +26,26 @@ }, "assist": { "enabled": true, - "actions": { "source": { "organizeImports": "on", "useSortedAttributes": "on" } } + "actions": { + "source": { + "organizeImports": { + "level": "on", + "options": { + "groups": [ + [":NODE:"], + ":BLANK_LINE:", + [":PACKAGE:"], + ":BLANK_LINE:", + ["@e2e/**"], + ":BLANK_LINE:", + ["@/**"], + ":BLANK_LINE:", + [":PATH:"] + ] + } + }, + "useSortedAttributes": "on" + } + } } } diff --git a/convex/leaderboards/queries.ts b/convex/leaderboards/queries.ts index 15fe6ea..93992ae 100644 --- a/convex/leaderboards/queries.ts +++ b/convex/leaderboards/queries.ts @@ -8,7 +8,6 @@ import type { DataModel, Id } from '../_generated/dataModel'; import { generateRandomString, weekBounds, windowAround } from '../shared/helpers'; import { internalMutation, mutation, query } from '../shared/queries'; import type { User } from '../users/models'; - import { createLeaderboardModel, type LeaderboardWithScores, diff --git a/convex/notifications/queries.ts b/convex/notifications/queries.ts index 3622d9c..f2a6b4a 100644 --- a/convex/notifications/queries.ts +++ b/convex/notifications/queries.ts @@ -3,7 +3,6 @@ import { zid } from 'convex-helpers/server/zod4'; import { z } from 'zod'; import { mutation, query } from '../shared/queries'; - import { pushNotifications } from './services'; export const readUserNotificationsStatus = query({ diff --git a/convex/puzzleGuessAttempts/queries.ts b/convex/puzzleGuessAttempts/queries.ts index 8d8363e..a7ccdf8 100644 --- a/convex/puzzleGuessAttempts/queries.ts +++ b/convex/puzzleGuessAttempts/queries.ts @@ -5,7 +5,6 @@ import { z } from 'zod'; import { checkWordleAttempt } from '@/utils/words'; import { mutation, query } from '../shared/queries'; - import { isAttemptCorrect } from './helpers'; import { createPuzzleGuessAttemptModel } from './models'; diff --git a/convex/puzzles/internal.ts b/convex/puzzles/internal.ts index e497c33..73651a7 100644 --- a/convex/puzzles/internal.ts +++ b/convex/puzzles/internal.ts @@ -4,7 +4,6 @@ import { pickRandomWord } from '@/utils/words'; import { internalMutation } from '../_generated/server'; import { pushNotifications } from '../notifications/services'; - import { puzzleType } from './models'; export const createDailyPuzzle = internalMutation({ diff --git a/convex/puzzles/queries.ts b/convex/puzzles/queries.ts index b5338ee..619f2ce 100644 --- a/convex/puzzles/queries.ts +++ b/convex/puzzles/queries.ts @@ -8,7 +8,6 @@ import { isAttemptCorrect } from '../puzzleGuessAttempts/helpers'; import { checkedLetterStatus } from '../puzzleGuessAttempts/models'; import { paginationOptsValidator } from '../shared/models'; import { mutation, query } from '../shared/queries'; - import { puzzleType } from './models'; export const read = query({ diff --git a/convex/users/queries.ts b/convex/users/queries.ts index 0d443d2..fe9f261 100644 --- a/convex/users/queries.ts +++ b/convex/users/queries.ts @@ -6,7 +6,6 @@ import { internal } from '../_generated/api'; import { leaderboardType } from '../leaderboards/models'; import { puzzleType } from '../puzzles/models'; import { internalMutation, mutation, query } from '../shared/queries'; - import { createUserModel, patchUserModel } from './models'; export const read = query({ diff --git a/db/seeders/dictionaryEntries/getDictionaryWords.js b/db/seeders/dictionaryEntries/getDictionaryWords.js index e1859dd..768c5af 100644 --- a/db/seeders/dictionaryEntries/getDictionaryWords.js +++ b/db/seeders/dictionaryEntries/getDictionaryWords.js @@ -2,6 +2,7 @@ import { createWriteStream } from 'node:fs'; import fs, { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; + import * as cheerio from 'cheerio'; import { XMLParser } from 'fast-xml-parser'; diff --git a/db/seeders/dictionaryEntries/getDictionaryWordsExplanations.js b/db/seeders/dictionaryEntries/getDictionaryWordsExplanations.js index 7ffed7e..ec99144 100644 --- a/db/seeders/dictionaryEntries/getDictionaryWordsExplanations.js +++ b/db/seeders/dictionaryEntries/getDictionaryWordsExplanations.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; import { fileURLToPath } from 'node:url'; + import * as cheerio from 'cheerio'; import { fetchDictionaryTermHtml } from './utils.js'; diff --git a/jest.setup.tsx b/jest.setup.tsx index 77521d2..5e6bb8d 100644 --- a/jest.setup.tsx +++ b/jest.setup.tsx @@ -1,3 +1,8 @@ +import dayjs from 'dayjs'; +import 'dayjs/locale/sl'; + +dayjs.locale('sl'); + jest.mock('@react-native-async-storage/async-storage', () => require('@react-native-async-storage/async-storage/jest/async-storage-mock') ); diff --git a/src/__tests__/play/daily-puzzle-solved.test.tsx b/src/__tests__/play/daily-puzzle-solved.test.tsx index 59dc67f..1a210af 100644 --- a/src/__tests__/play/daily-puzzle-solved.test.tsx +++ b/src/__tests__/play/daily-puzzle-solved.test.tsx @@ -90,7 +90,7 @@ describe('Daily puzzle solved screen', () => { usePuzzleStatisticsSpy.mockReturnValue({ isLoading: true, data: undefined }); render(); - expect(screen.queryByRole('spinbutton', { name: 'Nalagam statistiko dnevnih izzivov...' })).toBeOnTheScreen(); + expect(screen.queryByRole('progressbar', { name: 'Nalagam statistiko dnevnih izzivov...' })).toBeOnTheScreen(); expect(screen.queryByText('Statistika dnevnih izzivov')).not.toBeOnTheScreen(); }); @@ -125,7 +125,7 @@ describe('Daily puzzle solved screen', () => { usePuzzleStatisticsSpy.mockReturnValue({ isLoading: false, data: testPuzzleStatistics1 }); render(); - expect(screen.queryByRole('spinbutton', { name: 'Nalagam statistiko dnevnih izzivov...' })).not.toBeOnTheScreen(); + expect(screen.queryByRole('progressbar', { name: 'Nalagam statistiko dnevnih izzivov...' })).not.toBeOnTheScreen(); expect(screen.queryByText('Statistika dnevnih izzivov')).toBeOnTheScreen(); diff --git a/src/__tests__/play/training-puzzle-solved.test.tsx b/src/__tests__/play/training-puzzle-solved.test.tsx index 5bef299..8a8ea00 100644 --- a/src/__tests__/play/training-puzzle-solved.test.tsx +++ b/src/__tests__/play/training-puzzle-solved.test.tsx @@ -91,7 +91,7 @@ describe('Training puzzle solved screen', () => { usePuzzleStatisticsSpy.mockReturnValue({ isLoading: true, data: undefined }); render(); - expect(screen.queryByRole('spinbutton', { name: 'Nalagam statistiko trening izzivov...' })).toBeOnTheScreen(); + expect(screen.queryByRole('progressbar', { name: 'Nalagam statistiko trening izzivov...' })).toBeOnTheScreen(); expect(screen.queryByText('Trening statistika')).not.toBeOnTheScreen(); }); @@ -126,7 +126,7 @@ describe('Training puzzle solved screen', () => { usePuzzleStatisticsSpy.mockReturnValue({ isLoading: false, data: testPuzzleStatistics1 }); render(); - expect(screen.queryByRole('spinbutton', { name: 'Nalagam statistiko trening izzivov...' })).not.toBeOnTheScreen(); + expect(screen.queryByRole('progressbar', { name: 'Nalagam statistiko trening izzivov...' })).not.toBeOnTheScreen(); expect(screen.queryByText('Trening statistika')).toBeOnTheScreen(); diff --git a/src/__tests__/settings.test.tsx b/src/__tests__/settings.test.tsx index 25ad9ee..e76c96c 100644 --- a/src/__tests__/settings.test.tsx +++ b/src/__tests__/settings.test.tsx @@ -118,7 +118,7 @@ describe('Settings screen', () => { expect(screen.queryByRole('button', { name: 'Spremeni' })).toBeOnTheScreen(); expect(screen.queryByText('Profil ustvarjen')).toBeOnTheScreen(); - expect(screen.queryByText('01. Jul 2025')).toBeOnTheScreen(); + expect(screen.queryByText('01. jul. 2025')).toBeOnTheScreen(); expect(screen.queryByText('ID profila')).toBeOnTheScreen(); expect(screen.queryByText(testUser1._id)).toBeOnTheScreen(); diff --git a/src/__tests__/user-profile.test.tsx b/src/__tests__/user-profile.test.tsx new file mode 100644 index 0000000..37e46ca --- /dev/null +++ b/src/__tests__/user-profile.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from '@testing-library/react-native'; +import { Stack, useLocalSearchParams } from 'expo-router'; + +import UserProfileScreen from '@/app/(authenticated)/user-profile'; +import { puzzleType } from '@/convex/puzzles/models'; +import { usePuzzlesStatisticsQuery, useUserQuery } from '@/hooks/queries'; +import { testPuzzleStatistics1 } from '@/tests/fixtures/puzzleStatistics'; +import { testUser1 } from '@/tests/fixtures/users'; + +jest.mock('expo-router', () => ({ + ...jest.requireActual('expo-router'), + Stack: { + Screen: jest.fn().mockReturnValue(null), + }, + useLocalSearchParams: jest.fn(), +})); + +jest.mock('@/hooks/queries', () => ({ + ...jest.requireActual('@/hooks/queries'), + usePuzzlesStatisticsQuery: jest.fn(), + useUserQuery: jest.fn(), +})); + +describe('', () => { + const StackScreenSpy = Stack.Screen as unknown as jest.Mock; + const useLocalSearchParamsSpy = useLocalSearchParams as jest.Mock; + const usePuzzlesStatisticsQuerySpy = usePuzzlesStatisticsQuery as jest.Mock; + const useUserQuerySpy = useUserQuery as jest.Mock; + + beforeEach(() => { + useLocalSearchParamsSpy.mockReturnValue({ userId: testUser1._id }); + useUserQuerySpy.mockReturnValue({ isLoading: false, data: testUser1 }); + usePuzzlesStatisticsQuerySpy.mockReturnValue({ isLoading: false, isNotFound: false, data: testPuzzleStatistics1 }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call useUserQuery with the userId from route params', () => { + render(); + + expect(useUserQuerySpy).toHaveBeenCalledWith({ id: testUser1._id }); + }); + + it('should call usePuzzlesStatisticsQuery with "daily" puzzle type and the userId from route params', () => { + render(); + + expect(usePuzzlesStatisticsQuerySpy).toHaveBeenCalledWith({ + puzzleType: puzzleType.enum.daily, + userId: testUser1._id, + }); + }); + + it('should set the screen title to "Nalagam podatke..." while user data is loading', () => { + useUserQuerySpy.mockReturnValue({ isLoading: true, data: undefined }); + render(); + + expect(StackScreenSpy).toHaveBeenCalledWith( + expect.objectContaining({ options: expect.objectContaining({ title: 'Nalagam podatke...' }) }), + undefined + ); + }); + + it('should set the screen title to the user nickname once loaded', () => { + render(); + + expect(StackScreenSpy).toHaveBeenCalledWith( + expect.objectContaining({ options: expect.objectContaining({ title: testUser1.nickname }) }), + undefined + ); + }); + + it('should render loading indicator while user data is loading', () => { + useUserQuerySpy.mockReturnValue({ isLoading: true, data: undefined }); + render(); + + expect(screen.queryByRole('progressbar', { name: 'Nalagam podatke o uporabniku...' })).toBeOnTheScreen(); + expect(screen.queryByText('Statistika dnevnih izzivov')).not.toBeOnTheScreen(); + }); + + it('should render loading indicator while stats are loading', () => { + usePuzzlesStatisticsQuerySpy.mockReturnValue({ isLoading: true, data: undefined }); + render(); + + expect(screen.queryByRole('progressbar', { name: 'Nalagam podatke o uporabniku...' })).toBeOnTheScreen(); + expect(screen.queryByText('Statistika dnevnih izzivov')).not.toBeOnTheScreen(); + }); + + it('should render the member since date', () => { + render(); + + // testUser1._creationTime = 1751328000000 (2025-07-01); dayjs sl locale → "julij 2025" + expect(screen.queryByText(/Član od: julij 2025/)).toBeOnTheScreen(); + }); + + it('should render daily stats when data is loaded', () => { + render(); + + expect(screen.queryByRole('progressbar', { name: 'Nalagam podatke o uporabniku...' })).not.toBeOnTheScreen(); + expect(screen.queryByText('Statistika dnevnih izzivov')).toBeOnTheScreen(); + expect(screen.getByTestId('stat-total-played')).toHaveTextContent(/Odigranih5/); + expect(screen.getByTestId('stat-win-rate')).toHaveTextContent(/% rešenih80%/); + expect(screen.getByTestId('stat-current-streak')).toHaveTextContent(/Trenutni niz2/); + expect(screen.getByTestId('stat-max-streak')).toHaveTextContent(/Najdaljši niz3/); + }); + + it('should render win rate rounded up to nearest integer', () => { + usePuzzlesStatisticsQuerySpy.mockReturnValue({ + isLoading: false, + isNotFound: false, + data: { ...testPuzzleStatistics1, totalPlayed: 3, totalWon: 1 }, + }); + render(); + + // Math.ceil(1 / 3 * 100) = Math.ceil(33.33) = 34 + expect(screen.getByTestId('stat-win-rate')).toHaveTextContent(/34%/); + }); + + it('should render empty state when user has no stats', () => { + usePuzzlesStatisticsQuerySpy.mockReturnValue({ isLoading: false, isNotFound: true, data: null }); + render(); + + expect(screen.queryByText('Ta igralec še ni rešil nobene uganke.')).toBeOnTheScreen(); + expect(screen.queryByText('Statistika dnevnih izzivov')).not.toBeOnTheScreen(); + }); +}); diff --git a/src/app/(authenticated)/_layout.tsx b/src/app/(authenticated)/_layout.tsx index e189b63..a45ce0f 100644 --- a/src/app/(authenticated)/_layout.tsx +++ b/src/app/(authenticated)/_layout.tsx @@ -3,6 +3,7 @@ import { useCallback } from 'react'; import { Platform } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ModalViewBackButton } from '@/components/navigation'; import { useUser } from '@/hooks/useUser'; import { getOsMajorVersion } from '@/utils/platform'; @@ -44,6 +45,16 @@ export default function AuthenticatedLayout() { }} /> + onPresentLeaderboardActions(leaderboard)} title={leaderboard.name ?? leaderboard._id} > - + + router.navigate({ pathname: '/(authenticated)/user-profile', params: { userId } }) + } + scores={leaderboard.scores} + /> ))} diff --git a/src/app/(authenticated)/leaderboards/weekly-leaderboard.tsx b/src/app/(authenticated)/leaderboards/weekly-leaderboard.tsx index a7ff3f3..c91949b 100644 --- a/src/app/(authenticated)/leaderboards/weekly-leaderboard.tsx +++ b/src/app/(authenticated)/leaderboards/weekly-leaderboard.tsx @@ -1,4 +1,4 @@ -import { useFocusEffect, useNavigation } from 'expo-router'; +import { useFocusEffect, useNavigation, useRouter } from 'expo-router'; import { useCallback } from 'react'; import { ScrollView, View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; @@ -11,6 +11,7 @@ import { useLeaderboards } from '@/hooks/useLeaderboards'; export default function WeeklyLeaderboardScreen() { const navigation = useNavigation(); + const router = useRouter(); const { leaderboards: privateLeaderboards, isLoading, @@ -37,7 +38,12 @@ export default function WeeklyLeaderboardScreen() { onShowActions={() => onPresentLeaderboardActions(leaderboard)} title={leaderboard.name ?? leaderboard._id} > - + + router.navigate({ pathname: '/(authenticated)/user-profile', params: { userId } }) + } + scores={leaderboard.scores} + /> ))} diff --git a/src/app/(authenticated)/play/daily-puzzle-solved.tsx b/src/app/(authenticated)/play/daily-puzzle-solved.tsx index 34f70a3..01fb684 100644 --- a/src/app/(authenticated)/play/daily-puzzle-solved.tsx +++ b/src/app/(authenticated)/play/daily-puzzle-solved.tsx @@ -33,7 +33,7 @@ export default function DailyPuzzleSolvedScreen() { diff --git a/src/app/(authenticated)/play/training-puzzle-solved.tsx b/src/app/(authenticated)/play/training-puzzle-solved.tsx index 770ab29..bdbc6e4 100644 --- a/src/app/(authenticated)/play/training-puzzle-solved.tsx +++ b/src/app/(authenticated)/play/training-puzzle-solved.tsx @@ -39,7 +39,7 @@ export default function TrainingPuzzleSolvedScreen() { diff --git a/src/app/(authenticated)/user-profile.tsx b/src/app/(authenticated)/user-profile.tsx new file mode 100644 index 0000000..c76f06e --- /dev/null +++ b/src/app/(authenticated)/user-profile.tsx @@ -0,0 +1,132 @@ +import dayjs from 'dayjs'; +import { Stack, useLocalSearchParams } from 'expo-router'; +import { ActivityIndicator, ScrollView, View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; + +import { AttemptsDistributionGraph } from '@/components/elements'; +import { Card, Text } from '@/components/ui'; +import { puzzleType } from '@/convex/puzzles/models'; +import { usePuzzlesStatisticsQuery, useUserQuery } from '@/hooks/queries'; + +export default function UserProfileScreen() { + const { userId } = useLocalSearchParams<{ userId: string }>(); + + const { isLoading: isLoadingUser, data: user } = useUserQuery({ id: userId }); + const { + isLoading: isLoadingStats, + isNotFound: hasNoStats, + data: stats, + } = usePuzzlesStatisticsQuery({ + puzzleType: puzzleType.enum.daily, + userId, + }); + + const isLoading = isLoadingUser || isLoadingStats; + const title = isLoadingUser ? 'Nalagam podatke...' : (user?.nickname ?? 'Profil'); + const memberSince = user ? dayjs(user._creationTime).format('MMMM YYYY') : ''; + const winRate = stats ? Math.ceil((stats.totalWon / stats.totalPlayed) * 100) : 0; + + return ( + <> + + {isLoading ? ( + + + + ) : ( + + + + Član od: {memberSince} + + + {hasNoStats ? ( + + Ta igralec še ni rešil nobene uganke. + + ) : ( + + + Statistika dnevnih izzivov + + + + + Odigranih + + + {stats.totalPlayed} + + + + + % rešenih + + + {winRate}% + + + + + Trenutni niz + + + {stats.currentStreak} + + + + + Najdaljši niz + + + {stats.maxStreak} + + + + + + + + )} + + )} + + ); +} + +const styles = StyleSheet.create((theme, rt) => ({ + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + container: { + flexGrow: 1, + paddingTop: 0, + paddingHorizontal: theme.spacing[6], + paddingBottom: rt.insets.bottom + theme.spacing[6], + gap: theme.spacing[6], + }, + header: { + gap: theme.spacing[1], + }, + statsSection: { + gap: theme.spacing[6], + }, + statsRow: { + flexDirection: 'row', + gap: theme.spacing[4], + }, + statEntry: { + flex: 1, + alignItems: 'center', + padding: theme.spacing[4], + backgroundColor: rt.themeName === 'dark' ? theme.colors.grey[20] : theme.colors.grey[5], + borderRadius: 4, + }, +})); diff --git a/src/components/elements/GuessGrid/GuessGrid.hook.ts b/src/components/elements/GuessGrid/GuessGrid.hook.ts index 9f03868..2ae03e1 100644 --- a/src/components/elements/GuessGrid/GuessGrid.hook.ts +++ b/src/components/elements/GuessGrid/GuessGrid.hook.ts @@ -8,7 +8,6 @@ import { useToaster } from '@/hooks/useToaster'; import { deepClone } from '@/utils/clone'; import type { KeyboardKey } from '../Keyboard'; - import { findCurrentGridRowIdx, getUpdatedGrid } from './GuessGrid.helpers'; const initialGuesses = new Array(6).fill(new Array(5).fill(null)); diff --git a/src/components/elements/Leaderboard/Leaderboard.tsx b/src/components/elements/Leaderboard/Leaderboard.tsx index 65b45a7..c8d0e90 100644 --- a/src/components/elements/Leaderboard/Leaderboard.tsx +++ b/src/components/elements/Leaderboard/Leaderboard.tsx @@ -1,18 +1,19 @@ -import { View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { Text } from '@/components/ui'; import type { LeaderboardScoreWithUser } from '@/convex/leaderboards/models'; type Props = { + onEntryPress: (userId: string) => void; scores?: LeaderboardScoreWithUser[]; }; -export function Leaderboard({ scores = [] }: Readonly) { +export function Leaderboard({ onEntryPress, scores = [] }: Readonly) { return ( {scores.map((score) => ( - + onEntryPress(score.user._id)} style={styles.entry}> ) { {score.score} - + ))} ); diff --git a/src/components/navigation/ModalViewBackButton/ModalViewBackButton.test.tsx b/src/components/navigation/ModalViewBackButton/ModalViewBackButton.test.tsx new file mode 100644 index 0000000..3fc357c --- /dev/null +++ b/src/components/navigation/ModalViewBackButton/ModalViewBackButton.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { useRouter } from 'expo-router'; + +import { ModalViewBackButton } from './ModalViewBackButton'; + +jest.mock('expo-router', () => ({ + ...jest.requireActual('expo-router'), + useRouter: jest.fn(), +})); + +describe('', () => { + const useRouterSpy = useRouter as jest.Mock; + const mockBack = jest.fn(); + + beforeEach(() => { + useRouterSpy.mockReturnValue({ back: mockBack }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render nothing when canGoBack is false', () => { + render(); + + expect(screen.queryByRole('button', { name: /Nazaj/ })).not.toBeOnTheScreen(); + }); + + it('should render nothing when canGoBack is not provided', () => { + render(); + + expect(screen.queryByRole('button', { name: /Nazaj/ })).not.toBeOnTheScreen(); + }); + + it('should render the back button when canGoBack is true', () => { + render(); + + expect(screen.queryByText('Nazaj')).toBeOnTheScreen(); + }); + + it('should call router.back when pressed', () => { + render(); + + fireEvent.press(screen.getByText('Nazaj')); + + expect(mockBack).toHaveBeenCalled(); + }); +}); diff --git a/src/components/navigation/ModalViewBackButton/ModalViewBackButton.tsx b/src/components/navigation/ModalViewBackButton/ModalViewBackButton.tsx new file mode 100644 index 0000000..7da29d8 --- /dev/null +++ b/src/components/navigation/ModalViewBackButton/ModalViewBackButton.tsx @@ -0,0 +1,30 @@ +import { useRouter } from 'expo-router'; +import { ChevronLeftIcon } from 'lucide-react-native'; +import { Pressable } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; + +import { Text } from '@/components/ui'; + +export function ModalViewBackButton({ canGoBack, tintColor }: { canGoBack?: boolean; tintColor?: string }) { + const router = useRouter(); + + if (!canGoBack) { + return null; + } + + return ( + + + Nazaj + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flexDirection: 'row', + paddingLeft: theme.spacing[2], + paddingRight: theme.spacing[4], + alignItems: 'center', + }, +})); diff --git a/src/components/navigation/ModalViewBackButton/index.ts b/src/components/navigation/ModalViewBackButton/index.ts new file mode 100644 index 0000000..55e7cae --- /dev/null +++ b/src/components/navigation/ModalViewBackButton/index.ts @@ -0,0 +1 @@ +export * from './ModalViewBackButton'; diff --git a/src/components/navigation/index.ts b/src/components/navigation/index.ts index 20c56fd..e2818d3 100644 --- a/src/components/navigation/index.ts +++ b/src/components/navigation/index.ts @@ -1 +1,2 @@ export * from './GenericStackScreen'; +export * from './ModalViewBackButton'; diff --git a/src/components/ui/Button/Button.test.tsx b/src/components/ui/Button/Button.test.tsx index 493b967..c185141 100644 --- a/src/components/ui/Button/Button.test.tsx +++ b/src/components/ui/Button/Button.test.tsx @@ -21,7 +21,7 @@ describe('