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
98 changes: 85 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,101 @@
# Michess

A modern chess application built with React, Next.js, and TypeScript. Features a interactive chessboard with move validation and game state management.
A modern chess gameplay platform built with React, Vite, and TypeScript. Features matchmaking, game lobby, bot opponents, player game history, chess rating, anonymous games (without account), oauth and email login.

## Screenshots

<div style="display:flex; flex-wrap: wrap;">
<img src="screenshots/desktop-view.png" width="300"/>
<div style="display:flex; flex-wrap: wrap; justify-content: center;">
<img src="screenshots/home-page.png" width="440" hspace="30"/>
<p>
<img src="screenshots/desktop-view.png" width="300" hspace="10"/>
<img src="screenshots/mobile-view.png" height="220"/>
</div>

## Development setup

1. Install dependencies:
Install dependencies:

```bash
pnpm install
```

### Frontend

1. Start the development server:

```bash
pnpm install
pnpm nx serve web-chess
```

2. Start the development server:
2. Open your browser and navigate to `http://localhost:4200`

### Backend

1. Setup docker

```bash
pnpm nx serve web-chess
brew install docker docker-compose colima
colima start
```

3. Open your browser and navigate to `http://localhost:4200`
2. Setup your `apps/node-chess/.env.local` based on the example file `apps/node-chess/.env.example`.

3. Start databases

`pnpm start:db`

4. Run migrations

`pnpm migration:run`

5. Start backend service

`pnpm start:node`

## Project Structure

This is an Nx monorepo with the following key libraries:
This is an Nx monorepo with a clean separation between frontend, backend, and shared libraries.

- `core-game` - Chess game logic and move generation
- `react-chessboard` - React chessboard UI components
- `web-chess` - Main Next.js application
### Applications

- **`apps/web-chess`** - Main web application
- Vite
- React
- Tanstack Router/Query
- Radix-UI
- **`apps/node-chess`** - Main backend application
- NodeJS
- Hono
- Socket.IO
- PostgreSQL with Drizzle ORM
- Redis
- BullMQ

### Core Libraries

- **`libs/core-board`** - Board representation and move generation
- **`libs/core-game`** - Chess game logic and validation
- **`libs/core-rating`** - Glicko 2 rating system for player rankings

### UI Libraries

- **`libs/react-chessboard`** - Interactive chessboard React component
- Drag-and-drop piece movement
- Controlled and uncontrolled usage.
- Move highlighting
- Promotion dialog
- **`libs/react-dnd`** - Reusable React drag-and-drop utilities

### Backend Libraries

- **`libs/api-*`** - Main APIs that orchestrates and interacts with all parts of the application.
- **`libs/infra-*`** - Repositories and services that interact with data sources.
- **`libs/be-*`** - Backend specific generic library

### Other

- **`libs/api-schema`** - Validation logic for backend APIs, but also contains the DTOs used on FE-
- **`libs/common-*`** - Any common logic.

## Development

Expand All @@ -55,5 +119,13 @@ pnpm nx build web-chess
### Type Checking

```bash
pnpm run typecheck
pnpm typecheck
```

### Scripts

#### Performance test of chess move generation

