Appearance
Contributing Guide
This guide covers the development workflow, code style, naming conventions, and patterns used in the DoCurious frontend.
Development Workflow
- Create a feature branch from
main - Implement changes following the patterns described below
- Run linting and formatting:
npm run lint && npm run format:check - Run tests:
npm run test:run - Run coverage check:
npm run test:coverage(must stay above thresholds) - Verify the build succeeds:
npm run build - 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:checkKey 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-prettierandeslint-config-prettier
Formatting Defaults
- 2-space indentation
- Single quotes
- Semicolons
- Trailing commas in multiline
- 100-character print width
File Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Components | PascalCase | ChallengeCard.tsx |
| Pages | PascalCase | Dashboard.tsx |
| Stores | camelCase with use prefix | useChallengeStore.ts |
| Hooks | camelCase with use prefix | useFeatureFlag.ts |
| API modules | camelCase | challenge.api.ts, challenge.real.api.ts |
| Type files | camelCase with .types suffix | challenge.types.ts |
| Adapter files | camelCase with Adapter suffix | challengeAdapter.ts |
| Test files | Same name with .test suffix | ChallengeCard.test.tsx |
| Story files | Same name with .stories suffix | ChallengeCard.stories.tsx |
| Index files (barrel) | index.ts | src/components/challenge/index.ts |
| Locale files | Language code | en.json, es.json, fr.json |
| Theme presets | camelCase | trailhead.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):
- React and external libraries
- Internal modules (store, api, hooks)
- Components
- Types (using
import type) - 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:
- JSDoc header -- brief description of purpose and usage context
- Props interface -- named
{ComponentName}Props, with JSDoc on each prop - Named export --
export function, notexport default - Destructured props with defaults where appropriate
- 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.challengesKey conventions:
- Separate State and Actions interfaces
- Define
initialStateas a constant - All async actions set
isLoadingand handle errors - Export selectors alongside the store
- 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:
- Mock and real implementations share the same interface
- Mock uses
withDelay()for simulated latency - Real uses adapters to transform backend responses
- Both return
ApiResponse<T>orPaginatedResponse<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:
- One type file per domain (
*.types.ts) - All entity types extend
BaseEntity(providesid,createdAt,updatedAt) - Use string literal unions for enums (not TypeScript
enum) - Export from barrel (
src/types/index.ts) - 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:
- Named export (not default -- lazy loading uses
.then(m => ({ default: m.PageName }))) - Use selectors for store subscriptions
- Fetch data in
useEffecton mount - Show
PageSkeletonwhile 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 # BarrelThen register in the relevant barrels:
src/types/index.tssrc/api/index.tssrc/store/index.tssrc/routes/index.tsx
PR Process
- Branch from
mainwith a descriptive name:feat/wallet-v2,fix/auth-redirect,docs/api-reference - Keep PRs focused -- one feature or fix per PR
- Ensure all checks pass locally before pushing:
npm run lint— no lint errorsnpm run format:check— formatting is consistentnpm run test:run— all unit/integration tests passnpm run test:coverage— coverage stays above thresholds (17% statements, 16.5% branches, 13.5% functions, 17.5% lines)npm run build— production build succeeds
- CI will automatically run the full pipeline (unit → coverage → backend integration → E2E) on your PR
- Include a description of what changed and why
- If the change affects routing, document the new routes
- If the change adds a new store, API module, or adapter, update the relevant docs and add tests