Files
condado-newsletter/frontend/src/pages/CreateTaskPage.tsx
Gabriel Sancho a83ea85857 feat(frontend): generate task previews with local ollama
Replace the local preview stub with a real Ollama-backed test message flow using the configured local model.

Show the exact final prompt live on create and edit task pages, render generated output below it, and cover the integration with frontend tests.
2026-03-27 01:28:29 -03:00

405 lines
14 KiB
TypeScript

import { 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 {
buildTaskPreviewPrompt,
createTask,
generateTaskPreview,
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(' ')
}
const DEFAULT_TASK_FORM: TaskFormState = {
name: '',
prompt: '',
scheduleCron: buildCron(DEFAULT_CRON_PARTS),
emailLookback: 'last_week',
}
export default function CreateTaskPage() {
const { entityId = '' } = 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 [previewError, setPreviewError] = useState('')
const { data: entity, isLoading } = useQuery({
queryKey: ['entity', entityId],
queryFn: () => getEntity(entityId),
enabled: Boolean(entityId),
})
const createTaskMutation = useMutation({
mutationFn: createTask,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] })
navigate(`/entities/${entityId}`)
},
})
const previewMutation = useMutation({
mutationFn: generateTaskPreview,
onMutate: () => {
setPreview('')
setPreviewError('')
},
onSuccess: (value) => setPreview(value),
onError: (error) => {
setPreviewError(
error instanceof Error
? error.message
: 'Unable to generate a test message from the local model.'
)
},
})
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 currentTask = useMemo(
() => ({
entityId,
name: taskForm.name,
prompt: taskForm.prompt,
scheduleCron: taskForm.scheduleCron,
emailLookback: taskForm.emailLookback,
}),
[entityId, taskForm.emailLookback, taskForm.name, taskForm.prompt, taskForm.scheduleCron]
)
const finalPrompt = useMemo(() => {
if (!entity) {
return 'Entity details unavailable.'
}
return buildTaskPreviewPrompt(entity, currentTask)
}, [currentTask, entity])
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) {
return <div className="p-8 text-sm text-slate-300">Entity identifier is missing.</div>
}
if (isLoading) {
return <div className="p-8 text-sm text-slate-300">Loading...</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">
New 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
createTaskMutation.mutate({
entityId,
name: taskForm.name,
prompt: taskForm.prompt,
scheduleCron: taskForm.scheduleCron,
emailLookback: taskForm.emailLookback,
})
}}
>
<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
if (!entity) return
previewMutation.mutate({ entity, task: currentTask })
}}
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>
<div className="mt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
Final Prompt
</p>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
{finalPrompt}
</pre>
</div>
{previewError && (
<p role="alert" className="mt-4 text-sm text-red-300">
{previewError}
</p>
)}
{preview && (
<div className="mt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
Generated Message
</p>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
{preview}
</pre>
</div>
)}
</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 || createTaskMutation.isPending}
>
{createTaskMutation.isPending ? 'Creating…' : 'Create Task'}
</button>
</div>
</form>
</div>
</div>
)
}