diff --git a/CLAUDE.md b/CLAUDE.md
index 5686f30..4443aeb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,38 +74,11 @@ tasks (id, title, description, status, created_at, updated_at, completed_at)
## Development Commands
-### Testing
-```bash
-# Backend tests
-just test-unit # Unit tests (cargo test)
-just test-coverage # Coverage report (tarpaulin HTML)
-just test-integration # API tests (Hurl)
+**Primary command**: Use `just` for all development tasks. Run `just --list` to see all available commands.
-# Individual commands
-cargo test # Direct unit test execution
-hurl --test tests/api/*.hurl # Direct API test execution
-
-# Frontend tests (when implemented)
-npm test # Unit tests
-npm run test:e2e # End-to-end tests
-npm run test:coverage # Coverage report
-```
-
-### Development Server
-```bash
-# Backend (Rust server)
-just dev # Run backend server (cargo run)
-
-# Other backend commands
-just build # Build project
-just migrate # Run database migrations
-just reset-db # Reset database
-just fmt # Format code
-just lint # Run clippy
-
-# Frontend (when implemented)
-npm run dev # Vite dev server
-```
+**Key commands**:
+- `just check` - Validate all changes (primary validation command)
+- `just fmt` - Format code (resolve formatting errors)
## Current Phase: Core MVP Backend ✅
diff --git a/frontend/app/app.css b/frontend/app/app.css
index ba71dcb..e4d1dd9 100644
--- a/frontend/app/app.css
+++ b/frontend/app/app.css
@@ -1,59 +1,8 @@
-@import 'tailwindcss';
-
-@theme {
- --font-sans:
- 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
- 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
-
- /* Captain's Log Design Tokens */
- --color-primary-50: #eff6ff;
- --color-primary-100: #dbeafe;
- --color-primary-500: #3b82f6;
- --color-primary-950: #172554;
-
- --color-task-todo: #3b82f6;
- --color-task-done: #22c55e;
- --color-task-backlog: #6b7280;
-}
+/* Captain's Log - Global Styles */
+/* Base font family is handled by Material-UI theme */
html,
body {
- @apply bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100;
-
- @media (prefers-color-scheme: dark) {
- color-scheme: dark;
- }
-}
-
-/* Captain's Log Component Styles */
-.task-card {
- @apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 transition-all duration-200;
-}
-
-.task-card:hover {
- @apply shadow-md border-gray-300 dark:border-gray-600;
-}
-
-.quick-capture {
- @apply bg-white dark:bg-gray-800 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-4 transition-colors duration-200;
-}
-
-.quick-capture:focus-within {
- @apply border-blue-500 bg-blue-50 dark:bg-blue-950;
-}
-
-.status-badge {
- @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
-}
-
-.status-todo {
- @apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200;
-}
-
-.status-done {
- @apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
-}
-
-.status-backlog {
- @apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200;
+ margin: 0;
+ padding: 0;
}
diff --git a/frontend/app/components/ErrorFallback.tsx b/frontend/app/components/ErrorFallback.tsx
new file mode 100644
index 0000000..f76a546
--- /dev/null
+++ b/frontend/app/components/ErrorFallback.tsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import {
+ Alert,
+ AlertTitle,
+ Box,
+ Button,
+ Container,
+ Typography,
+ Paper,
+} from '@mui/material'
+import {
+ Refresh as RefreshIcon,
+ BugReport as BugReportIcon,
+} from '@mui/icons-material'
+
+interface ErrorFallbackProps {
+ error: Error
+ resetError: () => void
+}
+
+export default function ErrorFallback({
+ error,
+ resetError,
+}: ErrorFallbackProps) {
+ return (
+
+
+
+
+
+ Something went wrong
+
+
+ An unexpected error occurred. Please try refreshing the page or
+ contact support if the problem persists.
+
+
+
+
+ Error Details
+ {error.message}
+
+
+
+ }
+ onClick={resetError}
+ size="large"
+ >
+ Try Again
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/components/Layout.tsx b/frontend/app/components/Layout.tsx
new file mode 100644
index 0000000..169e9f5
--- /dev/null
+++ b/frontend/app/components/Layout.tsx
@@ -0,0 +1,216 @@
+import React, { useState } from 'react'
+import {
+ AppBar,
+ Box,
+ CssBaseline,
+ Drawer,
+ IconButton,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ Toolbar,
+ Typography,
+ useTheme,
+ Fab,
+ LinearProgress,
+} from '@mui/material'
+import {
+ Menu as MenuIcon,
+ Dashboard as DashboardIcon,
+ Add as AddIcon,
+ Settings as SettingsIcon,
+} from '@mui/icons-material'
+
+const drawerWidth = 240
+
+interface LayoutProps {
+ children: React.ReactNode
+ loading?: boolean
+}
+
+export default function Layout({ children, loading = false }: LayoutProps) {
+ const theme = useTheme()
+ const [mobileOpen, setMobileOpen] = useState(false)
+
+ const handleDrawerToggle = () => {
+ setMobileOpen(!mobileOpen)
+ }
+
+ const menuItems = [
+ {
+ text: 'Tasks',
+ icon: ,
+ path: '/',
+ },
+ {
+ text: 'Settings',
+ icon: ,
+ path: '/settings',
+ },
+ ]
+
+ const drawer = (
+
+
+ {menuItems.map(item => (
+
+
+
+ {item.icon}
+
+
+
+
+ ))}
+
+
+ )
+
+ return (
+
+
+
+ {/* Loading indicator */}
+ {loading && (
+
+ )}
+
+ {/* App Bar */}
+
+
+
+
+
+
+
+ ⚓ Captain's Log
+
+
+
+
+ {/* Drawer */}
+
+ {/* Mobile drawer */}
+
+ {drawer}
+
+
+ {/* Desktop drawer */}
+
+ {drawer}
+
+
+
+ {/* Main content */}
+
+
+ {children}
+
+
+ {/* Quick capture FAB */}
+
+
+
+
+ )
+}
diff --git a/frontend/app/components/LoadingSpinner.tsx b/frontend/app/components/LoadingSpinner.tsx
new file mode 100644
index 0000000..24955c3
--- /dev/null
+++ b/frontend/app/components/LoadingSpinner.tsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import { Box, CircularProgress, Typography, Skeleton } from '@mui/material'
+
+interface LoadingSpinnerProps {
+ size?: number
+ message?: string
+ variant?: 'spinner' | 'skeleton'
+}
+
+export default function LoadingSpinner({
+ size = 40,
+ message = 'Loading...',
+ variant = 'spinner',
+}: LoadingSpinnerProps) {
+ if (variant === 'skeleton') {
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {message}
+
+
+ )
+}
diff --git a/frontend/app/hooks/useApi.test.ts b/frontend/app/hooks/useApi.test.ts
new file mode 100644
index 0000000..e30aacf
--- /dev/null
+++ b/frontend/app/hooks/useApi.test.ts
@@ -0,0 +1,359 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { renderHook, act, waitFor } from '@testing-library/react'
+import { useApi, useApiForm, useApiCache } from './useApi'
+
+// Mock API function for testing
+const mockApiFunction = vi.fn()
+const mockFormSubmitFunction = vi.fn()
+const mockCacheFunction = vi.fn()
+
+describe('useApi', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useApi(mockApiFunction))
+
+ expect(result.current.data).toBeNull()
+ expect(result.current.loading).toBe(false)
+ expect(result.current.error).toBeNull()
+ })
+
+ it('should execute API function successfully', async () => {
+ const mockData = { id: 1, name: 'Test' }
+ mockApiFunction.mockResolvedValueOnce(mockData)
+
+ const { result } = renderHook(() => useApi(mockApiFunction))
+
+ let executeResult: unknown
+
+ await act(async () => {
+ executeResult = await result.current.execute('test-arg')
+ })
+
+ expect(result.current.data).toEqual(mockData)
+ expect(result.current.loading).toBe(false)
+ expect(result.current.error).toBeNull()
+ expect(executeResult).toEqual(mockData)
+ expect(mockApiFunction).toHaveBeenCalledWith('test-arg')
+ })
+
+ it('should handle API function errors', async () => {
+ const errorMessage = 'API Error'
+ mockApiFunction.mockRejectedValueOnce({ message: errorMessage })
+
+ const { result } = renderHook(() => useApi(mockApiFunction))
+
+ let executeResult: unknown
+
+ await act(async () => {
+ executeResult = await result.current.execute()
+ })
+
+ expect(result.current.data).toBeNull()
+ expect(result.current.loading).toBe(false)
+ expect(result.current.error).toBe(errorMessage)
+ expect(executeResult).toBeNull()
+ })
+
+ it('should call onSuccess callback', async () => {
+ const mockData = { id: 1 }
+ const onSuccess = vi.fn()
+ mockApiFunction.mockResolvedValueOnce(mockData)
+
+ const { result } = renderHook(() => useApi(mockApiFunction, { onSuccess }))
+
+ await act(async () => {
+ await result.current.execute()
+ })
+
+ expect(onSuccess).toHaveBeenCalledWith(mockData)
+ })
+
+ it('should call onError callback', async () => {
+ const errorMessage = 'Test Error'
+ const onError = vi.fn()
+ mockApiFunction.mockRejectedValueOnce({ message: errorMessage })
+
+ const { result } = renderHook(() => useApi(mockApiFunction, { onError }))
+
+ await act(async () => {
+ await result.current.execute()
+ })
+
+ expect(onError).toHaveBeenCalledWith(errorMessage)
+ })
+
+ it('should execute immediately when immediate option is true', async () => {
+ const mockData = { id: 1 }
+ mockApiFunction.mockResolvedValueOnce(mockData)
+
+ renderHook(() => useApi(mockApiFunction, { immediate: true }))
+
+ await waitFor(() => {
+ expect(mockApiFunction).toHaveBeenCalled()
+ })
+ })
+
+ it('should reset state', async () => {
+ const mockData = { id: 1 }
+ mockApiFunction.mockResolvedValueOnce(mockData)
+
+ const { result } = renderHook(() => useApi(mockApiFunction))
+
+ await act(async () => {
+ await result.current.execute()
+ })
+
+ expect(result.current.data).toEqual(mockData)
+
+ act(() => {
+ result.current.reset()
+ })
+
+ expect(result.current.data).toBeNull()
+ expect(result.current.loading).toBe(false)
+ expect(result.current.error).toBeNull()
+ })
+
+ it('should clear error', async () => {
+ mockApiFunction.mockRejectedValueOnce({ message: 'Test Error' })
+
+ const { result } = renderHook(() => useApi(mockApiFunction))
+
+ await act(async () => {
+ await result.current.execute()
+ })
+
+ expect(result.current.error).toBeTruthy()
+
+ act(() => {
+ result.current.clearError()
+ })
+
+ expect(result.current.error).toBeNull()
+ })
+
+ it('should cancel previous requests', async () => {
+ let resolveFirst: (value: unknown) => void
+ let resolveSecond: (value: unknown) => void
+
+ const firstPromise = new Promise(resolve => {
+ resolveFirst = resolve
+ })
+ const secondPromise = new Promise(resolve => {
+ resolveSecond = resolve
+ })
+
+ mockApiFunction
+ .mockReturnValueOnce(firstPromise)
+ .mockReturnValueOnce(secondPromise)
+
+ const { result } = renderHook(() => useApi(mockApiFunction))
+
+ // Start first request
+ act(() => {
+ result.current.execute('first')
+ })
+
+ // Start second request before first completes
+ act(() => {
+ result.current.execute('second')
+ })
+
+ // Resolve first request (should be cancelled)
+ await act(async () => {
+ resolveFirst({ data: 'first' })
+ await new Promise(resolve => setTimeout(resolve, 0))
+ })
+
+ // Resolve second request
+ await act(async () => {
+ resolveSecond({ data: 'second' })
+ await new Promise(resolve => setTimeout(resolve, 0))
+ })
+
+ expect(result.current.data).toEqual({ data: 'second' })
+ })
+})
+
+describe('useApiForm', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should handle form submission', async () => {
+ const formData = { name: 'Test' }
+ const responseData = { id: 1, name: 'Test' }
+ mockFormSubmitFunction.mockResolvedValueOnce(responseData)
+
+ const { result } = renderHook(() => useApiForm(mockFormSubmitFunction))
+
+ let submitResult: unknown
+
+ await act(async () => {
+ submitResult = await result.current.handleSubmit(formData)
+ })
+
+ expect(submitResult).toEqual(responseData)
+ expect(mockFormSubmitFunction).toHaveBeenCalledWith(formData)
+ expect(result.current.data).toEqual(responseData)
+ })
+
+ it('should reset on success when option is enabled', async () => {
+ const formData = { name: 'Test' }
+ const responseData = { id: 1, name: 'Test' }
+ mockFormSubmitFunction.mockResolvedValueOnce(responseData)
+
+ const { result } = renderHook(() =>
+ useApiForm(mockFormSubmitFunction, { resetOnSuccess: true })
+ )
+
+ await act(async () => {
+ await result.current.handleSubmit(formData)
+ })
+
+ expect(result.current.data).toBeNull()
+ })
+})
+
+describe('useApiCache', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should fetch and cache data', async () => {
+ const mockData = { id: 1, name: 'Test' }
+ mockCacheFunction.mockResolvedValueOnce(mockData)
+
+ const { result } = renderHook(() =>
+ useApiCache('test-key', mockCacheFunction)
+ )
+
+ let fetchResult: unknown
+
+ await act(async () => {
+ fetchResult = await result.current.fetchData()
+ })
+
+ expect(fetchResult).toEqual(mockData)
+ expect(result.current.data).toEqual(mockData)
+ expect(mockCacheFunction).toHaveBeenCalledTimes(1)
+ })
+
+ it('should return cached data when fresh', async () => {
+ const mockData = { id: 1, name: 'Test' }
+ mockCacheFunction.mockResolvedValueOnce(mockData)
+
+ const { result } = renderHook(() =>
+ useApiCache('test-key', mockCacheFunction, { staleTime: 60000 })
+ )
+
+ // First fetch
+ await act(async () => {
+ await result.current.fetchData()
+ })
+
+ // Second fetch should return cached data
+ let secondResult: unknown
+
+ await act(async () => {
+ secondResult = await result.current.fetchData()
+ })
+
+ expect(secondResult).toEqual(mockData)
+ expect(mockCacheFunction).toHaveBeenCalledTimes(1) // Should not call API again
+ })
+
+ it('should fetch fresh data when stale', async () => {
+ const mockData1 = { id: 1, name: 'Test 1' }
+ const mockData2 = { id: 2, name: 'Test 2' }
+
+ mockCacheFunction
+ .mockResolvedValueOnce(mockData1)
+ .mockResolvedValueOnce(mockData2)
+
+ const { result } = renderHook(() =>
+ useApiCache('test-key', mockCacheFunction, { staleTime: 1000 })
+ )
+
+ // First fetch
+ await act(async () => {
+ await result.current.fetchData()
+ })
+
+ expect(result.current.data).toEqual(mockData1)
+
+ // Advance time past stale time
+ act(() => {
+ vi.advanceTimersByTime(2000)
+ })
+
+ // Second fetch should get fresh data
+ await act(async () => {
+ await result.current.fetchData()
+ })
+
+ expect(result.current.data).toEqual(mockData2)
+ expect(mockCacheFunction).toHaveBeenCalledTimes(2)
+ })
+
+ it('should clear cache', async () => {
+ const mockData = { id: 1, name: 'Test' }
+ mockCacheFunction.mockResolvedValueOnce(mockData)
+
+ const { result } = renderHook(() =>
+ useApiCache('test-key', mockCacheFunction)
+ )
+
+ await act(async () => {
+ await result.current.fetchData()
+ })
+
+ expect(result.current.data).toEqual(mockData)
+
+ await act(async () => {
+ result.current.clearCache()
+ })
+
+ expect(result.current.data).toBeNull()
+ })
+
+ it('should indicate stale status', async () => {
+ const mockData = { id: 1, name: 'Test' }
+ mockCacheFunction.mockResolvedValueOnce(mockData)
+
+ const { result } = renderHook(() =>
+ useApiCache('test-key', mockCacheFunction, { staleTime: 1000 })
+ )
+
+ // Initially stale (no data)
+ expect(result.current.isStale).toBe(true)
+
+ // Fetch data
+ await act(async () => {
+ await result.current.fetchData()
+ })
+
+ // Should be fresh
+ expect(result.current.isStale).toBe(false)
+
+ // Advance time past stale time
+ act(() => {
+ vi.advanceTimersByTime(2000)
+ })
+
+ // Should be stale again
+ expect(result.current.isStale).toBe(true)
+ })
+})
diff --git a/frontend/app/hooks/useApi.ts b/frontend/app/hooks/useApi.ts
new file mode 100644
index 0000000..bdfacea
--- /dev/null
+++ b/frontend/app/hooks/useApi.ts
@@ -0,0 +1,227 @@
+import { useState, useCallback, useRef, useEffect } from 'react'
+import type { ApiError } from '~/types/task'
+
+interface UseApiState {
+ data: T | null
+ loading: boolean
+ error: string | null
+}
+
+interface UseApiActions {
+ execute: (...args: unknown[]) => Promise
+ reset: () => void
+ clearError: () => void
+}
+
+interface UseApiOptions {
+ immediate?: boolean
+ onSuccess?: (data: unknown) => void
+ onError?: (error: string) => void
+}
+
+export function useApi(
+ apiFunction: (...args: unknown[]) => Promise,
+ options: UseApiOptions = {}
+): UseApiState & UseApiActions {
+ const { immediate = false, onSuccess, onError } = options
+
+ const [state, setState] = useState>({
+ data: null,
+ loading: false,
+ error: null,
+ })
+
+ const mountedRef = useRef(true)
+ const abortControllerRef = useRef(null)
+
+ useEffect(() => {
+ return () => {
+ mountedRef.current = false
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort()
+ }
+ }
+ }, [])
+
+ const reset = useCallback(() => {
+ setState({
+ data: null,
+ loading: false,
+ error: null,
+ })
+ }, [])
+
+ const clearError = useCallback(() => {
+ setState(prev => ({ ...prev, error: null }))
+ }, [])
+
+ const execute = useCallback(
+ async (...args: unknown[]): Promise => {
+ // Cancel previous request if still pending
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort()
+ }
+
+ abortControllerRef.current = new AbortController()
+
+ setState(prev => ({ ...prev, loading: true, error: null }))
+
+ try {
+ const result = await apiFunction(...args)
+
+ if (!mountedRef.current) return null
+
+ setState(prev => ({
+ ...prev,
+ data: result,
+ loading: false,
+ }))
+
+ if (onSuccess) {
+ onSuccess(result)
+ }
+
+ return result
+ } catch (error) {
+ if (!mountedRef.current) return null
+
+ const apiError = error as ApiError
+ const errorMessage = apiError.message || 'An unknown error occurred'
+
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: errorMessage,
+ }))
+
+ if (onError) {
+ onError(errorMessage)
+ }
+
+ return null
+ } finally {
+ abortControllerRef.current = null
+ }
+ },
+ [apiFunction, onSuccess, onError]
+ )
+
+ // Execute immediately if requested
+ useEffect(() => {
+ if (immediate) {
+ execute()
+ }
+ }, [immediate, execute])
+
+ return {
+ ...state,
+ execute,
+ reset,
+ clearError,
+ }
+}
+
+// Utility hook for handling form submissions
+export function useApiForm(
+ submitFunction: (data: unknown) => Promise,
+ options: UseApiOptions & { resetOnSuccess?: boolean } = {}
+) {
+ const { resetOnSuccess = false, ...apiOptions } = options
+
+ const api = useApi(submitFunction, apiOptions)
+
+ const handleSubmit = useCallback(
+ async (data: unknown) => {
+ const result = await api.execute(data)
+
+ if (result && resetOnSuccess) {
+ api.reset()
+ }
+
+ return result
+ },
+ [api, resetOnSuccess]
+ )
+
+ return {
+ ...api,
+ handleSubmit,
+ }
+}
+
+// Utility hook for data caching and synchronization
+export function useApiCache(
+ key: string,
+ fetchFunction: () => Promise,
+ options: { cacheTime?: number; staleTime?: number } = {}
+) {
+ const { cacheTime = 5 * 60 * 1000, staleTime = 30 * 1000 } = options // 5min cache, 30s stale
+
+ const [cacheData, setCacheData] = useState<{
+ data: T | null
+ timestamp: number
+ } | null>(null)
+
+ const api = useApi(fetchFunction)
+
+ const getCachedData = useCallback((): T | null => {
+ if (!cacheData) return null
+
+ const now = Date.now()
+ const age = now - cacheData.timestamp
+
+ if (age > cacheTime) {
+ setCacheData(null)
+ return null
+ }
+
+ return cacheData.data
+ }, [cacheData, cacheTime])
+
+ const isStale = useCallback((): boolean => {
+ if (!cacheData) return true
+
+ const now = Date.now()
+ const age = now - cacheData.timestamp
+
+ return age > staleTime
+ }, [cacheData, staleTime])
+
+ const fetchData = useCallback(
+ async (force = false): Promise => {
+ // Return cached data if fresh and not forced
+ if (!force && !isStale()) {
+ const cached = getCachedData()
+ if (cached) return cached
+ }
+
+ const result = await api.execute()
+
+ if (result) {
+ setCacheData({
+ data: result,
+ timestamp: Date.now(),
+ })
+ }
+
+ return result
+ },
+ [api, isStale, getCachedData]
+ )
+
+ const clearCache = useCallback(() => {
+ setCacheData(null)
+ api.reset()
+ }, [api])
+
+ return {
+ data: api.data || getCachedData(),
+ loading: api.loading,
+ error: api.error,
+ fetchData,
+ clearCache,
+ get isStale() {
+ return isStale()
+ },
+ }
+}
diff --git a/frontend/app/hooks/useTask.test.ts b/frontend/app/hooks/useTask.test.ts
new file mode 100644
index 0000000..4004e4d
--- /dev/null
+++ b/frontend/app/hooks/useTask.test.ts
@@ -0,0 +1,211 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { renderHook, act, waitFor } from '@testing-library/react'
+import { useTask } from './useTask'
+import type { Task, UpdateTaskRequest } from '~/types/task'
+import { TaskStatus } from '~/types/task'
+import { apiClient } from '~/services/api'
+
+// Mock the API client
+vi.mock('~/services/api', () => ({
+ apiClient: {
+ getTask: vi.fn(),
+ updateTask: vi.fn(),
+ deleteTask: vi.fn(),
+ },
+}))
+
+const mockApiClient = apiClient as unknown as {
+ getTask: ReturnType
+ updateTask: ReturnType
+ deleteTask: ReturnType
+}
+
+// Sample task data
+const mockTask: Task = {
+ id: '550e8400-e29b-41d4-a716-446655440000',
+ title: 'Test Task',
+ description: 'Test Description',
+ status: TaskStatus.Todo,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-01T00:00:00Z',
+ completed_at: null,
+}
+
+describe('useTask', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useTask())
+
+ expect(result.current.task).toBeNull()
+ expect(result.current.loading).toBe(false)
+ expect(result.current.error).toBeNull()
+ })
+
+ describe('getTask', () => {
+ it('should fetch task successfully', async () => {
+ mockApiClient.getTask.mockResolvedValueOnce(mockTask)
+
+ const { result } = renderHook(() => useTask())
+
+ act(() => {
+ result.current.getTask(mockTask.id)
+ })
+
+ expect(result.current.loading).toBe(true)
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false)
+ })
+
+ expect(result.current.task).toEqual(mockTask)
+ expect(result.current.error).toBeNull()
+ expect(mockApiClient.getTask).toHaveBeenCalledWith(mockTask.id)
+ })
+
+ it('should handle fetch errors', async () => {
+ const errorMessage = 'Task not found'
+ mockApiClient.getTask.mockRejectedValueOnce({ message: errorMessage })
+
+ const { result } = renderHook(() => useTask())
+
+ act(() => {
+ result.current.getTask(mockTask.id)
+ })
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false)
+ })
+
+ expect(result.current.task).toBeNull()
+ expect(result.current.error).toBe(errorMessage)
+ })
+ })
+
+ describe('updateTask', () => {
+ it('should update task with optimistic update', async () => {
+ const updateData: UpdateTaskRequest = {
+ title: 'Updated Task',
+ status: TaskStatus.Done,
+ }
+ const updatedTask = { ...mockTask, ...updateData }
+
+ mockApiClient.getTask.mockResolvedValueOnce(mockTask)
+ mockApiClient.updateTask.mockResolvedValueOnce(updatedTask)
+
+ const { result } = renderHook(() => useTask())
+
+ // Set initial task
+ await act(async () => {
+ await result.current.getTask(mockTask.id)
+ })
+
+ let updateResult: Task | null = null
+
+ await act(async () => {
+ updateResult = await result.current.updateTask(mockTask.id, updateData)
+ })
+
+ expect(updateResult).toEqual(updatedTask)
+ expect(mockApiClient.updateTask).toHaveBeenCalledWith(
+ mockTask.id,
+ updateData
+ )
+ })
+
+ it('should handle update errors and revert optimistic update', async () => {
+ const updateData: UpdateTaskRequest = { status: TaskStatus.Done }
+ const errorMessage = 'Update failed'
+
+ // Setup initial task
+ mockApiClient.getTask.mockResolvedValueOnce(mockTask)
+
+ const { result } = renderHook(() => useTask())
+
+ // Set initial task state
+ await act(async () => {
+ await result.current.getTask(mockTask.id)
+ })
+
+ expect(result.current.task).toEqual(mockTask)
+
+ // Mock update failure and revert call
+ mockApiClient.updateTask.mockRejectedValueOnce({ message: errorMessage })
+ mockApiClient.getTask.mockResolvedValueOnce(mockTask)
+
+ let updateResult: Task | null = null
+
+ await act(async () => {
+ updateResult = await result.current.updateTask(mockTask.id, updateData)
+ })
+
+ expect(updateResult).toBeNull()
+ expect(result.current.error).toBe(errorMessage)
+ expect(result.current.loading).toBe(false)
+ })
+ })
+
+ describe('deleteTask', () => {
+ it('should delete task successfully', async () => {
+ mockApiClient.deleteTask.mockResolvedValueOnce(undefined)
+
+ const { result } = renderHook(() => useTask())
+
+ // Set initial task
+ await act(async () => {
+ mockApiClient.getTask.mockResolvedValueOnce(mockTask)
+ await result.current.getTask(mockTask.id)
+ })
+
+ await act(async () => {
+ await result.current.deleteTask(mockTask.id)
+ })
+
+ expect(result.current.task).toBeNull()
+ expect(result.current.error).toBeNull()
+ expect(mockApiClient.deleteTask).toHaveBeenCalledWith(mockTask.id)
+ })
+
+ it('should handle delete errors', async () => {
+ const errorMessage = 'Delete failed'
+ mockApiClient.deleteTask.mockRejectedValueOnce({ message: errorMessage })
+
+ const { result } = renderHook(() => useTask())
+
+ await act(async () => {
+ await result.current.deleteTask(mockTask.id)
+ })
+
+ expect(result.current.error).toBe(errorMessage)
+ expect(result.current.loading).toBe(false)
+ })
+ })
+
+ describe('clearError', () => {
+ it('should clear error state', async () => {
+ mockApiClient.getTask.mockRejectedValueOnce({ message: 'Test error' })
+
+ const { result } = renderHook(() => useTask())
+
+ // Trigger an error
+ await act(async () => {
+ await result.current.getTask(mockTask.id)
+ })
+
+ expect(result.current.error).toBeTruthy()
+
+ // Clear the error
+ act(() => {
+ result.current.clearError()
+ })
+
+ expect(result.current.error).toBeNull()
+ })
+ })
+})
diff --git a/frontend/app/hooks/useTask.ts b/frontend/app/hooks/useTask.ts
new file mode 100644
index 0000000..23a17b4
--- /dev/null
+++ b/frontend/app/hooks/useTask.ts
@@ -0,0 +1,114 @@
+import { useState, useCallback } from 'react'
+import type { Task, UpdateTaskRequest, ApiError } from '~/types/task'
+import { apiClient } from '~/services/api'
+
+interface UseTaskState {
+ task: Task | null
+ loading: boolean
+ error: string | null
+}
+
+interface UseTaskActions {
+ getTask: (id: string) => Promise
+ updateTask: (id: string, data: UpdateTaskRequest) => Promise
+ deleteTask: (id: string) => Promise
+ clearError: () => void
+}
+
+export function useTask(): UseTaskState & UseTaskActions {
+ const [state, setState] = useState({
+ task: null,
+ loading: false,
+ error: null,
+ })
+
+ const clearError = useCallback(() => {
+ setState(prev => ({ ...prev, error: null }))
+ }, [])
+
+ const getTask = useCallback(async (id: string) => {
+ setState(prev => ({ ...prev, loading: true, error: null }))
+
+ try {
+ const task = await apiClient.getTask(id)
+ setState(prev => ({ ...prev, task, loading: false }))
+ } catch (error) {
+ const apiError = error as ApiError
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: apiError.message,
+ }))
+ }
+ }, [])
+
+ const updateTask = useCallback(
+ async (id: string, data: UpdateTaskRequest): Promise => {
+ setState(prev => ({ ...prev, loading: true, error: null }))
+
+ try {
+ // Optimistic update
+ if (state.task && state.task.id === id) {
+ const optimisticTask: Task = {
+ ...state.task,
+ ...data,
+ updated_at: new Date().toISOString(),
+ }
+ setState(prev => ({ ...prev, task: optimisticTask }))
+ }
+
+ const updatedTask = await apiClient.updateTask(id, data)
+ setState(prev => ({ ...prev, task: updatedTask, loading: false }))
+ return updatedTask
+ } catch (error) {
+ const apiError = error as ApiError
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: apiError.message,
+ }))
+
+ // Revert optimistic update on error
+ if (state.task && state.task.id === id) {
+ try {
+ const originalTask = await apiClient.getTask(id)
+ setState(prev => ({ ...prev, task: originalTask }))
+ } catch {
+ // If we can't revert, just keep the optimistic state
+ }
+ }
+
+ return null
+ }
+ },
+ [state.task]
+ )
+
+ const deleteTask = useCallback(async (id: string) => {
+ setState(prev => ({ ...prev, loading: true, error: null }))
+
+ try {
+ await apiClient.deleteTask(id)
+ setState(prev => ({
+ ...prev,
+ task: prev.task?.id === id ? null : prev.task,
+ loading: false,
+ }))
+ } catch (error) {
+ const apiError = error as ApiError
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: apiError.message,
+ }))
+ }
+ }, [])
+
+ return {
+ ...state,
+ getTask,
+ updateTask,
+ deleteTask,
+ clearError,
+ }
+}
diff --git a/frontend/app/hooks/useTasks.test.ts b/frontend/app/hooks/useTasks.test.ts
new file mode 100644
index 0000000..7b68dfe
--- /dev/null
+++ b/frontend/app/hooks/useTasks.test.ts
@@ -0,0 +1,318 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { renderHook, act, waitFor } from '@testing-library/react'
+import { useTasks } from './useTasks'
+import type { Task, CreateTaskRequest } from '~/types/task'
+import { TaskStatus } from '~/types/task'
+import { apiClient } from '~/services/api'
+
+// Mock the API client
+vi.mock('~/services/api', () => ({
+ apiClient: {
+ listTasks: vi.fn(),
+ createTask: vi.fn(),
+ },
+}))
+
+const mockApiClient = apiClient as unknown as {
+ listTasks: ReturnType
+ createTask: ReturnType
+}
+
+// Sample task data
+const mockTasks: Task[] = [
+ {
+ id: '550e8400-e29b-41d4-a716-446655440000',
+ title: 'Test Task 1',
+ description: 'Test Description 1',
+ status: TaskStatus.Todo,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-01T00:00:00Z',
+ completed_at: null,
+ },
+ {
+ id: '550e8400-e29b-41d4-a716-446655440001',
+ title: 'Test Task 2',
+ description: 'Test Description 2',
+ status: TaskStatus.Done,
+ created_at: '2023-01-01T01:00:00Z',
+ updated_at: '2023-01-01T01:00:00Z',
+ completed_at: '2023-01-01T02:00:00Z',
+ },
+ {
+ id: '550e8400-e29b-41d4-a716-446655440002',
+ title: 'Test Task 3',
+ description: null,
+ status: TaskStatus.Backlog,
+ created_at: '2023-01-01T02:00:00Z',
+ updated_at: '2023-01-01T02:00:00Z',
+ completed_at: null,
+ },
+]
+
+describe('useTasks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useTasks({ autoFetch: false }))
+
+ expect(result.current.tasks).toEqual([])
+ expect(result.current.loading).toBe(false)
+ expect(result.current.error).toBeNull()
+ expect(result.current.lastFetch).toBeNull()
+ })
+
+ it('should auto-fetch tasks on mount by default', async () => {
+ mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
+
+ renderHook(() => useTasks())
+
+ await waitFor(() => {
+ expect(mockApiClient.listTasks).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should not auto-fetch when disabled', () => {
+ renderHook(() => useTasks({ autoFetch: false }))
+
+ expect(mockApiClient.listTasks).not.toHaveBeenCalled()
+ })
+
+ describe('fetchTasks', () => {
+ it('should fetch tasks successfully', async () => {
+ mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
+
+ const { result } = renderHook(() => useTasks({ autoFetch: false }))
+
+ await act(async () => {
+ await result.current.fetchTasks()
+ })
+
+ expect(result.current.tasks).toEqual(mockTasks)
+ expect(result.current.loading).toBe(false)
+ expect(result.current.error).toBeNull()
+ expect(result.current.lastFetch).toBeInstanceOf(Date)
+ })
+
+ it('should handle fetch errors', async () => {
+ const errorMessage = 'Fetch failed'
+ mockApiClient.listTasks.mockRejectedValueOnce({ message: errorMessage })
+
+ const { result } = renderHook(() => useTasks({ autoFetch: false }))
+
+ await act(async () => {
+ await result.current.fetchTasks()
+ })
+
+ expect(result.current.tasks).toEqual([])
+ expect(result.current.loading).toBe(false)
+ expect(result.current.error).toBe(errorMessage)
+ })
+ })
+
+ describe('createTask', () => {
+ it('should create task successfully', async () => {
+ const newTaskData: CreateTaskRequest = {
+ title: 'New Task',
+ description: 'New Description',
+ }
+ const newTask: Task = {
+ id: 'new-task-id',
+ title: newTaskData.title,
+ description: newTaskData.description || null,
+ status: TaskStatus.Todo,
+ created_at: '2023-01-01T03:00:00Z',
+ updated_at: '2023-01-01T03:00:00Z',
+ completed_at: null,
+ }
+
+ mockApiClient.createTask.mockResolvedValueOnce(newTask)
+
+ const { result } = renderHook(() => useTasks({ autoFetch: false }))
+
+ // Set initial tasks
+ await act(async () => {
+ mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
+ await result.current.fetchTasks()
+ })
+
+ let createResult: Task | null = null
+
+ await act(async () => {
+ createResult = await result.current.createTask(newTaskData)
+ })
+
+ expect(createResult).toEqual(newTask)
+ expect(result.current.tasks[0]).toEqual(newTask) // Should be first in list
+ expect(result.current.tasks).toHaveLength(mockTasks.length + 1)
+ expect(mockApiClient.createTask).toHaveBeenCalledWith(newTaskData)
+ })
+
+ it('should handle create errors', async () => {
+ const newTaskData: CreateTaskRequest = { title: '' }
+ const errorMessage = 'Title must not be empty'
+
+ mockApiClient.createTask.mockRejectedValueOnce({ message: errorMessage })
+
+ const { result } = renderHook(() => useTasks({ autoFetch: false }))
+
+ let createResult: Task | null = null
+
+ await act(async () => {
+ createResult = await result.current.createTask(newTaskData)
+ })
+
+ expect(createResult).toBeNull()
+ expect(result.current.error).toBe(errorMessage)
+ expect(result.current.loading).toBe(false)
+ })
+ })
+
+ describe('refreshTasks', () => {
+ it('should refresh tasks without loading state when tasks exist', async () => {
+ const { result } = renderHook(() => useTasks({ autoFetch: false }))
+
+ // Set initial tasks
+ await act(async () => {
+ mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
+ await result.current.fetchTasks()
+ })
+
+ // Refresh with updated tasks
+ const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }]
+ mockApiClient.listTasks.mockResolvedValueOnce(updatedTasks)
+
+ await act(async () => {
+ await result.current.refreshTasks()
+ })
+
+ expect(result.current.tasks).toEqual(updatedTasks)
+ })
+
+ it('should show loading state when no tasks exist', async () => {
+ mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
+
+ const { result } = renderHook(() => useTasks({ autoFetch: false }))
+
+ act(() => {
+ result.current.refreshTasks()
+ })
+
+ expect(result.current.loading).toBe(true)
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false)
+ })
+ })
+ })
+
+ describe('utility functions', () => {
+ beforeEach(async () => {
+ mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
+ })
+
+ it('should get task by ID', async () => {
+ const { result } = renderHook(() => useTasks())
+
+ await waitFor(() => {
+ expect(result.current.tasks).toHaveLength(mockTasks.length)
+ })
+
+ const foundTask = result.current.getTaskById(mockTasks[0].id)
+ expect(foundTask).toEqual(mockTasks[0])
+
+ const notFoundTask = result.current.getTaskById('nonexistent-id')
+ expect(notFoundTask).toBeUndefined()
+ })
+
+ it('should filter tasks by status', async () => {
+ const { result } = renderHook(() => useTasks())
+
+ await waitFor(() => {
+ expect(result.current.tasks).toHaveLength(mockTasks.length)
+ })
+
+ const todoTasks = result.current.filterTasksByStatus(TaskStatus.Todo)
+ expect(todoTasks).toHaveLength(1)
+ expect(todoTasks[0].status).toBe(TaskStatus.Todo)
+
+ const doneTasks = result.current.filterTasksByStatus(TaskStatus.Done)
+ expect(doneTasks).toHaveLength(1)
+ expect(doneTasks[0].status).toBe(TaskStatus.Done)
+
+ const backlogTasks = result.current.filterTasksByStatus(
+ TaskStatus.Backlog
+ )
+ expect(backlogTasks).toHaveLength(1)
+ expect(backlogTasks[0].status).toBe(TaskStatus.Backlog)
+ })
+ })
+
+ describe('clearError', () => {
+ it('should clear error state', async () => {
+ mockApiClient.listTasks.mockRejectedValueOnce({ message: 'Test error' })
+
+ const { result } = renderHook(() => useTasks({ autoFetch: false }))
+
+ // Trigger an error
+ await act(async () => {
+ await result.current.fetchTasks()
+ })
+
+ expect(result.current.error).toBeTruthy()
+
+ // Clear the error
+ act(() => {
+ result.current.clearError()
+ })
+
+ expect(result.current.error).toBeNull()
+ })
+ })
+
+ describe('refresh interval', () => {
+ it('should set up refresh interval', async () => {
+ vi.useFakeTimers()
+
+ const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }]
+
+ mockApiClient.listTasks
+ .mockResolvedValueOnce(mockTasks)
+ .mockResolvedValueOnce(updatedTasks)
+ .mockResolvedValue(updatedTasks) // Handle any extra calls
+
+ const { result, unmount } = renderHook(() =>
+ useTasks({ refreshInterval: 1000, autoFetch: false })
+ )
+
+ // Manual initial fetch
+ await act(async () => {
+ await result.current.fetchTasks()
+ })
+
+ expect(result.current.tasks).toHaveLength(mockTasks.length)
+
+ const initialCallCount = mockApiClient.listTasks.mock.calls.length
+
+ // Advance timer to trigger refresh
+ await act(async () => {
+ vi.advanceTimersByTime(1000)
+ await vi.runOnlyPendingTimersAsync()
+ })
+
+ // Should have made at least one more call
+ expect(mockApiClient.listTasks.mock.calls.length).toBeGreaterThan(
+ initialCallCount
+ )
+ expect(result.current.tasks).toHaveLength(updatedTasks.length)
+
+ unmount()
+ vi.useRealTimers()
+ }, 10000)
+ })
+})
diff --git a/frontend/app/hooks/useTasks.ts b/frontend/app/hooks/useTasks.ts
new file mode 100644
index 0000000..740d096
--- /dev/null
+++ b/frontend/app/hooks/useTasks.ts
@@ -0,0 +1,161 @@
+import { useState, useCallback, useEffect } from 'react'
+import type { Task, CreateTaskRequest, ApiError } from '~/types/task'
+import { TaskStatus } from '~/types/task'
+import { apiClient } from '~/services/api'
+
+interface UseTasksState {
+ tasks: Task[]
+ loading: boolean
+ error: string | null
+ lastFetch: Date | null
+}
+
+interface UseTasksActions {
+ fetchTasks: () => Promise
+ createTask: (data: CreateTaskRequest) => Promise
+ refreshTasks: () => Promise
+ clearError: () => void
+ getTaskById: (id: string) => Task | undefined
+ filterTasksByStatus: (status: TaskStatus) => Task[]
+}
+
+interface UseTasksOptions {
+ autoFetch?: boolean
+ refreshInterval?: number
+}
+
+export function useTasks(
+ options: UseTasksOptions = {}
+): UseTasksState & UseTasksActions {
+ const { autoFetch = true, refreshInterval } = options
+
+ const [state, setState] = useState({
+ tasks: [],
+ loading: false,
+ error: null,
+ lastFetch: null,
+ })
+
+ const clearError = useCallback(() => {
+ setState(prev => ({ ...prev, error: null }))
+ }, [])
+
+ const fetchTasks = useCallback(async () => {
+ setState(prev => ({ ...prev, loading: true, error: null }))
+
+ try {
+ const tasks = await apiClient.listTasks()
+ setState(prev => ({
+ ...prev,
+ tasks,
+ loading: false,
+ lastFetch: new Date(),
+ }))
+ } catch (error) {
+ const apiError = error as ApiError
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: apiError.message,
+ }))
+ }
+ }, [])
+
+ const createTask = useCallback(
+ async (data: CreateTaskRequest): Promise => {
+ setState(prev => ({ ...prev, loading: true, error: null }))
+
+ try {
+ const newTask = await apiClient.createTask(data)
+
+ // Add the new task to the beginning of the list (most recent first)
+ setState(prev => ({
+ ...prev,
+ tasks: [newTask, ...prev.tasks],
+ loading: false,
+ }))
+
+ return newTask
+ } catch (error) {
+ const apiError = error as ApiError
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: apiError.message,
+ }))
+ return null
+ }
+ },
+ []
+ )
+
+ const refreshTasks = useCallback(async () => {
+ // Force refresh without showing loading state if tasks already exist
+ const showLoading = state.tasks.length === 0
+
+ if (showLoading) {
+ setState(prev => ({ ...prev, loading: true, error: null }))
+ } else {
+ setState(prev => ({ ...prev, error: null }))
+ }
+
+ try {
+ const tasks = await apiClient.listTasks()
+ setState(prev => ({
+ ...prev,
+ tasks,
+ loading: false,
+ lastFetch: new Date(),
+ }))
+ } catch (error) {
+ const apiError = error as ApiError
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: apiError.message,
+ }))
+ }
+ }, [state.tasks])
+
+ const getTaskById = useCallback(
+ (id: string): Task | undefined => {
+ return state.tasks.find(task => task.id === id)
+ },
+ [state.tasks]
+ )
+
+ const filterTasksByStatus = useCallback(
+ (status: TaskStatus): Task[] => {
+ return state.tasks.filter(task => task.status === status)
+ },
+ [state.tasks]
+ )
+
+ // Auto-fetch tasks on mount
+ useEffect(() => {
+ if (autoFetch) {
+ fetchTasks()
+ }
+ }, [autoFetch, fetchTasks])
+
+ // Set up refresh interval if specified
+ useEffect(() => {
+ if (!refreshInterval) return
+
+ const interval = setInterval(() => {
+ refreshTasks()
+ }, refreshInterval)
+
+ return () => clearInterval(interval)
+ }, [refreshInterval, refreshTasks])
+
+ return {
+ ...state,
+ fetchTasks,
+ createTask,
+ refreshTasks,
+ clearError,
+ getTaskById,
+ filterTasksByStatus,
+ }
+}
diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx
index f9f8bdf..c7b440f 100644
--- a/frontend/app/root.tsx
+++ b/frontend/app/root.tsx
@@ -6,8 +6,12 @@ import {
Scripts,
ScrollRestoration,
} from 'react-router'
+import { ThemeProvider } from '@mui/material/styles'
+import { CssBaseline } from '@mui/material'
import type { Route } from './+types/root'
+import { theme } from './theme'
+import AppLayout from './components/Layout'
import './app.css'
export const links: Route.LinksFunction = () => [
@@ -42,7 +46,14 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export default function App() {
- return
+ return (
+
+
+
+
+
+
+ )
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
@@ -62,14 +73,19 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
}
return (
-
- {message}
- {details}
- {stack && (
-
- {stack}
-
- )}
-
+
+
+
+
+ {message}
+ {details}
+ {stack && (
+
+ {stack}
+
+ )}
+
+
+
)
}
diff --git a/frontend/app/routes/home.test.tsx b/frontend/app/routes/home.test.tsx
index c95397e..80dd150 100644
--- a/frontend/app/routes/home.test.tsx
+++ b/frontend/app/routes/home.test.tsx
@@ -3,8 +3,14 @@ import { describe, it, expect } from 'vitest'
import Home from './home'
describe('Home component', () => {
- it('should render welcome component', () => {
+ it('should render task management interface', () => {
render()
- expect(screen.getByText(/React Router/i)).toBeInTheDocument()
+ expect(screen.getByText(/Tasks/i)).toBeInTheDocument()
+ expect(
+ screen.getByText(/GTD-inspired task management system/i)
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText(/Task Management Interface Coming Soon/i)
+ ).toBeInTheDocument()
})
})
diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx
index 8440a09..b818ac6 100644
--- a/frontend/app/routes/home.tsx
+++ b/frontend/app/routes/home.tsx
@@ -1,13 +1,44 @@
import type { Route } from './+types/home'
-import { Welcome } from '../welcome/welcome'
+import { Box, Typography, Container } from '@mui/material'
export function meta(_: Route.MetaArgs) {
return [
- { title: 'New React Router App' },
- { name: 'description', content: 'Welcome to React Router!' },
+ { title: "Captain's Log - Tasks" },
+ { name: 'description', content: 'GTD-inspired task management system' },
]
}
export default function Home() {
- return
+ return (
+
+
+
+ Tasks
+
+
+ Your GTD-inspired task management system. Capture everything, see only
+ what matters.
+
+
+
+
+ Task Management Interface Coming Soon
+
+
+ The task list, task cards, and quick capture components will be
+ implemented in the next phase.
+
+
+
+
+ )
}
diff --git a/frontend/app/services/api.test.ts b/frontend/app/services/api.test.ts
new file mode 100644
index 0000000..3853bc1
--- /dev/null
+++ b/frontend/app/services/api.test.ts
@@ -0,0 +1,267 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { apiClient } from './api'
+import type { Task, CreateTaskRequest, UpdateTaskRequest } from '~/types/task'
+import { TaskStatus } from '~/types/task'
+
+// Mock fetch globally
+const mockFetch = vi.fn()
+global.fetch = mockFetch
+
+// Sample task data for testing
+const mockTask: Task = {
+ id: '550e8400-e29b-41d4-a716-446655440000',
+ title: 'Test Task',
+ description: 'Test Description',
+ status: TaskStatus.Todo,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-01T00:00:00Z',
+ completed_at: null,
+}
+
+const mockTasks: Task[] = [
+ mockTask,
+ {
+ ...mockTask,
+ id: '550e8400-e29b-41d4-a716-446655440001',
+ title: 'Another Task',
+ status: TaskStatus.Done,
+ completed_at: '2023-01-01T01:00:00Z',
+ },
+]
+
+describe('API Client', () => {
+ beforeEach(() => {
+ mockFetch.mockClear()
+ // Silence console.log during tests
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('listTasks', () => {
+ it('should fetch all tasks successfully', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: () => Promise.resolve(mockTasks),
+ })
+
+ const result = await apiClient.listTasks()
+
+ expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
+ headers: { 'Content-Type': 'application/json' },
+ })
+ expect(result).toEqual(mockTasks)
+ })
+
+ it('should handle empty task list', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: () => Promise.resolve([]),
+ })
+
+ const result = await apiClient.listTasks()
+
+ expect(result).toEqual([])
+ })
+
+ it('should throw error on failed request', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ statusText: 'Internal Server Error',
+ json: () => Promise.resolve({ message: 'Server error' }),
+ })
+
+ await expect(apiClient.listTasks()).rejects.toThrow('Server error')
+ })
+ })
+
+ describe('getTask', () => {
+ it('should fetch single task successfully', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: () => Promise.resolve(mockTask),
+ })
+
+ const result = await apiClient.getTask(mockTask.id)
+
+ expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
+ headers: { 'Content-Type': 'application/json' },
+ })
+ expect(result).toEqual(mockTask)
+ })
+
+ it('should handle task not found', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ json: () => Promise.resolve({ message: 'Task not found' }),
+ })
+
+ await expect(apiClient.getTask('nonexistent-id')).rejects.toThrow(
+ 'Task not found'
+ )
+ })
+ })
+
+ describe('createTask', () => {
+ it('should create task successfully', async () => {
+ const newTaskData: CreateTaskRequest = {
+ title: 'New Task',
+ description: 'New Description',
+ }
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 201,
+ statusText: 'Created',
+ json: () => Promise.resolve(mockTask),
+ })
+
+ const result = await apiClient.createTask(newTaskData)
+
+ expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(newTaskData),
+ })
+ expect(result).toEqual(mockTask)
+ })
+
+ it('should handle validation errors', async () => {
+ const invalidData: CreateTaskRequest = {
+ title: '',
+ }
+
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 422,
+ statusText: 'Unprocessable Entity',
+ json: () => Promise.resolve({ message: 'Title must not be empty' }),
+ })
+
+ await expect(apiClient.createTask(invalidData)).rejects.toThrow(
+ 'Title must not be empty'
+ )
+ })
+ })
+
+ describe('updateTask', () => {
+ it('should update task successfully', async () => {
+ const updateData: UpdateTaskRequest = {
+ title: 'Updated Task',
+ status: TaskStatus.Done,
+ }
+
+ const updatedTask = { ...mockTask, ...updateData }
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: () => Promise.resolve(updatedTask),
+ })
+
+ const result = await apiClient.updateTask(mockTask.id, updateData)
+
+ expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updateData),
+ })
+ expect(result).toEqual(updatedTask)
+ })
+
+ it('should handle partial updates', async () => {
+ const updateData: UpdateTaskRequest = {
+ status: TaskStatus.Done,
+ }
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: () => Promise.resolve({ ...mockTask, status: TaskStatus.Done }),
+ })
+
+ const result = await apiClient.updateTask(mockTask.id, updateData)
+
+ expect(result.status).toBe(TaskStatus.Done)
+ })
+ })
+
+ describe('deleteTask', () => {
+ it('should delete task successfully', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 204,
+ statusText: 'No Content',
+ })
+
+ const result = await apiClient.deleteTask(mockTask.id)
+ expect(result).toBeNull()
+
+ expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ })
+ })
+
+ it('should handle delete errors', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ json: () => Promise.resolve({ message: 'Task not found' }),
+ })
+
+ await expect(apiClient.deleteTask('nonexistent-id')).rejects.toThrow(
+ 'Task not found'
+ )
+ })
+ })
+
+ describe('Error Handling', () => {
+ it('should handle network errors', async () => {
+ mockFetch.mockRejectedValueOnce(new Error('Network error'))
+
+ await expect(apiClient.listTasks()).rejects.toThrow('Network error')
+ })
+
+ it('should handle malformed JSON responses', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ statusText: 'Internal Server Error',
+ json: () => Promise.reject(new Error('Invalid JSON')),
+ })
+
+ await expect(apiClient.listTasks()).rejects.toThrow(
+ 'HTTP 500: Internal Server Error'
+ )
+ })
+
+ it('should handle responses with no error message', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 400,
+ statusText: 'Bad Request',
+ json: () => Promise.resolve({}),
+ })
+
+ await expect(apiClient.listTasks()).rejects.toThrow(
+ 'HTTP 400: Bad Request'
+ )
+ })
+ })
+})
diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts
new file mode 100644
index 0000000..3c999d3
--- /dev/null
+++ b/frontend/app/services/api.ts
@@ -0,0 +1,113 @@
+import type {
+ Task,
+ CreateTaskRequest,
+ UpdateTaskRequest,
+ ApiError,
+} from '~/types/task'
+
+const API_BASE_URL = '/api'
+
+class ApiClient {
+ private async fetchWrapper(
+ endpoint: string,
+ options: RequestInit = {}
+ ): Promise {
+ const url = `${API_BASE_URL}${endpoint}`
+
+ const config: RequestInit = {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ ...options,
+ }
+
+ if (process.env.NODE_ENV === 'development') {
+ console.log(`API Request: ${config.method || 'GET'} ${url}`, {
+ body: config.body,
+ headers: config.headers,
+ })
+ }
+
+ try {
+ const response = await fetch(url, config)
+
+ if (process.env.NODE_ENV === 'development') {
+ console.log(`API Response: ${response.status} ${response.statusText}`, {
+ url,
+ status: response.status,
+ })
+ }
+
+ if (!response.ok) {
+ let errorMessage = `HTTP ${response.status}: ${response.statusText}`
+
+ try {
+ const errorData = await response.json()
+ errorMessage = errorData.message || errorMessage
+ } catch {
+ // If JSON parsing fails, use the default error message
+ }
+
+ throw new Error(errorMessage)
+ }
+
+ if (response.status === 204) {
+ return null as T
+ }
+
+ const data = await response.json()
+
+ if (process.env.NODE_ENV === 'development') {
+ console.log('API Response Data:', data)
+ }
+
+ return data
+ } catch (error) {
+ const apiError: ApiError = {
+ message:
+ error instanceof Error ? error.message : 'Unknown error occurred',
+ status:
+ error instanceof Error && 'status' in error
+ ? (error as { status?: number }).status
+ : undefined,
+ }
+
+ if (process.env.NODE_ENV === 'development') {
+ console.error('API Error:', apiError)
+ }
+
+ throw apiError
+ }
+ }
+
+ async listTasks(): Promise {
+ return this.fetchWrapper('/tasks')
+ }
+
+ async getTask(id: string): Promise {
+ return this.fetchWrapper(`/tasks/${id}`)
+ }
+
+ async createTask(data: CreateTaskRequest): Promise {
+ return this.fetchWrapper('/tasks', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ })
+ }
+
+ async updateTask(id: string, data: UpdateTaskRequest): Promise {
+ return this.fetchWrapper(`/tasks/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ })
+ }
+
+ async deleteTask(id: string): Promise {
+ return this.fetchWrapper(`/tasks/${id}`, {
+ method: 'DELETE',
+ })
+ }
+}
+
+export const apiClient = new ApiClient()
diff --git a/frontend/app/theme.ts b/frontend/app/theme.ts
new file mode 100644
index 0000000..d89fee5
--- /dev/null
+++ b/frontend/app/theme.ts
@@ -0,0 +1,178 @@
+import { createTheme } from '@mui/material/styles'
+
+// Color palette constants
+const colors = {
+ // Primary Colors
+ deepNavy: '#1e3a5f', // Main brand color for headers and primary actions
+ oceanBlue: '#2c5282', // Secondary blue for links and active states
+ compassGold: '#d69e2e', // Accent color for highlights and call-to-actions
+
+ // Status Colors
+ chartGreen: '#48bb78', // Completed tasks and success states
+ sunsetCoral: '#f56565', // Urgent tasks and error states
+ seaFoam: '#4fd1c7', // Information and notification states
+
+ // Neutrals
+ parchment: '#f7fafc', // Clean background color
+ fogGray: '#e2e8f0', // Subtle borders and dividers
+ stormGray: '#718096', // Secondary text
+ anchorDark: '#2d3748', // Primary text and headings
+}
+
+declare module '@mui/material/styles' {
+ interface Theme {
+ custom: {
+ task: {
+ todo: string
+ done: string
+ backlog: string
+ }
+ }
+ }
+
+ interface ThemeOptions {
+ custom?: {
+ task?: {
+ todo?: string
+ done?: string
+ backlog?: string
+ }
+ }
+ }
+}
+
+export const theme = createTheme({
+ palette: {
+ mode: 'light',
+ primary: {
+ main: colors.deepNavy,
+ light: colors.oceanBlue,
+ contrastText: '#ffffff',
+ },
+ secondary: {
+ main: colors.compassGold,
+ contrastText: '#ffffff',
+ },
+ success: {
+ main: colors.chartGreen,
+ },
+ error: {
+ main: colors.sunsetCoral,
+ },
+ info: {
+ main: colors.seaFoam,
+ },
+ background: {
+ default: colors.parchment,
+ paper: '#ffffff',
+ },
+ text: {
+ primary: colors.anchorDark,
+ secondary: colors.stormGray,
+ },
+ grey: {
+ 100: '#edf2f7',
+ 200: colors.fogGray,
+ 300: '#cbd5e0',
+ 500: colors.stormGray,
+ 700: colors.anchorDark,
+ },
+ },
+ typography: {
+ fontFamily: '"Inter", ui-sans-serif, system-ui, sans-serif',
+ h1: {
+ fontSize: '2rem',
+ fontWeight: 700,
+ lineHeight: 1.2,
+ },
+ h2: {
+ fontSize: '1.5rem',
+ fontWeight: 600,
+ lineHeight: 1.3,
+ },
+ h3: {
+ fontSize: '1.25rem',
+ fontWeight: 600,
+ lineHeight: 1.4,
+ },
+ body1: {
+ fontSize: '1rem',
+ lineHeight: 1.6,
+ },
+ body2: {
+ fontSize: '0.875rem',
+ lineHeight: 1.5,
+ },
+ },
+ shape: {
+ borderRadius: 12,
+ },
+ components: {
+ MuiAppBar: {
+ styleOverrides: {
+ root: {
+ backgroundColor: colors.deepNavy,
+ color: '#ffffff',
+ boxShadow:
+ '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
+ borderBottom: 'none',
+ },
+ },
+ },
+ MuiDrawer: {
+ styleOverrides: {
+ paper: {
+ backgroundColor: '#ffffff',
+ borderRight: `1px solid ${colors.fogGray}`,
+ },
+ },
+ },
+ MuiButton: {
+ styleOverrides: {
+ root: {
+ textTransform: 'none',
+ borderRadius: '0.75rem',
+ fontWeight: 500,
+ },
+ contained: {
+ boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
+ '&:hover': {
+ boxShadow:
+ '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
+ },
+ },
+ },
+ },
+ MuiCard: {
+ styleOverrides: {
+ root: {
+ borderRadius: '0.75rem',
+ boxShadow:
+ '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
+ border: `1px solid ${colors.fogGray}`,
+ '&:hover': {
+ boxShadow:
+ '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
+ borderColor: '#cbd5e0',
+ },
+ },
+ },
+ },
+ MuiTextField: {
+ styleOverrides: {
+ root: {
+ '& .MuiOutlinedInput-root': {
+ borderRadius: '0.75rem',
+ },
+ },
+ },
+ },
+ },
+ custom: {
+ task: {
+ todo: colors.oceanBlue,
+ done: colors.chartGreen,
+ backlog: colors.stormGray,
+ },
+ },
+})
diff --git a/frontend/app/types/task.ts b/frontend/app/types/task.ts
new file mode 100644
index 0000000..9641c8d
--- /dev/null
+++ b/frontend/app/types/task.ts
@@ -0,0 +1,35 @@
+export enum TaskStatus {
+ Todo = 'todo',
+ Done = 'done',
+ Backlog = 'backlog',
+}
+
+export interface Task {
+ id: string
+ title: string
+ description: string | null
+ status: TaskStatus
+ created_at: string
+ updated_at: string
+ completed_at: string | null
+}
+
+export interface CreateTaskRequest {
+ title: string
+ description?: string
+}
+
+export interface UpdateTaskRequest {
+ title?: string
+ description?: string
+ status?: TaskStatus
+}
+
+export interface ApiError {
+ message: string
+ status?: number
+}
+
+export interface TaskListResponse {
+ tasks: Task[]
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c0adf94..3eeffb4 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -6,6 +6,10 @@
"": {
"name": "frontend",
"dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@mui/icons-material": "^7.3.2",
+ "@mui/material": "^7.3.2",
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"isbot": "^5.1.27",
@@ -29,7 +33,6 @@
"eslint-plugin-react-refresh": "^0.4.20",
"jsdom": "^27.0.0",
"prettier": "^3.6.2",
- "tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"typescript-eslint": "^8.44.0",
"vite": "^6.3.3",
@@ -92,7 +95,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
@@ -158,7 +160,6 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.3",
@@ -247,7 +248,6 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -271,7 +271,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
@@ -358,7 +357,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -368,7 +366,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -402,7 +399,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.4"
@@ -539,7 +535,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -549,7 +544,6 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -564,7 +558,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
"integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -583,7 +576,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -731,6 +723,167 @@
"node": ">=18"
}
},
+ "node_modules/@emotion/babel-plugin": {
+ "version": "11.13.5",
+ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
+ "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.16.7",
+ "@babel/runtime": "^7.18.3",
+ "@emotion/hash": "^0.9.2",
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/serialize": "^1.3.3",
+ "babel-plugin-macros": "^3.1.0",
+ "convert-source-map": "^1.5.0",
+ "escape-string-regexp": "^4.0.0",
+ "find-root": "^1.1.0",
+ "source-map": "^0.5.7",
+ "stylis": "4.2.0"
+ }
+ },
+ "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/babel-plugin/node_modules/source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@emotion/cache": {
+ "version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
+ "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/sheet": "^1.4.0",
+ "@emotion/utils": "^1.4.2",
+ "@emotion/weak-memoize": "^0.4.0",
+ "stylis": "4.2.0"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.9.2",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
+ "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/is-prop-valid": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
+ "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/memoize": "^0.9.0"
+ }
+ },
+ "node_modules/@emotion/memoize": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
+ "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/react": {
+ "version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
+ "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "@emotion/babel-plugin": "^11.13.5",
+ "@emotion/cache": "^11.14.0",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
+ "@emotion/utils": "^1.4.2",
+ "@emotion/weak-memoize": "^0.4.0",
+ "hoist-non-react-statics": "^3.3.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@emotion/serialize": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
+ "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/hash": "^0.9.2",
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/unitless": "^0.10.0",
+ "@emotion/utils": "^1.4.2",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@emotion/sheet": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
+ "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/styled": {
+ "version": "11.14.1",
+ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
+ "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "@emotion/babel-plugin": "^11.13.5",
+ "@emotion/is-prop-valid": "^1.3.0",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
+ "@emotion/utils": "^1.4.2"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.0.0-rc.0",
+ "react": ">=16.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
+ "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/use-insertion-effect-with-fallbacks": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
+ "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@emotion/utils": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
+ "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/weak-memoize": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
+ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
+ "license": "MIT"
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
@@ -1449,7 +1602,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -1471,7 +1623,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -1481,14 +1632,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1501,6 +1650,251 @@
"integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==",
"license": "MIT"
},
+ "node_modules/@mui/core-downloads-tracker": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz",
+ "integrity": "sha512-AOyfHjyDKVPGJJFtxOlept3EYEdLoar/RvssBTWVAvDJGIE676dLi2oT/Kx+FoVXFoA/JdV7DEMq/BVWV3KHRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ }
+ },
+ "node_modules/@mui/icons-material": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.2.tgz",
+ "integrity": "sha512-TZWazBjWXBjR6iGcNkbKklnwodcwj0SrChCNHc9BhD9rBgET22J1eFhHsEmvSvru9+opDy3umqAimQjokhfJlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@mui/material": "^7.3.2",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/material": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.2.tgz",
+ "integrity": "sha512-qXvbnawQhqUVfH1LMgMaiytP+ZpGoYhnGl7yYq2x57GYzcFL/iPzSZ3L30tlbwEjSVKNYcbiKO8tANR1tadjUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.3",
+ "@mui/core-downloads-tracker": "^7.3.2",
+ "@mui/system": "^7.3.2",
+ "@mui/types": "^7.4.6",
+ "@mui/utils": "^7.3.2",
+ "@popperjs/core": "^2.11.8",
+ "@types/react-transition-group": "^4.4.12",
+ "clsx": "^2.1.1",
+ "csstype": "^3.1.3",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.1.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.5.0",
+ "@emotion/styled": "^11.3.0",
+ "@mui/material-pigment-css": "^7.3.2",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ },
+ "@mui/material-pigment-css": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/material/node_modules/react-is": {
+ "version": "19.1.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
+ "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==",
+ "license": "MIT"
+ },
+ "node_modules/@mui/private-theming": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.2.tgz",
+ "integrity": "sha512-ha7mFoOyZGJr75xeiO9lugS3joRROjc8tG1u4P50dH0KR7bwhHznVMcYg7MouochUy0OxooJm/OOSpJ7gKcMvg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.3",
+ "@mui/utils": "^7.3.2",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/styled-engine": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.2.tgz",
+ "integrity": "sha512-PkJzW+mTaek4e0nPYZ6qLnW5RGa0KN+eRTf5FA2nc7cFZTeM+qebmGibaTLrgQBy3UpcpemaqfzToBNkzuxqew==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.3",
+ "@emotion/cache": "^11.14.0",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/sheet": "^1.4.0",
+ "csstype": "^3.1.3",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.4.1",
+ "@emotion/styled": "^11.3.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/system": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.2.tgz",
+ "integrity": "sha512-9d8JEvZW+H6cVkaZ+FK56R53vkJe3HsTpcjMUtH8v1xK6Y1TjzHdZ7Jck02mGXJsE6MQGWVs3ogRHTQmS9Q/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.3",
+ "@mui/private-theming": "^7.3.2",
+ "@mui/styled-engine": "^7.3.2",
+ "@mui/types": "^7.4.6",
+ "@mui/utils": "^7.3.2",
+ "clsx": "^2.1.1",
+ "csstype": "^3.1.3",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.5.0",
+ "@emotion/styled": "^11.3.0",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/types": {
+ "version": "7.4.6",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.6.tgz",
+ "integrity": "sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.3"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/utils": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.2.tgz",
+ "integrity": "sha512-4DMWQGenOdLnM3y/SdFQFwKsCLM+mqxzvoWp9+x2XdEzXapkznauHLiXtSohHs/mc0+5/9UACt1GdugCX2te5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.3",
+ "@mui/types": "^7.4.6",
+ "@types/prop-types": "^15.7.15",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.1.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/utils/node_modules/react-is": {
+ "version": "19.1.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
+ "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==",
+ "license": "MIT"
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1612,6 +2006,16 @@
"node": ">=14"
}
},
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
"node_modules/@react-router/dev": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.9.1.tgz",
@@ -2526,11 +2930,22 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/parse-json": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
+ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.1.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -2546,6 +2961,15 @@
"@types/react": "^19.0.0"
}
},
+ "node_modules/@types/react-transition-group": {
+ "version": "4.4.12",
+ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
+ "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.44.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz",
@@ -3089,6 +3513,21 @@
"@babel/types": "^7.23.6"
}
},
+ "node_modules/babel-plugin-macros": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "cosmiconfig": "^7.0.0",
+ "resolve": "^1.19.0"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3288,7 +3727,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3401,6 +3839,15 @@
"node": ">=18"
}
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3516,6 +3963,31 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
+ "node_modules/cosmiconfig": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
+ "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cosmiconfig/node_modules/yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3587,7 +4059,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -3608,7 +4079,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3708,6 +4178,16 @@
"license": "MIT",
"peer": true
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3792,6 +4272,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/error-ex": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -3891,7 +4380,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -4343,6 +4831,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/find-root": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
+ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
+ "license": "MIT"
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -4613,6 +5107,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "node_modules/hoist-non-react-statics/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/hosted-git-info": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.3.tgz",
@@ -4719,7 +5228,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
@@ -4767,11 +5275,16 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "license": "MIT"
+ },
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -4879,7 +5392,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4939,7 +5451,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
"integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
- "dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -5255,6 +5766,12 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "license": "MIT"
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -5285,6 +5802,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -5656,6 +6185,15 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5750,7 +6288,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
@@ -5759,6 +6296,30 @@
"node": ">=6"
}
},
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse-json/node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "license": "MIT"
+ },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -5801,6 +6362,12 @@
"node": ">=8"
}
},
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "license": "MIT"
+ },
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
@@ -5831,6 +6398,15 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@@ -5852,7 +6428,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -5995,6 +6570,23 @@
"node": ">=10"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -6148,6 +6740,22 @@
"node": ">=18"
}
},
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -6186,11 +6794,30 @@
"node": ">=0.10.0"
}
},
+ "node_modules/resolve": {
+ "version": "1.22.10",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
+ "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
@@ -6776,6 +7403,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/stylis": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
+ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
+ "license": "MIT"
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -6789,6 +7422,18 @@
"node": ">=8"
}
},
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index c16f93e..95643b1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -16,6 +16,10 @@
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@mui/icons-material": "^7.3.2",
+ "@mui/material": "^7.3.2",
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"isbot": "^5.1.27",
@@ -39,7 +43,6 @@
"eslint-plugin-react-refresh": "^0.4.20",
"jsdom": "^27.0.0",
"prettier": "^3.6.2",
- "tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"typescript-eslint": "^8.44.0",
"vite": "^6.3.3",
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
deleted file mode 100644
index 7a4c415..0000000
--- a/frontend/tailwind.config.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-export default {
- content: [
- "./app/**/*.{js,jsx,ts,tsx}",
- ],
- theme: {
- extend: {
- fontFamily: {
- sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
- },
- colors: {
- primary: {
- 50: '#eff6ff',
- 100: '#dbeafe',
- 200: '#bfdbfe',
- 300: '#93c5fd',
- 400: '#60a5fa',
- 500: '#3b82f6',
- 600: '#2563eb',
- 700: '#1d4ed8',
- 800: '#1e40af',
- 900: '#1e3a8a',
- },
- success: {
- 50: '#f0fdf4',
- 100: '#dcfce7',
- 200: '#bbf7d0',
- 300: '#86efac',
- 400: '#4ade80',
- 500: '#22c55e',
- 600: '#16a34a',
- 700: '#15803d',
- 800: '#166534',
- 900: '#14532d',
- },
- warning: {
- 50: '#fffbeb',
- 100: '#fef3c7',
- 200: '#fde68a',
- 300: '#fcd34d',
- 400: '#fbbf24',
- 500: '#f59e0b',
- 600: '#d97706',
- 700: '#b45309',
- 800: '#92400e',
- 900: '#78350f',
- },
- danger: {
- 50: '#fef2f2',
- 100: '#fee2e2',
- 200: '#fecaca',
- 300: '#fca5a5',
- 400: '#f87171',
- 500: '#ef4444',
- 600: '#dc2626',
- 700: '#b91c1c',
- 800: '#991b1b',
- 900: '#7f1d1d',
- },
- },
- spacing: {
- '18': '4.5rem',
- '88': '22rem',
- },
- borderRadius: {
- 'xl': '0.75rem',
- '2xl': '1rem',
- },
- boxShadow: {
- 'card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
- 'card-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
- },
- },
- },
- plugins: [],
-}
\ No newline at end of file
diff --git a/justfile b/justfile
index c0e67ce..ad5f466 100644
--- a/justfile
+++ b/justfile
@@ -25,5 +25,7 @@ fmt-check: fmt-check-backend fmt-check-frontend
lint: lint-backend lint-frontend
+check: fmt-check lint test
+
clean: clean-backend clean-frontend
diff --git a/plan/01_CORE_MVP/frontend.md b/plan/01_CORE_MVP/frontend.md
index 93f956c..9188f0a 100644
--- a/plan/01_CORE_MVP/frontend.md
+++ b/plan/01_CORE_MVP/frontend.md
@@ -75,14 +75,14 @@ captains-log/
## Phase 2: Core API Integration (Days 3-4)
### Task 2.1: Define TypeScript Types
-- [ ] **File**: `frontend/src/types/task.ts`
+- [x] **File**: `frontend/src/types/task.ts`
- Create Task interface matching backend TaskModel
- Add TaskStatus enum (Todo, Done, Backlog)
- Include API response types and error types
- **Expected outcome**: Type definitions compile without errors
### Task 2.2: Backend API Client
-- [ ] **File**: `frontend/src/services/api.ts`
+- [x] **File**: `frontend/src/services/api.ts`
- Implement API client with fetch wrapper
- Add all task endpoints: GET, POST, PUT, DELETE /api/tasks
- Include error handling and response parsing
@@ -90,7 +90,7 @@ captains-log/
- **Expected outcome**: API client can communicate with backend
### Task 2.3: Custom React Hooks for API
-- [ ] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
+- [x] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
- Create useTask hook for single task operations (get, update, delete)
- Create useTasks hook for task list operations (list, create)
- Include loading states, error handling, and optimistic updates
@@ -98,7 +98,7 @@ captains-log/
- **Expected outcome**: Hooks provide clean API for components
### Task 2.4: API Integration Tests
-- [ ] **File**: `frontend/tests/api.test.ts`
+- [x] **File**: `frontend/tests/api.test.ts`
- Test API client with mock responses
- Test custom hooks with mock API calls
- Test error handling scenarios
@@ -107,7 +107,26 @@ captains-log/
## Phase 3: Core Components (Days 5-6)
-### Task 3.1: Task Card Component
+**UI Framework**: Use Material-UI (MUI) for consistent design system and components alongside Tailwind CSS for custom styling.
+
+### Task 3.1: Main App Component and Routing
+- [ ] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx`
+ - Setup React Router with basic navigation
+ - Create main layout with MUI AppBar/Drawer and task area
+ - Implement responsive design with MUI breakpoints and Tailwind utilities
+ - Add MUI loading states (CircularProgress) and error boundaries
+ - Configure MUI theme with custom colors
+ - **Expected outcome**: Full application loads and navigates properly with Material Design
+
+### Task 3.2: Task List Component
+- [ ] **File**: `frontend/src/components/TaskList.tsx`
+ - Display tasks using MUI List/Grid components
+ - Filter tasks by status using MUI Chip/Select components
+ - Sort tasks with MUI Select dropdown
+ - Implement virtual scrolling with MUI virtualization
+ - **Expected outcome**: TaskList displays tasks efficiently with Material Design
+
+### Task 3.3: Task Card Component
- [ ] **File**: `frontend/src/components/TaskCard.tsx`
- Display task with title, description, status, dates
- Implement inline editing for title and description
@@ -116,15 +135,16 @@ captains-log/
- Mobile-friendly touch interactions
- **Expected outcome**: TaskCard displays and edits tasks correctly
-### Task 3.2: Task Form Component
+### Task 3.4: Task Form Component
- [ ] **File**: `frontend/src/components/TaskForm.tsx`
- - Create/edit form with all task properties
- - Form validation and error display
- - Handle form submission with API calls
- - Support both modal and inline modes
- - **Expected outcome**: TaskForm creates and updates tasks
+ - Create/edit form using Formik for form state management
+ - Use MUI TextField, Select, and Button components
+ - Form validation with Yup schema and error display
+ - Handle form submission with API calls through Formik onSubmit
+ - Support both MUI Dialog modal and inline modes
+ - **Expected outcome**: TaskForm creates and updates tasks with robust form handling
-### Task 3.3: Quick Capture Component
+### Task 3.5: Quick Capture Component
- [ ] **File**: `frontend/src/components/QuickCapture.tsx`
- Minimal input for rapid task creation
- Auto-focus and keyboard shortcuts
@@ -132,14 +152,14 @@ captains-log/
- Smart defaults for new tasks
- **Expected outcome**: QuickCapture enables fast task entry
-### Task 3.4: Status Badge Component
+### Task 3.6: Status Badge Component
- [ ] **File**: `frontend/src/components/StatusBadge.tsx`
- Visual status indicators with colors
- Consistent styling across components
- Accessible color schemes
- **Expected outcome**: StatusBadge provides clear status visualization
-### Task 3.5: Component Unit Tests
+### Task 3.7: Component Unit Tests
- [ ] **Files**: `frontend/tests/components/*.test.tsx`
- Test all components with React Testing Library
- Test user interactions and state changes
@@ -149,22 +169,6 @@ captains-log/
## Phase 4: Main Application (Days 7-8)
-### Task 4.1: Task List Component
-- [ ] **File**: `frontend/src/components/TaskList.tsx`
- - Display tasks in organized list/grid layout
- - Filter tasks by status (Todo, In Progress, Done, Someday)
- - Sort tasks by created date, priority, due date
- - Implement virtual scrolling for performance
- - **Expected outcome**: TaskList displays tasks efficiently
-
-### Task 4.2: Main App Component and Routing
-- [ ] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx`
- - Setup React Router with basic navigation
- - Create main layout with header and task area
- - Implement responsive design with Tailwind
- - Add loading states and error boundaries
- - **Expected outcome**: Full application loads and navigates properly
-
### Task 4.3: State Management and Persistence
- [ ] **File**: `frontend/src/hooks/useApi.ts`
- Implement localStorage for offline task caching