diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3c31281 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,257 @@ +name: Test Suite + +on: + pull_request: + branches: [main, dev, dev-*] + types: [opened, synchronize, reopened] + push: + branches: [main] + +# Cancel in-progress runs when a new workflow with the same name is triggered +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + # Unit and Integration Tests + unit-tests: + name: Unit & Integration Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit and integration tests + run: npm test -- --run --reporter=verbose + env: + CI: true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-results + path: | + coverage/ + test-results/ + retention-days: 7 + + # E2E Tests + e2e-tests: + name: E2E Tests (Playwright) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Start dev server + run: | + npm run dev & + npx wait-on http://localhost:8080 --timeout 60000 + env: + CI: true + + - name: Setup Supabase CLI + uses: supabase/setup-cli@v1 + with: + version: latest + + - name: Start Supabase local + run: | + supabase start + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + + - name: Run Playwright tests + run: npx playwright test --project=chromium + env: + CI: true + # Add any required env variables for e2e tests + VITE_SUPABASE_URL: http://127.0.0.1:54321 + VITE_SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload test screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-screenshots + path: test-results/ + retention-days: 7 + + - name: Stop Supabase + if: always() + run: supabase stop + + # Type Check + type-check: + name: TypeScript Type Check + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run type check + run: npm run type-check || npx tsc --noEmit + + # Lint Check + lint: + name: ESLint Check + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint || npx eslint . --ext .ts,.tsx,.js,.jsx + + # Build Check + build: + name: Build Check + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + env: + CI: true + + - name: Check build output + run: | + if [ ! -d "dist" ]; then + echo "Build failed: dist directory not created" + exit 1 + fi + + # Status Check - Required for PR merging + test-status: + name: Test Status Check + runs-on: ubuntu-latest + needs: [unit-tests, e2e-tests, type-check, lint, build] + if: always() + + steps: + - name: Check test results + run: | + if [ "${{ needs.unit-tests.result }}" != "success" ] || \ + [ "${{ needs.e2e-tests.result }}" != "success" ] || \ + [ "${{ needs.type-check.result }}" != "success" ] || \ + [ "${{ needs.lint.result }}" != "success" ] || \ + [ "${{ needs.build.result }}" != "success" ]; then + echo "❌ One or more test jobs failed" + echo "Unit Tests: ${{ needs.unit-tests.result }}" + echo "E2E Tests: ${{ needs.e2e-tests.result }}" + echo "Type Check: ${{ needs.type-check.result }}" + echo "Lint: ${{ needs.lint.result }}" + echo "Build: ${{ needs.build.result }}" + exit 1 + else + echo "✅ All tests passed!" + exit 0 + fi + + - name: Post status comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const status = { + unit: '${{ needs.unit-tests.result }}', + e2e: '${{ needs.e2e-tests.result }}', + typeCheck: '${{ needs.type-check.result }}', + lint: '${{ needs.lint.result }}', + build: '${{ needs.build.result }}' + }; + + const icon = (result) => result === 'success' ? '✅' : '❌'; + + const comment = `## 🧪 Test Results + + | Job | Status | + |-----|--------| + | Unit & Integration Tests | ${icon(status.unit)} ${status.unit} | + | E2E Tests (Playwright) | ${icon(status.e2e)} ${status.e2e} | + | TypeScript Check | ${icon(status.typeCheck)} ${status.typeCheck} | + | ESLint | ${icon(status.lint)} ${status.lint} | + | Build | ${icon(status.build)} ${status.build} | + + ${Object.values(status).every(s => s === 'success') ? '✅ **All checks passed!** Ready to merge.' : '❌ **Some checks failed.** Please fix the issues before merging.'}`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); diff --git a/docs/ARCHITECTURE_GUIDE.md b/docs/ARCHITECTURE_GUIDE.md new file mode 100644 index 0000000..3cfb4d1 --- /dev/null +++ b/docs/ARCHITECTURE_GUIDE.md @@ -0,0 +1,132 @@ +# Architecture Guide + +This repo uses a **feature-first** folder structure. + +## High-level map + +- `src/features//...` + - Feature-owned UI, hooks, services, and (optionally) feature-local tests. +- `src/pages/*` + - Thin route wrappers that re-export the corresponding feature page. +- `src/components/*` + - Shared UI and app-level components used by multiple features (layout, shared widgets). + - `src/components/ui/*` is the shared UI kit. +- `src/hooks/*` + - Cross-feature hooks (auth, subscription, theme, generic infra hooks). +- `src/services/*` + - Cross-feature services (analytics, test runner, editor bounds, etc.). +- `src/shared/*` + - Shared “app infrastructure” (e.g. notification service, shared queries) that is not feature-owned. +- `src/types/*` + - Cross-feature shared types. + +## What goes where (rules of thumb) + +### 1) Creating a new feature +Create a folder: + +- `src/features//` + +Common subfolders: + +- `src/features//components/` +- `src/features//hooks/` +- `src/features//services/` +- `src/features//types/` (only if types are feature-private) + +Feature pages: + +- `src/features//Page.tsx` (or `HubPage.tsx`, `SolverPage.tsx`, etc.) + +Then create a thin wrapper route: + +- `src/pages/.tsx` that simply `export { default } from "@/features//<...>";` + +### 2) Creating a new component +Decide ownership first: + +- **Feature-owned component**: put it in `src/features//components/`. + - Examples: Survey steps, System Design canvas UI, Behavioral interview UI. +- **Shared component** (reused by multiple features): put it in `src/components/`. + - Examples: `Sidebar`, `ConfirmDialog`, shared editor/diagram components. +- **UI primitives** (buttons, dialog, tabs, etc.): keep in `src/components/ui/`. + +### 3) Creating a new hook + +- **Feature-specific behavior/data**: `src/features//hooks/`. +- **Cross-feature / infrastructure** (auth, subscription, theme, localStorage helpers, etc.): `src/hooks/`. + +### 4) Creating a new service + +- **Feature-specific persistence/integration**: `src/features//services/`. +- **Cross-feature infrastructure**: `src/services/`. + +### 5) Shared utilities and types + +- If multiple features use it: + - Types: `src/types/*` + - Utilities: `src/lib/*` or `src/utils/*` + - Shared services: `src/shared/*` or `src/services/*` depending on responsibility + +## Import boundaries (recommended) + +- Features can import from: + - `@/components/*` (shared UI) + - `@/components/ui/*` + - `@/hooks/*` (cross-feature) + - `@/services/*` (cross-feature) + - `@/shared/*`, `@/types/*`, `@/utils/*`, `@/lib/*` + - Their own feature code: `@/features//*` + +- Shared code (`src/components`, `src/hooks`, `src/services`) should **avoid** importing from feature folders when possible. + - Exception: “shared dashboard widgets” may currently import feature hooks (e.g. `useProblems`). If these are actually dashboard-owned, prefer moving them under `src/features/dashboard/components/*`. + +## Testing conventions + +This repo currently uses a **mixed** approach: + +### A) Centralized test folders (current common pattern) + +- Shared hooks: `src/hooks/__tests__/*` +- Shared services: `src/services/__tests__/*` +- Shared components: `src/components/__tests__/*` + +This works well for cross-feature code. + +### B) Co-located tests (recommended for new feature code) + +For feature-owned code, prefer co-location: + +- `src/features//components/__tests__/*` +- `src/features//hooks/__tests__/*` +- `src/features//services/__tests__/*` + +Example already in repo: + +- `src/features/survey/components/steps/__tests__/PaywallStep.test.tsx` + +### Suggested rule going forward + +- **If the code is feature-owned**: co-locate tests under that feature. +- **If the code is shared cross-feature**: keep tests in the centralized `src//__tests__` folders. +- Avoid testing `src/pages/*` wrappers; test the feature pages instead. + +## Known follow-up opportunities (optional cleanups) + +These are not required for correctness, but they improve “clean architecture”: + +- **Dashboard widgets in `src/components/*`** + - Several dashboard-centric components in `src/components` import feature hooks (e.g. `useProblems`). Consider moving them into `src/features/dashboard/components/*`. +- **Admin access logic duplication** + - `ADMIN_EMAILS` is duplicated in `Sidebar` and `AdminRoute`. Consider extracting to a single module (e.g. `src/features/admin/constants.ts` or `src/shared/auth/adminAccess.ts`). + +## Quick checklist when adding code + +- Is it feature-owned? + - Yes -> `src/features//...` + - No -> shared folders (`src/components`, `src/hooks`, `src/services`, `src/shared`) +- Does it need a route? + - Add `src/pages/.tsx` wrapper +- Does it need tests? + - Feature-owned -> co-locate under the feature + - Shared -> keep in centralized `__tests__` folder diff --git a/docs/CODE_PRINCIPLES.md b/docs/CODE_PRINCIPLES.md new file mode 100644 index 0000000..c722748 --- /dev/null +++ b/docs/CODE_PRINCIPLES.md @@ -0,0 +1,396 @@ +# 🎯 Code Principles - Quick Reference + +> **TL;DR**: DRY, KISS, SOLID - but don't overdo it! + +--- + +## 🚦 Quick Decision Guide + +### "Should I extract this into a utility?" + +``` +Is it used 3+ times? → YES, extract +Is it complex (>20 lines)? → MAYBE, consider extracting +Is it simple (1-5 lines)? → NO, keep it inline +Is it used only once? → NO, wait until needed again +``` + +### "Should I create a new component?" + +``` +Used in 2+ places? → YES +Single clear responsibility? → YES +Over 200 lines? → YES +Used only once and simple? → NO +Just for organization? → NO +``` + +### "Should I create a custom hook?" + +``` +Reusable logic used 3+ times? → YES +Complex state management? → YES +Needs isolated testing? → YES +Simple useState wrapper? → NO +Used only once? → NO +``` + +### "Should I create a service?" + +``` +Talks to external API? → YES +Complex business logic? → YES +Needs mocking for tests? → YES +Simple transformation? → NO +One-off operation? → NO +``` + +--- + +## ✅ DO's + +### **1. Extract After Third Repetition (Rule of Three)** +```typescript +// First time: Write it +const result1 = data.filter(x => x.active).map(x => x.name); + +// Second time: Notice it +const result2 = data.filter(x => x.active).map(x => x.name); + +// Third time: Extract it +const getActiveNames = (items) => items.filter(x => x.active).map(x => x.name); +const result3 = getActiveNames(data); +``` + +### **2. Keep Functions Small and Focused** +```typescript +// ✅ Good - one thing +const validateEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +const saveUser = async (user: User) => await db.users.save(user); +const notifyUser = (msg: string) => toast.success(msg); + +// ❌ Bad - too many responsibilities +const validateAndSaveAndNotifyUser = async (user: User) => { + // validation + saving + notification = violation +}; +``` + +### **3. Name Things Clearly** +```typescript +// ✅ Good - clear intent +const isValidEmail = (email: string) => /regex/.test(email); +const activateUserAccount = (userId: string) => { /* ... */ }; +const formatDateForDisplay = (date: Date) => date.toLocaleDateString(); + +// ❌ Bad - unclear +const check = (e: string) => /regex/.test(e); +const doStuff = (id: string) => { /* ... */ }; +const format = (d: Date) => d.toLocaleDateString(); +``` + +### **4. Use Consistent Patterns** +```typescript +// ✅ Consistent async state management +const { data, loading, error, execute } = useAsyncOperation(); + +// ✅ Consistent notifications +notifications.success('Saved!'); +notifications.error('Failed to save'); + +// ✅ Consistent service calls +const users = await userService.getAll(); +const user = await userService.getById(id); +``` + +### **5. Comment the "Why", Not the "What"** +```typescript +// ❌ Bad - obvious +// Set loading to true +setLoading(true); + +// ✅ Good - explains business logic +// Delay vim mode init to avoid race condition with Monaco editor mounting +setTimeout(() => loadVimMode(), 500); + +// ✅ Good - explains decision +// Using debounce instead of throttle because we want the last value, not first +const debouncedSave = debounce(save, 1000); +``` + +--- + +## ❌ DON'Ts + +### **1. Don't Create Premature Abstractions** +```typescript +// ❌ Bad - over-abstracted before needed +interface IGenericRepositoryFactoryProvider { + create(): IRepository; +} + +// ✅ Good - wait until you have 3+ similar cases +const fetchUsers = async () => await api.get('/users'); +// When you have 3 similar fetches, THEN extract +``` + +### **2. Don't Create God Objects/Components** +```typescript +// ❌ Bad - does everything +const ProblemSolverPage = () => { + // 1000 lines of code handling: + // - problem display + // - code editing + // - test running + // - coaching + // - submissions + // - analytics + // - ... +}; + +// ✅ Good - composed from smaller pieces +const ProblemSolverPage = () => ( + <> + + + + + +); +``` + +### **3. Don't Over-Engineer Simple Things** +```typescript +// ❌ Bad - too complex for simple task +const concatenateStrings = (strings: string[]): string => + strings.reduce((accumulator, current, index) => + accumulator + (index > 0 ? ' ' : '') + current, + ''); + +// ✅ Good - simple and clear +const concatenateStrings = (strings: string[]) => strings.join(' '); +``` + +### **4. Don't Create Unnecessary Interfaces** +```typescript +// ❌ Bad - only one implementation +interface IUserService { + getUser(id: string): Promise; +} +class UserService implements IUserService { + getUser(id: string): Promise { /* ... */ } +} + +// ✅ Good - add interface only when needed (2+ implementations) +class UserService { + getUser(id: string): Promise { /* ... */ } +} +``` + +### **5. Don't Ignore TypeScript Errors** +```typescript +// ❌ Bad - using any everywhere +const processData = (data: any) => { /* ... */ }; + +// ❌ Bad - using @ts-ignore without comment +// @ts-ignore +const result = someFunction(); + +// ✅ Good - proper types +const processData = (data: User[]) => { /* ... */ }; + +// ✅ Good - @ts-ignore with explanation +// @ts-ignore - third-party library has incorrect types, see issue #123 +const result = someFunction(); +``` + +--- + +## 🔧 Common Patterns + +### **Async State Management** +```typescript +// Use this pattern consistently +const { data, loading, error, execute } = useAsyncOperation(); + +useEffect(() => { + execute( + () => userService.getAll(), + { + successMessage: 'Users loaded', + errorMessage: 'Failed to load users' + } + ); +}, []); +``` + +### **Service Layer** +```typescript +// Always go through services, not direct DB access +// ❌ Bad +const { data } = await supabase.from('users').select('*'); + +// ✅ Good +const users = await userService.getAll(); +``` + +### **Error Handling** +```typescript +// Consistent error handling +try { + await operation(); + notifications.success('Operation successful'); +} catch (error) { + logger.error('Operation failed', error); + notifications.error('Operation failed'); + throw error; // Re-throw if caller needs to handle +} +``` + +### **Component Structure** +```typescript +// Consistent component organization +const MyComponent = ({ prop1, prop2 }: Props) => { + // 1. Hooks (order: useState, useRef, useEffect, custom hooks) + const [state, setState] = useState(); + const ref = useRef(); + const { data, loading } = useCustomHook(); + + // 2. Event handlers + const handleClick = () => { /* ... */ }; + + // 3. Computed values + const displayText = useMemo(() => /* ... */, []); + + // 4. Effects + useEffect(() => { /* ... */ }, []); + + // 5. Render + return
...
; +}; +``` + +--- + +## 📏 File Size Guidelines + +``` +0-150 lines → ✅ Perfect +150-300 lines → ✅ Good +300-500 lines → 🟡 Consider splitting +500-800 lines → 🟠 Should split +800+ lines → 🔴 Must split +``` + +### When a file hits 300+ lines, ask: +1. Can I extract components? +2. Can I extract hooks? +3. Can I extract utilities? +4. Does it have a single responsibility? + +--- + +## 🧪 Testing Principles + +### **What to Test** +✅ Business logic +✅ Edge cases +✅ Error handling +✅ User interactions +✅ State changes + +### **What NOT to Test** +❌ Implementation details +❌ Third-party libraries +❌ CSS/styling +❌ Mocks themselves + +### **Test Structure** +```typescript +describe('Feature', () => { + // Setup + beforeEach(() => { /* ... */ }); + + // Happy path + it('should work in normal case', () => { /* ... */ }); + + // Edge cases + it('should handle empty input', () => { /* ... */ }); + it('should handle invalid input', () => { /* ... */ }); + + // Error cases + it('should handle API errors', () => { /* ... */ }); +}); +``` + +--- + +## 🎨 Code Review Checklist + +Before submitting PR, check: + +- [ ] No files over 500 lines +- [ ] No duplicate code (follow DRY) +- [ ] Functions are small (<50 lines) +- [ ] Clear function/variable names +- [ ] Types are explicit (no `any`) +- [ ] Error handling is consistent +- [ ] Tests are included +- [ ] No console.logs (use logger) +- [ ] No commented-out code +- [ ] Follows existing patterns + +--- + +## 🚀 Performance Tips + +### **React Performance** +```typescript +// ✅ Memoize expensive computations +const expensiveValue = useMemo(() => + complexCalculation(data), + [data] +); + +// ✅ Memoize callbacks passed to children +const handleClick = useCallback(() => { + doSomething(); +}, []); + +// ✅ Split large lists with virtualization +import { VirtualList } from '@/components/VirtualList'; + +// ❌ Don't memoize everything +const simpleValue = useMemo(() => a + b, [a, b]); // Overkill! +``` + +### **Bundle Size** +```typescript +// ✅ Lazy load routes +const Dashboard = lazy(() => import('./pages/Dashboard')); + +// ✅ Import only what you need +import { Button } from '@/components/ui/button'; + +// ❌ Don't import entire libraries +import _ from 'lodash'; // Imports everything! +import { debounce } from 'lodash'; // Better, but still large +import debounce from 'lodash/debounce'; // Best! +``` + +--- + +## 📚 Quick Links + +- [Full Refactoring Guide](./REFACTORING_GUIDE.md) +- [Type Safety Plan](./TYPE_SAFETY_IMPLEMENTATION_PLAN.md) +- [Testing Guide](../tests/README.md) + +--- + +**Remember**: +- **Pragmatic > Perfect** +- **Simple > Clever** +- **Working > Ideal** +- **Readable > Compact** + +_"Any fool can write code that a computer can understand. Good programmers write code that humans can understand."_ - Martin Fowler diff --git a/docs/CODING_PATTERNS.md b/docs/CODING_PATTERNS.md new file mode 100644 index 0000000..fce23f1 --- /dev/null +++ b/docs/CODING_PATTERNS.md @@ -0,0 +1,646 @@ +# 📋 Coding Patterns Guide + +> **Quick Reference**: How to write code in this codebase + +This guide shows you **EXACTLY** what to use for common coding tasks. Follow these patterns to keep the codebase consistent and maintainable. + +--- + +## 🎯 Quick Decision Tree + +``` +Need to do async operation? → Use useAsyncOperation +Need to manage async state? → Use useAsyncState +Need to show notification? → Use notifications service +Need to query database? → Use query builders +Need to add new feature? → Follow patterns below +``` + +--- + +## 📊 State Management + +### ✅ DO: Use `useAsyncState` for Simple Async State + +**When**: You need data, loading, and error state for async operations + +**Pattern**: +```typescript +import { useAsyncState } from '@/shared/hooks/useAsyncState'; + +const { data, loading, error, setData, setLoading, setError, reset } = + useAsyncState(); + +// Manual control +const fetchUsers = async () => { + setLoading(true); + try { + const users = await userService.getAll(); + setData(users); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } +}; +``` + +**Location**: `src/shared/hooks/useAsyncState.ts` + +--- + +### ✅ DO: Use `useAsyncOperation` for Async Operations with Error Handling + +**When**: You need automatic error handling, loading states, and notifications + +**Pattern**: +```typescript +import { useAsyncOperation } from '@/shared/hooks/useAsyncOperation'; + +const { data, loading, error, execute } = useAsyncOperation(); + +// Automatic error handling + notifications +const loadUsers = async () => { + await execute( + userService.getAll, + { + successMessage: 'Users loaded successfully', + errorMessage: 'Failed to load users', + onSuccess: (users) => { + // Optional callback after success + console.log(`Loaded ${users.length} users`); + } + } + ); +}; + +// With parameters +const createUser = async (name: string, email: string) => { + await execute( + userService.create, + name, + email, + { + successMessage: 'User created!', + onSuccess: (user) => navigate(`/users/${user.id}`) + } + ); +}; +``` + +**Location**: `src/shared/hooks/useAsyncOperation.ts` + +**Why**: Consistent error handling, automatic loading states, built-in notifications + +--- + +### ❌ DON'T: Create Custom State Management for Async + +```typescript +// ❌ BAD - Duplicate async state pattern +const [data, setData] = useState(null); +const [loading, setLoading] = useState(false); +const [error, setError] = useState(null); + +const fetchData = async () => { + setLoading(true); + try { + const result = await api.get('/data'); + setData(result); + } catch (err) { + setError(err); + toast.error('Failed'); + } finally { + setLoading(false); + } +}; + +// ✅ GOOD - Use useAsyncOperation +const { data, loading, error, execute } = useAsyncOperation(); + +const fetchData = async () => { + await execute( + () => api.get('/data'), + { successMessage: 'Loaded!', errorMessage: 'Failed' } + ); +}; +``` + +--- + +## 🔔 Notifications + +### ✅ DO: Use `notifications` Service for All Toasts + +**Pattern**: +```typescript +import { notifications } from '@/shared/services/notificationService'; + +// Success notification +notifications.success('Data saved successfully'); + +// Error notification +notifications.error('Failed to save data'); +notifications.error(error); // Handles Error objects + +// Info notification +notifications.info('Processing will take a few minutes'); + +// Warning notification +notifications.warning('This action cannot be undone'); + +// Loading notification (dismissible) +const loadingId = notifications.loading('Uploading file...'); +// Later: toast.dismiss(loadingId); + +// Promise-based notification (auto-handles loading/success/error) +await notifications.promise( + uploadFile(), + { + loading: 'Uploading...', + success: 'File uploaded!', + error: 'Upload failed' + } +); + +// Custom duration +notifications.success('Saved!', { duration: 2000 }); +``` + +**Location**: `src/shared/services/notificationService.ts` + +**Default Durations**: +- Success: 4 seconds +- Error: 6 seconds +- Info: 4 seconds +- Warning: 5 seconds +- Loading: Infinity (manual dismiss) + +--- + +### ❌ DON'T: Use Toast Directly + +```typescript +// ❌ BAD - Direct toast usage +import { toast } from 'sonner'; +toast.success('Saved'); +toast.error('Failed', { duration: 5000 }); + +// ✅ GOOD - Use notifications service +import { notifications } from '@/shared/services/notificationService'; +notifications.success('Saved'); +notifications.error('Failed', { duration: 5000 }); +``` + +**Why**: Centralized control, consistent durations, easier to mock in tests + +--- + +## 🗄️ Database Queries + +### ✅ DO: Use Query Builders for Supabase + +**Pattern**: +```typescript +import { problemQueries } from '@/shared/queries/problemQueries'; + +// Get all problems +const { data, error } = await problemQueries.getAll(); + +// Get by ID +const { data } = await problemQueries.getById('two-sum'); + +// Get by difficulty +const { data } = await problemQueries.getByDifficulty('Easy'); + +// Get by category +const { data } = await problemQueries.getByCategory('Arrays'); + +// Search +const { data } = await problemQueries.searchByTitle('two'); + +// Chain with additional filters +const { data } = await problemQueries + .getByDifficulty('Easy') + .limit(10); +``` + +**Location**: `src/shared/queries/problemQueries.ts` + +--- + +### ❌ DON'T: Write Raw Supabase Queries Everywhere + +```typescript +// ❌ BAD - Raw query scattered everywhere +const { data } = await supabase + .from('problems') + .select('*') + .eq('difficulty', 'Easy') + .order('created_at', { ascending: false }); + +// ✅ GOOD - Use query builder +const { data } = await problemQueries.getByDifficulty('Easy'); +``` + +**Why**: DRY principle, easier to test, single source of truth for queries + +--- + +## 🎨 Component Patterns + +### ✅ DO: Keep Components Under 300 Lines + +**Pattern**: +```typescript +// Break large components into smaller ones +const UserProfile = () => ( + <> + + + + + +); +``` + +**File Size Guidelines**: +- ✅ 0-150 lines: Perfect +- ✅ 150-300 lines: Good +- 🟡 300-500 lines: Consider splitting +- 🔴 500+ lines: Must split + +--- + +### ✅ DO: Extract Repeated Logic into Custom Hooks + +**When**: Logic is used 3+ times + +**Pattern**: +```typescript +// ❌ BAD - Repeated in multiple components +const [user, setUser] = useState(null); +useEffect(() => { + const { data } = await supabase.auth.getUser(); + setUser(data.user); +}, []); + +// ✅ GOOD - Extract to custom hook +const useCurrentUser = () => { + const [user, setUser] = useState(null); + useEffect(() => { + const fetchUser = async () => { + const { data } = await supabase.auth.getUser(); + setUser(data.user); + }; + fetchUser(); + }, []); + return user; +}; + +// Usage +const user = useCurrentUser(); +``` + +**Rule of Three**: Extract after **3rd repetition**, not before + +--- + +## 🔄 Data Fetching + +### ✅ DO: Combine useAsyncOperation with Query Builders + +**Pattern**: +```typescript +import { useAsyncOperation } from '@/shared/hooks/useAsyncOperation'; +import { problemQueries } from '@/shared/queries/problemQueries'; + +const ProblemsPage = () => { + const { data: problems, loading, error, execute } = useAsyncOperation(); + + useEffect(() => { + execute( + async () => { + const { data, error } = await problemQueries.getAll(); + if (error) throw error; + return data; + }, + { + errorMessage: 'Failed to load problems' + } + ); + }, []); + + if (loading) return ; + if (error) return ; + if (!problems) return null; + + return ; +}; +``` + +--- + +### ✅ DO: Use Optimistic Updates for Better UX + +**Pattern**: +```typescript +const { data, setData, execute } = useAsyncOperation(); + +const addTodo = async (text: string) => { + // Optimistic update + const tempTodo = { id: 'temp', text, completed: false }; + setData([...(data || []), tempTodo]); + + // Actual API call + await execute( + async () => { + const { data: newTodo } = await todoQueries.create(text); + return [...(data || []), newTodo]; + }, + { + successMessage: 'Todo added!', + onError: () => { + // Revert on error + setData(data); + } + } + ); +}; +``` + +--- + +## 🧪 Testing Patterns + +### ✅ DO: Write Tests for Business Logic + +**What to Test**: +- ✅ Business logic +- ✅ Edge cases +- ✅ Error handling +- ✅ User interactions + +**What NOT to Test**: +- ❌ Implementation details +- ❌ Third-party libraries +- ❌ Mocks themselves + +**Pattern**: +```typescript +describe('useAsyncOperation', () => { + it('should handle success with notification', async () => { + const { result } = renderHook(() => useAsyncOperation()); + + await act(async () => { + await result.current.execute( + () => Promise.resolve('data'), + { successMessage: 'Success!' } + ); + }); + + expect(result.current.data).toBe('data'); + expect(toast.success).toHaveBeenCalledWith('Success!'); + }); +}); +``` + +--- + +## 📁 File Organization + +### ✅ DO: Use Feature-Based Structure for New Features + +**Pattern**: +``` +src/ + features/ + flashcards/ + components/ + FlashcardReviewInterface.tsx + FlashcardCard.tsx + hooks/ + useFlashcards.ts + useFlashcardProgress.ts + services/ + flashcardService.ts + __tests__/ + flashcards.test.ts + coaching/ + components/ + hooks/ + services/ +``` + +**Benefits**: Related code stays together, easier to find, easier to test + +--- + +## 🚨 Error Handling + +### ✅ DO: Use Try-Catch with useAsyncOperation + +**Pattern**: +```typescript +const { execute } = useAsyncOperation(); + +const handleSubmit = async (data: FormData) => { + await execute( + async () => { + // Validate + if (!data.email) throw new Error('Email is required'); + + // Submit + const result = await submitForm(data); + return result; + }, + { + successMessage: 'Form submitted!', + errorMessage: 'Submission failed', + onSuccess: () => navigate('/success'), + onError: (err) => { + // Custom error handling + if (err.message.includes('email')) { + setEmailError(err.message); + } + } + } + ); +}; +``` + +--- + +### ✅ DO: Convert Unknown Errors to Error Objects + +**Pattern**: +```typescript +try { + await operation(); +} catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + notifications.error(error); +} +``` + +--- + +## 🎯 Performance + +### ✅ DO: Memoize Expensive Computations + +**Pattern**: +```typescript +const expensiveValue = useMemo(() => { + return computeExpensiveValue(data); +}, [data]); + +const handleClick = useCallback(() => { + doSomething(); +}, []); +``` + +**When**: Computation is expensive OR callback is passed to child components + +--- + +### ❌ DON'T: Memoize Everything + +```typescript +// ❌ BAD - Unnecessary memoization +const sum = useMemo(() => a + b, [a, b]); + +// ✅ GOOD - Simple calculation, no memoization needed +const sum = a + b; +``` + +--- + +## 📝 Code Style + +### ✅ DO: Use Descriptive Names + +```typescript +// ✅ GOOD +const isValidEmail = (email: string) => /regex/.test(email); +const activateUserAccount = (userId: string) => { /* ... */ }; + +// ❌ BAD +const check = (e: string) => /regex/.test(e); +const doStuff = (id: string) => { /* ... */ }; +``` + +--- + +### ✅ DO: Comment the "Why", Not the "What" + +```typescript +// ❌ BAD - Obvious +// Set loading to true +setLoading(true); + +// ✅ GOOD - Explains business logic +// Delay vim mode init to avoid race condition with Monaco editor mounting +setTimeout(() => loadVimMode(), 500); +``` + +--- + +## 🔧 Common Patterns Cheat Sheet + +| Task | Pattern | Import From | +|------|---------|-------------| +| Async operation with error handling | `useAsyncOperation` | `@/shared/hooks/useAsyncOperation` | +| Simple async state | `useAsyncState` | `@/shared/hooks/useAsyncState` | +| Show notification | `notifications.success()` | `@/shared/services/notificationService` | +| Query problems | `problemQueries.getAll()` | `@/shared/queries/problemQueries` | +| Handle form submission | `useAsyncOperation` + `execute` | `@/shared/hooks/useAsyncOperation` | +| Loading state | Automatic in `useAsyncOperation` | - | +| Error state | Automatic in `useAsyncOperation` | - | + +--- + +## ✨ Real-World Examples + +### Example 1: Fetching and Displaying Data + +```typescript +import { useAsyncOperation } from '@/shared/hooks/useAsyncOperation'; +import { problemQueries } from '@/shared/queries/problemQueries'; + +const ProblemsPage = () => { + const { data, loading, execute } = useAsyncOperation(); + + useEffect(() => { + execute( + async () => { + const { data, error } = await problemQueries.getByDifficulty('Easy'); + if (error) throw error; + return data; + }, + { errorMessage: 'Failed to load problems' } + ); + }, []); + + if (loading) return ; + return ; +}; +``` + +### Example 2: Form Submission + +```typescript +import { useAsyncOperation } from '@/shared/hooks/useAsyncOperation'; +import { notifications } from '@/shared/services/notificationService'; + +const CreateProblemForm = () => { + const { loading, execute } = useAsyncOperation(); + + const handleSubmit = async (formData: ProblemInput) => { + await execute( + () => problemService.create(formData), + { + successMessage: 'Problem created successfully!', + errorMessage: 'Failed to create problem', + onSuccess: (problem) => { + navigate(`/problems/${problem.id}`); + } + } + ); + }; + + return
; +}; +``` + +### Example 3: Optimistic Update + +```typescript +const { data: todos, setData, execute } = useAsyncOperation(); + +const toggleTodo = async (id: string) => { + // Optimistic update + const updated = todos?.map(t => + t.id === id ? { ...t, completed: !t.completed } : t + ); + setData(updated || []); + + // Actual update + await execute( + () => todoService.toggle(id), + { + onError: () => setData(todos || []) // Revert on error + } + ); +}; +``` + +--- + +## 🎓 Learning More + +- [Code Principles](./CODE_PRINCIPLES.md) - DRY, KISS, SOLID guidelines +- [Refactoring Guide](./REFACTORING_GUIDE.md) - Comprehensive refactoring plan +- [Testing Guide](../tests/README.md) - Testing strategy and patterns + +--- + +**Remember**: Consistency > Cleverness. Follow these patterns and the codebase stays maintainable! 🚀 diff --git a/docs/CODING_STANDARDS.md b/docs/CODING_STANDARDS.md new file mode 100644 index 0000000..cfe15af --- /dev/null +++ b/docs/CODING_STANDARDS.md @@ -0,0 +1,825 @@ +# 📐 SimplyAlgo Coding Standards + +> **Version**: 1.0 +> **Last Updated**: December 2025 +> **Status**: Active + +This document defines the coding standards, patterns, and best practices for the SimplyAlgo codebase. **All new code should follow these guidelines.** + +--- + +## 📖 Table of Contents + +1. [Project Architecture](#project-architecture) +2. [File Structure](#file-structure) +3. [TypeScript Standards](#typescript-standards) +4. [React Patterns](#react-patterns) +5. [State Management](#state-management) +6. [Error Handling](#error-handling) +7. [Logging](#logging) +8. [Database Access](#database-access) +9. [Testing](#testing) +10. [Analytics](#analytics) +11. [Available Utilities](#available-utilities) +12. [Component Guidelines](#component-guidelines) +13. [Performance](#performance) +14. [Security](#security) + +--- + +## 🏗️ Project Architecture + +### Feature-Based Structure + +We use a **feature-based** folder structure. Related code lives together. + +``` +src/ +├── features/ # Feature modules (primary code location) +│ ├── problems/ +│ │ ├── components/ # Feature-specific components +│ │ ├── hooks/ # Feature-specific hooks +│ │ ├── types.ts # Feature types +│ │ └── ProblemSolverPage.tsx +│ ├── behavioral/ +│ ├── flashcards/ +│ └── admin/ +│ +├── shared/ # Shared across features +│ ├── hooks/ # Reusable hooks (useAsyncState, etc.) +│ ├── services/ # Notification service, etc. +│ └── queries/ # Supabase query builders +│ +├── services/ # Data services (ProblemService, etc.) +│ +├── components/ # Shared UI components +│ ├── ui/ # shadcn/ui components +│ ├── chat/ # Chat components +│ └── flashcards/ # Flashcard components +│ +├── hooks/ # Global hooks (useAuth, useSubscription) +│ +├── utils/ # Pure utility functions +│ ├── logger.ts # Logging utility +│ ├── uiUtils.ts # UI utilities (getDifficultyColor, etc.) +│ └── code.ts # Code utilities +│ +├── types/ # Global TypeScript types +│ +└── integrations/ # Third-party integrations + └── supabase/ +``` + +### When to Create a New Feature Folder + +✅ **DO** create a new feature folder when: +- Building a new user-facing feature (e.g., `/flashcards`, `/behavioral`) +- Feature has 3+ components or 2+ hooks +- Feature is self-contained and could theoretically be deleted without breaking others + +❌ **DON'T** create a feature folder for: +- Single utility functions (put in `utils/`) +- Single shared components (put in `components/`) +- Configuration files + +--- + +## 📁 File Structure + +### Component File Structure + +```typescript +// 1. Imports (grouped) +import { useState, useEffect } from "react"; // React +import { Button } from "@/components/ui/button"; // UI components +import { useAuth } from "@/hooks/useAuth"; // Hooks +import { ProblemService } from "@/services/problemService"; // Services +import { logger } from "@/utils/logger"; // Utils +import type { Problem } from "./types"; // Types (last) + +// 2. Types (if not in separate file) +interface ComponentProps { + problemId: string; + onComplete: () => void; +} + +// 3. Constants +const CACHE_TIME = 5 * 60 * 1000; // 5 minutes + +// 4. Component +export const MyComponent = ({ problemId, onComplete }: ComponentProps) => { + // ... implementation +}; +``` + +### Naming Conventions + +| Type | Convention | Example | +|------|------------|---------| +| Components | PascalCase | `ProblemCard.tsx` | +| Hooks | camelCase with `use` prefix | `useProblems.ts` | +| Utils | camelCase | `formatDate.ts` | +| Types | PascalCase | `Problem`, `UserSession` | +| Services | PascalCase with `Service` suffix | `ProblemService.ts` | +| Test files | Same name + `.test.ts` | `useProblems.test.ts` | +| Constants | SCREAMING_SNAKE_CASE | `MAX_RETRIES` | + +--- + +## 🔷 TypeScript Standards + +### Strict Type Safety + +```typescript +// ✅ DO: Explicit types for function parameters and returns +const fetchProblem = async (id: string): Promise => { + // ... +}; + +// ❌ DON'T: Implicit `any` or loose typing +const fetchProblem = async (id) => { + // ... +}; +``` + +### Type vs Interface + +```typescript +// Use `interface` for object shapes that may be extended +interface User { + id: string; + name: string; +} + +// Use `type` for unions, intersections, or computed types +type Difficulty = "Easy" | "Medium" | "Hard"; +type UserWithRole = User & { role: string }; +``` + +### Avoid `any` + +```typescript +// ✅ DO: Use `unknown` and narrow the type +const handleError = (error: unknown) => { + if (error instanceof Error) { + logger.error("Error:", error); + } +}; + +// ❌ DON'T: Use `any` +const handleError = (error: any) => { + console.log(error.message); // Unsafe +}; +``` + +### Use Type Imports + +```typescript +// ✅ DO: Use `type` import for types +import type { Problem } from "@/types"; + +// ❌ DON'T: Mix runtime and type imports +import { Problem } from "@/types"; // If Problem is only a type +``` + +--- + +## ⚛️ React Patterns + +### Functional Components Only + +```typescript +// ✅ DO: Functional component with explicit props +export const ProblemCard = ({ problem, onSelect }: ProblemCardProps) => { + return
onSelect(problem.id)}>{problem.title}
; +}; + +// ❌ DON'T: Class components +class ProblemCard extends React.Component { ... } +``` + +### Custom Hooks for Logic + +Extract complex logic into custom hooks: + +```typescript +// ✅ DO: Custom hook for data fetching +const useProblemData = (problemId: string) => { + const [problem, setProblem] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + ProblemService.getById(problemId) + .then(setProblem) + .finally(() => setLoading(false)); + }, [problemId]); + + return { problem, loading }; +}; + +// ❌ DON'T: All logic in component +const ProblemPage = () => { + // 200 lines of fetching, processing, etc. +}; +``` + +### Component Size Limits + +| Size | Action | +|------|--------| +| < 200 lines | ✅ Good | +| 200-400 lines | ⚠️ Consider splitting | +| > 400 lines | ❌ Must split | + +--- + +## 🔄 State Management + +### For Async Operations: Use `useAsyncState` + +```typescript +import { useAsyncState } from "@/shared/hooks/useAsyncState"; + +const MyComponent = () => { + const { data, loading, error, setData, setLoading, setError, reset } = + useAsyncState(); + + const fetchData = async () => { + setLoading(true); + try { + const problems = await ProblemService.getAll(); + setData(problems); // Automatically clears loading and error + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } + }; + + // ... +}; +``` + +### For Server Data: Use React Query + +```typescript +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +const useProblems = () => { + return useQuery({ + queryKey: ["problems"], + queryFn: () => ProblemService.getAll(), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; +``` + +### State Location Guide + +| State Type | Location | +|------------|----------| +| UI state (modals, toggles) | `useState` in component | +| Form state | `useState` or React Hook Form | +| Server data | React Query | +| Async operation state | `useAsyncState` | +| Global app state | Context (sparingly) | + +--- + +## ⚠️ Error Handling + +### Use `getErrorMessage` Utility + +```typescript +import { getErrorMessage } from "@/utils/uiUtils"; + +// ✅ DO: Use utility for consistent error extraction +try { + await someAsyncOperation(); +} catch (err) { + const message = getErrorMessage(err, "Operation failed"); + setError(message); + notifications.error(message); +} + +// ❌ DON'T: Manual error checking +try { + await someAsyncOperation(); +} catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + // ... +} +``` + +### Error Types + +```typescript +// For user-facing errors, use string messages +setError("Failed to load problem"); + +// For logging, preserve the full error object +logger.error("[Component] Operation failed", error); +``` + +### Try-Catch Pattern + +```typescript +const handleSubmit = async () => { + setLoading(true); + try { + await ProblemService.submit(data); + notifications.success("Submitted successfully!"); + onClose(); + } catch (err) { + logger.error("[Submit] Failed", err); + notifications.error(getErrorMessage(err, "Failed to submit")); + } finally { + setLoading(false); + } +}; +``` + +--- + +## 📝 Logging + +### Use the `logger` Utility + +```typescript +import { logger } from "@/utils/logger"; + +// ✅ DO: Use logger with component context +logger.debug("[ProblemSolver] Loaded problem", { problemId }); +logger.info("[Auth] User logged in", { userId }); +logger.warn("[Payment] Retry attempt", { attempt: 2 }); +logger.error("[API] Request failed", error, { endpoint: "/problems" }); + +// ❌ DON'T: Use console directly +console.log("Loaded problem", problemId); +console.error("Error:", error); +``` + +### Log Levels + +| Level | When to Use | +|-------|-------------| +| `debug` | Development debugging, state changes | +| `info` | Important events (login, purchases) | +| `warn` | Recoverable issues, deprecation warnings | +| `error` | Errors that affect functionality | + +### Log Format + +Always include component context in brackets: + +```typescript +logger.debug("[ComponentName] Action description", { relevantData }); +``` + +--- + +## 🗄️ Database Access + +### Use Service Classes + +```typescript +// ✅ DO: Use service for database access +import { ProblemService } from "@/services/problemService"; + +const problems = await ProblemService.getAllWithRelations(); +const problem = await ProblemService.getById(id); +await ProblemService.toggleStar(userId, problemId, isStarred); + +// ❌ DON'T: Direct Supabase calls in components +import { supabase } from "@/integrations/supabase/client"; + +const { data } = await supabase.from("problems").select("*"); +``` + +### Available Services + +| Service | Purpose | +|---------|---------| +| `ProblemService` | Problems, categories, stars, attempts | +| `UserAttemptsService` | Submissions, drafts, progress | + +### Creating New Services + +```typescript +// src/services/flashcardService.ts +import { supabase } from "@/integrations/supabase/client"; +import { logger } from "@/utils/logger"; + +export class FlashcardService { + static async getDueCards(userId: string): Promise { + const { data, error } = await supabase + .from("flashcard_decks") + .select("*") + .eq("user_id", userId) + .lte("next_review_date", new Date().toISOString()); + + if (error) { + logger.error("[FlashcardService] Error fetching due cards", { userId, error }); + throw error; + } + + return data ?? []; + } +} +``` + +--- + +## 🧪 Testing + +### Test File Location + +``` +src/ +├── features/ +│ └── problems/ +│ ├── hooks/ +│ │ ├── useProblems.ts +│ │ └── __tests__/ +│ │ └── useProblems.test.ts +└── services/ + ├── problemService.ts + └── __tests__/ + └── problemService.test.ts +``` + +### Test Structure + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; + +describe("useProblems", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("fetching problems", () => { + it("should return problems for authenticated user", async () => { + // Arrange + const mockProblems = [{ id: "1", title: "Two Sum" }]; + vi.mocked(ProblemService.getAll).mockResolvedValue(mockProblems); + + // Act + const { result } = renderHook(() => useProblems("user-123")); + + // Assert + await waitFor(() => { + expect(result.current.problems).toEqual(mockProblems); + }); + }); + + it("should handle errors gracefully", async () => { + // ... + }); + }); +}); +``` + +### What to Test + +| Type | Coverage Target | +|------|-----------------| +| Services | 90%+ (all methods) | +| Hooks | 80%+ (main paths + edge cases) | +| Utils | 100% (pure functions) | +| Components | 60%+ (user interactions) | + +### Mocking Patterns + +```typescript +// Mocking Supabase +vi.mock("@/integrations/supabase/client", () => ({ + supabase: { + from: vi.fn(), + }, +})); + +// Mocking services +vi.mock("@/services/problemService", () => ({ + ProblemService: { + getAll: vi.fn(), + getById: vi.fn(), + }, +})); + +// Mocking logger (to prevent console noise) +vi.mock("@/utils/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); +``` + +### Running Tests + +```bash +# Run all tests +npm test + +# Run in watch mode +npm test -- --watch + +# Run specific file +npm test -- --run src/services/__tests__/problemService.test.ts + +# Run with coverage +npm test -- --coverage +``` + +--- + +## 📊 Analytics + +### PostHog Integration + +Track all meaningful user interactions: + +```typescript +import { analytics } from "@/services/analytics"; + +// Track events +analytics.track("problem_started", { + problemId: "two-sum", + difficulty: "Easy", + source: "problem_list", +}); + +analytics.track("solution_submitted", { + problemId: "two-sum", + passed: true, + timeSpent: 120, // seconds +}); + +analytics.track("flashcard_reviewed", { + deckId: "deck-123", + rating: 3, + timeSpent: 45, +}); +``` + +### When to Add Analytics + +✅ **MUST track:** +- Feature usage (starting a problem, completing a review) +- Conversion events (signup, subscription, upgrade) +- Errors that affect user experience +- Performance metrics (load times, API latency) + +⚠️ **SHOULD track:** +- Navigation patterns +- Feature engagement (time spent, interactions) +- A/B test variants + +❌ **DON'T track:** +- Every click or scroll +- Sensitive user data +- Debug/development events + +### Event Naming Convention + +``` +{noun}_{past_tense_verb} + +Examples: +- problem_started +- solution_submitted +- flashcard_reviewed +- subscription_purchased +- coaching_session_completed +``` + +### Adding Analytics to New Features + +```typescript +// In your component or hook +import { analytics } from "@/services/analytics"; + +const handleComplete = () => { + // 1. Perform the action + await submitSolution(code); + + // 2. Track the event + analytics.track("solution_submitted", { + problemId, + difficulty: problem.difficulty, + passed: result.passed, + timeSpent: Math.round((Date.now() - startTime) / 1000), + }); + + // 3. Show feedback + notifications.success("Solution submitted!"); +}; +``` + +--- + +## 🧰 Available Utilities + +### UI Utilities (`@/utils/uiUtils`) + +```typescript +import { getDifficultyColor, getErrorMessage, formatRelativeTime } from "@/utils/uiUtils"; + +// Difficulty badge colors +const colorClass = getDifficultyColor("Hard"); // "bg-red-500/10 text-red-500" + +// Error message extraction +const message = getErrorMessage(error, "Default message"); + +// Relative time formatting +const timeAgo = formatRelativeTime(new Date()); // "just now" +``` + +### Code Utilities (`@/utils/code`) + +```typescript +import { normalizeCode, formatCode, extractFunctionName } from "@/utils/code"; + +const normalized = normalizeCode(userCode); // Removes whitespace, comments +``` + +### Logger (`@/utils/logger`) + +```typescript +import { logger } from "@/utils/logger"; + +logger.debug("[Component] Message", { data }); +logger.info("[Component] Message", { data }); +logger.warn("[Component] Message", { data }); +logger.error("[Component] Message", error, { data }); +``` + +### Notifications (`@/shared/services/notificationService`) + +```typescript +import { notifications } from "@/shared/services/notificationService"; + +notifications.success("Action completed!"); +notifications.error("Something went wrong"); +notifications.info("Helpful information"); +notifications.loading("Processing..."); + +// Domain-specific +notifications.codeSaved(); +notifications.testsPassed(); +notifications.apiError("save the problem"); +``` + +--- + +## 🎨 Component Guidelines + +### Component Template + +```typescript +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { logger } from "@/utils/logger"; +import { notifications } from "@/shared/services/notificationService"; +import { getErrorMessage } from "@/utils/uiUtils"; +import type { MyComponentProps } from "./types"; + +/** + * Brief description of what this component does. + */ +export const MyComponent = ({ prop1, prop2, onComplete }: MyComponentProps) => { + const [loading, setLoading] = useState(false); + + const handleAction = async () => { + setLoading(true); + try { + await someAction(); + logger.info("[MyComponent] Action completed", { prop1 }); + notifications.success("Done!"); + onComplete(); + } catch (err) { + logger.error("[MyComponent] Action failed", err); + notifications.error(getErrorMessage(err, "Action failed")); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+ ); +}; +``` + +### Splitting Large Components + +When a component exceeds 400 lines, split it: + +```typescript +// Before: MonolithicComponent.tsx (800 lines) + +// After: +// MonolithicComponent.tsx (200 lines) - orchestrator +// components/Header.tsx (100 lines) +// components/Content.tsx (200 lines) +// components/Footer.tsx (100 lines) +// hooks/useComponentLogic.ts (150 lines) +// types.ts (50 lines) +``` + +--- + +## ⚡ Performance + +### Memoization + +```typescript +// ✅ DO: Memoize expensive computations +const sortedProblems = useMemo( + () => problems.sort((a, b) => a.title.localeCompare(b.title)), + [problems] +); + +// ✅ DO: Memoize callbacks passed to child components +const handleSelect = useCallback( + (id: string) => onSelect(id), + [onSelect] +); + +// ❌ DON'T: Memoize everything +const simpleValue = useMemo(() => props.count + 1, [props.count]); // Overkill +``` + +### Lazy Loading + +```typescript +// ✅ DO: Lazy load large components +const AdminDashboard = lazy(() => import("@/features/admin/AdminDashboard")); + +// Use with Suspense +}> + + +``` + +--- + +## 🔒 Security + +### Never Trust Client Input + +```typescript +// ✅ DO: Validate and sanitize on the server +// ✅ DO: Use parameterized queries (Supabase handles this) + +// ❌ DON'T: Trust user input for sensitive operations +// ❌ DON'T: Store sensitive data in localStorage +// ❌ DON'T: Expose API keys in client code +``` + +### Authentication + +```typescript +// Always check auth status before sensitive operations +const { user, isAuthenticated } = useAuth(); + +if (!isAuthenticated) { + return ; +} +``` + +--- + +## ✅ Checklist for New Features + +Before submitting code for a new feature, verify: + +- [ ] **Types**: All functions have explicit types, no `any` +- [ ] **Error Handling**: Uses `getErrorMessage()`, shows user feedback +- [ ] **Logging**: Uses `logger` utility, not `console` +- [ ] **Tests**: Unit tests for hooks/services, minimum 80% coverage +- [ ] **Analytics**: Key events tracked with PostHog +- [ ] **Component Size**: No file exceeds 400 lines +- [ ] **Service Layer**: Database access through services, not direct Supabase +- [ ] **Notifications**: Uses `notifications` service for user feedback +- [ ] **Accessibility**: Proper aria labels, keyboard navigation +- [ ] **Performance**: Memoized where needed, lazy loaded if large +- [ ] **Documentation**: JSDoc comments for public functions + +--- + +## 📚 Related Documentation + +- [REFACTORING_GUIDE.md](./REFACTORING_GUIDE.md) - Codebase improvement plan +- [TYPE_SAFETY_IMPLEMENTATION_PLAN.md](./TYPE_SAFETY_IMPLEMENTATION_PLAN.md) - TypeScript guidelines +- [tests/README.md](../tests/README.md) - Testing documentation + +--- + +**Remember: Good code is code that others (including future you) can understand and maintain.** 🚀 diff --git a/eslint.config.js b/eslint.config.js index 8ea2214..88ea3e0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist"] }, + { ignores: ["dist", "coverage"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], @@ -32,4 +32,12 @@ export default tseslint.config( "react-hooks/exhaustive-deps": "warn", }, }, + // Disable react-hooks rules for test files (Playwright fixtures use "use" function) + { + files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}", "tests/**/*.{ts,tsx}"], + rules: { + "react-hooks/rules-of-hooks": "off", + "react-hooks/exhaustive-deps": "off", + }, + }, ); diff --git a/package-lock.json b/package-lock.json index b641063..47d63f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "mermaid": "^10.9.1", "monaco-vim": "^0.4.2", "next-themes": "^0.3.0", + "posthog-js": "^1.309.1", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -86,6 +87,7 @@ }, "devDependencies": { "@eslint/js": "^9.9.0", + "@playwright/test": "^1.57.0", "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", @@ -93,6 +95,7 @@ "@types/node": "^22.5.5", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react-swc": "^3.5.0", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", @@ -1232,12 +1235,37 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true }, + "node_modules/@posthog/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.8.1.tgz", + "integrity": "sha512-jfzBtQIk9auRi/biO+G/gumK5KxqsD5wOr7XpYMROE/I3pazjP4zIziinp21iQuIQJMXrDvwt9Af3njgOGwtew==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3981,6 +4009,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4989,6 +5027,17 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/core-js": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -10537,6 +10586,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -10699,6 +10795,35 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/posthog-js": { + "version": "1.309.1", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.309.1.tgz", + "integrity": "sha512-JUJcQhYzNNKO0cgnSbowCsVi2RTu75XGZ2EmnTQti4tMGRCTOv/HCnZasdFniBGZ0rLugQkaScYca/84Ta2u5Q==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@posthog/core": "1.8.1", + "core-js": "^3.38.1", + "fflate": "^0.4.8", + "preact": "^10.19.3", + "web-vitals": "^4.2.4" + } + }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, + "node_modules/preact": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz", + "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13753,6 +13878,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", diff --git a/package.json b/package.json index 32b0379..7221a33 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,29 @@ "build:dev": "vite build --mode development", "lint": "eslint .", "typecheck": "tsc --noEmit", + "typecheck:strict:flashcards": "tsc --noEmit -p tsconfig.strict.json", "preview": "vite preview", "api": "cd code-executor-api && npm run dev", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug", + "test:e2e:codegen": "playwright codegen", "test:edge": "cd supabase/functions && deno test --allow-net --allow-env openrouter-usage/ behavioral-interview-feedback/ stripe-checkout/ stripe-webhook/ stripe-cancel-subscription/ stripe-customer-portal/ stripe-get-subscription-details/ generate-flashcard-summary/ generate-interview-feedback/ generate-interview-questions/ upload-resume/", - "test:all": "npm run test && npm run test:edge", + "test:all": "npm run test && npm run test:e2e && npm run test:edge", "validate": "npm run lint && npm run typecheck && npm run build", "validate:ci": "bun run lint && bun run typecheck && bun run build", + "ci": "npm run ci:test && npm run ci:lint && npm run ci:typecheck && npm run ci:build", + "ci:test": "vitest run --reporter=verbose", + "ci:e2e": "playwright test --project=chromium", + "ci:lint": "eslint . --ext .ts,.tsx,.js,.jsx", + "ci:typecheck": "tsc --noEmit", + "ci:build": "npm run build", + "ci:full": "npm run ci:test && npm run ci:e2e && npm run ci:lint && npm run ci:typecheck && npm run ci:build", "prepare": "husky" }, "dependencies": { @@ -73,6 +86,7 @@ "mermaid": "^10.9.1", "monaco-vim": "^0.4.2", "next-themes": "^0.3.0", + "posthog-js": "^1.309.1", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -101,6 +115,7 @@ }, "devDependencies": { "@eslint/js": "^9.9.0", + "@playwright/test": "^1.57.0", "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", @@ -108,6 +123,7 @@ "@types/node": "^22.5.5", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react-swc": "^3.5.0", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..447e8ee --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..cf44dae --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,132 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:8080', + + /* Slow down actions for debugging (set to 0 for normal speed) */ + launchOptions: { + slowMo: 500, // 500ms delay between actions - remove or set to 0 for fast tests + }, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + /* Take screenshot on failure */ + screenshot: 'only-on-failure', + + /* Record video on failure */ + video: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + // Use your actual Firefox browser with your Google account already logged in + { + name: 'firefox-with-profile', + use: { + ...devices['Desktop Firefox'], + channel: 'firefox', // Use installed Firefox + launchOptions: { + firefoxUserPrefs: { + // Disable some automation detection + 'dom.webdriver.enabled': false, + 'useAutomationExtension': false, + }, + }, + }, + }, + + // Use your actual Chrome browser with your Google account already logged in + { + name: 'chrome-with-profile', + use: { + ...devices['Desktop Chrome'], + channel: 'chrome', // Use installed Chrome instead of Chromium + // This will use your default Chrome profile with your Google login + launchOptions: { + args: [ + '--disable-blink-features=AutomationControlled', // Hide automation flags + ], + }, + }, + }, + + // Setup project - runs once to authenticate + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + }, + + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + // Use saved auth state from setup + storageState: './tests/playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + storageState: './tests/playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + + { + name: 'webkit', + use: { + ...devices['Desktop Safari'], + storageState: './tests/playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + + /* Test against mobile viewports. */ + { + name: 'Mobile Chrome', + use: { + ...devices['Pixel 5'], + storageState: './tests/playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + { + name: 'Mobile Safari', + use: { + ...devices['iPhone 12'], + storageState: './tests/playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + ], + + /* Run your local dev server before starting the tests */ + /* Commented out - run dev server manually before tests */ + // webServer: { + // command: 'npm run dev', + // url: 'http://localhost:5173', + // reuseExistingServer: true, + // timeout: 120 * 1000, + // }, +}); diff --git a/public/bg.png b/public/bg.png new file mode 100644 index 0000000..471cd65 Binary files /dev/null and b/public/bg.png differ diff --git a/src/App.tsx b/src/App.tsx index 3e2e0e6..88724e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,6 @@ import Dashboard from "./pages/Dashboard"; import Problems from "./pages/Problems"; import SystemDesign from "./pages/SystemDesign"; import SystemDesignSolver from "./pages/SystemDesignSolver"; -import ExcalidrawDesign from "./pages/ExcalidrawDesign"; import ProblemSolverNew from "./pages/ProblemSolverNew"; import Profile from "./pages/Profile"; import Settings from "./pages/Settings"; @@ -26,11 +25,24 @@ import TechnicalInterview from "./pages/TechnicalInterview"; import DataStructureRedirect from "./components/DataStructureRedirect"; import NotFound from "./pages/NotFound"; import AdminDashboard from "./pages/AdminDashboard"; +import TermsOfService from "./pages/TermsOfService"; +import PrivacyPolicy from "./pages/PrivacyPolicy"; import { ProtectedRoute } from "./components/route/ProtectedRoute"; import { AdminRoute } from "./components/route/AdminRoute"; +import { AnalyticsProvider } from "./components/analytics/AnalyticsProvider"; import { Analytics } from '@vercel/analytics/react'; -const queryClient = new QueryClient(); +// Configure React Query with sensible cache defaults +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 2 * 60 * 1000, // 2 minutes - data is fresh for 2 mins + gcTime: 30 * 60 * 1000, // 30 minutes - cache kept in memory + refetchOnWindowFocus: false, // Don't refetch on window focus + retry: 1, // Only retry once on failure + }, + }, +}); const App = () => ( @@ -44,58 +56,61 @@ const App = () => ( - - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - {/* System Design - Disabled for launch */} - {/* + + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {/* System Design - Disabled for launch */} + {/* @@ -119,129 +134,130 @@ const App = () => ( } /> */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + + diff --git a/src/components/AIAccessControl.tsx b/src/components/AIAccessControl.tsx new file mode 100644 index 0000000..59a95eb --- /dev/null +++ b/src/components/AIAccessControl.tsx @@ -0,0 +1,86 @@ +import { AlertCircle, Clock } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { AIAccessStatus, getAccessDeniedMessage } from '@/hooks/useAIAccessStatus'; + +interface AIAccessDeniedBannerProps { + status: AIAccessStatus; + feature?: 'ai_coach' | 'ai_chat' | 'system_design'; + className?: string; +} + +/** + * Banner component to display when AI access is denied. + * + * Usage: + * ```tsx + * const status = useAIAccessStatus(); + * + * if (status.isOnCooldown || !status.canUseAICoach) { + * return ; + * } + * ``` + */ +export function AIAccessDeniedBanner({ status, feature, className }: AIAccessDeniedBannerProps) { + const message = getAccessDeniedMessage(status); + + // Determine the specific reason + let title = 'AI Access Unavailable'; + let icon = ; + + if (status.isOnCooldown) { + title = 'AI Access Paused'; + icon = ; + } else if (status.dailyLimitReached) { + title = 'Daily Limit Reached'; + } else if (status.monthlyLimitReached) { + title = 'Monthly Limit Reached'; + } else if (feature === 'ai_coach' && !status.canUseAICoach) { + title = 'AI Coach Disabled'; + } else if ((feature === 'ai_chat' || feature === 'system_design') && !status.canUseAIChat) { + title = 'AI Chat Disabled'; + } + + return ( + + {icon} + {title} + {message} + + ); +} + +interface AIAccessGateProps { + status: AIAccessStatus; + feature: 'ai_coach' | 'ai_chat' | 'system_design'; + children: React.ReactNode; + fallback?: React.ReactNode; +} + +/** + * Gate component that only renders children if AI access is allowed. + * + * Usage: + * ```tsx + * const status = useAIAccessStatus(); + * + * + * + * + * ``` + */ +export function AIAccessGate({ status, feature, children, fallback }: AIAccessGateProps) { + const hasAccess = !status.isOnCooldown && + !status.dailyLimitReached && + !status.monthlyLimitReached && + (feature === 'ai_coach' ? status.canUseAICoach : status.canUseAIChat); + + if (status.loading) { + return null; // Or a loading skeleton + } + + if (!hasAccess) { + return fallback ? <>{fallback} : ; + } + + return <>{children}; +} diff --git a/src/components/AIChat.tsx b/src/components/AIChat.tsx index 233a0d5..a9cae90 100644 --- a/src/components/AIChat.tsx +++ b/src/components/AIChat.tsx @@ -1,24 +1,11 @@ import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Send, - Bot, - User, - Trash2, - Loader2, - Mic, - MicOff, - Maximize2, - Sparkles, -} from "lucide-react"; +import { Bot, User, Loader2, Maximize2, Sparkles } from "lucide-react"; import { useState, useEffect, useRef } from "react"; -// Using a lightweight custom fullscreen overlay instead of Radix Dialog to avoid MIME issues in some dev setups import { useChatSession } from "@/hooks/useChatSession"; import { useSpeechToText } from "@/hooks/useSpeechToText"; import type { CoachingMode } from "@/types"; -import TextareaAutosize from "react-textarea-autosize"; -import { CodeSnippet, Problem } from "@/types"; import Mermaid from "@/components/diagram/Mermaid"; import FlowCanvas from "@/components/diagram/FlowCanvas"; import type { FlowGraph } from "@/types"; @@ -32,15 +19,18 @@ import { CanvasContainer } from "@/components/canvas"; import { hasInteractiveDemo } from "@/components/visualizations/registry"; import "katex/dist/katex.min.css"; import { logger } from "@/utils/logger"; - -interface AIChatProps { - problemId: string; - problemDescription: string; - onInsertCodeSnippet?: (snippet: CodeSnippet) => void; - problemTestCases?: unknown[]; - problem?: Problem; - currentCode?: string; -} +import { useTrackFeatureTime, Features } from "@/hooks/useFeatureTracking"; +import { trackEvent, AnalyticsEvents } from "@/services/analytics"; + +// Extracted components +import { ChatHeader } from "./chat/ChatHeader"; +import { ChatInput } from "./chat/ChatInput"; +import { BlurredHint } from "./chat/BlurredHint"; +import { BlurredSection } from "./chat/BlurredSection"; +import { CodeBlockWithInsert } from "./chat/CodeBlockWithInsert"; +import { createChatMarkdownComponents } from "./chat/ChatMarkdownComponents"; +import { splitContentAndHint, formatTime } from "./chat/utils/chatUtils"; +import type { AIChatProps, ActiveDiagram } from "./chat/types"; const AIChat = ({ problemId, @@ -52,26 +42,20 @@ const AIChat = ({ }: AIChatProps) => { const [input, setInput] = useState(""); const scrollAreaRef = useRef(null); - type ActiveDiagram = - | { engine: "mermaid"; code: string } - | { engine: "reactflow"; graph: FlowGraph }; const [isDiagramOpen, setIsDiagramOpen] = useState(false); - const [activeDiagram, setActiveDiagram] = useState( - null, - ); + const [activeDiagram, setActiveDiagram] = useState(null); const [hoveredMessageId, setHoveredMessageId] = useState(null); - // Track message IDs we've already auto-requested diagrams for (avoid loops) const autoRequestedRef = useRef>(new Set()); // Canvas state const [isCanvasOpen, setIsCanvasOpen] = useState(false); const [canvasTitle, setCanvasTitle] = useState("Interactive Component"); - // Coaching mode state - // Single coaching mode: Socratic (toggle removed) - const coachingMode: CoachingMode = 'socratic'; - + // Single coaching mode: Socratic + const coachingMode: CoachingMode = "socratic"; + // Track AI Chat feature usage + useTrackFeatureTime(Features.AI_CHAT, { problemId }); const { session, @@ -92,7 +76,6 @@ const AIChat = ({ // Speech-to-text functionality const { isListening, - isSupported: speechSupported, hasNativeSupport, isProcessing, startListening, @@ -106,35 +89,39 @@ const AIChat = ({ }); }, onError: (error) => { - logger.error('[AIChat] Speech recognition error', { error }); + logger.error("[AIChat] Speech recognition error", { error }); }, }); const handleSend = async () => { if (!input.trim()) return; + trackEvent(AnalyticsEvents.AI_CHAT_MESSAGE_SENT, { + problemId, + messageLength: input.length, + }); await sendMessage(input); setInput(""); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); + const toggleMicrophone = async () => { + if (!hasNativeSupport) return; + if (isListening) { + stopListening(); + } else { + await startListening(); } }; - // Auto-request diagrams when conditions are met; no manual Visualize button + // Auto-request diagrams when conditions are met useEffect(() => { if (!messages.length) return; - // If any assistant message already has a diagram, do not auto-request again on reload const anyDiagramExists = messages.some( (m) => m.role === "assistant" && - Boolean((m as unknown as { diagram?: unknown }).diagram), + Boolean((m as unknown as { diagram?: unknown }).diagram) ); if (anyDiagramExists) return; - // Only consider the latest assistant message for auto-request const lastAssistant = [...messages] .reverse() .find((m) => m.role === "assistant"); @@ -143,10 +130,10 @@ const AIChat = ({ const lastUserMsg = [...messages].reverse().find((m) => m.role === "user")?.content || ""; const userAsked = /(visualize|diagram|draw|flowchart|mermaid)/i.test( - lastUserMsg, + lastUserMsg ); const hasDiagram = Boolean( - (lastAssistant as unknown as { diagram?: unknown }).diagram, + (lastAssistant as unknown as { diagram?: unknown }).diagram ); const assistantSuggests = (lastAssistant as unknown as { suggestDiagram?: boolean }) @@ -170,16 +157,16 @@ const AIChat = ({ } }, [messages.length]); - const handleGenerateComponent = async (messageContent: string) => { + const handleGenerateComponent = async () => { if (!problem) { - logger.error('[AIChat] No problem context available for visualization'); + logger.error("[AIChat] No problem context available for visualization"); return; } - - // Simply open the modal with our direct component setCanvasTitle(`${problem.title} - Interactive Demo`); setIsCanvasOpen(true); - logger.debug('[AIChat] Opening visualization', { problemTitle: problem.title }); + logger.debug("[AIChat] Opening visualization", { + problemTitle: problem.title, + }); }; const openDiagramDialog = (diagram: ActiveDiagram) => { @@ -187,423 +174,40 @@ const AIChat = ({ setIsDiagramOpen(true); }; - const toggleMicrophone = async () => { - if (!hasNativeSupport) return; - - if (isListening) { - stopListening(); - } else { - await startListening(); - } - }; - // Auto-scroll to bottom when new messages arrive useEffect(() => { const scrollToBottom = () => { if (scrollAreaRef.current) { - // Try multiple selectors for the scrollable element const scrollElement = - scrollAreaRef.current.querySelector("[data-radix-scroll-area-viewport]") || - scrollAreaRef.current.querySelector("[data-radix-scroll-area-content]") || + scrollAreaRef.current.querySelector( + "[data-radix-scroll-area-viewport]" + ) || + scrollAreaRef.current.querySelector( + "[data-radix-scroll-area-content]" + ) || scrollAreaRef.current; - logger.debug('[AIChat] Attempting to scroll', { - scrollAreaRef: !!scrollAreaRef.current, - scrollElement: !!scrollElement, - scrollHeight: scrollElement?.scrollHeight, - currentScrollTop: scrollElement?.scrollTop - }); - if (scrollElement) { - // Use requestAnimationFrame to ensure DOM has updated requestAnimationFrame(() => { scrollElement.scrollTop = scrollElement.scrollHeight; - logger.debug('[AIChat] Scrolled to bottom', { scrollTop: scrollElement.scrollTop }); }); } } }; - // Small delay to ensure content is rendered const timeoutId = setTimeout(scrollToBottom, 100); return () => clearTimeout(timeoutId); }, [messages, isTyping]); - // Extract hints, solutions, and hints for next step sections - const splitContentAndHint = ( - content: string, - ): { body: string; hint?: string; hintsForNextStep?: string; solution?: string } => { - const lines = content.split("\n"); - let inCode = false; - let hint: string | undefined; - let hintsForNextStep: string | undefined; - let solution: string | undefined; - const bodyLines: string[] = []; - - let captureMode: 'none' | 'hintsForNextStep' | 'solution' = 'none'; - const capturedHintsLines: string[] = []; - const capturedSolutionLines: string[] = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - - // Track code fence state - if (trimmed.startsWith("```") && (trimmed === "```" || /^```\w+/.test(trimmed))) { - inCode = !inCode; - - // If we're capturing, add to capture buffer - if (captureMode === 'hintsForNextStep') { - capturedHintsLines.push(line); - continue; - } else if (captureMode === 'solution') { - capturedSolutionLines.push(line); - continue; - } else { - bodyLines.push(line); - continue; - } - } - - // Look for special sections when not in code - if (!inCode) { - // Single-line hint pattern - const hintMatch = trimmed.match(/^Hint\s*:\s*(.+)$/i); - if (hintMatch) { - hint = hintMatch[1]; - continue; // Don't add this line to body - } - - // Match various hint patterns: - // - "Hints for Next Step:" - // - "Hint:" - // - "Hints:" - // - "Next Step Hint:" - // - "Think about:" - if (trimmed.match(/^Hints?\s+for\s+(the\s+)?Next\s+Step\s*:?$/i) || - trimmed.match(/^Hints?\s*:$/i) || - trimmed.match(/^Next\s+Step\s+Hints?\s*:?$/i) || - trimmed.match(/^Think\s+about\s*:?$/i)) { - // Finalize previous capture before switching - if (captureMode === 'hintsForNextStep' && capturedHintsLines.length > 0) { - hintsForNextStep = capturedHintsLines.join("\n").trim(); - capturedHintsLines.length = 0; - } else if (captureMode === 'solution' && capturedSolutionLines.length > 0) { - solution = capturedSolutionLines.join("\n").trim(); - capturedSolutionLines.length = 0; - } - captureMode = 'hintsForNextStep'; - continue; - } - - // "Solution:" or "Complete Solution:" section - if (trimmed.match(/^(Complete\s+)?Solution\s*:?$/i)) { - // Finalize previous capture before switching - if (captureMode === 'hintsForNextStep' && capturedHintsLines.length > 0) { - hintsForNextStep = capturedHintsLines.join("\n").trim(); - capturedHintsLines.length = 0; - } else if (captureMode === 'solution' && capturedSolutionLines.length > 0) { - solution = capturedSolutionLines.join("\n").trim(); - capturedSolutionLines.length = 0; - } - captureMode = 'solution'; - continue; - } - - // Check if we hit a new major section (stop capturing) - // Look for patterns like "Question:", "Approach:", etc. but not numbered lists - if (captureMode !== 'none' && trimmed.match(/^[A-Z][a-z]*(\s+[A-Z][a-z]*)*\s*:$/) && !trimmed.match(/^\d+\./)) { - // New section detected, stop capturing current mode - if (captureMode === 'hintsForNextStep') { - hintsForNextStep = capturedHintsLines.join("\n").trim(); - capturedHintsLines.length = 0; - } else if (captureMode === 'solution') { - solution = capturedSolutionLines.join("\n").trim(); - capturedSolutionLines.length = 0; - } - captureMode = 'none'; - bodyLines.push(line); - continue; - } - } - - // Add lines to appropriate buffer - if (captureMode === 'hintsForNextStep') { - capturedHintsLines.push(line); - } else if (captureMode === 'solution') { - capturedSolutionLines.push(line); - } else { - bodyLines.push(line); - } - } - - // Capture any remaining content - if (captureMode === 'hintsForNextStep' && capturedHintsLines.length > 0) { - hintsForNextStep = capturedHintsLines.join("\n").trim(); - } - if (captureMode === 'solution' && capturedSolutionLines.length > 0) { - solution = capturedSolutionLines.join("\n").trim(); - } - - return { - body: bodyLines.join("\n").trim(), - hint, - hintsForNextStep, - solution - }; - }; - - const BlurredHint: React.FC<{ text: string }> = ({ text }) => { - const [isRevealed, setIsRevealed] = useState(false); - return ( -
setIsRevealed((v) => !v)} - role="button" - aria-label={isRevealed ? "Hide hint" : "Click to reveal hint"} - > -
- {isRevealed ? ( - - - - - ) : ( - - - - )} - - {isRevealed ? "Hide Hint" : "Click to reveal hint"} - -
- {isRevealed ? ( -
💡 {text}
- ) : ( -
- This hint will help guide you to the solution... -
- )} -
- ); - }; - - const BlurredSection: React.FC<{ title: string; content: string; icon?: string; color?: string }> = ({ - title, - content, - icon = "💡", - color = "amber" - }) => { - const [isRevealed, setIsRevealed] = useState(false); - - const colorClasses = { - amber: { - bg: "bg-amber-50 dark:bg-amber-950/30", - border: "border-amber-200 dark:border-amber-600", - text: "text-amber-700 dark:text-amber-400" - }, - green: { - bg: "bg-green-50 dark:bg-green-950/30", - border: "border-green-200 dark:border-green-600", - text: "text-green-700 dark:text-green-400" - }, - blue: { - bg: "bg-blue-50 dark:bg-blue-950/30", - border: "border-blue-200 dark:border-blue-600", - text: "text-blue-700 dark:text-blue-400" - } - }; - - const colors = colorClasses[color as keyof typeof colorClasses] || colorClasses.amber; - - return ( -
setIsRevealed((v) => !v)} - role="button" - aria-label={isRevealed ? `Hide ${title}` : `Click to reveal ${title}`} - > -
- {isRevealed ? ( - - - - - ) : ( - - - - )} - - {icon} {title} - - - {isRevealed ? "Click to hide" : "Click to reveal"} - -
- {isRevealed ? ( -
- - {String(children).replace(/\n$/, "")} - - ); - } - return ( - {children} - ); - }, - }} - > - {content} - -
- ) : ( -
- {title === "Solution" - ? "Complete solution code and explanation..." - : "Strategic hints to guide your next steps..."} -
- )} -
- ); - }; - - const CodeBlockWithInsert: React.FC<{ - code: string; - lang: string; - onInsert?: (snippet: CodeSnippet) => void; - showOverride?: boolean; - }> = ({ code, lang, onInsert, showOverride }) => { - const [showLocal, setShowLocal] = useState(false); - const isMermaid = lang === "mermaid"; - const visible = showOverride !== undefined ? showOverride : showLocal; - return ( -
setShowLocal(true)} - onMouseLeave={() => setShowLocal(false)} - > - {isMermaid ? ( - - ) : ( - - {code.replace(/\n$/, "")} - - )} - {onInsert && !isMermaid && ( - - )} -
- ); - }; - - const formatTime = (timestamp: Date) => { - return timestamp.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - }; - return ( {/* Chat Header */} -
-
-
- -
-
-
AI Coach
-
- {loading ? "Loading chat..." : session ? "Chat loaded" : "Online"} -
-
-
- -
- {session && messages.length > 0 && ( - - )} -
-
+ 0} + onClearConversation={clearConversation} + /> {/* Messages */}
@@ -637,10 +241,13 @@ const AIChat = ({
{messages.map((message) => (
-
- {/* Avatar for assistant (left side) */} +
+ {/* Avatar for assistant */} {message.role === "assistant" && ( -
+
)} @@ -648,86 +255,37 @@ const AIChat = ({ {/* Message Content */}
{ - if (message.role === 'assistant') setHoveredMessageId(message.id); + if (message.role === "assistant") + setHoveredMessageId(message.id); }} onMouseLeave={() => { - if (message.role === 'assistant') setHoveredMessageId(null); + if (message.role === "assistant") + setHoveredMessageId(null); }} > {message.role === "user" ? ( -

{message.content}

+

+ {message.content} +

) : ( (() => { - const { body, hint, hintsForNextStep, solution } = splitContentAndHint( - message.content, - ); + const { body, hint, hintsForNextStep, solution } = + splitContentAndHint(message.content); return (
) { - // Convert italics to bold for better emphasis - return {children}; - }, - strong({ children, ...props }: React.HTMLAttributes) { - return {children}; - }, - span({ className, children, ...props }: React.HTMLAttributes) { - // Handle KaTeX math spans - if (className && className.includes('katex')) { - return {children}; - } - return {children}; - }, - code({ className, children, ...props }: React.HTMLAttributes) { - const match = /language-(\w+)/.exec(className || ""); - const lang = match?.[1] || "python"; - // Strictly require a language match to treat as a block with "Add" button - // This ensures inline code (which has no language class) is always rendered inline - const isBlock = !!match; - - if (isBlock) { - // Determine visibility from message hover flag - const hovered = hoveredMessageId === message.id; - return ( - - ); - } - return ( - {children} - ); - }, - p: ({ children }) => ( -

{children}

- ), - ul: ({ children }) => ( -
    - {children} -
- ), - ol: ({ children }) => ( -
    - {children} -
- ), - li: ({ - children, - }: { - children?: React.ReactNode; - }) =>
  • {children}
  • , - }} + components={createChatMarkdownComponents({ + onInsertCodeSnippet, + hoveredMessageId, + messageId: message.id, + })} > {body}
    @@ -754,7 +312,7 @@ const AIChat = ({ )}
    - {/* Mermaid diagram bubble - uses DB-attached diagram */} + {/* Mermaid diagram bubble */} {message.role === "assistant" && (() => { const attached = ( @@ -764,20 +322,14 @@ const AIChat = ({ | { engine: "reactflow"; graph: FlowGraph }; } ).diagram; - const diag: { - engine: "mermaid"; - code: string; - } | null = + const diag = attached && attached.engine === "mermaid" - ? (attached as { - engine: "mermaid"; - code: string; - }) + ? (attached as { engine: "mermaid"; code: string }) : null; if (!diag) return null; return (
    -
    +
    Diagram{" "} @@ -806,7 +358,7 @@ const AIChat = ({ ); })()} - {/* React Flow diagram bubble - uses DB-attached diagram */} + {/* React Flow diagram bubble */} {message.role === "assistant" && (() => { const attached = ( @@ -816,10 +368,7 @@ const AIChat = ({ | { engine: "reactflow"; graph: FlowGraph }; } ).diagram; - const diag: { - engine: "reactflow"; - graph: FlowGraph; - } | null = + const diag = attached && attached.engine === "reactflow" ? (attached as { engine: "reactflow"; @@ -829,7 +378,7 @@ const AIChat = ({ if (!diag) return null; return (
    -
    +
    Diagram{" "} @@ -858,34 +407,33 @@ const AIChat = ({ ); })()} - {/* Action: Interactive Demo button - show only for problems with a registered interactive demo AND message has a diagram */} - {message.role === "assistant" && hasInteractiveDemo(problem?.id) && Boolean((message as unknown as { diagram?: unknown }).diagram) && ( -
    -
    - + {/* Interactive Demo button */} + {message.role === "assistant" && + hasInteractiveDemo(problem?.id) && + Boolean( + (message as unknown as { diagram?: unknown }).diagram + ) && ( +
    +
    + +
    -
    - )} + )} - {/* Code snippets: render whenever present on assistant messages */} + {/* Code snippets */} {message.role === "assistant" && message.codeSnippets && message.codeSnippets.length > 0 && - // Avoid duplicate UI: if markdown code blocks exist, prefer inline with overlay !/```/.test(message.content) && (
    {message.codeSnippets.map((snippet) => ( @@ -908,7 +456,6 @@ const AIChat = ({ /> )}
    -
    {snippet.code}
    - {snippet.insertionHint?.description && (

    {snippet.insertionHint.description} @@ -942,7 +488,7 @@ const AIChat = ({

    - {/* Avatar for user (right side) */} + {/* Avatar for user */} {message.role === "user" && (
    @@ -953,11 +499,11 @@ const AIChat = ({ ))} {isTyping && ( -
    -
    +
    +
    -
    +
    {/* Message Input */} -
    -
    -
    - setInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={ - isListening - ? " Listening..." - : isProcessing - ? "🔄 Processing audio..." - : "Ask your AI coach anything..." - } - disabled={loading || isTyping} - className={`w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${hasNativeSupport ? "pr-10" : "pr-3" - }`} - minRows={1} - maxRows={6} - /> - {hasNativeSupport && ( - - )} - {speechError && ( -
    - ⚠️ -
    - )} -
    - -
    -
    + + + {/* Diagram Modal */} {isDiagramOpen && (
    void; onSubmit?: () => void; isRunning: boolean; - editorRef?: React.MutableRefObject; + editorRef?: React.MutableRefObject; hideSubmit?: boolean; // Coach Mode props onStartCoaching?: () => void; @@ -53,13 +54,16 @@ const CodeEditor = ({ const [code, setCode] = useState(initialCode); const [vimMode, setVimMode] = useState(() => { // Load vim mode preference from localStorage - const saved = localStorage.getItem("editor-vim-mode"); + if (typeof window === "undefined") return false; + const saved = window.localStorage.getItem("editor-vim-mode"); return saved === "true"; }); const { currentTheme, selectedTheme, setCurrentTheme, defineCustomThemes } = useEditorTheme(); - const editorRef = useRef(null); - const vimModeRef = useRef(null); + const editorRef = useRef(null); + const vimModeRef = useRef<{ dispose: () => void } | null>(null); + const isMountedRef = useRef(true); + const vimInitTimeoutRef = useRef | null>(null); const { saveCode, loadLatestCode, isSaving, lastSaved, hasUnsavedChanges } = useAutoSave(problemId, { @@ -74,6 +78,7 @@ const CodeEditor = ({ if (typeof window !== "undefined" && editorRef.current) { try { const { initVimMode } = await import("monaco-vim"); + if (!isMountedRef.current) return; if (vimStatusbarRef.current) { // Dispose existing vim mode if it exists if (vimModeRef.current) { @@ -87,9 +92,10 @@ const CodeEditor = ({ } } catch (error) { logger.warn("[CodeEditor] Vim mode not available", { error }); - toast.error("Failed to load Vim mode"); + if (!isMountedRef.current) return; + notifications.error("Failed to load Vim mode"); setVimMode(false); - localStorage.setItem("editor-vim-mode", "false"); + window.localStorage.setItem("editor-vim-mode", "false"); } } }; @@ -97,7 +103,9 @@ const CodeEditor = ({ const handleVimModeToggle = async (enabled: boolean) => { setVimMode(enabled); // Persist vim mode preference to localStorage - localStorage.setItem("editor-vim-mode", enabled.toString()); + if (typeof window !== "undefined") { + window.localStorage.setItem("editor-vim-mode", enabled.toString()); + } if (enabled) { await loadVimMode(); @@ -127,7 +135,9 @@ const CodeEditor = ({ // If no user context yet, just use initial code setCode(initialCode); } - }, [problemId, loadLatestCode]); // Add loadLatestCode as dependency + // Note: onCodeChange and initialCode are intentionally excluded to prevent editor resets on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [problemId, loadLatestCode]); // Handle theme changes after editor is mounted useEffect(() => { @@ -137,9 +147,28 @@ const CodeEditor = ({ } }, [currentTheme]); + // Initialize vim mode when editor mounts or vimMode changes + useEffect(() => { + if (vimMode && editorRef.current && vimStatusbarRef.current) { + // Use a small delay to ensure editor is fully ready + const timeoutId = setTimeout(() => { + void loadVimMode(); + }, 100); + return () => clearTimeout(timeoutId); + } else if (!vimMode && vimModeRef.current) { + vimModeRef.current.dispose(); + vimModeRef.current = null; + } + }, [vimMode]); + // Cleanup vim mode on unmount useEffect(() => { return () => { + isMountedRef.current = false; + if (vimInitTimeoutRef.current) { + clearTimeout(vimInitTimeoutRef.current); + vimInitTimeoutRef.current = null; + } if (vimModeRef.current) { vimModeRef.current.dispose(); vimModeRef.current = null; @@ -287,7 +316,9 @@ const CodeEditor = ({ // Load vim mode if enabled (after editor is fully mounted) if (vimMode) { // Give editor more time to fully initialize - setTimeout(() => loadVimMode(), 500); + vimInitTimeoutRef.current = setTimeout(() => { + void loadVimMode(); + }, 500); } }} theme={currentTheme} diff --git a/src/components/CodeSnippetButton.tsx b/src/components/CodeSnippetButton.tsx index 24c9d5a..f89ac7e 100644 --- a/src/components/CodeSnippetButton.tsx +++ b/src/components/CodeSnippetButton.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button"; import { CodeSnippet } from "@/types"; import { Code, Plus } from "lucide-react"; import { useState } from "react"; +import { logger } from "@/utils/logger"; interface CodeSnippetButtonProps { snippet: CodeSnippet; @@ -23,7 +24,7 @@ const CodeSnippetButton = ({ try { await onInsert(snippet); } catch (error) { - console.error("Failed to insert code snippet:", error); + logger.error("[CodeSnippetButton] Failed to insert code snippet:", error); } finally { setTimeout(() => { setIsInserting(false); diff --git a/src/components/CompanyIcon.tsx b/src/components/CompanyIcon.tsx index d622937..c96fb8a 100644 --- a/src/components/CompanyIcon.tsx +++ b/src/components/CompanyIcon.tsx @@ -53,12 +53,12 @@ const CompanyIcon: React.FC = ({ const getCompanyIcon = (companyName: string) => { const normalizedName = companyName.toLowerCase().replace(/\s+/g, ''); - - const iconMap: Record; + + const iconMap: Record; color: string; darkColor?: string; // Optional dark mode color - name: string; + name: string; }> = { 'google': { icon: SiGoogle, diff --git a/src/components/CoreBattleCards.tsx b/src/components/CoreBattleCards.tsx index 3c87ea1..22f541e 100644 --- a/src/components/CoreBattleCards.tsx +++ b/src/components/CoreBattleCards.tsx @@ -1,11 +1,12 @@ -import { Card, CardContent } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Code, Grid3x3, Share2, MessageCircle, Webhook, Blocks, UsersRound } from "lucide-react"; +import React from "react"; +import { Code, Share2, MessageCircle, Webhook, Blocks, ChevronRight } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import { isFeatureEnabled, isFeatureEnabledBooleal } from "@/config/features"; +import { isFeatureEnabled, isFeatureEnabledBoolean, type FeatureFlag } from "@/config/features"; const CoreBattleCards = () => { const navigate = useNavigate(); + const [showUpcomingAssessments, setShowUpcomingAssessments] = React.useState(false); + const [showUpcomingInterviews, setShowUpcomingInterviews] = React.useState(false); const assessments = [ { @@ -16,6 +17,7 @@ const CoreBattleCards = () => { iconColor: "text-primary", path: "/problems", featureFlag: true, + status: "ACTIVE" }, { title: "System Design", @@ -25,6 +27,7 @@ const CoreBattleCards = () => { iconColor: "text-primary", path: "/system-design", featureFlag: false, // Disabled for launch + status: "COMING SOON" }, { title: "Implement Script/API", @@ -33,6 +36,7 @@ const CoreBattleCards = () => { color: "bg-primary/10", iconColor: "text-primary", featureFlag: false, + status: "COMING SOON" }, ]; @@ -45,6 +49,7 @@ const CoreBattleCards = () => { iconColor: "text-primary", path: "/technical-interview", featureFlag: "TECHNICAL_INTERVIEW" as const, + status: "COMING SOON" }, { title: "System-Design", @@ -53,16 +58,19 @@ const CoreBattleCards = () => { color: "bg-success/20", iconColor: "text-primary", featureFlag: false, + status: "COMING SOON" }, { title: "Behavioral Interviews", description: "Soft skills & behavioral interviews", icon: MessageCircle, - color: "bg-success/20", - iconColor: "text-primary", + color: "bg-amber/20", + iconColor: "text-amber-600", featureFlag: true, path: "/behavioral", - }, + status: "AVAILABLE", + isYellow: true, // Flag to render with yellow styling + }, { title: "Script/API Follow-up", description: "Follow-up questions on 'build a script or API' assessment", @@ -70,100 +78,147 @@ const CoreBattleCards = () => { color: "bg-success/20", iconColor: "text-primary", featureFlag: false, - }, + status: "COMING SOON" + }, ]; - // Get all assessments and interviews (don't filter by feature flags) - const allAssessments = assessments; - const allInterviews = interviews; + const availableAssessments = assessments.filter(a => isFeatureEnabledBoolean(a.featureFlag)); + const upcomingAssessments = assessments.filter(a => !isFeatureEnabledBoolean(a.featureFlag)); + + const availableInterviews = interviews.filter(i => { + return typeof i.featureFlag === 'string' + ? isFeatureEnabled(i.featureFlag as FeatureFlag) + : isFeatureEnabledBoolean(i.featureFlag); + }); + const upcomingInterviews = interviews.filter(i => { + return typeof i.featureFlag === 'string' + ? !isFeatureEnabled(i.featureFlag as FeatureFlag) + : !isFeatureEnabledBoolean(i.featureFlag); + }); return ( -
    -

    Assessment prep

    -
    - {allAssessments.map((battle) => { - const Icon = battle.icon; - const isEnabled = isFeatureEnabledBooleal(battle.featureFlag); - return ( - +
    +

    Assessment Prep

    + + {/* Available items */} +
    + {availableAssessments.map((battle) => ( + + ))} +
    -

    - {battle.title} -

    -

    - {battle.description} -

    - - {!isEnabled && ( -
    - - Coming soon - -
    - )} - - - ); - })} -
    -

    Interview prep

    -
    - {allInterviews.map((interview) => { - const Icon = interview.icon; - // Check if featureFlag is a string (feature flag name) or boolean - const isEnabled = typeof interview.featureFlag === 'string' - ? isFeatureEnabled(interview.featureFlag) - : isFeatureEnabledBooleal(interview.featureFlag); - return ( - { - if (isEnabled && interview.path) { - navigate(interview.path); - } - }} + {/* Upcoming modules - collapsible */} + {upcomingAssessments.length > 0 && ( +
    + + + {showUpcomingAssessments && ( +
    + {upcomingAssessments.map((battle) => ( +
    +
    +
    +

    {battle.title}

    + + coming soon + +
    +

    {battle.description}

    +
    +
    + ))} +
    + )} +
    + )} + + +
    +

    Interview Prep

    + + {/* Available items */} +
    + {availableInterviews.map((interview) => { + const isYellow = 'isYellow' in interview && interview.isYellow; + return ( + + ); + })} +
    + + {/* Upcoming modules - collapsible */} + {upcomingInterviews.length > 0 && ( +
    + -

    - {interview.title} -

    -

    - {interview.description} -

    - - {!isEnabled && ( -
    - - Coming soon - + {showUpcomingInterviews && ( +
    + {upcomingInterviews.map((interview) => ( +
    +
    +
    +

    {interview.title}

    + + coming soon + +
    +

    {interview.description}

    +
    - )} - - - ); - })} -
    + ))} +
    + )} +
    + )} +
    ); }; diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx index bb89b41..bc28812 100644 --- a/src/components/DashboardHeader.tsx +++ b/src/components/DashboardHeader.tsx @@ -13,20 +13,11 @@ const DashboardHeader = () => { const avatarUrl = getUserAvatarUrl(user, undefined, 80); return ( -
    -
    -
    -
    -
    -
    -

    Dashboard

    -
    -
    - +
    - )} -
    - - - {/* Your Goal */} -
    -
    -
    - -
    -
    - Goal: -
    - {userGoal} -
    -
    + +
    +
    +

    + System Config +

    + {onUpdatePlan && ( + + )}
    -
    - {/* Commitment */} -
    -
    -
    - -
    -
    - Commitment: -
    - {userCommitment} -
    +
    +
    + Goal + {userGoal}
    -
    -
    - - {/* Focus Areas */} -
    -
    -
    - +
    + Commitment + {userCommitment}
    -
    - Focus Areas: -
    - {userFocusAreas.map((area, index) => ( -
    - {area} -
    - ))} -
    +
    + Focus + {userFocusAreas}
    diff --git a/src/components/PrimaryFocusCard.tsx b/src/components/PrimaryFocusCard.tsx new file mode 100644 index 0000000..6beecda --- /dev/null +++ b/src/components/PrimaryFocusCard.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ArrowRight } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@/hooks/useAuth"; +import { UserAttemptsService } from "@/services/userAttempts"; +import { formatTimeAgo } from "@/lib/date"; +import { useProblems } from "@/features/problems/hooks/useProblems"; +import { logger } from "@/utils/logger"; + +type LastActivity = { + problemTitle: string; + category: string; + timeAgo: string; + problemId: string; +} | null; + +const PrimaryFocusCard = () => { + const navigate = useNavigate(); + const { user } = useAuth(); + const { problems } = useProblems(user?.id); + const [lastActivity, setLastActivity] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let mounted = true; + const loadLastActivity = async () => { + if (!user?.id) { + setLastActivity(null); + setLoading(false); + return; + } + + try { + const rows = await UserAttemptsService.getRecentActivity(user.id, 1); + if (!mounted) return; + + if (rows.length > 0) { + const lastAttempt = rows[0]; + const problem = problems.find(p => p.id === lastAttempt.problem_id); + + setLastActivity({ + problemTitle: lastAttempt.problem?.title ?? lastAttempt.problem_id, + category: problem?.category ?? "Problem Solving", + timeAgo: formatTimeAgo(lastAttempt.updated_at), + problemId: lastAttempt.problem_id, + }); + } else { + setLastActivity(null); + } + } catch (error) { + logger.error("[PrimaryFocusCard] Error loading last activity:", error); + setLastActivity(null); + } finally { + if (mounted) setLoading(false); + } + }; + + loadLastActivity(); + return () => { mounted = false; }; + }, [user?.id, problems]); + + return ( +
    +
    + +
    +
    +
    +
    + Active +
    +

    Continue Training

    +
    + {loading ? ( + + ) : lastActivity ? ( + <> + Problem Solving + + {lastActivity.category} + + ) : ( + <> + Problem Solving + + Start your first problem + + )} +
    +
    + +
    + {loading ? ( + + ) : lastActivity ? ( + <> + Last session: + {lastActivity.timeAgo} + + ) : ( + No recent activity + )} +
    + + +
    +
    + ); +}; + +export default PrimaryFocusCard; diff --git a/src/components/ProgressRadar.tsx b/src/components/ProgressRadar.tsx index 1adc663..42757ed 100644 --- a/src/components/ProgressRadar.tsx +++ b/src/components/ProgressRadar.tsx @@ -3,6 +3,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { useAuth } from "@/hooks/useAuth"; import { useUserStats } from "@/hooks/useUserStats"; import { supabase } from "@/integrations/supabase/client"; +import { Progress } from "@/components/ui/progress"; const ProgressRadar = () => { const { user } = useAuth(); @@ -31,52 +32,26 @@ const ProgressRadar = () => { return ( -
    -
    - {/* Outer ring */} - - {/* Background circle */} - - {/* Progress circle */} - - +
    +

    + Progress +

    - {/* Center content */} -
    -
    -
    - {overallPercent}% -
    -
    Progress
    +
    +
    +
    + {stats.totalSolved} + / {totalProblems}
    +

    problems solved

    -
    -
    - Progress -
    -
    - {stats.totalSolved} solved out of {totalProblems} +
    + +
    + {overallPercent}% coverage +
    +
    diff --git a/src/components/RecentActivity.tsx b/src/components/RecentActivity.tsx index 7782675..04d2e11 100644 --- a/src/components/RecentActivity.tsx +++ b/src/components/RecentActivity.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { ChevronRight, Plus } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ChevronRight } from "lucide-react"; import { useAuth } from "@/hooks/useAuth"; import { UserAttemptsService } from "@/services/userAttempts"; import { formatTimeAgo } from "@/lib/date"; +import { cn } from "@/lib/utils"; type ActivityItem = { id: string; @@ -31,7 +32,7 @@ const RecentActivity = () => { return; } setLoading(true); - const rows = await UserAttemptsService.getRecentActivity(user.id, 3); + const rows = await UserAttemptsService.getRecentActivity(user.id, 4); if (!mounted) return; const mapped: ActivityItem[] = rows.map((r) => ({ id: r.id, @@ -50,64 +51,53 @@ const RecentActivity = () => { }; }, [user?.id]); - const quickActions = useMemo( - () => [ - // { - // title: "Random Pattern Drill", - // icon: Plus, - // action: () => console.log("Random Pattern Drill"), - // }, - // { - // title: "Random LC Problem", - // icon: Plus, - // action: () => console.log("Random LC Problem"), - // }, - ], - [], - ); - return ( - - - Recent Activity - - - -
    - {loading ? ( -
    Loading...
    - ) : items.length === 0 ? ( -
    No recent activity yet.
    - ) : ( - items.map((a) => ( -
    navigate(`/problem/${a.problem_id}`)} - role="button" - aria-label={`Open ${a.title}`} - > -
    -
    - {a.status === "passed" - ? "Solved" - : a.status === "failed" - ? "Attempt failed" - : "Attempted"} - : {a.title} -
    -
    - {a.difficulty ? `${a.difficulty} • ` : ""} - {formatTimeAgo(a.updated_at)} -
    -
    -
    - -
    + +
    +

    + Recent Activity +

    + +
    + {loading ? ( +
    + {[...Array(3)].map((_, i) => ( + + ))}
    - )) - )} + ) : items.length === 0 ? ( +
    No recent activity.
    + ) : ( + items.map((a) => ( + + )) + )} +
    diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7e91a17..d5d55e2 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -4,38 +4,14 @@ import { Progress } from "@/components/ui/progress"; import FeedbackModal from "@/components/FeedbackModal"; import { Home, - BarChart3, Settings, - List, - Database, - Layers, - Brain, - Hash, - Type, - ArrowLeftRight, - SlidersHorizontal, - Search, - TreePine, - FolderTree, - Mountain, - RotateCcw, - Sparkles, - Code2, - TrendingUp, - Zap, - Network, - Grid3X3, - DollarSign, - Calendar, - Calculator, - Binary, - User, - MessageSquare, Shield, + MessageSquare, + Sparkles, } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@/hooks/useAuth"; -import { useProblems } from "@/hooks/useProblems"; +import { useProblems } from "@/features/problems/hooks/useProblems"; const ADMIN_EMAILS = [ "tazigrigolia@gmail.com", @@ -45,7 +21,7 @@ const ADMIN_EMAILS = [ const Sidebar = () => { const location = useLocation(); const navigate = useNavigate(); - const { user, signOut } = useAuth(); + const { user } = useAuth(); const { categories } = useProblems(user?.id); const isAdmin = user?.email && ADMIN_EMAILS.includes(user.email); @@ -55,8 +31,7 @@ const Sidebar = () => { { icon: Settings, label: "Settings", path: "/settings" }, ]; - // No per-category icons in progress list (simplified UI) - + // Default Sidebar return (
    {/* Logo */} @@ -82,8 +57,8 @@ const Sidebar = () => { key={item.path} variant={isActive ? "default" : "ghost"} className={`w-full justify-start ${isActive - ? "bg-primary text-primary-foreground" - : "hover:bg-secondary text-foreground" + ? "bg-primary text-primary-foreground" + : "hover:bg-secondary text-foreground" }`} onClick={() => navigate(item.path)} > @@ -93,13 +68,12 @@ const Sidebar = () => { ); })} - {/* Admin Button - Only show for admin users */} {isAdmin && ( - + {/* {subscription?.status === 'active' && (