feat(frontend): add task details edit flow

Add a task details action from the entity page and route it to a prefilled edit task page.

Extend the local task API with single-task read and update helpers, and cover the new flow with frontend tests.
This commit is contained in:
2026-03-27 00:48:14 -03:00
parent b6ff8ee16e
commit 6538c1783d
7 changed files with 658 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getTask,
getTasksByEntity,
updateTask,
type EntityTaskResponse,
} from '@/api/tasksApi'
const taskOne: EntityTaskResponse = {
id: 'task-1',
entityId: 'entity-1',
name: 'Weekly Check-in',
prompt: 'Summarize jokes',
scheduleCron: '0 9 * * 1',
emailLookback: 'last_week',
createdAt: '2026-03-26T10:00:00Z',
}
const taskTwo: EntityTaskResponse = {
id: 'task-2',
entityId: 'entity-2',
name: 'Monthly Memo',
prompt: 'Escalate sandwich policy',
scheduleCron: '0 11 1 * *',
emailLookback: 'last_month',
createdAt: '2026-03-26T11:00:00Z',
}
describe('tasksApi', () => {
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
it('should_returnTask_when_getTaskCalledWithExistingId', async () => {
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
await expect(getTask('task-1')).resolves.toEqual(taskOne)
})
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_updateStoredTask_when_updateTaskCalled', async () => {
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
const updatedTask = await updateTask('task-1', {
entityId: 'entity-1',
name: 'Daily Check-in',
prompt: 'Ask about ceremonial coffee',
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
})
expect(updatedTask).toEqual({
...taskOne,
name: 'Daily Check-in',
prompt: 'Ask about ceremonial coffee',
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
})
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([
{
...taskOne,
name: 'Daily Check-in',
prompt: 'Ask about ceremonial coffee',
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
},
taskTwo,
])
})
})

View File

@@ -0,0 +1,125 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import EditTaskPage from '@/pages/EditTaskPage'
import * as entitiesApi from '@/api/entitiesApi'
import * as tasksApi from '@/api/tasksApi'
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return { ...actual, useNavigate: () => mockNavigate }
})
vi.mock('@/api/entitiesApi')
vi.mock('@/api/tasksApi')
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MemoryRouter initialEntries={['/entities/entity-1/tasks/task-1']}>
<Routes>
<Route path="/entities/:entityId/tasks/:taskId" element={children} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
const mockEntity = {
id: 'entity-1',
name: 'Entity A',
email: 'a@a.com',
jobTitle: 'Ops',
personality: 'Formal',
scheduleCron: '0 9 * * 1',
contextWindowDays: 3,
active: true,
createdAt: '',
}
const mockTask = {
id: 'task-1',
entityId: 'entity-1',
name: 'Weekly Check-in',
prompt: 'Summarize jokes',
scheduleCron: '0 9 * * 1',
emailLookback: 'last_week' as const,
createdAt: '2026-03-26T10:00:00Z',
}
describe('EditTaskPage', () => {
beforeEach(() => {
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask)
mockNavigate.mockClear()
})
it('should_renderPrefilledTaskForm_when_pageLoads', async () => {
render(<EditTaskPage />, { wrapper })
await screen.findByRole('link', { name: /back to entity a/i })
expect(screen.getByRole('heading', { name: /edit task/i })).toBeInTheDocument()
expect(screen.getByLabelText(/task name/i)).toHaveValue('Weekly Check-in')
expect(screen.getByLabelText(/task prompt/i)).toHaveValue('Summarize jokes')
expect(screen.getByLabelText(/^Email Period$/i)).toHaveValue('last_week')
expect(screen.getByLabelText(/^Minute$/i)).toHaveValue('0')
expect(screen.getByLabelText(/^Hour$/i)).toHaveValue('9')
expect(screen.getByLabelText(/^Day of Month$/i)).toHaveValue('*')
expect(screen.getByLabelText(/^Month$/i)).toHaveValue('*')
expect(screen.getByLabelText(/^Day of Week$/i)).toHaveValue('1')
})
it('should_generatePreviewAndUpdateTask_when_formSubmitted', async () => {
vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense')
vi.mocked(tasksApi.updateTask).mockResolvedValue({
...mockTask,
name: 'Daily Check-in',
prompt: 'Ask about ceremonial coffee',
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
})
render(<EditTaskPage />, { wrapper })
await screen.findByRole('link', { name: /back to entity a/i })
fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Daily Check-in' } })
fireEvent.change(screen.getByLabelText(/task prompt/i), {
target: { value: 'Ask about ceremonial coffee' },
})
fireEvent.change(screen.getByLabelText(/^Email Period$/i), {
target: { value: 'last_day' },
})
fireEvent.click(screen.getByRole('button', { name: /Weekdays/i }))
fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } })
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
await waitFor(() => {
expect(tasksApi.generateTaskPreview).toHaveBeenCalled()
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual(
expect.objectContaining({
entityId: 'entity-1',
name: 'Daily Check-in',
prompt: 'Ask about ceremonial coffee',
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
})
)
expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /save changes/i }))
await waitFor(() => {
expect(tasksApi.updateTask).toHaveBeenCalledWith('task-1', {
entityId: 'entity-1',
name: 'Daily Check-in',
prompt: 'Ask about ceremonial coffee',
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
})
expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1')
})
})
})