```bash
pnpm perft
```
2 changes: 2 additions & 0 deletions apps/node-chess/src/config/service/AppConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const getConfig = (): AppConfig => {
},
llm: {
geminiApiKey: readEnvStrict('GEMINI_API_KEY'),
openAiApiKey: readEnvStrict('OPENAI_API_KEY'),
deepSeekApiKey: readEnvStrict('DEEPSEEK_API_KEY'),
},
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const OpponentTypeSelector = ({ value = 'random', onChange }: Props) => {
onValueChange={(val) => onChange?.(val as OpponentType)}
columns="2"
gap="2"
color={'gray'}
>
<RadioCards.Item value="random">
<Flex direction="column" gap="1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ export const PlayerGamesOverview: React.FC<Props> = ({ onJoinGame }) => {
ongoingGamesPage?.items.length === 0 &&
!ongoingQueryError;

const defaultTab = isNoOngoingGames ? 'completed' : 'ongoing';

// Check if there are any ongoing games where it's the player's turn
const hasPlayerTurnGames =
ongoingGamesPage?.items.some((game) => game.turn === game.ownSide) ?? false;
return (
<Card size={{ initial: '1', sm: '3' }}>
<Tabs.Root defaultValue={isNoOngoingGames ? 'completed' : 'ongoing'}>
<Tabs.Root key={defaultTab} defaultValue={defaultTab}>
<Flex justify="between" align="center" mb="4">
<Heading size="4" weight="medium">
My Games
Expand Down
29 changes: 29 additions & 0 deletions apps/web-chess/src/app/pages/__tests__/HomePage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { render, screen } from '../../../test/utils/custom-testing-library';
import { HomePage } from '../HomePage';

describe('HomePage', () => {
it('should render all main sections without loading spinners', async () => {
render(<HomePage />);

// Check that main card headers are present
expect(
await screen.findByRole('heading', { name: 'Play' }),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Lobby' })).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'My Games' }),
).toBeInTheDocument();
expect(screen.getByText('Active Players')).toBeInTheDocument();

// Verify no loading spinners are present
expect(screen.queryByRole('status')).toBeFalsy();
});

it('should render privacy policy link in footer', async () => {
render(<HomePage />);

const privacyLink = await screen.findByText('Privacy Policy');
expect(privacyLink).toBeInTheDocument();
expect(privacyLink.closest('a')).toHaveAttribute('href', '/privacy-policy');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { render, screen } from '../../../test/utils/custom-testing-library';
import { RequestResetPasswordPage } from '../RequestResetPasswordPage';

describe('RequestResetPasswordPage', () => {
it('should render the request reset password form', async () => {
render(<RequestResetPasswordPage />);

expect(await screen.findByText('Reset Password')).toBeInTheDocument();
expect(
screen.getByText('Enter your email to reset your password'),
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});

it('should render sign in link', async () => {
render(<RequestResetPasswordPage />);

const signInLink = await screen.findByText('Sign in');
expect(signInLink).toBeInTheDocument();
expect(signInLink.closest('a')).toHaveAttribute('href', '/sign-in');
});

it('should render OR separator', async () => {
render(<RequestResetPasswordPage />);

expect(await screen.findByText('OR')).toBeInTheDocument();
});
});
35 changes: 35 additions & 0 deletions apps/web-chess/src/app/pages/__tests__/ResetPasswordPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { render, screen } from '../../../test/utils/custom-testing-library';
import { ResetPasswordPage } from '../ResetPasswordPage';

describe('ResetPasswordPage', () => {
it('should render the reset password form when token is provided', async () => {
render(<ResetPasswordPage token="valid-token-123" />);

expect(await screen.findByText('Reset Password')).toBeInTheDocument();
expect(
screen.getByText('Complete the form to reset your password'),
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Confirm Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});

it('should show error message when token is not provided', async () => {
render(<ResetPasswordPage />);

expect(
await screen.findByText(
'Something went wrong. Please try resetting your password again.',
),
).toBeInTheDocument();
expect(screen.queryByPlaceholderText('Password')).toBeFalsy();
});

it('should render sign in link', async () => {
render(<ResetPasswordPage token="valid-token-123" />);

const signInLink = await screen.findByText('Sign in');
expect(signInLink).toBeInTheDocument();
expect(signInLink.closest('a')).toHaveAttribute('href', '/sign-in');
});
});
39 changes: 39 additions & 0 deletions apps/web-chess/src/app/pages/__tests__/SignInPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { render, screen } from '../../../test/utils/custom-testing-library';
import { SignInPage } from '../SignInPage';

describe('SignInPage', () => {
it('should render the sign in form', async () => {
render(<SignInPage />);

expect(await screen.findByText('Sign in')).toBeTruthy();
expect(
screen.getByText('Welcome back! Please enter your details.'),
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /sign in/i }),
).toBeInTheDocument();
});

it('should show success message when referer is reset-password', async () => {
render(<SignInPage referer="reset-password" />);

expect(
await screen.findByText(
'Your password has been reset successfully. You can now login.',
),
).toBeTruthy();
expect(
screen.queryByText('Welcome back! Please enter your details.'),
).toBeFalsy();
});

it('should render sign up link', async () => {
render(<SignInPage />);

const signUpLink = await screen.findByText('Sign up');
expect(signUpLink).toBeInTheDocument();
expect(signUpLink.closest('a')).toHaveAttribute('href', '/sign-up');
});
});
37 changes: 37 additions & 0 deletions apps/web-chess/src/app/pages/__tests__/SignUpPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { render, screen } from '../../../test/utils/custom-testing-library';
import { SignUpPage } from '../SignUpPage';

describe('SignUpPage', () => {
it('should render the sign up form', async () => {
render(<SignUpPage />);

expect(await screen.findByText('Sign up')).toBeInTheDocument();
expect(
screen.getByText('Create your account to get started'),
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Username')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Confirm Password')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /create account/i }),
).toBeInTheDocument();
});

it('should render social sign in option', async () => {
render(<SignUpPage />);

expect(
await screen.findByRole('button', { name: /continue with google/i }),
).toBeInTheDocument();
});

it('should render sign in link', async () => {
render(<SignUpPage />);

const signInLink = await screen.findByText('Sign in');
expect(signInLink).toBeInTheDocument();
expect(signInLink.closest('a')).toHaveAttribute('href', '/sign-in');
});
});
45 changes: 45 additions & 0 deletions apps/web-chess/src/app/pages/__tests__/WelcomePage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { render, screen } from '../../../test/utils/custom-testing-library';
import { WelcomePage } from '../WelcomePage';

describe('WelcomePage', () => {
it('should render welcome message with user name', async () => {
render(<WelcomePage type={undefined} />);

expect(
await screen.findByText('Welcome to Chessmonky Test User!'),
).toBeInTheDocument();
expect(
screen.getByText('Complete the steps below or skip for now'),
).toBeInTheDocument();
expect(screen.getByText('Start playing')).toBeInTheDocument();
});

it('should render username form when type is social', async () => {
render(<WelcomePage type="social" />);

expect(
await screen.findByText('Welcome to Chessmonky Test User!'),
).toBeInTheDocument();
expect(screen.getByLabelText('Username')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});

it('should render email verification message when type is email', async () => {
render(<WelcomePage type="email" />);

expect(
await screen.findByText('Welcome to Chessmonky Test User!'),
).toBeInTheDocument();
expect(
screen.getByText(/We sent a verification link/i),
).toBeInTheDocument();
});

it('should render start playing link', async () => {
render(<WelcomePage type={undefined} />);

const startLink = await screen.findByText('Start playing');
expect(startLink).toBeInTheDocument();
expect(startLink.closest('a')).toHaveAttribute('href', '/');
});
});
Loading
Loading