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('', () => {
);
expect(screen.queryByText('Press me!')).not.toBeOnTheScreen();
- expect(screen.queryByRole('spinbutton')).toBeOnTheScreen();
+ expect(screen.queryByRole('progressbar')).toBeOnTheScreen();
});
it('should add size and color props to icon wrapper in ', () => {
diff --git a/src/components/ui/Button/Button.tsx b/src/components/ui/Button/Button.tsx
index 09c491b..15e2157 100644
--- a/src/components/ui/Button/Button.tsx
+++ b/src/components/ui/Button/Button.tsx
@@ -3,6 +3,7 @@ import { ActivityIndicator, Pressable, type StyleProp, Text, type ViewStyle } fr
import { StyleSheet, type UnistylesVariants } from 'react-native-unistyles';
import { defaultTheme } from '@/styles/themes';
+
import type { IconProps } from '../Icon';
type Props = PropsWithChildren<{
@@ -86,7 +87,7 @@ export function Button({
{loading ? (
({
diff --git a/src/hooks/useLeaderboards/useLeaderboards.test.ts b/src/hooks/useLeaderboards/useLeaderboards.test.ts
index d085157..648cad1 100644
--- a/src/hooks/useLeaderboards/useLeaderboards.test.ts
+++ b/src/hooks/useLeaderboards/useLeaderboards.test.ts
@@ -24,7 +24,6 @@ import {
import { useLeaderboardsQuery } from '../queries';
import { useToaster } from '../useToaster';
import { useUser } from '../useUser';
-
import { useLeaderboards } from './useLeaderboards';
jest.mock('posthog-react-native', () => ({
diff --git a/src/hooks/usePushNotifications/usePushNotifications.test.ts b/src/hooks/usePushNotifications/usePushNotifications.test.ts
index 4401338..8ff423b 100644
--- a/src/hooks/usePushNotifications/usePushNotifications.test.ts
+++ b/src/hooks/usePushNotifications/usePushNotifications.test.ts
@@ -7,7 +7,6 @@ import { registerForPushNotificationsAsync } from '@/utils/notifications';
import { useRegisterUserForPushNotificationsMutation, useToggleUserPushNotificationsMutation } from '../mutations';
import { useUserNotificationsStatusQuery } from '../queries';
import { useToaster } from '../useToaster';
-
import { usePushNotifications } from './usePushNotifications';
jest.mock('expo-router', () => ({
diff --git a/src/hooks/usePuzzlesStatistics/usePuzzlesStatistics.test.ts b/src/hooks/usePuzzlesStatistics/usePuzzlesStatistics.test.ts
index 8b9651c..141bb73 100644
--- a/src/hooks/usePuzzlesStatistics/usePuzzlesStatistics.test.ts
+++ b/src/hooks/usePuzzlesStatistics/usePuzzlesStatistics.test.ts
@@ -6,7 +6,6 @@ import { testUser1 } from '@/tests/fixtures/users';
import { usePuzzlesStatisticsQuery } from '../queries';
import { useUser } from '../useUser';
-
import { usePuzzleStatistics } from './usePuzzlesStatistics';
jest.mock('../queries', () => ({
diff --git a/src/hooks/useTrainingPuzzle/useTrainingPuzzle.test.ts b/src/hooks/useTrainingPuzzle/useTrainingPuzzle.test.ts
index c4d8638..59d710e 100644
--- a/src/hooks/useTrainingPuzzle/useTrainingPuzzle.test.ts
+++ b/src/hooks/useTrainingPuzzle/useTrainingPuzzle.test.ts
@@ -17,7 +17,6 @@ import {
import { useActiveTrainingPuzzleQuery, usePuzzleAttemptsQuery } from '../queries';
import { useToaster } from '../useToaster';
import { useUser } from '../useUser';
-
import { useTrainingPuzzle } from './useTrainingPuzzle';
jest.mock('posthog-react-native', () => ({
diff --git a/src/hooks/useUser/useUser.test.ts b/src/hooks/useUser/useUser.test.ts
index 31045c7..16194ed 100644
--- a/src/hooks/useUser/useUser.test.ts
+++ b/src/hooks/useUser/useUser.test.ts
@@ -8,7 +8,6 @@ import { testUser1 } from '@/tests/fixtures/users';
import { useCreateUserMutation, useDeleteUserMutation, usePatchUserMutation } from '../mutations';
import { useUserQuery } from '../queries';
import { useToaster } from '../useToaster';
-
import { LOADING_USER_ID, useUser } from './useUser';
jest.mock('posthog-react-native', () => ({