diff --git a/.github/workflows/frontend-test.yml b/.github/workflows/frontend-test.yml index d1f3e67a..53d7986d 100644 --- a/.github/workflows/frontend-test.yml +++ b/.github/workflows/frontend-test.yml @@ -1,5 +1,4 @@ -name: frontend-tests-and-coverage - +name: Frontend on: push: branches: @@ -8,63 +7,16 @@ on: branches: - "main" - "dev" - jobs: - test-and-coverage: + prettier: runs-on: ubuntu-latest - permissions: - # pull-requests: write # to add comments to the PRs - contents: read - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js + - uses: actions/checkout@v3 + - name: Use Node.js uses: actions/setup-node@v3 with: node-version: "20.x" - - - name: Install dependencies - run: npm ci + - run: npm ci working-directory: frontend - - # 1. RUN ONLY TESTS ON PUSH - - name: Run frontend tests - if: github.event_name == 'push' - run: npm test + - run: npm test working-directory: frontend - - # 2. RUN COVERAGE ON PRs - - name: Run jest with coverage - if: github.event_name == 'pull_request' - run: npx jest --coverage --coverageReporters=cobertura - working-directory: frontend - - # # 3. POST COMMENT ON PR - # - name: Jest Coverage Comment - # if: > - # github.event_name == 'pull_request' - # uses: MishaKav/jest-coverage-comment@main - # with: - # coverage-summary-path: ./frontend/coverage/coverage-summary.json - # title: Frontend Coverage Report - - # 3. WRITE COVERAGE SUMMARY TO CHECKS - - name: Write Summary from File - if: github.event_name == 'pull_request' - uses: irongut/CodeCoverageSummary@v1.3.0 - with: - filename: frontend/coverage/cobertura-coverage.xml - badge: true - format: markdown - output: both - - # 4. PUSH TO SUMMARY PAGE - - name: Write to Job Summary - if: github.event_name == 'pull_request' - run: | - if [ -f "code-coverage-results.md" ]; then - cat code-coverage-results.md >> $GITHUB_STEP_SUMMARY - else - echo "Coverage file not found" - fi diff --git a/assets/02_home.png b/assets/02_home.png index 4f46e66d..17acad4e 100644 Binary files a/assets/02_home.png and b/assets/02_home.png differ diff --git a/assets/02_home_mobile.png b/assets/02_home_mobile.png index f07e72d4..4f2b9ee0 100644 Binary files a/assets/02_home_mobile.png and b/assets/02_home_mobile.png differ diff --git a/assets/03_home.png b/assets/03_home.png index d19c3fb3..bd0c0988 100644 Binary files a/assets/03_home.png and b/assets/03_home.png differ diff --git a/backend/controllers/add_task.go b/backend/controllers/add_task.go index c684e8bf..e44aa727 100644 --- a/backend/controllers/add_task.go +++ b/backend/controllers/add_task.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "os" ) var GlobalJobQueue *JobQueue @@ -63,27 +62,9 @@ func AddTaskHandler(w http.ResponseWriter, r *http.Request) { } // Validate dependencies - origin := os.Getenv("CONTAINER_ORIGIN") - existingTasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, uuid) - if err != nil { - if err := utils.ValidateDependencies(depends, ""); err != nil { - http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) - return - } - } else { - taskDeps := make([]utils.TaskDependency, len(existingTasks)) - for i, task := range existingTasks { - taskDeps[i] = utils.TaskDependency{ - UUID: task.UUID, - Depends: task.Depends, - Status: task.Status, - } - } - - if err := utils.ValidateCircularDependencies(depends, "", taskDeps); err != nil { - http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) - return - } + if err := utils.ValidateDependencies(depends, ""); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return } dueDateStr, err := utils.ConvertOptionalISOToTaskwarriorFormat(dueDate) if err != nil { diff --git a/backend/controllers/app_handlers.go b/backend/controllers/app_handlers.go index 29ba4300..4f30abde 100644 --- a/backend/controllers/app_handlers.go +++ b/backend/controllers/app_handlers.go @@ -44,6 +44,8 @@ func (a *App) OAuthHandler(w http.ResponseWriter, r *http.Request) { // @Failure 500 {string} string "Internal server error" // @Router /auth/callback [get] func (a *App) OAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { + utils.Logger.Info("Fetching user info...") + code := r.URL.Query().Get("code") t, err := a.Config.Exchange(context.Background(), code) @@ -84,6 +86,8 @@ func (a *App) OAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { return } + // utils.Logger.Infof("User Info: %v", userInfo) + frontendOriginDev := os.Getenv("FRONTEND_ORIGIN_DEV") http.Redirect(w, r, frontendOriginDev+"/home", http.StatusSeeOther) } @@ -105,6 +109,7 @@ func (a *App) UserInfoHandler(w http.ResponseWriter, r *http.Request) { return } + // utils.Logger.Infof("Sending User Info: %v", userInfo) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(userInfo) } @@ -142,4 +147,5 @@ func (a *App) LogoutHandler(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusOK) + // utils.Logger.Info("User has logged out") } diff --git a/backend/controllers/complete_tasks.go b/backend/controllers/complete_tasks.go index d9dc99b9..b971f50b 100644 --- a/backend/controllers/complete_tasks.go +++ b/backend/controllers/complete_tasks.go @@ -52,6 +52,7 @@ func BulkCompleteTaskHandler(w http.ResponseWriter, r *http.Request) { logStore := models.GetLogStore() + // Create a *single* job for all UUIDs job := Job{ Name: "Bulk Complete Tasks", Execute: func() error { diff --git a/backend/controllers/controllers_test.go b/backend/controllers/controllers_test.go index 619016f2..921d69c3 100644 --- a/backend/controllers/controllers_test.go +++ b/backend/controllers/controllers_test.go @@ -54,6 +54,7 @@ func Test_OAuthHandler(t *testing.T) { func Test_OAuthCallbackHandler(t *testing.T) { app := setup() + // This part of the test requires mocking the OAuth provider which can be complex. Simplified for demonstration. req, err := http.NewRequest("GET", "/auth/callback?code=testcode", nil) assert.NoError(t, err) @@ -61,12 +62,14 @@ func Test_OAuthCallbackHandler(t *testing.T) { handler := http.HandlerFunc(app.OAuthCallbackHandler) handler.ServeHTTP(rr, req) + // Since actual OAuth flow can't be tested in unit test, we are focusing on ensuring no panic assert.NotEqual(t, http.StatusInternalServerError, rr.Code) } func Test_UserInfoHandler(t *testing.T) { app := setup() + // Create a request object to pass to the session store req, err := http.NewRequest("GET", "/api/user", nil) assert.NoError(t, err) @@ -77,7 +80,7 @@ func Test_UserInfoHandler(t *testing.T) { "uuid": "uuid-test", "encryption_secret": "secret-test", } - session.Save(req, httptest.NewRecorder()) + session.Save(req, httptest.NewRecorder()) // Save the session rr := httptest.NewRecorder() handler := http.HandlerFunc(app.UserInfoHandler) @@ -122,6 +125,7 @@ func Test_LogoutHandler(t *testing.T) { } func Test_AddTaskHandler_WithDueDate(t *testing.T) { + // Initialize job queue GlobalJobQueue = NewJobQueue() requestBody := map[string]interface{}{ @@ -147,6 +151,7 @@ func Test_AddTaskHandler_WithDueDate(t *testing.T) { } func Test_AddTaskHandler_WithoutDueDate(t *testing.T) { + // Initialize job queue GlobalJobQueue = NewJobQueue() requestBody := map[string]interface{}{ @@ -192,6 +197,7 @@ func Test_AddTaskHandler_MissingDescription(t *testing.T) { assert.Contains(t, rr.Body.String(), "Description is required") } +// Task Dependencies Tests func Test_AddTaskHandler_WithDependencies(t *testing.T) { GlobalJobQueue = NewJobQueue() diff --git a/backend/controllers/delete_task.go b/backend/controllers/delete_task.go index 34fcd628..5af79555 100644 --- a/backend/controllers/delete_task.go +++ b/backend/controllers/delete_task.go @@ -47,6 +47,10 @@ func DeleteTaskHandler(w http.ResponseWriter, r *http.Request) { return } + // if err := tw.DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid); err != nil { + // http.Error(w, err.Error(), http.StatusInternalServerError) + // return + // } logStore := models.GetLogStore() job := Job{ Name: "Delete Task", diff --git a/backend/controllers/edit_task.go b/backend/controllers/edit_task.go index 1e65a851..f22cff37 100644 --- a/backend/controllers/edit_task.go +++ b/backend/controllers/edit_task.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "os" ) // EditTaskHandler godoc @@ -63,56 +62,8 @@ func EditTaskHandler(w http.ResponseWriter, r *http.Request) { } // Validate dependencies - origin := os.Getenv("CONTAINER_ORIGIN") - existingTasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, uuid) - if err != nil { - if err := utils.ValidateDependencies(depends, taskUUID); err != nil { - http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) - return - } - } else { - taskDeps := make([]utils.TaskDependency, len(existingTasks)) - for i, task := range existingTasks { - taskDeps[i] = utils.TaskDependency{ - UUID: task.UUID, - Depends: task.Depends, - Status: task.Status, - } - } - - if err := utils.ValidateCircularDependencies(depends, taskUUID, taskDeps); err != nil { - http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) - return - } - } - - start, err = utils.ConvertISOToTaskwarriorFormat(start) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid start date format: %v", err), http.StatusBadRequest) - return - } - - due, err = utils.ConvertISOToTaskwarriorFormat(due) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid due date format: %v", err), http.StatusBadRequest) - return - } - - end, err = utils.ConvertISOToTaskwarriorFormat(end) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid end date format: %v", err), http.StatusBadRequest) - return - } - - entry, err = utils.ConvertISOToTaskwarriorFormat(entry) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid entry date format: %v", err), http.StatusBadRequest) - return - } - - wait, err = utils.ConvertISOToTaskwarriorFormat(wait) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid wait date format: %v", err), http.StatusBadRequest) + if err := utils.ValidateDependencies(depends, uuid); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) return } diff --git a/backend/controllers/get_logs.go b/backend/controllers/get_logs.go index 42a21886..e1faeeb2 100644 --- a/backend/controllers/get_logs.go +++ b/backend/controllers/get_logs.go @@ -39,6 +39,7 @@ func SyncLogsHandler(w http.ResponseWriter, r *http.Request) { logStore := models.GetLogStore() logs := logStore.GetLogs(last) + // Return logs as JSON w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(logs); err != nil { http.Error(w, "Failed to encode logs", http.StatusInternalServerError) diff --git a/backend/controllers/job_queue.go b/backend/controllers/job_queue.go index 30b2fd64..6672edba 100644 --- a/backend/controllers/job_queue.go +++ b/backend/controllers/job_queue.go @@ -2,6 +2,8 @@ package controllers import ( "sync" + + "ccsync_backend/utils" ) type Job struct { @@ -35,18 +37,23 @@ func (q *JobQueue) AddJob(job Job) { func (q *JobQueue) processJobs() { for job := range q.jobChannel { + utils.Logger.Infof("Executing job: %s", job.Name) + go BroadcastJobStatus(JobStatus{ Job: job.Name, Status: "in-progress", }) if err := job.Execute(); err != nil { + utils.Logger.Errorf("Error executing job %s: %v", job.Name, err) + go BroadcastJobStatus(JobStatus{ Job: job.Name, Status: "failure", }) } else { - // utils.Logger.Infof("Success in executing job %s", job.Name) + utils.Logger.Infof("Success in executing job %s", job.Name) + go BroadcastJobStatus(JobStatus{ Job: job.Name, Status: "success", diff --git a/backend/controllers/modify_task.go b/backend/controllers/modify_task.go index f0a2833d..e3f9645a 100644 --- a/backend/controllers/modify_task.go +++ b/backend/controllers/modify_task.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "os" ) // ModifyTaskHandler godoc @@ -62,27 +61,9 @@ func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) { } // Validate dependencies - origin := os.Getenv("CONTAINER_ORIGIN") - existingTasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, uuid) - if err != nil { - if err := utils.ValidateDependencies(depends, taskUUID); err != nil { - http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) - return - } - } else { - taskDeps := make([]utils.TaskDependency, len(existingTasks)) - for i, task := range existingTasks { - taskDeps[i] = utils.TaskDependency{ - UUID: task.UUID, - Depends: task.Depends, - Status: task.Status, - } - } - - if err := utils.ValidateCircularDependencies(depends, taskUUID, taskDeps); err != nil { - http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) - return - } + if err := utils.ValidateDependencies(depends, uuid); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return } // if err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID); err != nil { diff --git a/backend/controllers/websocket.go b/backend/controllers/websocket.go index 6f318b50..c2df6e31 100644 --- a/backend/controllers/websocket.go +++ b/backend/controllers/websocket.go @@ -31,25 +31,31 @@ func WebSocketHandler(w http.ResponseWriter, r *http.Request) { defer ws.Close() clients[ws] = true + utils.Logger.Info("New WebSocket connection established!") + for { _, _, err := ws.ReadMessage() if err != nil { delete(clients, ws) + utils.Logger.Info("WebSocket connection closed:", err) break } } } func BroadcastJobStatus(jobStatus JobStatus) { + utils.Logger.Infof("Broadcasting: %+v", jobStatus) broadcast <- jobStatus } func JobStatusManager() { for { jobStatus := <-broadcast + utils.Logger.Infof("Sending to clients: %+v", jobStatus) for client := range clients { err := client.WriteJSON(jobStatus) if err != nil { + utils.Logger.Errorf("WebSocket Write Error: %v", err) client.Close() delete(clients, client) } diff --git a/backend/utils/datetime.go b/backend/utils/datetime.go index 365cc59c..90d148d0 100644 --- a/backend/utils/datetime.go +++ b/backend/utils/datetime.go @@ -15,8 +15,6 @@ func ConvertISOToTaskwarriorFormat(isoDatetime string) (string, error) { "2006-01-02T15:04:05.000Z", // "2025-12-27T14:30:00.000Z" (frontend datetime with milliseconds) "2006-01-02T15:04:05Z", // "2025-12-27T14:30:00Z" (datetime without milliseconds) "2006-01-02", // "2025-12-27" (date only) - "20060102T150405Z", // "20260128T000000Z" (compact ISO format) - "20060102", // "20260128" (compact date only) } var parsedTime time.Time @@ -26,8 +24,8 @@ func ConvertISOToTaskwarriorFormat(isoDatetime string) (string, error) { for i, format := range formats { parsedTime, err = time.Parse(format, isoDatetime) if err == nil { - // Check if it's date-only format - isDateOnly = (i == 2 || i == 4) // "2006-01-02" or "20060102" formats + // Check if it's date-only format (last format in array) + isDateOnly = (i == 2) // "2006-01-02" format break } } diff --git a/backend/utils/utils_test.go b/backend/utils/utils_test.go index 39bb0e14..609bb9c8 100644 --- a/backend/utils/utils_test.go +++ b/backend/utils/utils_test.go @@ -85,45 +85,20 @@ func Test_ExecCommandForOutputInDir(t *testing.T) { } } -func Test_ValidateDependencies_EmptyList(t *testing.T) { - depends := []string{} +func Test_ValidateDependencies_ValidDependencies(t *testing.T) { + depends := []string{"task-uuid-1", "task-uuid-2"} currentTaskUUID := "current-task-uuid" err := ValidateDependencies(depends, currentTaskUUID) assert.NoError(t, err) } -// Circular Dependency Detection Tests -func Test_detectCycle_NoCycle(t *testing.T) { //A -> B -> C - graph := map[string][]string{ - "A": {"B"}, - "B": {"C"}, - "C": {}, - } - - hasCycle := detectCycle(graph, "A") - assert.False(t, hasCycle, "Should not detect cycle in linear dependency") -} - -func Test_detectCycle_SimpleCycle(t *testing.T) { // A -> B -> A - graph := map[string][]string{ - "A": {"B"}, - "B": {"A"}, - } - - hasCycle := detectCycle(graph, "A") - assert.True(t, hasCycle, "Should detect simple cycle A -> B -> A") +func Test_ValidateDependencies_EmptyList(t *testing.T) { + depends := []string{} + currentTaskUUID := "current-task-uuid" + err := ValidateDependencies(depends, currentTaskUUID) + assert.NoError(t, err) } -func Test_detectCycle_ComplexCycle(t *testing.T) { // A -> B -> C -> A - graph := map[string][]string{ - "A": {"B"}, - "B": {"C"}, - "C": {"A"}, - } - - hasCycle := detectCycle(graph, "A") - assert.True(t, hasCycle, "Should detect complex cycle A -> B -> C -> A") -} func TestConvertISOToTaskwarriorFormat(t *testing.T) { tests := []struct { name string @@ -161,24 +136,6 @@ func TestConvertISOToTaskwarriorFormat(t *testing.T) { expected: "", hasError: true, }, - { - name: "Compact ISO datetime format (Taskwarrior export)", - input: "20260128T000000Z", - expected: "2026-01-28T00:00:00", - hasError: false, - }, - { - name: "Compact ISO datetime format with time", - input: "20260128T143000Z", - expected: "2026-01-28T14:30:00", - hasError: false, - }, - { - name: "Compact date only format", - input: "20260128", - expected: "2026-01-28", - hasError: false, - }, } for _, tt := range tests { diff --git a/backend/utils/validation.go b/backend/utils/validation.go index 8f2d2cbd..a439e055 100644 --- a/backend/utils/validation.go +++ b/backend/utils/validation.go @@ -19,54 +19,3 @@ func ValidateDependencies(depends []string, currentTaskUUID string) error { return nil } - -type TaskDependency struct { - UUID string `json:"uuid"` - Depends []string `json:"depends"` - Status string `json:"status"` -} - -func ValidateCircularDependencies(depends []string, currentTaskUUID string, existingTasks []TaskDependency) error { - if len(depends) == 0 { - return nil - } - - dependencyGraph := make(map[string][]string) - for _, task := range existingTasks { - if task.Status == "pending" { - dependencyGraph[task.UUID] = task.Depends - } - } - - dependencyGraph[currentTaskUUID] = depends - - if hasCycle := detectCycle(dependencyGraph, currentTaskUUID); hasCycle { - return fmt.Errorf("circular dependency detected: adding these dependencies would create a cycle") - } - - return nil -} - -// (0): unvisited, (1): visiting,(2): visited -func detectCycle(graph map[string][]string, startNode string) bool { - color := make(map[string]int) - return dfsHasCycle(graph, startNode, color) -} - -func dfsHasCycle(graph map[string][]string, node string, color map[string]int) bool { - if color[node] == 1 { - return true - } - if color[node] == 2 { - return false - } - - color[node] = 1 - for _, dep := range graph[node] { - if dfsHasCycle(graph, dep, color) { - return true - } - } - color[node] = 2 - return false -} diff --git a/development/setup.sh b/development/setup.sh index 834e5175..5861c1e5 100755 --- a/development/setup.sh +++ b/development/setup.sh @@ -47,12 +47,12 @@ if [ ! -f "$ROOT_DIR/backend/.env" ]; then exit 1 fi -# if [ ! -f "$ROOT_DIR/frontend/.env" ]; then -# echo "Error: frontend/.env file not found." -# echo "Please create it with required environment variables." -# echo "See development/README.md or https://its-me-abhishek.github.io/ccsync-docs/ for details." -# exit 1 -# fi +if [ ! -f "$ROOT_DIR/frontend/.env" ]; then + echo "Error: frontend/.env file not found." + echo "Please create it with required environment variables." + echo "See development/README.md or https://its-me-abhishek.github.io/ccsync-docs/ for details." + exit 1 +fi echo "Installing dependencies..." echo "" diff --git a/frontend/coverage-report.json b/frontend/coverage-report.json new file mode 100644 index 00000000..4c82c0ee --- /dev/null +++ b/frontend/coverage-report.json @@ -0,0 +1,3 @@ +{ + "frontend": "80.71%" +} diff --git a/frontend/src/__tests__/main.test.tsx b/frontend/src/__tests__/main.test.tsx index 27fc47e1..ea92522c 100644 --- a/frontend/src/__tests__/main.test.tsx +++ b/frontend/src/__tests__/main.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; + jest.mock('../App.tsx', () => ({ __esModule: true, default: () =>
App
, @@ -11,28 +12,37 @@ jest.mock('@/components/utils/ThemeProvider.tsx', () => ({ ), })); -describe('main.tsx bootstrap', () => { - const renderMock = jest.fn(); - +describe('main.tsx', () => { beforeEach(() => { - jest.spyOn(ReactDOM, 'createRoot').mockReturnValue({ - render: renderMock, - } as any); - document.body.innerHTML = '
'; }); - afterEach(() => { - jest.restoreAllMocks(); - document.body.innerHTML = ''; + it('has root element available for React app', () => { + const rootElement = document.getElementById('root'); + expect(rootElement).toBeInTheDocument(); + expect(rootElement).not.toBeNull(); + }); + + it('can create React root without errors', () => { + const rootElement = document.getElementById('root'); + expect(() => { + ReactDOM.createRoot(rootElement!); + }).not.toThrow(); + }); + + it('imports required React dependencies', () => { + expect(React).toBeDefined(); + expect(ReactDOM).toBeDefined(); + expect(React.StrictMode).toBeDefined(); }); - it('creates React root and renders the app', async () => { - await import('../main.tsx'); + it('verifies App and ThemeProvider components are available', () => { + const App = require('../App.tsx').default; + const { ThemeProvider } = require('@/components/utils/ThemeProvider.tsx'); - expect(ReactDOM.createRoot).toHaveBeenCalledWith( - document.getElementById('root') - ); - expect(renderMock).toHaveBeenCalled(); + expect(App).toBeDefined(); + expect(ThemeProvider).toBeDefined(); + expect(typeof App).toBe('function'); + expect(typeof ThemeProvider).toBe('function'); }); }); diff --git a/frontend/src/components/HomeComponents/BottomBar/BottomBar.tsx b/frontend/src/components/HomeComponents/BottomBar/BottomBar.tsx index 5dfff819..b0726065 100644 --- a/frontend/src/components/HomeComponents/BottomBar/BottomBar.tsx +++ b/frontend/src/components/HomeComponents/BottomBar/BottomBar.tsx @@ -24,6 +24,7 @@ const BottomBar: React.FC = ({ }) => { return (
+ {/* Nav Links */}
+ {/* Filters */} ), })); - -jest.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenu: ({ children }: any) =>
{children}
, - DropdownMenuTrigger: ({ children }: any) =>
{children}
, - DropdownMenuContent: ({ children }: any) =>
{children}
, - DropdownMenuItem: ({ children, onClick, onSelect }: any) => ( -
onSelect?.(e)}> - {children} -
- ), - DropdownMenuLabel: ({ children }: any) =>
{children}
, +jest.mock('@/components/utils/ExportTasks', () => ({ + exportTasksAsJSON: jest.fn(), + exportTasksAsTXT: jest.fn(), +})); +jest.mock('../navbar-utils', () => ({ + ...jest.requireActual('../navbar-utils'), + deleteAllTasks: jest.fn(), })); - jest.mock('@/components/ui/dialog', () => ({ Dialog: ({ children }: any) =>
{children}
, DialogTrigger: ({ children }: any) =>
{children}
, DialogContent: ({ children }: any) => ( -
{children}
+
{children}
), DialogHeader: ({ children }: any) =>
{children}
, DialogTitle: ({ children }: any) =>

{children}

, DialogDescription: ({ children }: any) =>

{children}

, })); +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NavbarDesktop } from '../NavbarDesktop'; +import { Props, routeList } from '../navbar-utils'; -jest.mock('@/components/ui/avatar', () => ({ - Avatar: ({ children }: any) =>
{children}
, - AvatarImage: () => avatar, - AvatarFallback: ({ children }: any) => , -})); - -jest.mock('@/components/utils/ThemeModeToggle', () => ({ - ModeToggle: () =>
, +jest.mock('../navbar-utils', () => ({ + deleteAllTasks: jest.fn(), + handleLogout: jest.fn(), + routeList: [ + { href: '#', label: 'Home' }, + { href: '#tasks', label: 'Tasks' }, + { href: '#setup-guide', label: 'Setup Guide' }, + { href: '#faq', label: 'FAQ' }, + ], })); - jest.mock('@/components/HomeComponents/DevLogs/DevLogs', () => ({ - DevLogs: () =>
, -})); - -jest.mock('@/components/utils/TaskAutoSync', () => ({ - useTaskAutoSync: jest.fn(), -})); - -jest.mock('@/components/utils/ExportTasks', () => ({ - exportTasksAsJSON: jest.fn(), - exportTasksAsTXT: jest.fn(), + DevLogs: () =>
, })); jest.mock('@/components/utils/URLs', () => ({ @@ -65,30 +48,19 @@ jest.mock('@/components/utils/URLs', () => ({ githubRepoURL: 'https://github.com/test/repo', }, })); - -jest.mock('../navbar-utils', () => ({ - routeList: [ - { href: '#', label: 'Home' }, - { href: '#tasks', label: 'Tasks' }, - { href: '#faq', label: 'FAQ' }, - ], - deleteAllTasks: jest.fn(), - handleLogout: jest.fn(), -})); - const mockSetIsLoading = jest.fn(); -const baseProps: Props = { - imgurl: 'http://example.com/avatar.png', +const mockProps: Props = { + imgurl: 'http://example.com/image.png', email: 'test@example.com', encryptionSecret: 'secret', origin: 'http://localhost:3000', - UUID: 'uuid', + UUID: '1234-5678', tasks: [], }; -const props = { - ...baseProps, +const extendedProps = { + ...mockProps, isLoading: false, setIsLoading: mockSetIsLoading, }; @@ -97,28 +69,30 @@ describe('NavbarDesktop', () => { afterEach(() => { jest.clearAllMocks(); }); - - it('renders navigation links', () => { - render(); + it('renders the navigation links correctly', () => { + render(); routeList.forEach((route) => { expect(screen.getByText(route.label)).toBeInTheDocument(); }); }); + it('opens user menu and displays email', async () => { + render(); - it('opens user menu and shows email', async () => { - render(); + const avatarFallback = screen.getByText('CN'); + await userEvent.click(avatarFallback); - await userEvent.click(screen.getByText('CN')); - expect(screen.getAllByText('test@example.com').length).toBeGreaterThan(0); + expect(screen.getAllByText('test@example.com')[0]).toBeInTheDocument(); }); - it('opens GitHub repo in new tab', async () => { + it('opens github link when clicked', async () => { const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); - render(); - await userEvent.click(screen.getByText('CN')); - await userEvent.click(screen.getByText('GitHub')); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('CN')); + await user.click(screen.getByText('GitHub')); expect(openSpy).toHaveBeenCalledWith( 'https://github.com/test/repo', @@ -127,40 +101,46 @@ describe('NavbarDesktop', () => { openSpy.mockRestore(); }); - - it('exports tasks as TXT', async () => { + it('exports tasks as TXT and triggers export handler', async () => { + const user = userEvent.setup(); const { exportTasksAsTXT } = require('@/components/utils/ExportTasks'); - render(); - await userEvent.click(screen.getByText('CN')); - await userEvent.click(screen.getByText('Export tasks')); - await userEvent.click(screen.getByText('Download .txt')); + render(); + + await user.click(screen.getByText('CN')); + await user.click(screen.getByText('Export tasks')); + + expect(screen.getByText(/Would you like to download/i)).toBeInTheDocument(); + + await user.click(screen.getByText('Download .txt')); expect(exportTasksAsTXT).toHaveBeenCalledWith([]); }); - it('exports tasks as JSON', async () => { const { exportTasksAsJSON } = require('@/components/utils/ExportTasks'); - render(); + render(); + await userEvent.click(screen.getByText('CN')); await userEvent.click(screen.getByText('Export tasks')); await userEvent.click(screen.getByText('Download .json')); expect(exportTasksAsJSON).toHaveBeenCalledWith([]); }); + it('shows slider when auto sync is enabled', async () => { + const user = userEvent.setup(); + render(); - it('enables auto sync and shows slider', async () => { - render(); - - await userEvent.click(screen.getByText('CN')); - await userEvent.click(screen.getByText('toggle')); + await user.click(screen.getByText('CN')); + await user.click(screen.getByText('toggle')); expect(screen.getByTestId('sync-slider')).toBeInTheDocument(); }); +}); - it('renders consistently (snapshot)', () => { - const { asFragment } = render(); +describe('NavbarDesktop snapshot', () => { + it('renders correctly', () => { + const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/frontend/src/components/HomeComponents/Navbar/__tests__/__snapshots__/NavbarDesktop.test.tsx.snap b/frontend/src/components/HomeComponents/Navbar/__tests__/__snapshots__/NavbarDesktop.test.tsx.snap index 4a8e3e30..72a1291c 100644 --- a/frontend/src/components/HomeComponents/Navbar/__tests__/__snapshots__/NavbarDesktop.test.tsx.snap +++ b/frontend/src/components/HomeComponents/Navbar/__tests__/__snapshots__/NavbarDesktop.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`NavbarDesktop renders consistently (snapshot) 1`] = ` +exports[`NavbarDesktop snapshot renders correctly 1`] = ` + {/* Link to container */} + {/* Client ID */} { expect(screen.getByText(sampleText)).toBeInTheDocument(); expect(screen.getByTestId('copy-icon')).toBeInTheDocument(); }); - it('toggles sensitive value visibility and masks the text', () => { - const sensitiveText = 'API_KEY 12345'; - - render( - - ); - expect(screen.getByText(sensitiveText)).toBeInTheDocument(); - expect(screen.getByTestId('eye-off-icon')).toBeInTheDocument(); - fireEvent.click( - screen.getByRole('button', { name: /hide sensitive value/i }) - ); - expect(screen.getByText('API_KEY •••••')).toBeInTheDocument(); - expect(screen.getByTestId('eye-icon')).toBeInTheDocument(); - }); it('copies text to clipboard and shows toast message', async () => { render(); diff --git a/frontend/src/components/HomeComponents/SetupGuide/__tests__/SetupGuide.test.tsx b/frontend/src/components/HomeComponents/SetupGuide/__tests__/SetupGuide.test.tsx index 7a2d9ba4..f8d762c1 100644 --- a/frontend/src/components/HomeComponents/SetupGuide/__tests__/SetupGuide.test.tsx +++ b/frontend/src/components/HomeComponents/SetupGuide/__tests__/SetupGuide.test.tsx @@ -1,88 +1,86 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { SetupGuide } from '../SetupGuide'; +import { Props } from '../../../utils/types'; +import { url } from '@/components/utils/URLs'; -// Using jest.mock to mock external dependencies -jest.mock('@/components/utils/URLs', () => ({ - url: { - containerOrigin: 'https://test-container', - }, -})); - -// Mock CopyableCode component +// Mocking the CopyableCode component jest.mock('../CopyableCode', () => ({ - CopyableCode: ({ text }: { text: string }) => ( -
{text}
+ CopyableCode: ({ + text, + copyText, + isSensitive, + }: { + text: string; + copyText: string; + isSensitive?: boolean; + }) => ( +
+ {text} +
), })); -// Mock exportConfigSetup utility -const mockExportConfigSetup = jest.fn(); -jest.mock('../utils', () => ({ - exportConfigSetup: (props: any) => mockExportConfigSetup(props), -})); - -const defaultProps = { - name: 'Test User', - encryption_secret: 'secret123', - uuid: 'uuid-1234', +const props: Props = { + name: 'test-name', + encryption_secret: 'test-encryption-secret', + uuid: 'test-uuid', }; - -describe('SetupGuide', () => { - test('renders setup guide sections', () => { - render(); - - // Section exists - expect(document.querySelector('#setup-guide')).toBeInTheDocument(); - - // Sub-section headings - expect(screen.getByText('PREREQUISITES')).toBeInTheDocument(); - expect(screen.getByText('CONFIGURATION')).toBeInTheDocument(); - expect(screen.getByText('SYNC')).toBeInTheDocument(); +describe('SetupGuide Component', () => { + test('renders SetupGuide component with correct text', () => { + render(); }); - test('renders configuration commands using props', () => { - render(); - - expect( - screen.getByText( - `task config sync.encryption_secret ${defaultProps.encryption_secret}` - ) - ).toBeInTheDocument(); - - expect( - screen.getByText(`task config sync.server.client_id ${defaultProps.uuid}`) - ).toBeInTheDocument(); - - expect( - screen.getByText('task config sync.server.origin https://test-container') - ).toBeInTheDocument(); + test('renders CopyableCode components with correct props', () => { + render(); + + // Check for CopyableCode components + const copyableCodeElements = screen.getAllByTestId('copyable-code'); + expect(copyableCodeElements.length).toBe(5); + + // Validate the text and copyText props of each CopyableCode component + expect(copyableCodeElements[0]).toHaveAttribute( + 'data-text', + 'task --version' + ); + expect(copyableCodeElements[0]).toHaveAttribute( + 'data-copytext', + 'task --version' + ); + expect(copyableCodeElements[1]).toHaveAttribute( + 'data-text', + `task config sync.encryption_secret ${props.encryption_secret}` + ); + expect(copyableCodeElements[1]).toHaveAttribute( + 'data-copytext', + `task config sync.encryption_secret ${props.encryption_secret}` + ); + expect(copyableCodeElements[2]).toHaveAttribute( + 'data-text', + `task config sync.server.origin ${url.containerOrigin}` + ); + expect(copyableCodeElements[2]).toHaveAttribute( + 'data-copytext', + `task config sync.server.origin ${url.containerOrigin}` + ); + expect(copyableCodeElements[3]).toHaveAttribute( + 'data-text', + `task config sync.server.client_id ${props.uuid}` + ); + expect(copyableCodeElements[3]).toHaveAttribute( + 'data-copytext', + `task config sync.server.client_id ${props.uuid}` + ); }); +}); - test('clicking download configuration triggers download logic', () => { - mockExportConfigSetup.mockReturnValue('config-content'); - - // Polyfill missing browser APIs - Object.defineProperty(global.URL, 'createObjectURL', { - writable: true, - value: jest.fn(() => 'blob:http://localhost/file'), - }); - - Object.defineProperty(global.URL, 'revokeObjectURL', { - writable: true, - value: jest.fn(), - }); - - const appendSpy = jest.spyOn(document.body, 'appendChild'); - const removeSpy = jest.spyOn(document.body, 'removeChild'); - - render(); - - fireEvent.click(screen.getByText(/DOWNLOAD CONFIGURATION/i)); - - expect(mockExportConfigSetup).toHaveBeenCalledWith(defaultProps); - expect(URL.createObjectURL).toHaveBeenCalled(); - expect(URL.revokeObjectURL).toHaveBeenCalled(); - expect(appendSpy).toHaveBeenCalled(); - expect(removeSpy).toHaveBeenCalled(); +describe('SetupGuide component using snapshot', () => { + test('renders correctly', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/frontend/src/components/HomeComponents/SetupGuide/__tests__/__snapshots__/SetupGuide.test.tsx.snap b/frontend/src/components/HomeComponents/SetupGuide/__tests__/__snapshots__/SetupGuide.test.tsx.snap new file mode 100644 index 00000000..5569a9c0 --- /dev/null +++ b/frontend/src/components/HomeComponents/SetupGuide/__tests__/__snapshots__/SetupGuide.test.tsx.snap @@ -0,0 +1,155 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SetupGuide component using snapshot renders correctly 1`] = ` + +
+

+ + Setup + + Guide +

+
+
+
+
+
+

+ + 1. + + PREREQUISITES +

+
+ Ensure that Taskwarrior 3.0 or greater is installed on your system +
+ task --version +
+
+
+
+

+ + 2. + + CONFIGURATION +

+
+ You will need an encryption secret used to encrypt and decrypt your tasks. This can be any secret string, and must match for all replicas sharing tasks. For most of these, you will need an encryption secret used to encrypt and decrypt your tasks. +
+ task config sync.encryption_secret test-encryption-secret +
+
+ Configure Taskwarrior with these commands, run these commands one block at a time +
+
+ task config sync.server.origin http://localhost:8080/ +
+
+ task config sync.server.client_id test-uuid +
+
+ For more information about how this works, refer to the + + task-sync(5) + + manpage for details on how to configure the new sync implementation. +
+
+
+
+

+ + 3. + + SYNC +

+
+ Finally, setup the sync for your Taskwarrior client! +
+ task sync init +
+
+
+
+

+ +

+
+
+
+
+
+
+
+`; diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index 1b60ba10..485f6c5b 100644 --- a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx @@ -36,7 +36,7 @@ export const AddTaskdialog = ({ isCreatingNewProject, setIsCreatingNewProject, uniqueProjects = [], - allTasks = [], + allTasks = [], // Add this prop }: AddTaskDialogProps) => { const [annotationInput, setAnnotationInput] = useState(''); const [dependencySearch, setDependencySearch] = useState(''); diff --git a/frontend/src/components/HomeComponents/Tasks/ReportsView.tsx b/frontend/src/components/HomeComponents/Tasks/ReportsView.tsx index 408a08a8..7c5b9330 100644 --- a/frontend/src/components/HomeComponents/Tasks/ReportsView.tsx +++ b/frontend/src/components/HomeComponents/Tasks/ReportsView.tsx @@ -25,16 +25,7 @@ export const ReportsView: React.FC = ({ tasks }) => { if (!parsedDate) return false; const modifiedDate = getStartOfDay(parsedDate); - - if (modifiedDate >= filterDate) { - return true; - } - - if (task.status === 'pending' && task.due && isOverdue(task.due)) { - return true; - } - - return false; + return modifiedDate >= filterDate; }) .reduce( (acc, task) => { diff --git a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx index d2e5770e..87aeff3a 100644 --- a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx @@ -1,7 +1,7 @@ import { EditTaskDialogProps } from '../../utils/types'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { DateTimePicker } from '@/components/ui/date-time-picker'; +import { DatePicker } from '@/components/ui/date-picker'; import { Dialog, DialogClose, @@ -69,7 +69,7 @@ export const TaskDialog = ({ }: EditTaskDialogProps) => { const handleDialogOpenChange = (open: boolean) => { if (open) { - onSelectTask(task, index); + onSelectTask(task, index); // Notify parent that this task is selected } onOpenChange(open); }; @@ -265,7 +265,7 @@ export const TaskDialog = ({ {editState.isEditingDueDate ? (
- + onDateChange={(date) => onUpdateState({ editedDueDate: date - ? hasTime - ? date.toISOString() - : format(date, 'yyyy-MM-dd') + ? format(date, 'yyyy-MM-dd') : '', }) } - placeholder="Select due date and time" + placeholder="Select due date" /> @@ -1461,6 +1479,7 @@ export const TaskDialog = ({
+ {/* Non-scrollable footer */} {task.status == 'pending' ? ( diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 4a3fec6e..2549a94a 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -65,11 +65,15 @@ export const Tasks = ( setIsLoading: (val: boolean) => void; } ) => { + interface Option { + label: string; + value: string; + } const [showReports, setShowReports] = useState(false); - const [uniqueTags, setUniqueTags] = useState([]); + const [uniqueTags, setUniqueTags] = useState([]); const [tasks, setTasks] = useState([]); const [selectedTags, setSelectedTags] = useState([]); - const [uniqueProjects, setUniqueProjects] = useState([]); + const [uniqueProjects, setUniqueProjects] = useState([]); const [selectedProjects, setSelectedProjects] = useState([]); const [tempTasks, setTempTasks] = useState([]); const [selectedStatuses, setSelectedStatuses] = useState([]); @@ -116,6 +120,8 @@ export const Tasks = ( resetState: resetEditState, } = useEditTask(_selectedTask); + // Handler for dialog open/close + const debouncedSearch = debounce((value: string) => { setDebouncedTerm(value); setCurrentPage(1); @@ -201,6 +207,7 @@ export const Tasks = ( } }, [props.email]); + // Update the displayed time every 10 seconds useEffect(() => { const interval = setInterval(() => { setLastSyncTime((prevTime) => prevTime); @@ -216,6 +223,7 @@ export const Tasks = ( .equals(props.email) .toArray(); + // Set all tasks setTasks(sortTasksById(tasksFromDB, 'desc')); setTempTasks(sortTasksById(tasksFromDB, 'desc')); @@ -223,13 +231,44 @@ export const Tasks = ( const filteredProjects = Array.from(projectsSet) .filter((project) => project !== '') .sort((a, b) => (a > b ? 1 : -1)); - setUniqueProjects(filteredProjects); + const projectOptions = filteredProjects.map((project) => { + const pTasks = tasksFromDB.filter((t) => t.project === project); + const total = pTasks.length; + const completed = pTasks.filter( + (t) => t.status === 'completed' + ).length; + const percentage = + total === 0 ? 0 : Math.round((completed / total) * 100); + return { + value: project, + label: `${project} (${completed}/${total} tasks completed, ${percentage}%)`, + }; + }); + setUniqueProjects(projectOptions); + + // Extract unique tags const tagsSet = new Set(tasksFromDB.flatMap((task) => task.tags || [])); const filteredTags = Array.from(tagsSet) .filter((tag) => tag !== '') .sort((a, b) => (a > b ? 1 : -1)); - setUniqueTags(filteredTags); + + const tagOptions = filteredTags.map((tag) => { + const tTasks = tasksFromDB.filter( + (t) => t.tags && t.tags.includes(tag) + ); + const total = tTasks.length; + const completed = tTasks.filter( + (t) => t.status === 'completed' + ).length; + const percentage = + total === 0 ? 0 : Math.round((completed / total) * 100); + return { + value: tag, + label: `${tag} (${completed}/${total} tasks completed, ${percentage}%)`, + }; + }); + setUniqueTags(tagOptions); } catch (error) { console.error('Error fetching tasks:', error); } @@ -253,6 +292,7 @@ export const Tasks = ( UUID, backendURL: url.backendURL, }); + console.log(taskwarriorTasks); await db.transaction('rw', db.tasks, async () => { await db.tasks.where('email').equals(user_email).delete(); @@ -269,13 +309,52 @@ export const Tasks = ( setTasks(sortedTasks); setTempTasks(sortedTasks); + // Update unique projects after a successful sync so the Project dropdown is populated const projectsSet = new Set(sortedTasks.map((task) => task.project)); const filteredProjects = Array.from(projectsSet) .filter((project) => project !== '') .sort((a, b) => (a > b ? 1 : -1)); - setUniqueProjects(filteredProjects); + + const projectOptions = filteredProjects.map((project) => { + const pTasks = sortedTasks.filter((t) => t.project === project); + const total = pTasks.length; + const completed = pTasks.filter( + (t) => t.status === 'completed' + ).length; + const percentage = + total === 0 ? 0 : Math.round((completed / total) * 100); + return { + value: project, + label: `${project} (${completed}/${total} tasks completed, ${percentage}%)`, + }; + }); + setUniqueProjects(projectOptions); + + // Update unique tags + const tagsSet = new Set(sortedTasks.flatMap((task) => task.tags || [])); + const filteredTags = Array.from(tagsSet) + .filter((tag) => tag !== '') + .sort((a, b) => (a > b ? 1 : -1)); + + const tagOptions = filteredTags.map((tag) => { + const tTasks = sortedTasks.filter( + (t) => t.tags && t.tags.includes(tag) + ); + const total = tTasks.length; + const completed = tTasks.filter( + (t) => t.status === 'completed' + ).length; + const percentage = + total === 0 ? 0 : Math.round((completed / total) * 100); + return { + value: tag, + label: `${tag} (${completed}/${total} tasks completed, ${percentage}%)`, + }; + }); + setUniqueTags(tagOptions); }); + // Store last sync timestamp using hashed key const currentTime = Date.now(); const hashedKey = hashKey('lastSyncTime', user_email); localStorage.setItem(hashedKey, currentTime.toString()); @@ -290,7 +369,7 @@ export const Tasks = ( } finally { props.setIsLoading(false); } - }, [props.email, props.encryptionSecret, props.UUID]); + }, [props.email, props.encryptionSecret, props.UUID]); // Add dependencies async function handleAddTask(task: TaskFormData) { try { @@ -313,6 +392,7 @@ export const Tasks = ( backendURL: url.backendURL, }); + console.log('Task added successfully!'); setNewTask({ description: '', priority: '', @@ -370,10 +450,10 @@ export const Tasks = ( annotations, }); + console.log('Task edited successfully!'); setIsAddTaskOpen(false); } catch (error) { console.error('Failed to edit task:', error); - throw error; } } @@ -430,6 +510,7 @@ export const Tasks = ( }; const handleMarkComplete = async (taskuuid: string) => { + // Find the task being completed const taskToComplete = tasks.find((t) => t.uuid === taskuuid); if (!taskToComplete) { toast.error('Task not found'); @@ -458,6 +539,7 @@ export const Tasks = ( } } + // If all dependencies are completed, allow completion setUnsyncedTaskUuids((prev) => new Set([...prev, taskuuid])); await markTaskAsCompleted( @@ -482,7 +564,7 @@ export const Tasks = ( const handleSelectTask = (task: Task, index: number) => { setSelectedTask(task); setSelectedIndex(index); - resetEditState(); + resetEditState(); // as before }; const handleSaveDescription = (task: Task, description: string) => { @@ -653,46 +735,28 @@ export const Tasks = ( ); }; - const handleDependsSaveClick = async (task: Task, depends: string[]) => { - try { - setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); - - await handleEditTaskOnBackend( - props.email, - props.encryptionSecret, - props.UUID, - task.description, - task.tags, - task.uuid.toString(), - task.project, - task.start, - task.entry || '', - task.wait || '', - task.end || '', - depends, - task.due || '', - task.recur || '', - task.annotations || [] - ); - } catch (error) { - console.error('Failed to save dependencies:', error); + const handleDependsSaveClick = (task: Task, depends: string[]) => { + task.depends = depends; - setUnsyncedTaskUuids((prev) => { - const newSet = new Set(prev); - newSet.delete(task.uuid); - return newSet; - }); + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); - toast.error('Failed to save dependencies. Please try again.', { - position: 'bottom-left', - autoClose: 3000, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: true, - progress: undefined, - }); - } + handleEditTaskOnBackend( + props.email, + props.encryptionSecret, + props.UUID, + task.description, + task.tags, + task.uuid.toString(), + task.project, + task.start, + task.entry || '', + task.wait || '', + task.end || '', + task.depends, + task.due || '', + task.recur || '', + task.annotations || [] + ); }; const handleRecurSaveClick = (task: Task, recur: string) => { @@ -744,9 +808,11 @@ export const Tasks = ( const aOverdue = a.status === 'pending' && isOverdue(a.due); const bOverdue = b.status === 'pending' && isOverdue(b.due); + // Overdue always on top if (aOverdue && !bOverdue) return -1; if (!aOverdue && bOverdue) return 1; + // Otherwise fall back to ID sort and status sort return 0; }); }; @@ -754,12 +820,14 @@ export const Tasks = ( useEffect(() => { let filteredTasks = [...tasks]; + // Project filter if (selectedProjects.length > 0) { filteredTasks = filteredTasks.filter( (task) => task.project && selectedProjects.includes(task.project) ); } + // Status filter if (selectedStatuses.length > 0) { filteredTasks = filteredTasks.filter((task) => { const isTaskOverdue = task.status === 'pending' && isOverdue(task.due); @@ -805,6 +873,7 @@ export const Tasks = ( const updatedTags = editedTags.filter((tag) => tag.trim() !== ''); const tagsToRemove = removedTags.map((tag) => `${tag}`); const finalTags = [...updatedTags, ...tagsToRemove]; + console.log(finalTags); setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); @@ -868,6 +937,7 @@ export const Tasks = ( backendURL: url.backendURL, }); + console.log('Priority updated successfully!'); toast.success('Priority updated successfully!'); } catch (error) { console.error('Failed to update priority:', error); @@ -909,8 +979,10 @@ export const Tasks = ( if (!showReports && !_isDialogOpen) { const task = currentTasks[selectedIndex]; if (!task) return; + // Step 1 const openBtn = document.getElementById(`task-row-${task.id}`); openBtn?.click(); + // Step 2 setTimeout(() => { const confirmBtn = document.getElementById( `mark-task-complete-${task.id}` @@ -933,8 +1005,10 @@ export const Tasks = ( if (!showReports && !_isDialogOpen) { const task = currentTasks[selectedIndex]; if (!task) return; + // Step 1 const openBtn = document.getElementById(`task-row-${task.id}`); openBtn?.click(); + // Step 2 setTimeout(() => { const confirmBtn = document.getElementById( `mark-task-as-deleted-${task.id}` @@ -978,13 +1052,13 @@ export const Tasks = ( Tasks -
+
- {/* Mobile-only Sync button */} + {/* Mobile-only Sync button (desktop already shows a Sync button with filters) */} -
+
+
+
- - {getTimeSinceLastSync(lastSyncTime)} - -
- - - - - + + {getTimeSinceLastSync(lastSyncTime)} + +
+
+ + + + t.status !== 'deleted') + .length > 0 && + selectedTaskUUIDs.length === currentTasks.filter((t) => t.status !== 'deleted') - .length > 0 && - selectedTaskUUIDs.length === - currentTasks.filter( - (t) => t.status !== 'deleted' - ).length + .length + } + onChange={(e) => { + if (e.target.checked) { + setSelectedTaskUUIDs( + currentTasks + .filter((task) => task.status !== 'deleted') + .map((task) => task.uuid) + ); + } else { + setSelectedTaskUUIDs([]); } - onChange={(e) => { - if (e.target.checked) { - setSelectedTaskUUIDs( - currentTasks - .filter((task) => task.status !== 'deleted') - .map((task) => task.uuid) - ); - } else { - setSelectedTaskUUIDs([]); - } - }} - /> - - - ID{' '} - {idSortOrder === 'asc' ? ( - - ) : ( - - )} - - - Description - - - Status - - - - - {/* Display tasks */} - {props.isLoading ? ( - - ) : ( - currentTasks.map((task: Task, index: number) => ( - { - if (checked) { - setSelectedTaskUUIDs([ - ...selectedTaskUUIDs, - uuid, - ]); - } else { - setSelectedTaskUUIDs( - selectedTaskUUIDs.filter((id) => id !== uuid) - ); - } - }} - onSelectTask={handleSelectTask} - selectedIndex={selectedIndex} - task={task} - isOpen={ - _isDialogOpen && _selectedTask?.uuid === task.uuid - } - onOpenChange={handleDialogOpenChange} - editState={editState} - onUpdateState={updateEditState} - allTasks={tasks} - uniqueProjects={uniqueProjects} - isCreatingNewProject={isCreatingNewProject} - setIsCreatingNewProject={setIsCreatingNewProject} - onSaveDescription={handleSaveDescription} - onSaveTags={handleSaveTags} - onSavePriority={handleSavePriority} - onSaveProject={handleProjectSaveClick} - onSaveWaitDate={handleWaitDateSaveClick} - onSaveStartDate={handleStartDateSaveClick} - onSaveEntryDate={handleEntryDateSaveClick} - onSaveEndDate={handleEndDateSaveClick} - onSaveDueDate={handleDueDateSaveClick} - onSaveDepends={handleDependsSaveClick} - onSaveRecur={handleRecurSaveClick} - onSaveAnnotations={handleSaveAnnotations} - onMarkComplete={handleMarkComplete} - onMarkDeleted={handleMarkDelete} - isOverdue={isOverdue} - isUnsynced={unsyncedTaskUuids.has(task.uuid)} - /> - )) - )} - - {/* Display empty rows */} - {!props.isLoading && emptyRows > 0 && ( - - - - )} - -
-
-
-
-
- - -
-
+ Status + + + + + {/* Display tasks */} + {props.isLoading ? ( + + ) : ( + currentTasks.map((task: Task, index: number) => ( + { + if (checked) { + setSelectedTaskUUIDs([ + ...selectedTaskUUIDs, + uuid, + ]); + } else { + setSelectedTaskUUIDs( + selectedTaskUUIDs.filter((id) => id !== uuid) + ); + } + }} + onSelectTask={handleSelectTask} + selectedIndex={selectedIndex} + task={task} + isOpen={ + _isDialogOpen && _selectedTask?.uuid === task.uuid + } + onOpenChange={handleDialogOpenChange} + editState={editState} + onUpdateState={updateEditState} + allTasks={tasks} + uniqueProjects={uniqueProjects.map((p) => p.value)} + isCreatingNewProject={isCreatingNewProject} + setIsCreatingNewProject={setIsCreatingNewProject} + onSaveDescription={handleSaveDescription} + onSaveTags={handleSaveTags} + onSavePriority={handleSavePriority} + onSaveProject={handleProjectSaveClick} + onSaveWaitDate={handleWaitDateSaveClick} + onSaveStartDate={handleStartDateSaveClick} + onSaveEntryDate={handleEntryDateSaveClick} + onSaveEndDate={handleEndDateSaveClick} + onSaveDueDate={handleDueDateSaveClick} + onSaveDepends={handleDependsSaveClick} + onSaveRecur={handleRecurSaveClick} + onSaveAnnotations={handleSaveAnnotations} + onMarkComplete={handleMarkComplete} + onMarkDeleted={handleMarkDelete} + isOverdue={isOverdue} + isUnsynced={unsyncedTaskUuids.has(task.uuid)} + /> + )) + )} - {/* Pagination */} -
- -
-
- {/* Intentionally empty for spacing */} + {/* Display empty rows */} + {!props.isLoading && emptyRows > 0 && ( + + + + )} + + +
+
+
+
+ +
- {selectedTaskUUIDs.length > 0 && ( -
- {/* Bulk Complete Dialog */} - {!selectedTaskUUIDs.some((uuid) => { - const task = currentTasks.find((t) => t.uuid === uuid); - return task?.status === 'completed'; - }) && ( - - - - - - - - - Are you - {' '} - sure? - - - - - - - - - - - - - - )} - {/* Bulk Delete Dialog */} + {/* Pagination */} +
+ +
+
+ {/* Intentionally empty for spacing */} +
+
+ {selectedTaskUUIDs.length > 0 && ( +
+ {/* Bulk Complete Dialog */} + {!selectedTaskUUIDs.some((uuid) => { + const task = currentTasks.find((t) => t.uuid === uuid); + return task?.status === 'completed'; + }) && ( @@ -1344,7 +1376,7 @@ export const Tasks = ( -
- )} -
+ )} + + {/* Bulk Delete Dialog */} + + + + + + + + + Are you + {' '} + sure? + + + + + + + + + + + + + +
+ )} ) : ( <> @@ -1383,7 +1454,7 @@ export const Tasks = ( onSubmit={handleAddTask} isCreatingNewProject={isCreatingNewProject} setIsCreatingNewProject={setIsCreatingNewProject} - uniqueProjects={uniqueProjects} + uniqueProjects={uniqueProjects.map((p) => p.value)} allTasks={tasks} />
diff --git a/frontend/src/components/HomeComponents/Tasks/UseEditTask.tsx b/frontend/src/components/HomeComponents/Tasks/UseEditTask.tsx index 023ced3d..7e4bbf40 100644 --- a/frontend/src/components/HomeComponents/Tasks/UseEditTask.tsx +++ b/frontend/src/components/HomeComponents/Tasks/UseEditTask.tsx @@ -34,6 +34,7 @@ export const useEditTask = (selectedTask: Task | null) => { annotationInput: '', }); + // Update edited tags when selected task changes useEffect(() => { if (selectedTask) { setState((prev) => ({ @@ -45,7 +46,6 @@ export const useEditTask = (selectedTask: Task | null) => { editedRecur: selectedTask.recur || '', originalRecur: selectedTask.recur || '', editedAnnotations: selectedTask.annotations || [], - editedDepends: selectedTask.depends || [], })); } }, [selectedTask]); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/ReportView.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/ReportView.test.tsx new file mode 100644 index 00000000..845c2b19 --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/ReportView.test.tsx @@ -0,0 +1,168 @@ +import { render } from '@testing-library/react'; +import { ReportsView } from '../ReportsView'; + +const mockToday = new Date('2025-11-11T10:00:00.000Z'); + +type TaskStatus = 'pending' | 'completed'; +type DateOffset = 'dailyData' | 'weeklyData' | 'monthlyData'; + +const createMockTask = ( + overrides: Partial<{ + id: number; + status: TaskStatus; + dateOffset: DateOffset; + tags: string[]; + depends: string[]; + }> = {} +) => { + const { + id = 1, + status = 'pending', + dateOffset = 'dailyData', + tags = ['tag1', 'tag2'], + depends = ['depends1', 'depends2'], + } = overrides; + + const getDateForOffset = (offset: DateOffset): string => { + let date: Date; + switch (offset) { + case 'dailyData': + date = mockToday; + break; + case 'weeklyData': + const startOfWeek = new Date(mockToday); + startOfWeek.setUTCDate( + startOfWeek.getUTCDate() - startOfWeek.getUTCDay() + ); + date = startOfWeek; + break; + case 'monthlyData': + date = new Date(mockToday.getUTCFullYear(), mockToday.getUTCMonth(), 1); + break; + } + // Return Taskwarrior format: YYYYMMDDTHHMMSSZ + return date.toISOString().replace(/[-:]/g, '').replace('.000', ''); + }; + + return { + id, + description: 'mockDescription', + project: 'mockProject', + status, + tags, + uuid: `mockUuid-${id}`, + urgency: 1, + priority: 'mockPriority', + due: status === 'pending' ? getDateForOffset(dateOffset) : '', + start: 'mockStart', + end: status === 'completed' ? getDateForOffset(dateOffset) : '', + entry: getDateForOffset(dateOffset), + wait: 'mockWait', + modified: '', + depends, + rtype: 'mockRtype', + recur: 'mockRecur', + annotations: [], + email: 'mockEmail', + }; +}; + +// Builders pour des scénarios spécifiques +const createDailyTasks = () => [ + createMockTask({ id: 1, status: 'pending', dateOffset: 'dailyData' }), + createMockTask({ id: 2, status: 'completed', dateOffset: 'dailyData' }), +]; + +const createWeeklyTasks = () => [ + ...createDailyTasks(), + createMockTask({ id: 3, status: 'pending', dateOffset: 'weeklyData' }), + createMockTask({ id: 4, status: 'completed', dateOffset: 'weeklyData' }), + createMockTask({ id: 5, status: 'pending', dateOffset: 'monthlyData' }), + createMockTask({ id: 6, status: 'completed', dateOffset: 'monthlyData' }), +]; + +const createMonthlyTasks = () => [ + ...createWeeklyTasks(), + createMockTask({ id: 7, status: 'pending', dateOffset: 'monthlyData' }), + createMockTask({ id: 8, status: 'completed', dateOffset: 'monthlyData' }), +]; + +const mockTasksWithOneTask = createDailyTasks().slice(0, 1); +const mockTasksWithSeveralTasks = createMonthlyTasks(); + +jest.mock('recharts', () => ({ + ResponsiveContainer: ({ children, width, height }: any) => ( +
+ {children} +
+ ), + BarChart: ({ data, margin, children }: any) => ( +
+ {data.map((item: any, index: number) => ( +
+ {Object.entries(item).map(([key, value]) => ( + + {key}: {String(value)}{' '} + + ))} +
+ ))} + {children} +
+ ), + CartesianGrid: ({ strokeDasharray, stroke }: any) => ( +
+ ), + XAxis: ({ dataKey, stroke }: any) => ( +
+ ), + YAxis: ({ allowDecimals, stroke }: any) => ( +
+ ), + Tooltip: ({ contentStyle, labelClassName }: any) => ( +
+ ), + Legend: ({ wrapperClassName }: any) => ( +
+ ), + Bar: ({ dataKey, fill, name }: any) => ( +
+ ), +})); + +beforeEach(() => { + jest.useFakeTimers().setSystemTime(mockToday); +}); +afterEach(() => { + jest.useRealTimers(); +}); + +describe('ReportsView Component using Snapshot', () => { + test('renders correctly with only one task', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot('one task'); + }); + test('renders correctly with only several tasks', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot('several tasks'); + }); +}); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/ReportsView.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/ReportsView.test.tsx index 49fbe3e2..66d851ae 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/ReportsView.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/ReportsView.test.tsx @@ -97,6 +97,9 @@ describe('ReportsView', () => { }); it('counts pending tasks with past due date as overdue', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-01-15')); + const todayDate = new Date(); const today = toTWFormat(todayDate); @@ -119,6 +122,8 @@ describe('ReportsView', () => { expect(data[0].overdue).toBe(2); expect(data[0].ongoing).toBe(1); expect(data[0].completed).toBe(1); + + jest.useRealTimers(); }); it('treats task with no due date as ongoing', () => { @@ -235,6 +240,9 @@ describe('ReportsView', () => { }); it('handles mixed statuses correctly', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-01-15')); + const todayDate = new Date(); const today = toTWFormat(todayDate); @@ -258,6 +266,8 @@ describe('ReportsView', () => { expect(data[0].completed).toBe(1); expect(data[0].ongoing).toBe(1); expect(data[0].overdue).toBe(1); + + jest.useRealTimers(); }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 5232b324..124982a8 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -158,6 +158,7 @@ jest.mock('../hooks', () => ({ jest.mock('../Pagination', () => { return jest.fn((props) => (
+ {/* Render props to make them testable */} {props.totalPages} {props.currentPage}
@@ -1101,11 +1102,11 @@ describe('Tasks Component', () => { }); test.each([ - ['Wait', 'Wait:', 'Select wait date and time'], - ['End', 'End:', 'Select end date and time'], - ['Due', 'Due:', 'Select due date and time'], - ['Start', 'Start:', 'Select start date and time'], - ['Entry', 'Entry:', 'Select entry date and time'], + ['Wait', 'Wait:', 'Pick a date'], + ['End', 'End:', 'Select end date'], + ['Due', 'Due:', 'Select due date'], + ['Start', 'Start:', 'Pick a date'], + ['Entry', 'Entry:', 'Pick a date'], ])('shows red when task %s date is edited', async (_, label, placeholder) => { render(); @@ -1134,7 +1135,7 @@ describe('Tasks Component', () => { }); const dialog = screen.getByRole('dialog'); - const day15 = within(dialog).getAllByText('15')[0]; + const day15 = within(dialog).getByText('15'); fireEvent.click(day15); const saveButton = screen.getByLabelText('save'); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/__snapshots__/ReportView.test.tsx.snap b/frontend/src/components/HomeComponents/Tasks/__tests__/__snapshots__/ReportView.test.tsx.snap new file mode 100644 index 00000000..2310c32b --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/__snapshots__/ReportView.test.tsx.snap @@ -0,0 +1,979 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReportsView Component using Snapshot renders correctly with only one task: one task 1`] = ` + +
+
+
+

+ Daily Report +

+
+ + +
+
+
+
+
+ + name: Today + + + completed: 0 + + + ongoing: 1 + + + overdue: 0 + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Weekly Report +

+
+ + +
+
+
+
+
+ + name: This Week + + + completed: 0 + + + ongoing: 1 + + + overdue: 0 + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Monthly Report +

+
+ + +
+
+
+
+
+ + name: This Month + + + completed: 0 + + + ongoing: 1 + + + overdue: 0 + +
+
+
+
+
+
+
+
+
+
+
+
+
+ +`; + +exports[`ReportsView Component using Snapshot renders correctly with only several tasks: several tasks 1`] = ` + +
+
+
+

+ Daily Report +

+
+ + +
+
+
+
+
+ + name: Today + + + completed: 1 + + + ongoing: 1 + + + overdue: 0 + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Weekly Report +

+
+ + +
+
+
+
+
+ + name: This Week + + + completed: 2 + + + ongoing: 1 + + + overdue: 1 + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Monthly Report +

+
+ + +
+
+
+
+
+ + name: This Month + + + completed: 4 + + + ongoing: 1 + + + overdue: 3 + +
+
+
+
+
+
+
+
+
+
+
+
+
+ +`; diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/hooks.test.ts b/frontend/src/components/HomeComponents/Tasks/__tests__/hooks.test.ts index 8be41e4d..cac1948e 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/hooks.test.ts +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/hooks.test.ts @@ -30,30 +30,6 @@ describe('fetchTaskwarriorTasks', () => { expect(result).toEqual([{ id: '1', description: 'Test task' }]); }); - test('sends request with correct URL and headers', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => [], - }); - - await fetchTaskwarriorTasks({ - email: 'user@test.com', - encryptionSecret: 'secret123', - UUID: 'uuid123', - backendURL: 'http://localhost:8080/', - }); - - expect(fetch).toHaveBeenCalledWith('http://localhost:8080/tasks', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-User-Email': 'user@test.com', - 'X-Encryption-Secret': 'secret123', - 'X-User-UUID': 'uuid123', - }, - }); - }); - test('Throws erros when response is not okk', async () => { (fetch as jest.Mock).mockResolvedValueOnce({ ok: false, @@ -68,23 +44,10 @@ describe('fetchTaskwarriorTasks', () => { }) ).rejects.toThrow('Failed to fetch tasks from backend'); }); - - test('handles network errors', async () => { - (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); - - await expect( - fetchTaskwarriorTasks({ - email: 'test@example.com', - encryptionSecret: 'mockEncryptionSecret', - UUID: 'mockUUID', - backendURL: 'http://backend/', - }) - ).rejects.toThrow('Network error'); - }); }); describe('addTaskToBackend', () => { - test('sends correct request body with all required fields', async () => { + test('sends correct request body with optional fields', async () => { (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, }); @@ -108,233 +71,14 @@ describe('addTaskToBackend', () => { backendURL: 'http://backend/', }); - expect(fetch).toHaveBeenCalledWith('http://backend/add-task', { - method: 'POST', - body: expect.any(String), - headers: { - 'Content-Type': 'application/json', - }, - }); - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.email).toBe('test@example.com'); expect(body.description).toBe('New Task'); expect(body.tags).toEqual(['work']); expect(body.annotations).toHaveLength(1); expect(body.annotations[0].description).toBe('note'); }); - test('includes optional due field when provided', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - }); - - await addTaskToBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - description: 'Task with due', - project: '', - priority: '', - due: '2025-12-31', - start: '', - entry: '', - wait: '', - recur: '', - tags: [], - annotations: [], - backendURL: 'http://backend/', - }); - - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.due).toBe('2025-12-31'); - }); - - test('excludes due field when empty string', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - }); - - await addTaskToBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - description: 'Task without due', - project: '', - priority: '', - due: '', - start: '', - entry: '', - wait: '', - recur: '', - tags: [], - annotations: [], - backendURL: 'http://backend/', - }); - - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.due).toBeUndefined(); - }); - - test('includes start field when provided', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - }); - - await addTaskToBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - description: 'Task', - project: '', - priority: '', - start: '2025-01-15', - entry: '', - wait: '', - recur: '', - tags: [], - annotations: [], - backendURL: 'http://backend/', - }); - - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.start).toBe('2025-01-15'); - }); - - test('includes depends field when array has items', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - }); - - await addTaskToBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - description: 'Task', - project: '', - priority: '', - start: '', - entry: '', - wait: '', - recur: '', - tags: [], - annotations: [], - depends: ['task1', 'task2'], - backendURL: 'http://backend/', - }); - - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.depends).toEqual(['task1', 'task2']); - }); - - test('excludes depends field when empty array', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - }); - - await addTaskToBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - description: 'Task', - project: '', - priority: '', - start: '', - entry: '', - wait: '', - recur: '', - tags: [], - annotations: [], - depends: [], - backendURL: 'http://backend/', - }); - - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.depends).toBeUndefined(); - }); - - test('includes end field when provided', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - }); - - await addTaskToBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - description: 'Task', - project: '', - priority: '', - start: '', - entry: '', - wait: '', - end: '2025-02-01', - recur: '', - tags: [], - annotations: [], - backendURL: 'http://backend/', - }); - - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.end).toBe('2025-02-01'); - }); - - test('includes recur field when provided', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - }); - - await addTaskToBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - description: 'Task', - project: '', - priority: '', - start: '', - entry: '', - wait: '', - recur: 'weekly', - tags: [], - annotations: [], - backendURL: 'http://backend/', - }); - - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.recur).toBe('weekly'); - }); - - test('filters out empty annotations', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - }); - - await addTaskToBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - description: 'Task', - project: '', - priority: '', - start: '', - entry: '', - wait: '', - recur: '', - tags: [], - annotations: [ - { entry: '1', description: 'valid note' }, - { entry: '2', description: '' }, - { entry: '3', description: ' ' }, - ], - backendURL: 'http://backend/', - }); - - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.annotations).toHaveLength(1); - expect(body.annotations[0].description).toBe('valid note'); - }); - test('throws error when backend responds back with error', async () => { (fetch as jest.Mock).mockResolvedValueOnce({ ok: false, @@ -359,31 +103,6 @@ describe('addTaskToBackend', () => { }) ).rejects.toThrow('Backend error'); }); - - test('throws default error message when backend error text is empty', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - text: async () => '', - }); - - await expect( - addTaskToBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - description: 'Task', - project: '', - priority: '', - start: '', - entry: '', - wait: '', - recur: '', - tags: [], - annotations: [], - backendURL: 'http://backend/', - }) - ).rejects.toThrow('Failed to add task'); - }); }); describe('editTaskOnBackend', () => { @@ -412,47 +131,6 @@ describe('editTaskOnBackend', () => { expect(response).toBeDefined(); }); - test('sends all task fields in request body', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ ok: true }); - - await editTaskOnBackend({ - email: 'user@test.com', - encryptionSecret: 'secret', - UUID: 'user-uuid', - taskUUID: 'task-uuid-123', - description: 'Updated description', - tags: ['tag1', 'tag2'], - project: 'MyProject', - start: '2025-01-01', - entry: '2025-01-01', - wait: '2025-01-05', - end: '2025-02-01', - depends: ['dep1'], - due: '2025-01-31', - recur: 'monthly', - annotations: [{ entry: '1', description: 'note' }], - backendURL: 'http://backend/', - }); - - expect(fetch).toHaveBeenCalledWith('http://backend/edit-task', { - method: 'POST', - body: expect.any(String), - headers: { - 'Content-Type': 'application/json', - }, - }); - - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.email).toBe('user@test.com'); - expect(body.taskUUID).toBe('task-uuid-123'); - expect(body.description).toBe('Updated description'); - expect(body.tags).toEqual(['tag1', 'tag2']); - expect(body.project).toBe('MyProject'); - expect(body.start).toBe('2025-01-01'); - expect(body.depends).toEqual(['dep1']); - expect(body.annotations).toEqual([{ entry: '1', description: 'note' }]); - }); - test('throws error on failure', async () => { (fetch as jest.Mock).mockResolvedValueOnce({ ok: false, @@ -478,40 +156,12 @@ describe('editTaskOnBackend', () => { annotations: [], backendURL: 'http://backend/', }) - ).rejects.toThrow('321Edit failed'); - }); - - test('throws default error message when backend returns empty error', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - text: async () => '', - }); - - await expect( - editTaskOnBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - taskUUID: 'task-uuid', - description: 'Updated', - tags: [], - project: '', - start: '', - entry: '', - wait: '', - end: '', - depends: [], - due: '', - recur: '', - annotations: [], - backendURL: 'http://backend/', - }) - ).rejects.toThrow('321'); + ).rejects.toThrow('Edit failed'); }); }); describe('modifyTaskOnBackend', () => { - test('modifies task successfully', async () => { + test('edits task successfully', async () => { (fetch as jest.Mock).mockResolvedValueOnce({ ok: true }); const response = await modifyTaskOnBackend({ @@ -529,48 +179,12 @@ describe('modifyTaskOnBackend', () => { }); expect(response).toBeDefined(); - expect(response.ok).toBe(true); - }); - - test('sends correct request body with taskuuid field name', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ ok: true }); - - await modifyTaskOnBackend({ - email: 'user@test.com', - encryptionSecret: 'secret', - UUID: 'user-uuid', - taskUUID: 'task-uuid-123', - description: 'Modified task', - project: 'Work', - priority: 'M', - status: 'completed', - due: '2025-12-31', - tags: ['important'], - backendURL: 'http://backend/', - }); - - expect(fetch).toHaveBeenCalledWith('http://backend/modify-task', { - method: 'POST', - body: expect.any(String), - headers: { - 'Content-Type': 'application/json', - }, - }); - - const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body); - expect(body.taskuuid).toBe('task-uuid-123'); - expect(body.description).toBe('Modified task'); - expect(body.project).toBe('Work'); - expect(body.priority).toBe('M'); - expect(body.status).toBe('completed'); - expect(body.due).toBe('2025-12-31'); - expect(body.tags).toEqual(['important']); }); test('throws error on failure', async () => { (fetch as jest.Mock).mockResolvedValueOnce({ ok: false, - text: async () => 'Modify failed', + text: async () => 'Edit failed', }); await expect( @@ -587,50 +201,15 @@ describe('modifyTaskOnBackend', () => { status: 'pending', backendURL: 'http://backend/', }) - ).rejects.toThrow('Modify failed'); - }); - - test('throws default error message when backend returns empty error text', async () => { - (fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - text: async () => '', - }); - - await expect( - modifyTaskOnBackend({ - email: 'test@example.com', - encryptionSecret: 'secret', - UUID: 'uuid', - taskUUID: 'task-uuid', - description: 'Updated', - tags: [], - project: '', - due: '', - priority: 'L', - status: 'pending', - backendURL: 'http://backend/', - }) - ).rejects.toThrow('Failed to modify task'); + ).rejects.toThrow('Edit failed'); }); }); -describe('TasksDatabase', () => { +describe('TaskDatabase', () => { test('initializes dexie database with tasks table', () => { const db = new TasksDatabase(); expect(db.tasks).toBeDefined(); expect(db.name).toBe('tasksDB'); }); - - test('has correct version number', () => { - const db = new TasksDatabase(); - expect(db.verno).toBe(1); - }); - - test('tasks table exists and is a Dexie Table', () => { - const db = new TasksDatabase(); - expect(db.tasks).toBeDefined(); - expect(db.tables.length).toBeGreaterThan(0); - expect(db.table('tasks')).toBeDefined(); - }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts index a68469aa..0cd855cc 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts @@ -1,5 +1,6 @@ import { toast } from 'react-toastify'; import { + // formattedDate, getDisplayedPages, handleCopy, handleDate, @@ -71,6 +72,18 @@ describe('sortTasks', () => { }); }); +// describe('formattedDate', () => { +// it('formats valid ISO date string correctly', () => { +// const dateString = '2023-06-17T12:00:00Z'; +// expect(formattedDate(dateString)).toBe('Jun 17, 2023, 5:30:00 PM'); +// }); + +// it('returns input string if date parsing fails', () => { +// const invalidDateString = 'invalid-date-string'; +// expect(formattedDate(invalidDateString)).toBe(invalidDateString); +// }); +// }); + describe('sortTasksById', () => { const tasks: Task[] = [ createTask(2, 'completed', '2', '2', ['2']), diff --git a/frontend/src/components/HomeComponents/Tasks/hooks.ts b/frontend/src/components/HomeComponents/Tasks/hooks.ts index 710861aa..46f532e7 100644 --- a/frontend/src/components/HomeComponents/Tasks/hooks.ts +++ b/frontend/src/components/HomeComponents/Tasks/hooks.ts @@ -78,14 +78,16 @@ export const addTaskToBackend = async ({ tags, }; + // Only include due if it's provided if (due !== undefined && due !== '') { requestBody.due = due; } + // Only include start if it's provided if (start !== undefined && start !== '') { requestBody.start = start; } - + // Add dependencies if provided if (depends && depends.length > 0) { requestBody.depends = depends; } @@ -94,10 +96,12 @@ export const addTaskToBackend = async ({ requestBody.end = end; } + // Only include recur if it's provided if (recur !== undefined && recur !== '') { requestBody.recur = recur; } + // Add annotations to request body, filtering out empty descriptions requestBody.annotations = annotations.filter( (annotation) => annotation.description && annotation.description.trim() !== '' diff --git a/frontend/src/components/HomeComponents/Tasks/report-download-utils.ts b/frontend/src/components/HomeComponents/Tasks/report-download-utils.ts index c74abb0d..c7dbcb0f 100644 --- a/frontend/src/components/HomeComponents/Tasks/report-download-utils.ts +++ b/frontend/src/components/HomeComponents/Tasks/report-download-utils.ts @@ -54,7 +54,7 @@ export const exportChartToPNG = async ( const canvas = await html2canvas(element, { backgroundColor: '#1c1c1c', - scale: 2, + scale: 2, // Higher quality logging: false, useCORS: true, }); diff --git a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts index 50879366..5819c916 100644 --- a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts +++ b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts @@ -37,7 +37,9 @@ export const markTaskAsCompleted = async ( }), }); - if (!response) { + if (response) { + console.log('Task marked as completed successfully!'); + } else { console.error('Failed to mark task as completed'); } } catch (error) { @@ -68,10 +70,9 @@ export const bulkMarkTasksAsCompleted = async ( }); if (response.ok) { + console.log('Bulk completion successful!'); toast.success( - `${taskUUIDs.length} ${ - taskUUIDs.length === 1 ? 'task' : 'tasks' - } marked as completed.` + `${taskUUIDs.length} ${taskUUIDs.length === 1 ? 'task' : 'tasks'} marked as completed.` ); return true; } else { @@ -109,10 +110,9 @@ export const bulkMarkTasksAsDeleted = async ( }); if (response.ok) { + console.log('Bulk deletion successful!'); toast.success( - `${taskUUIDs.length} ${ - taskUUIDs.length === 1 ? 'task' : 'tasks' - } deleted.` + `${taskUUIDs.length} ${taskUUIDs.length === 1 ? 'task' : 'tasks'} deleted.` ); return true; } else { @@ -146,7 +146,9 @@ export const markTaskAsDeleted = async ( }), }); - if (!response) { + if (response) { + console.log('Task marked as deleted successfully!'); + } else { console.error('Failed to mark task as deleted'); } } catch (error) { @@ -181,6 +183,8 @@ export const formattedDate = (dateString: string) => { }; export const parseTaskwarriorDate = (dateString: string) => { + // Taskwarrior date format: YYYYMMDDTHHMMSSZ + if (!dateString) return null; const year = dateString.substring(0, 4); @@ -265,13 +269,9 @@ export const getTimeSinceLastSync = ( const diffDays = Math.floor(diffHours / 24); if (diffSeconds < 60) { - return `Last updated ${diffSeconds} second${ - diffSeconds !== 1 ? 's' : '' - } ago`; + return `Last updated ${diffSeconds} second${diffSeconds !== 1 ? 's' : ''} ago`; } else if (diffMinutes < 60) { - return `Last updated ${diffMinutes} minute${ - diffMinutes !== 1 ? 's' : '' - } ago`; + return `Last updated ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`; } else if (diffHours < 24) { return `Last updated ${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; } else { @@ -279,13 +279,17 @@ export const getTimeSinceLastSync = ( } }; +/** + * Simple hash function for creating a hash of email + key + * This prevents storing plain email addresses in localStorage + */ export const hashKey = (key: string, email: string): string => { const str = key + email; let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; - hash = hash & hash; + hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(36); }; diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx index 01fd0fe7..68a2f0d7 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/HomePage.tsx @@ -41,18 +41,23 @@ export const HomePage: React.FC = () => { console.error('Failed to fetch tasks:', error); toast.error('Failed to fetch tasks. Please check your connection.', { position: 'bottom-left', + // ... toast config }); } finally { setIsLoading(false); } }; + // Launch onboarding tour for new HomePage visitors. useEffect(() => { fetchUserInfo(); }, []); useEffect(() => { if (!userInfo || !userInfo.uuid) { + console.log( + 'User info or UUID is not available yet, skipping WebSocket setup.' + ); return; } @@ -60,22 +65,26 @@ export const HomePage: React.FC = () => { getTasks(userInfo.email, userInfo.encryption_secret, userInfo.uuid); } + console.log('Setting up WebSocket with clientID:', userInfo.uuid); const socketURL = `${url.backendURL.replace(/^http/, 'ws')}ws?clientID=${ userInfo.uuid }`; const socket = new WebSocket(socketURL); - // socket.onopen = () => console.log('WebSocket connected!'); + socket.onopen = () => console.log('WebSocket connected!'); socket.onmessage = (event) => { + // console.log("Message received:", event.data); try { const data = JSON.parse(event.data); if (data.status === 'success') { + // Skip refresh for Edit Task to prevent dialog blinking if (data.job !== 'Edit Task') { getTasks(userInfo.email, userInfo.encryption_secret, userInfo.uuid); } if (data.job === 'Add Task') { + console.log('Task added successfully'); toast.success('Task added successfully!', { position: 'bottom-left', autoClose: 3000, @@ -86,6 +95,7 @@ export const HomePage: React.FC = () => { progress: undefined, }); } else if (data.job === 'Edit Task') { + console.log('Task edited successfully'); toast.success('Task edited successfully!', { position: 'bottom-left', autoClose: 3000, @@ -117,6 +127,7 @@ export const HomePage: React.FC = () => { }); } } else if (data.status == 'failure') { + console.log(`Failed to ${data.job || 'perform action'}`); toast.error(`Failed to ${data.job || 'perform action'}`, { position: 'bottom-left', autoClose: 3000, @@ -136,6 +147,7 @@ export const HomePage: React.FC = () => { socket.onerror = (error) => console.error('WebSocket error:', error); return () => { + console.log('Cleaning up WebSocket...'); socket.close(); }; }, [userInfo]); diff --git a/frontend/src/components/LandingComponents/Hero/HeroCards.tsx b/frontend/src/components/LandingComponents/Hero/HeroCards.tsx index 6c46972c..8e09d475 100644 --- a/frontend/src/components/LandingComponents/Hero/HeroCards.tsx +++ b/frontend/src/components/LandingComponents/Hero/HeroCards.tsx @@ -10,6 +10,7 @@ const popIn = { export const HeroCards = () => { return ( + // Prevent overflow at lg breakpoint
{ beforeEach(() => { mockSocket = { + onopen: null, onclose: null, onmessage: null, onerror: null, @@ -433,6 +434,16 @@ describe('HomePage', () => { expect(mockToastError).not.toHaveBeenCalled(); }); + it('triggers WebSocket onopen without errors', async () => { + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalled(); + }); + + expect(() => mockSocket.onopen()).not.toThrow(); + }); + it('handles WebSocket error event without crashing', async () => { render(); diff --git a/frontend/src/components/ui/date-time-picker.tsx b/frontend/src/components/ui/date-time-picker.tsx index a4d830b1..45ffd6d1 100644 --- a/frontend/src/components/ui/date-time-picker.tsx +++ b/frontend/src/components/ui/date-time-picker.tsx @@ -32,10 +32,11 @@ export function DateTimePicker({ const [hasTime, setHasTime] = React.useState(false); const isInternalUpdate = React.useRef(false); + // Update internal date when prop changes (but not from our own updates) React.useEffect(() => { if (!isInternalUpdate.current) { setInternalDate(date); - setHasTime(false); + setHasTime(false); // Only reset hasTime for external updates } isInternalUpdate.current = false; }, [date]); @@ -44,6 +45,7 @@ export function DateTimePicker({ const handleDateSelect = (selectedDate: Date | undefined) => { if (selectedDate) { + // Create a new date using the local date components to avoid timezone issues const newDate = new Date( selectedDate.getFullYear(), selectedDate.getMonth(), @@ -55,6 +57,7 @@ export function DateTimePicker({ ); setInternalDate(newDate); + // Mark as internal update and send the local date object directly with hasTime = false isInternalUpdate.current = true; onDateTimeChange(newDate, false); } else { @@ -69,10 +72,12 @@ export function DateTimePicker({ type: 'hour' | 'minute' | 'ampm', value: string ) => { + // Prevent time selection if no date is selected if (!internalDate) { return; } + // Mark that user has explicitly selected time setHasTime(true); const newDate = new Date(internalDate); @@ -83,8 +88,10 @@ export function DateTimePicker({ const isPM = currentHours >= 12; if (hour === 12) { + // 12 AM = 0, 12 PM = 12 newDate.setHours(isPM ? 12 : 0); } else { + // 1-11 AM = 1-11, 1-11 PM = 13-23 newDate.setHours(isPM ? hour + 12 : hour); } } else if (type === 'minute') { @@ -99,6 +106,7 @@ export function DateTimePicker({ } setInternalDate(newDate); + // Mark as internal update and send full datetime when time is explicitly selected isInternalUpdate.current = true; onDateTimeChange(newDate, true); }; diff --git a/frontend/src/components/ui/key-button.tsx b/frontend/src/components/ui/key-button.tsx index d4cae30f..056fcf50 100644 --- a/frontend/src/components/ui/key-button.tsx +++ b/frontend/src/components/ui/key-button.tsx @@ -4,6 +4,8 @@ export const Key = ({ lable }: { lable: string }) => { src={`https://key.pics/key/${lable.toUpperCase()}.svg?size=15&color=dark&fontStyle=Bold&fontSize=12`} alt={lable} className="hidden md:inline-block ml-2" - > + > + {/* {key} */} + ); }; diff --git a/frontend/src/components/ui/multi-select.tsx b/frontend/src/components/ui/multi-select.tsx index 5089635a..6942fafe 100644 --- a/frontend/src/components/ui/multi-select.tsx +++ b/frontend/src/components/ui/multi-select.tsx @@ -19,10 +19,15 @@ import { const ALL_ITEMS_VALUE = '__ALL__'; +interface Option { + label: string; + value: string; +} + interface MultiSelectFilterProps { id?: string; title: string; - options: string[]; + options: Option[] | string[]; selectedValues: string[]; onSelectionChange: (values: string[]) => void; className?: string; @@ -52,6 +57,13 @@ export function MultiSelectFilter({ onSelectionChange(newSelectedValues); }; + const normalizedOptions: Option[] = options.map((option) => { + if (typeof option === 'string') { + return { label: option, value: option }; + } + return option; + }); + return ( @@ -88,12 +100,12 @@ export function MultiSelectFilter({ > All {title} - {options.map((option) => { - const isSelected = selectedValues.includes(option); + {normalizedOptions.map((option) => { + const isSelected = selectedValues.includes(option.value); return ( handleSelect(option)} + key={option.value} + onSelect={() => handleSelect(option.value)} > - {option} + {option.label} ); })} diff --git a/frontend/src/components/utils/TaskAutoSync.tsx b/frontend/src/components/utils/TaskAutoSync.tsx index a4f7524e..49455f19 100644 --- a/frontend/src/components/utils/TaskAutoSync.tsx +++ b/frontend/src/components/utils/TaskAutoSync.tsx @@ -6,6 +6,7 @@ export const useTaskAutoSync = (props: AutoSyncProps) => { const { isLoading, setIsLoading, isAutoSyncEnabled, syncInterval } = props; const handleSync = useCallback(async () => { if (isLoading) { + console.log('Auto-sync: Sync already in progress, skipping.'); return; } setIsLoading(true); @@ -22,6 +23,7 @@ export const useTaskAutoSync = (props: AutoSyncProps) => { let intervalId: NodeJS.Timeout | undefined = undefined; if (isAutoSyncEnabled) { intervalId = setInterval(() => { + console.log('Auto-sync: Triggering periodic sync...'); handleSync(); }, syncInterval); } diff --git a/frontend/src/components/utils/__tests__/TaskAutoSync.test.tsx b/frontend/src/components/utils/__tests__/TaskAutoSync.test.tsx index 01524cd8..1ccffe6a 100644 --- a/frontend/src/components/utils/__tests__/TaskAutoSync.test.tsx +++ b/frontend/src/components/utils/__tests__/TaskAutoSync.test.tsx @@ -7,6 +7,7 @@ jest.mock('../../HomeComponents/Tasks/Tasks', () => ({ syncTasksWithTwAndDb: jest.fn(), })); +const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(); describe('useTaskAutoSync', () => { @@ -56,6 +57,9 @@ describe('useTaskAutoSync', () => { jest.advanceTimersByTime(1000); }); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Auto-sync: Triggering periodic sync...' + ); expect(mockSyncTasksWithTwAndDb).toHaveBeenCalledTimes(1); }); @@ -77,6 +81,9 @@ describe('useTaskAutoSync', () => { await result.current.handleSync(); }); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Auto-sync: Sync already in progress, skipping.' + ); expect(mockSyncTasksWithTwAndDb).not.toHaveBeenCalled(); expect(mockSetIsLoading).not.toHaveBeenCalled(); }); diff --git a/frontend/src/components/utils/__tests__/use-hotkeys.test.ts b/frontend/src/components/utils/__tests__/use-hotkeys.test.ts deleted file mode 100644 index 25b55a09..00000000 --- a/frontend/src/components/utils/__tests__/use-hotkeys.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { useHotkeys } from '../use-hotkeys'; - -describe('useHotkeys', () => { - let callback: jest.Mock; - - beforeEach(() => { - callback = jest.fn(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should call callback when specified keys are pressed', () => { - renderHook(() => useHotkeys(['ctrl', 's'], callback)); - - const event = new KeyboardEvent('keydown', { - key: 's', - ctrlKey: true, - bubbles: true, - }); - - window.dispatchEvent(event); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('should handle multiple modifier keys', () => { - renderHook(() => useHotkeys(['ctrl', 'shift', 'a'], callback)); - - const event = new KeyboardEvent('keydown', { - key: 'a', - ctrlKey: true, - shiftKey: true, - bubbles: true, - }); - - window.dispatchEvent(event); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('should be case insensitive for key matching', () => { - renderHook(() => useHotkeys(['s'], callback)); - - const event = new KeyboardEvent('keydown', { - key: 'S', - bubbles: true, - }); - - window.dispatchEvent(event); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('should not trigger when keys do not match', () => { - renderHook(() => useHotkeys(['ctrl', 's'], callback)); - - const event = new KeyboardEvent('keydown', { - key: 'a', - ctrlKey: true, - bubbles: true, - }); - - window.dispatchEvent(event); - - expect(callback).not.toHaveBeenCalled(); - }); - - it('should not trigger when modifier keys are missing', () => { - renderHook(() => useHotkeys(['ctrl', 's'], callback)); - - const event = new KeyboardEvent('keydown', { - key: 's', - bubbles: true, - }); - - window.dispatchEvent(event); - - expect(callback).not.toHaveBeenCalled(); - }); - - it('should not trigger when focus is in an input field', () => { - renderHook(() => useHotkeys(['ctrl', 's'], callback)); - - const input = document.createElement('input'); - document.body.appendChild(input); - - const event = new KeyboardEvent('keydown', { - key: 's', - ctrlKey: true, - bubbles: true, - }); - - Object.defineProperty(event, 'target', { value: input, enumerable: true }); - window.dispatchEvent(event); - - expect(callback).not.toHaveBeenCalled(); - - document.body.removeChild(input); - }); - - it('should not trigger when focus is in a textarea', () => { - renderHook(() => useHotkeys(['ctrl', 's'], callback)); - - const textarea = document.createElement('textarea'); - document.body.appendChild(textarea); - - const event = new KeyboardEvent('keydown', { - key: 's', - ctrlKey: true, - bubbles: true, - }); - - Object.defineProperty(event, 'target', { - value: textarea, - enumerable: true, - }); - window.dispatchEvent(event); - - expect(callback).not.toHaveBeenCalled(); - - document.body.removeChild(textarea); - }); - - it('should not trigger when focus is in a select element', () => { - renderHook(() => useHotkeys(['ctrl', 's'], callback)); - - const select = document.createElement('select'); - document.body.appendChild(select); - - const event = new KeyboardEvent('keydown', { - key: 's', - ctrlKey: true, - bubbles: true, - }); - - Object.defineProperty(event, 'target', { value: select, enumerable: true }); - window.dispatchEvent(event); - - expect(callback).not.toHaveBeenCalled(); - - document.body.removeChild(select); - }); - - it('should not trigger when focus is in a contentEditable element', () => { - renderHook(() => useHotkeys(['ctrl', 's'], callback)); - - const div = document.createElement('div'); - div.contentEditable = 'true'; - document.body.appendChild(div); - - const event = new KeyboardEvent('keydown', { - key: 's', - ctrlKey: true, - bubbles: true, - }); - - // Mock both contentEditable and isContentEditable - Object.defineProperty(div, 'isContentEditable', { - value: true, - writable: true, - configurable: true, - }); - Object.defineProperty(event, 'target', { value: div, enumerable: true }); - - window.dispatchEvent(event); - - expect(callback).not.toHaveBeenCalled(); - - document.body.removeChild(div); - }); - - it('should prevent default behavior when hotkey is triggered', () => { - renderHook(() => useHotkeys(['ctrl', 's'], callback)); - - const event = new KeyboardEvent('keydown', { - key: 's', - ctrlKey: true, - bubbles: true, - }); - - const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); - - window.dispatchEvent(event); - - expect(preventDefaultSpy).toHaveBeenCalled(); - }); - - it('should handle alt modifier key', () => { - renderHook(() => useHotkeys(['alt', 'f'], callback)); - - const event = new KeyboardEvent('keydown', { - key: 'f', - altKey: true, - bubbles: true, - }); - - window.dispatchEvent(event); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('should cleanup event listener on unmount', () => { - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - - const { unmount } = renderHook(() => useHotkeys(['ctrl', 's'], callback)); - - unmount(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'keydown', - expect.any(Function) - ); - - removeEventListenerSpy.mockRestore(); - }); -}); diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts index e10f761e..1e0c3b21 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -62,7 +62,7 @@ export type AutoSyncProps = { isLoading: boolean; setIsLoading: (val: boolean) => void; isAutoSyncEnabled: boolean; - syncInterval: number; + syncInterval: number; // <-- This prop controls the timer }; export interface EditTaskState { diff --git a/frontend/src/components/utils/utils.ts b/frontend/src/components/utils/utils.ts index a47bd6cb..a4eef230 100644 --- a/frontend/src/components/utils/utils.ts +++ b/frontend/src/components/utils/utils.ts @@ -5,6 +5,7 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +// Debounce utility export function debounce void>( func: T, wait: number