Skip to content

Contributing Guide

This guide covers the development workflow, code style, naming conventions, and patterns used in the DoCurious frontend.

Development Workflow

  1. Create a feature branch from main
  2. Implement changes following the patterns described below
  3. Run linting and formatting: npm run lint && npm run format:check
  4. Run tests: npm run test:run
  5. Run coverage check: npm run test:coverage (must stay above thresholds)
  6. Verify the build succeeds: npm run build
  7. Open a pull request against main

CI PIPELINE

The GitHub Actions workflow automatically runs unit tests, coverage checks, backend integration tests (against Postgres 17), and Playwright E2E tests on every push and PR to main. All jobs must pass before merging. See Testing for details.

Code Style

ESLint + Prettier

The project uses ESLint 9 with TypeScript support and Prettier for formatting:

bash
# Lint
npm run lint

# Auto-format
npm run format

# Check formatting (CI)
npm run format:check

Key rules:

  • TypeScript strict mode is enabled
  • React Hooks rules are enforced (eslint-plugin-react-hooks)
  • React Refresh compatibility is checked (eslint-plugin-react-refresh)
  • Prettier integration via eslint-plugin-prettier and eslint-config-prettier

Formatting Defaults

  • 2-space indentation
  • Single quotes
  • Semicolons
  • Trailing commas in multiline
  • 100-character print width

File Naming Conventions

TypeConventionExample
ComponentsPascalCaseChallengeCard.tsx
PagesPascalCaseDashboard.tsx
StorescamelCase with use prefixuseChallengeStore.ts
HookscamelCase with use prefixuseFeatureFlag.ts
API modulescamelCasechallenge.api.ts, challenge.real.api.ts
Type filescamelCase with .types suffixchallenge.types.ts
Adapter filescamelCase with Adapter suffixchallengeAdapter.ts
Test filesSame name with .test suffixChallengeCard.test.tsx
Story filesSame name with .stories suffixChallengeCard.stories.tsx
Index files (barrel)index.tssrc/components/challenge/index.ts
Locale filesLanguage codeen.json, es.json, fr.json
Theme presetscamelCasetrailhead.ts, fieldJournal.ts

Import Conventions

Barrel Exports

Every major directory has an index.ts barrel file. Import from the barrel, not from individual files:

typescript
// Good
import { useChallengeStore, selectChallenges } from '../store'
import { ChallengeCard, ChallengeGrid } from '../components/challenge'
import type { Challenge, UserRole } from '../types'
import { challengeApi, success, error } from '../api'

// Bad
import { useChallengeStore } from '../store/useChallengeStore'
import { ChallengeCard } from '../components/challenge/ChallengeCard'

Type-Only Imports

Use import type when importing only types to enable proper tree-shaking:

typescript
// Good
import type { Challenge, ChallengeStatus } from '../types'
import type { ApiResponse, PaginatedResponse } from '../types'

// Also good (inline type imports)
import { useChallengeStore, type ChallengeStore } from '../store'

Import Order

Group imports in this order (separated by blank lines):

  1. React and external libraries
  2. Internal modules (store, api, hooks)
  3. Components
  4. Types (using import type)
  5. Assets and styles
typescript
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'

import { useChallengeStore, selectChallenges } from '../store'
import { challengeApi } from '../api'
import { useFeatureFlag } from '../hooks/useFeatureFlag'

import { ChallengeCard } from '../components/challenge'
import { PageSkeleton } from '../components/common'

import type { Challenge, ChallengeFilters } from '../types'

Component Pattern

Every component follows this structure:

tsx
/**
 * ChallengeCard
 *
 * Displays a challenge summary in a card format.
 * Used in explore grids, search results, and recommendation rows.
 */

import type { Challenge } from '../../types'

interface ChallengeCardProps {
  /** The challenge to display */
  challenge: Challenge
  /** Called when the card is clicked */
  onClick?: (id: string) => void
  /** Whether to show the save button */
  showSave?: boolean
}

export function ChallengeCard({ challenge, onClick, showSave = true }: ChallengeCardProps) {
  return (
    <div
      className="bg-[var(--background)] border border-[var(--border)] rounded-[var(--theme-card-radius)]"
      onClick={() => onClick?.(challenge.id)}
    >
      <img src={challenge.coverImageUrl} alt={challenge.title} />
      <h3>{challenge.title}</h3>
      {challenge.isFree && <span>Free</span>}
    </div>
  )
}

Key conventions:

  1. JSDoc header -- brief description of purpose and usage context
  2. Props interface -- named {ComponentName}Props, with JSDoc on each prop
  3. Named export -- export function, not export default
  4. Destructured props with defaults where appropriate
  5. CSS variables for theme-aware styling

Store Pattern

typescript
/**
 * Challenge Store
 *
 * Manages challenge data including CRUD, search, and user-challenge relationships.
 */

import { create } from 'zustand'
import { challengeApi } from '../api'
import type { Challenge } from '../types'

// 1. State interface
interface ChallengeState {
  challenges: Challenge[]
  isLoading: boolean
  error: string | null
}

// 2. Actions interface
interface ChallengeActions {
  fetchChallenges: () => Promise<void>
  clearError: () => void
}

// 3. Combined type
type ChallengeStore = ChallengeState & ChallengeActions

// 4. Initial state
const initialState: ChallengeState = {
  challenges: [],
  isLoading: false,
  error: null,
}

