Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions .zed/settings.json
Original file line number Diff line number Diff line change
@@ -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"
},
},
},
},
Expand Down
36 changes: 36 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
22 changes: 21 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
1 change: 0 additions & 1 deletion convex/leaderboards/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion convex/notifications/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 0 additions & 1 deletion convex/puzzleGuessAttempts/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
1 change: 0 additions & 1 deletion convex/puzzles/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 0 additions & 1 deletion convex/puzzles/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 0 additions & 1 deletion convex/users/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions db/seeders/dictionaryEntries/getDictionaryWords.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 5 additions & 0 deletions jest.setup.tsx
Original file line number Diff line number Diff line change
@@ -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')
);
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/play/daily-puzzle-solved.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('Daily puzzle solved screen', () => {
usePuzzleStatisticsSpy.mockReturnValue({ isLoading: true, data: undefined });
render(<DailyPuzzleSolvedScreen />);

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();
});

Expand Down Expand Up @@ -125,7 +125,7 @@ describe('Daily puzzle solved screen', () => {
usePuzzleStatisticsSpy.mockReturnValue({ isLoading: false, data: testPuzzleStatistics1 });
render(<DailyPuzzleSolvedScreen />);

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();

Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/play/training-puzzle-solved.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('Training puzzle solved screen', () => {
usePuzzleStatisticsSpy.mockReturnValue({ isLoading: true, data: undefined });
render(<TrainingPuzzleSolvedScreen />);

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();
});

Expand Down Expand Up @@ -126,7 +126,7 @@ describe('Training puzzle solved screen', () => {
usePuzzleStatisticsSpy.mockReturnValue({ isLoading: false, data: testPuzzleStatistics1 });
render(<TrainingPuzzleSolvedScreen />);

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();

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
127 changes: 127 additions & 0 deletions src/__tests__/user-profile.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<UserProfileScreen />', () => {
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(<UserProfileScreen />);

expect(useUserQuerySpy).toHaveBeenCalledWith({ id: testUser1._id });
});

it('should call usePuzzlesStatisticsQuery with "daily" puzzle type and the userId from route params', () => {
render(<UserProfileScreen />);

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(<UserProfileScreen />);

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(<UserProfileScreen />);

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(<UserProfileScreen />);

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(<UserProfileScreen />);

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(<UserProfileScreen />);

// 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(<UserProfileScreen />);

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(<UserProfileScreen />);

// 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(<UserProfileScreen />);

expect(screen.queryByText('Ta igralec še ni rešil nobene uganke.')).toBeOnTheScreen();
expect(screen.queryByText('Statistika dnevnih izzivov')).not.toBeOnTheScreen();
});
});
Loading
Loading