View File

@@ -57,6 +57,10 @@ describe('EntityDetailPage', () => {
'href', 'href',
'/entities/entity-1/tasks/new' '/entities/entity-1/tasks/new'
) )
expect(screen.getByRole('link', { name: /details/i })).toHaveAttribute(
'href',
'/entities/entity-1/tasks/task-1'
)
}) })
}) })
}) })

View File

@@ -20,6 +20,8 @@ export interface EntityTaskCreateDto {
emailLookback: EmailLookback emailLookback: EmailLookback
} }
export type EntityTaskUpdateDto = EntityTaskCreateDto
function readTasks(): EntityTaskResponse[] { function readTasks(): EntityTaskResponse[] {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return [] if (!raw) return []
@@ -67,6 +69,11 @@ export async function getTasksByEntity(entityId: string): Promise<EntityTaskResp
return readTasks().filter((task) => task.entityId === entityId) return readTasks().filter((task) => task.entityId === entityId)
} }
/** Returns one scheduled task by identifier. */
export async function getTask(taskId: string): Promise<EntityTaskResponse | null> {
return readTasks().find((task) => task.id === taskId) ?? null
}
/** Creates a scheduled task in local storage. */ /** Creates a scheduled task in local storage. */
export async function createTask(data: EntityTaskCreateDto): Promise<EntityTaskResponse> { export async function createTask(data: EntityTaskCreateDto): Promise<EntityTaskResponse> {
const current = readTasks() const current = readTasks()
@@ -80,3 +87,24 @@ export async function createTask(data: EntityTaskCreateDto): Promise<EntityTaskR
writeTasks(current) writeTasks(current)
return task return task
} }
/** Updates one scheduled task in local storage. */
export async function updateTask(
taskId: string,
data: EntityTaskUpdateDto
): Promise<EntityTaskResponse | null> {
const current = readTasks()
const existingTask = current.find((task) => task.id === taskId)
if (!existingTask) {
return null
}
const updatedTask: EntityTaskResponse = {
...existingTask,
...data,
}
writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task)))
return updatedTask
}

View File