// 5. Create store
export const useChallengeStore = create<ChallengeStore>()((set) => ({
  ...initialState,

  fetchChallenges: async () => {
    set({ isLoading: true, error: null })
    const response = await challengeApi.getChallenges()
    if (response.success) {
      set({ challenges: response.data, isLoading: false })
    } else {
      set({ error: response.error ?? 'Failed to fetch', isLoading: false })
    }
  },

  clearError: () => set({ error: null }),
}))

// 6. Selectors
export const selectChallenges = (state: ChallengeStore) => state.challenges

Key conventions:

  1. Separate State and Actions interfaces
  2. Define initialState as a constant
  3. All async actions set isLoading and handle errors
  4. Export selectors alongside the store
  5. Export everything through the barrel (src/store/index.ts)

API Pattern

Mock API

typescript
/**
 * Challenge API (Mock)
 *
 * Mock implementation using the in-memory database.
 */

import { mockDb } from './mockDb'
import { success, error, paginated, withDelay } from './client'
import type { Challenge, ApiResponse, PaginatedResponse } from '../types'

export const challengeApi = {
  async getChallenges(params?: PaginationParams): Promise<PaginatedResponse<Challenge>> {
    return withDelay(() => {
      const items = mockDb.getAll<Challenge>('challenges')
      return paginated(items, params)
    })
  },

  async getChallengeById(id: string): Promise<ApiResponse<Challenge>> {
    return withDelay(() => {
      const item = mockDb.findById<Challenge>('challenges', id)
      if (!item) return error('Challenge not found')
      return success(item)
    })
  },
}

Real API

typescript
/**
 * Challenge API (Real)
 *
 * Real implementation using HTTP client.
 */

import { http } from './httpClient'
import { adaptChallenge } from '../adapters'
import type { Challenge, ApiResponse } from '../types'

export const realChallengeApi = {
  async getChallenges(): Promise<ApiResponse<Challenge[]>> {
    const response = await http.get('/challenges')
    return {
      success: true,
      data: response.data.map(adaptChallenge),
    }
  },
}

Key conventions:

  1. Mock and real implementations share the same interface
  2. Mock uses withDelay() for simulated latency
  3. Real uses adapters to transform backend responses
  4. Both return ApiResponse<T> or PaginatedResponse<T>

Type Pattern

typescript
/**
 * Challenge System Types
 *
 * Types for challenges, track records, and related entities.
 * Based on Doc 2: Challenge System & Track Records.
 */

import type { BaseEntity, UUID } from './common.types'

// String literal union types for enums
export type ChallengeStatus = 'draft' | 'pending_review' | 'approved' | 'rejected' | 'archived'

// Entity interfaces extend BaseEntity
export interface Challenge extends BaseEntity {
  title: string
  description: string
  status: ChallengeStatus
  // ...
}

// Filter/query types are separate interfaces
export interface ChallengeFilters {
  search?: string
  categoryIds?: UUID[]
  difficulty?: DifficultyLevel[]
  // ...
}

Key conventions:

  1. One type file per domain (*.types.ts)
  2. All entity types extend BaseEntity (provides id, createdAt, updatedAt)
  3. Use string literal unions for enums (not TypeScript enum)
  4. Export from barrel (src/types/index.ts)
  5. JSDoc header referencing the spec document

Page Pattern

tsx
/**
 * MyChallenges Page
 *
 * Displays the user's challenges organized by status tabs.
 */

import { useEffect } from 'react'
import { useChallengeStore, selectMyChallenges } from '../store'
import { ChallengeGrid } from '../components/challenge'
import { PageSkeleton } from '../components/common'

export function MyChallenges() {
  const myChallenges = useChallengeStore(selectMyChallenges)
  const fetchMyChallenges = useChallengeStore(s => s.fetchMyChallenges)
  const isLoading = useChallengeStore(s => s.isLoading)

  useEffect(() => {
    fetchMyChallenges()
  }, [fetchMyChallenges])

  if (isLoading) return <PageSkeleton />

  return (
    <div>
      <h1>My Challenges</h1>
      <ChallengeGrid challenges={myChallenges} />
    </div>
  )
}

Key conventions:

  1. Named export (not default -- lazy loading uses .then(m => ({ default: m.PageName })))
  2. Use selectors for store subscriptions
  3. Fetch data in useEffect on mount
  4. Show PageSkeleton while loading

Directory Structure for New Features

When adding a new domain feature, create these files:

src/
  types/myDomain.types.ts          # Type definitions
  api/myDomain.api.ts              # Mock API
  api/myDomain.real.api.ts         # Real API
  store/useMyDomainStore.ts        # Zustand store
  components/myDomain/             # Components
    MyDomainCard.tsx
    MyDomainList.tsx
    index.ts                       # Barrel
  pages/myDomain/                  # Pages
    MyDomainPage.tsx
    index.ts                       # Barrel

Then register in the relevant barrels:

  • src/types/index.ts
  • src/api/index.ts
  • src/store/index.ts
  • src/routes/index.tsx

PR Process

  1. Branch from main with a descriptive name: feat/wallet-v2, fix/auth-redirect, docs/api-reference
  2. Keep PRs focused -- one feature or fix per PR
  3. Ensure all checks pass locally before pushing:
    • npm run lint — no lint errors
    • npm run format:check — formatting is consistent
    • npm run test:run — all unit/integration tests pass
    • npm run test:coverage — coverage stays above thresholds (17% statements, 16.5% branches, 13.5% functions, 17.5% lines)
    • npm run build — production build succeeds
  4. CI will automatically run the full pipeline (unit → coverage → backend integration → E2E) on your PR
  5. Include a description of what changed and why
  6. If the change affects routing, document the new routes
  7. If the change adds a new store, API module, or adapter, update the relevant docs and add tests

DoCurious Platform Documentation