diff --git a/frontend/src/__tests__/api/tasksApi.test.ts b/frontend/src/__tests__/api/tasksApi.test.ts index 0d7a4ff..1a530e4 100644 --- a/frontend/src/__tests__/api/tasksApi.test.ts +++ b/frontend/src/__tests__/api/tasksApi.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + createTask, + deleteTask, + getAllTasks, getTask, + inactivateTask, getTasksByEntity, updateTask, type EntityTaskResponse, @@ -13,6 +17,7 @@ const taskOne: EntityTaskResponse = { prompt: 'Summarize jokes', scheduleCron: '0 9 * * 1', emailLookback: 'last_week', + active: true, createdAt: '2026-03-26T10:00:00Z', } @@ -23,6 +28,7 @@ const taskTwo: EntityTaskResponse = { prompt: 'Escalate sandwich policy', scheduleCron: '0 11 1 * *', emailLookback: 'last_month', + active: false, createdAt: '2026-03-26T11:00:00Z', } @@ -38,12 +44,66 @@ describe('tasksApi', () => { await expect(getTask('task-1')).resolves.toEqual(taskOne) }) + it('should_returnInactiveTask_when_getTaskCalledWithInactiveId', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + await expect(getTask('task-2')).resolves.toEqual(taskTwo) + }) + it('should_returnTasksForEntity_when_getTasksByEntityCalled', async () => { localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne]) }) + it('should_hideInactiveTasks_when_getAllTasksCalled', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + await expect(getAllTasks()).resolves.toEqual([taskOne]) + }) + + it('should_hideInactiveTasksForEntity_when_getTasksByEntityCalled', async () => { + localStorage.setItem( + 'condado:entity-tasks', + JSON.stringify([ + taskOne, + taskTwo, + { + ...taskOne, + id: 'task-3', + active: false, + }, + ]) + ) + + await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne]) + }) + + it('should_storeActiveTaskByDefault_when_createTaskCalled', async () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000003') + + const createdTask = await createTask({ + entityId: 'entity-1', + name: 'Daily Check-in', + prompt: 'Ask about ceremonial coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_day', + }) + + expect(createdTask).toEqual( + expect.objectContaining({ + id: '00000000-0000-0000-0000-000000000003', + active: true, + }) + ) + expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([ + expect.objectContaining({ + id: '00000000-0000-0000-0000-000000000003', + active: true, + }), + ]) + }) + it('should_updateStoredTask_when_updateTaskCalled', async () => { localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) @@ -73,4 +133,30 @@ describe('tasksApi', () => { taskTwo, ]) }) + + it('should_markTaskInactive_when_inactivateTaskCalled', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + const updatedTask = await inactivateTask('task-1') + + expect(updatedTask).toEqual({ + ...taskOne, + active: false, + }) + expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([ + { + ...taskOne, + active: false, + }, + taskTwo, + ]) + }) + + it('should_removeTask_when_deleteTaskCalled', async () => { + localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) + + await deleteTask('task-1') + + expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([taskTwo]) + }) }) \ No newline at end of file diff --git a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx index a35c556..651fc37 100644 --- a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx @@ -68,6 +68,7 @@ describe('CreateTaskPage', () => { prompt: 'Talk about coffee', scheduleCron: '0 8 * * 1-5', emailLookback: 'last_week', + active: true, createdAt: '2026-03-26T10:00:00Z', }) diff --git a/frontend/src/__tests__/pages/EditTaskPage.test.tsx b/frontend/src/__tests__/pages/EditTaskPage.test.tsx index 7bd9f63..786b687 100644 --- a/frontend/src/__tests__/pages/EditTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/EditTaskPage.test.tsx @@ -15,15 +15,22 @@ vi.mock('react-router-dom', async () => { vi.mock('@/api/entitiesApi') vi.mock('@/api/tasksApi') -const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - - - - - -) +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + + + + ) + + render(, { wrapper }) + + return { queryClient } +} const mockEntity = { id: 'entity-1', @@ -44,6 +51,7 @@ const mockTask = { prompt: 'Summarize jokes', scheduleCron: '0 9 * * 1', emailLookback: 'last_week' as const, + active: true, createdAt: '2026-03-26T10:00:00Z', } @@ -51,11 +59,13 @@ describe('EditTaskPage', () => { beforeEach(() => { vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity) vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask) + vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false }) + vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined) mockNavigate.mockClear() }) it('should_renderPrefilledTaskForm_when_pageLoads', async () => { - render(, { wrapper }) + renderPage() await screen.findByRole('link', { name: /back to entity a/i }) @@ -80,7 +90,8 @@ describe('EditTaskPage', () => { emailLookback: 'last_day', }) - render(, { wrapper }) + const { queryClient } = renderPage() + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') await screen.findByRole('link', { name: /back to entity a/i }) fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Daily Check-in' } }) @@ -119,6 +130,41 @@ describe('EditTaskPage', () => { scheduleCron: '0 8 * * 1-5', emailLookback: 'last_day', }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) + expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') + }) + }) + + it('should_inactivateTaskAndNavigateBack_when_inactivateClicked', async () => { + const { queryClient } = renderPage() + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') + + await screen.findByRole('link', { name: /back to entity a/i }) + fireEvent.click(screen.getByRole('button', { name: /inactivate/i })) + + await waitFor(() => { + expect(tasksApi.inactivateTask).toHaveBeenCalledWith('task-1') + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) + expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') + }) + }) + + it('should_deleteTaskAndNavigateBack_when_deleteClicked', async () => { + const { queryClient } = renderPage() + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') + + await screen.findByRole('link', { name: /back to entity a/i }) + fireEvent.click(screen.getByRole('button', { name: /delete/i })) + + await waitFor(() => { + expect(tasksApi.deleteTask).toHaveBeenCalledWith('task-1') + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') }) }) diff --git a/frontend/src/__tests__/pages/EntityDetailPage.test.tsx b/frontend/src/__tests__/pages/EntityDetailPage.test.tsx index c4dd081..b9d6961 100644 --- a/frontend/src/__tests__/pages/EntityDetailPage.test.tsx +++ b/frontend/src/__tests__/pages/EntityDetailPage.test.tsx @@ -42,6 +42,7 @@ describe('EntityDetailPage', () => { prompt: 'Summarize jokes', scheduleCron: '0 9 * * 1', emailLookback: 'last_week', + active: true, createdAt: '2026-03-26T10:00:00Z', }, ]) diff --git a/frontend/src/api/tasksApi.ts b/frontend/src/api/tasksApi.ts index 958af7b..101b36c 100644 --- a/frontend/src/api/tasksApi.ts +++ b/frontend/src/api/tasksApi.ts @@ -9,6 +9,7 @@ export interface EntityTaskResponse { prompt: string scheduleCron: string emailLookback: EmailLookback + active: boolean createdAt: string } @@ -27,7 +28,12 @@ function readTasks(): EntityTaskResponse[] { if (!raw) return [] try { - return JSON.parse(raw) as EntityTaskResponse[] + return (JSON.parse(raw) as Array & { active?: boolean }>).map( + (task) => ({ + ...task, + active: task.active ?? true, + }) + ) } catch { return [] } @@ -61,12 +67,12 @@ export async function generateTaskPreview(data: EntityTaskCreateDto): Promise { - return readTasks() + return readTasks().filter((task) => task.active) } /** Returns scheduled tasks for a specific entity. */ export async function getTasksByEntity(entityId: string): Promise { - return readTasks().filter((task) => task.entityId === entityId) + return readTasks().filter((task) => task.entityId === entityId && task.active) } /** Returns one scheduled task by identifier. */ @@ -80,6 +86,7 @@ export async function createTask(data: EntityTaskCreateDto): Promise (task.id === taskId ? updatedTask : task))) return updatedTask } + +/** Marks one scheduled task as inactive in local storage. */ +export async function inactivateTask(taskId: string): Promise { + const current = readTasks() + const existingTask = current.find((task) => task.id === taskId) + + if (!existingTask) { + return null + } + + const updatedTask: EntityTaskResponse = { + ...existingTask, + active: false, + } + + writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task))) + return updatedTask +} + +/** Deletes one scheduled task from local storage. */ +export async function deleteTask(taskId: string): Promise { + writeTasks(readTasks().filter((task) => task.id !== taskId)) +} diff --git a/frontend/src/pages/EditTaskPage.tsx b/frontend/src/pages/EditTaskPage.tsx index a02761e..901e72a 100644 --- a/frontend/src/pages/EditTaskPage.tsx +++ b/frontend/src/pages/EditTaskPage.tsx @@ -3,8 +3,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useNavigate, useParams } from 'react-router-dom' import { getEntity } from '../api/entitiesApi' import { + deleteTask, generateTaskPreview, getTask, + inactivateTask, updateTask, type EmailLookback, } from '../api/tasksApi' @@ -93,6 +95,18 @@ const DEFAULT_TASK_FORM: TaskFormState = { emailLookback: 'last_week', } +async function invalidateTaskQueries( + queryClient: ReturnType, + entityId: string, + taskId: string +) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] }), + queryClient.invalidateQueries({ queryKey: ['entity-task', taskId] }), + queryClient.invalidateQueries({ queryKey: ['entity-tasks'] }), + ]) +} + export default function EditTaskPage() { const { entityId = '', taskId = '' } = useParams() const navigate = useNavigate() @@ -138,8 +152,23 @@ export default function EditTaskPage() { emailLookback: data.emailLookback, }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] }) - await queryClient.invalidateQueries({ queryKey: ['entity-task', taskId] }) + await invalidateTaskQueries(queryClient, entityId, taskId) + navigate(`/entities/${entityId}`) + }, + }) + + const inactivateTaskMutation = useMutation({ + mutationFn: () => inactivateTask(taskId), + onSuccess: async () => { + await invalidateTaskQueries(queryClient, entityId, taskId) + navigate(`/entities/${entityId}`) + }, + }) + + const deleteTaskMutation = useMutation({ + mutationFn: () => deleteTask(taskId), + onSuccess: async () => { + await invalidateTaskQueries(queryClient, entityId, taskId) navigate(`/entities/${entityId}`) }, }) @@ -386,6 +415,22 @@ export default function EditTaskPage() {
+ +