@@ -0,0 +1,408 @@
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { getEntity } from '../api/entitiesApi'
import {
generateTaskPreview,
getTask,
updateTask,
type EmailLookback,
} from '../api/tasksApi'
interface TaskFormState {
name: string
prompt: string
scheduleCron: string
emailLookback: EmailLookback
}
interface CronParts {
minute: string
hour: string
dayOfMonth: string
month: string
dayOfWeek: string
}
interface RegularitySuggestion {
id: string
label: string
description: string
cronParts: CronParts
}
const DEFAULT_CRON_PARTS: CronParts = {
minute: '0',
hour: '9',
dayOfMonth: '*',
month: '*',
dayOfWeek: '1-5',
}
const REGULARITY_SUGGESTIONS: RegularitySuggestion[] = [
{
id: 'daily',
label: 'Daily',
description: 'Every day at 09:00',
cronParts: { minute: '0', hour: '9', dayOfMonth: '*', month: '*', dayOfWeek: '*' },
},
{
id: 'weekdays',
label: 'Weekdays',
description: 'Monday to Friday at 09:00',
cronParts: { minute: '0', hour: '9', dayOfMonth: '*', month: '*', dayOfWeek: '1-5' },
},
{
id: 'weekly',
label: 'Weekly',
description: 'Every Monday at 09:00',
cronParts: { minute: '0', hour: '9', dayOfMonth: '*', month: '*', dayOfWeek: '1' },
},
{
id: 'monthly',
label: 'Monthly',
description: 'Day 1 of each month at 09:00',
cronParts: { minute: '0', hour: '9', dayOfMonth: '1', month: '*', dayOfWeek: '*' },
},
]
function buildCron(parts: CronParts): string {
return [parts.minute, parts.hour, parts.dayOfMonth, parts.month, parts.dayOfWeek].join(' ')
}
function parseCron(scheduleCron: string): CronParts {
const parts = scheduleCron.split(' ')
if (parts.length !== 5) {
return DEFAULT_CRON_PARTS
}
return {
minute: parts[0],
hour: parts[1],
dayOfMonth: parts[2],
month: parts[3],
dayOfWeek: parts[4],
}
}
const DEFAULT_TASK_FORM: TaskFormState = {
name: '',
prompt: '',
scheduleCron: buildCron(DEFAULT_CRON_PARTS),
emailLookback: 'last_week',
}
export default function EditTaskPage() {
const { entityId = '', taskId = '' } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
const [preview, setPreview] = useState('')
const { data: entity, isLoading: isLoadingEntity } = useQuery({
queryKey: ['entity', entityId],
queryFn: () => getEntity(entityId),
enabled: Boolean(entityId),
})
const { data: task, isLoading: isLoadingTask } = useQuery({
queryKey: ['entity-task', taskId],
queryFn: () => getTask(taskId),
enabled: Boolean(taskId),
})
useEffect(() => {
if (!task) {
return
}
const nextCronParts = parseCron(task.scheduleCron)
setCronParts(nextCronParts)
setTaskForm({
name: task.name,
prompt: task.prompt,
scheduleCron: task.scheduleCron,
emailLookback: task.emailLookback,
})
}, [task])
const updateTaskMutation = useMutation({
mutationFn: (data: TaskFormState) =>
updateTask(taskId, {
entityId,
name: data.name,
prompt: data.prompt,
scheduleCron: data.scheduleCron,
emailLookback: data.emailLookback,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] })
await queryClient.invalidateQueries({ queryKey: ['entity-task', taskId] })
navigate(`/entities/${entityId}`)
},
})
const previewMutation = useMutation({
mutationFn: generateTaskPreview,
onSuccess: (value) => setPreview(value),
})
const canSubmit = useMemo(() => {
const hasFilledCronParts = Object.values(cronParts).every((value) => value.trim().length > 0)
return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts)
}, [cronParts, taskForm.name, taskForm.prompt])
const applyCronParts = (nextCronParts: CronParts) => {
setCronParts(nextCronParts)
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
}
const regularitySummary = useMemo(
() =>
`At ${cronParts.hour.padStart(2, '0')}:${cronParts.minute.padStart(2, '0')} - day ${cronParts.dayOfMonth}, month ${cronParts.month}, week day ${cronParts.dayOfWeek}`,
[cronParts.dayOfMonth, cronParts.dayOfWeek, cronParts.hour, cronParts.minute, cronParts.month]
)
if (!entityId || !taskId) {
return <div className="p-8 text-sm text-slate-300">Task identifier is missing.</div>
}
if (isLoadingEntity || isLoadingTask) {
return <div className="p-8 text-sm text-slate-300">Loading...</div>
}
if (!task) {
return (
<div className="p-8 text-sm text-slate-300">
<p>Task not found.</p>
<Link to={`/entities/${entityId}`} className="mt-4 inline-block text-cyan-400 hover:text-cyan-300">
Back to {entity?.name ?? 'Entity'}
</Link>
</div>
)
}
return (
<div className="min-h-screen overflow-y-auto bg-slate-950 py-8">
<div className="mx-auto max-w-2xl px-4 sm:px-6">
<nav className="mb-6">
<Link
to={`/entities/${entityId}`}
className="text-sm text-cyan-400 hover:text-cyan-300"
>
Back to {entity?.name ?? 'Entity'}
</Link>
</nav>
<h1 className="text-2xl font-bold text-slate-100">
Edit Task
{entity && (
<span className="ml-2 text-base font-normal text-slate-400"> {entity.name}</span>
)}
</h1>
<form
className="mt-6 space-y-6"
onSubmit={(event) => {
event.preventDefault()
if (!canSubmit) return
updateTaskMutation.mutate(taskForm)
}}
>
<div>
<label htmlFor="task-name" className="text-sm font-medium text-slate-200">
Task Name
</label>
<input
id="task-name"
value={taskForm.name}
onChange={(event) => setTaskForm((prev) => ({ ...prev, name: event.target.value }))}
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
required
/>
</div>
<div>
<label htmlFor="task-prompt" className="text-sm font-medium text-slate-200">
Task Prompt
</label>
<textarea
id="task-prompt"
value={taskForm.prompt}
onChange={(event) =>
setTaskForm((prev) => ({ ...prev, prompt: event.target.value }))
}
className="mt-1 min-h-36 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
required
/>
</div>
<div>
<label htmlFor="task-lookback" className="text-sm font-medium text-slate-200">
Email Period
</label>
<select
id="task-lookback"
value={taskForm.emailLookback}
onChange={(event) =>
setTaskForm((prev) => ({
...prev,
emailLookback: event.target.value as EmailLookback,
}))
}
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
>
<option value="last_day">Last 24 hours</option>
<option value="last_week">Last week</option>
<option value="last_month">Last month</option>
</select>
</div>
<div className="rounded-md border border-slate-800 bg-slate-900/60 p-4">
<p className="text-sm font-medium text-slate-200">Task Schedule</p>
<p className="mt-1 text-xs text-slate-400">
Pick a regularity then fine-tune each cron field. Use <code className="text-slate-300">*</code> for "every".
</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{REGULARITY_SUGGESTIONS.map((suggestion) => (
<button
key={suggestion.id}
type="button"
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-left text-slate-200 hover:border-cyan-500/70"
onClick={() => applyCronParts(suggestion.cronParts)}
>
<span className="block text-sm font-semibold">{suggestion.label}</span>
<span className="block text-xs text-slate-400">{suggestion.description}</span>
</button>
))}
</div>
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
<div>
<label htmlFor="cron-minute" className="text-xs font-medium text-slate-300">
Minute
</label>
<input
id="cron-minute"
type="text"
value={cronParts.minute}
onChange={(event) =>
applyCronParts({ ...cronParts, minute: event.target.value })
}
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
placeholder="0-59 or *"
/>
</div>
<div>
<label htmlFor="cron-hour" className="text-xs font-medium text-slate-300">
Hour
</label>
<input
id="cron-hour"
type="text"
value={cronParts.hour}
onChange={(event) =>
applyCronParts({ ...cronParts, hour: event.target.value })
}
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
placeholder="0-23 or *"
/>
</div>
<div>
<label htmlFor="cron-day-of-month" className="text-xs font-medium text-slate-300">
Day of Month
</label>
<input
id="cron-day-of-month"
type="text"
value={cronParts.dayOfMonth}
onChange={(event) =>
applyCronParts({ ...cronParts, dayOfMonth: event.target.value })
}
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
placeholder="1-31 or *"
/>
</div>
<div>
<label htmlFor="cron-month" className="text-xs font-medium text-slate-300">
Month
</label>
<input
id="cron-month"
type="text"
value={cronParts.month}
onChange={(event) =>
applyCronParts({ ...cronParts, month: event.target.value })
}
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
placeholder="1-12 or *"
/>
</div>
<div>
<label htmlFor="cron-day-of-week" className="text-xs font-medium text-slate-300">
Day of Week
</label>
<input
id="cron-day-of-week"
type="text"
value={cronParts.dayOfWeek}
onChange={(event) =>
applyCronParts({ ...cronParts, dayOfWeek: event.target.value })
}
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
placeholder="0-7 or *"
/>
</div>
</div>
<p className="mt-3 text-xs text-slate-400">{regularitySummary}</p>
<p className="mt-1 text-xs text-slate-500">Cron: {taskForm.scheduleCron}</p>
</div>
<div className="rounded-md border border-slate-800 bg-slate-900 p-4">
<button
type="button"
onClick={() => {
if (!canSubmit) return
previewMutation.mutate({
entityId,
name: taskForm.name,
prompt: taskForm.prompt,
scheduleCron: taskForm.scheduleCron,
emailLookback: taskForm.emailLookback,
})
}}
disabled={!canSubmit || previewMutation.isPending}
className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50"
>
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
</button>
{preview && (
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
{preview}
</pre>
)}
</div>
<div className="flex justify-end gap-3 pb-8">
<button
type="button"
onClick={() => navigate(`/entities/${entityId}`)}
className="rounded-md border border-slate-700 px-4 py-2 text-sm text-slate-200 hover:border-slate-500"
>
Cancel
</button>
<button
type="submit"
className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400 disabled:opacity-50"
disabled={!canSubmit || updateTaskMutation.isPending}
>
{updateTaskMutation.isPending ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -67,6 +67,14 @@ export default function EntityDetailPage() {
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
Email context: {getEmailLookbackLabel(task.emailLookback)} Email context: {getEmailLookbackLabel(task.emailLookback)}
</p> </p>
<div className="pt-2">
<Link
to={`/entities/${entityId}/tasks/${task.id}`}
className="inline-flex rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-200 hover:border-cyan-500 hover:text-cyan-300"
>
Details
</Link>
</div>
</li> </li>
))} ))}
{tasks.length === 0 && ( {tasks.length === 0 && (

View File

@@ -8,6 +8,7 @@ const DashboardPage = lazy(() => import('../pages/DashboardPage'))
const EntitiesPage = lazy(() => import('../pages/EntitiesPage')) const EntitiesPage = lazy(() => import('../pages/EntitiesPage'))
const EntityDetailPage = lazy(() => import('../pages/EntityDetailPage')) const EntityDetailPage = lazy(() => import('../pages/EntityDetailPage'))
const CreateTaskPage = lazy(() => import('../pages/CreateTaskPage')) const CreateTaskPage = lazy(() => import('../pages/CreateTaskPage'))
const EditTaskPage = lazy(() => import('../pages/EditTaskPage'))
const LogsPage = lazy(() => import('../pages/LogsPage')) const LogsPage = lazy(() => import('../pages/LogsPage'))
function Protected({ children }: { children: ReactNode }) { function Protected({ children }: { children: ReactNode }) {
@@ -64,6 +65,14 @@ export const router = createBrowserRouter([
</Protected> </Protected>
), ),
}, },
{
path: '/entities/:entityId/tasks/:taskId',
element: (
<Protected>
<EditTaskPage />
</Protected>
),
},
{ {
path: '/logs', path: '/logs',
element: ( element: (