feat(frontend): show inactive tasks on entity page
Return all tasks for an entity so inactive items remain visible in the entity detail view while global task listings stay active-only. Add inactive task styling and coverage for the entity page state.
This commit is contained in:
@@ -54,7 +54,14 @@ describe('tasksApi', () => {
|
||||
it('should_returnTasksForEntity_when_getTasksByEntityCalled', async () => {
|
||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||
|
||||
await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne])
|
||||
await expect(getTasksByEntity('entity-1')).resolves.toEqual([
|
||||
taskOne,
|
||||
{
|
||||
...taskOne,
|
||||
id: 'task-3',
|
||||
active: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should_hideInactiveTasks_when_getAllTasksCalled', async () => {
|
||||
|
||||
@@ -64,4 +64,39 @@ describe('EntityDetailPage', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should_renderInactiveTasksWithStatus_when_entityHasInactiveTasks', async () => {
|
||||
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')
|
||||
|
||||
vi.mocked(entitiesApi.getEntity).mockResolvedValue({
|
||||
id: 'entity-1',
|
||||
name: 'Entity A',
|
||||
email: 'a@a.com',
|
||||
jobTitle: 'Ops',
|
||||
personality: 'Formal',
|
||||
scheduleCron: '0 9 * * 1',
|
||||
contextWindowDays: 3,
|
||||
active: true,
|
||||
createdAt: '',
|
||||
})
|
||||
vi.mocked(tasksApi.getTasksByEntity).mockResolvedValue([
|
||||
{
|
||||
id: 'task-2',
|
||||
entityId: 'entity-1',
|
||||
name: 'Retired Task',
|
||||
prompt: 'Archive the sandwich minutes',
|
||||
scheduleCron: '0 9 * * 1',
|
||||
emailLookback: 'last_week',
|
||||
active: false,
|
||||
createdAt: '2026-03-26T10:00:00Z',
|
||||
},
|
||||
])
|
||||
|
||||
render(<EntityDetailPage />, { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Retired Task/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Inactive/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,158 +1,158 @@
|
||||
const STORAGE_KEY = 'condado:entity-tasks'
|
||||
|
||||
export type EmailLookback = 'last_day' | 'last_week' | 'last_month'
|
||||
|
||||
export interface EntityTaskResponse {
|
||||
id: string
|
||||
entityId: string
|
||||
name: string
|
||||
prompt: string
|
||||
scheduleCron: string
|
||||
emailLookback: EmailLookback
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface EntityTaskCreateDto {
|
||||
entityId: string
|
||||
name: string
|
||||
prompt: string
|
||||
scheduleCron: string
|
||||
emailLookback: EmailLookback
|
||||
}
|
||||
|
||||
export type EntityTaskUpdateDto = EntityTaskCreateDto
|
||||
|
||||
function readTasks(): EntityTaskResponse[] {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
|
||||
try {
|
||||
return (JSON.parse(raw) as Array<Omit<EntityTaskResponse, 'active'> & { active?: boolean }>).map(
|
||||
(task) => ({
|
||||
...task,
|
||||
active: task.active ?? true,
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeTasks(tasks: EntityTaskResponse[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks))
|
||||
}
|
||||
|
||||
export function getEmailLookbackLabel(value: EmailLookback): string {
|
||||
if (value === 'last_day') return 'Last 24 hours'
|
||||
if (value === 'last_month') return 'Last month'
|
||||
return 'Last week'
|
||||
}
|
||||
|
||||
/** Simulates a task preview generated from the configured prompt. */
|
||||
export async function generateTaskPreview(data: EntityTaskCreateDto): Promise<string> {
|
||||
return [
|
||||
`SUBJECT: Internal Alignment Update - ${data.name}`,
|
||||
'BODY:',
|
||||
`Dear Team,`,
|
||||
'',
|
||||
`In strict accordance with our communication standards, this message was generated from the prompt: "${data.prompt}".`,
|
||||
`Context window considered: ${getEmailLookbackLabel(data.emailLookback)}.`,
|
||||
'Operational interpretation: please proceed casually, but with ceremonial seriousness.',
|
||||
'',
|
||||
'Regards,',
|
||||
'Automated Task Preview',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Returns all scheduled tasks currently configured in local storage. */
|
||||
export async function getAllTasks(): Promise<EntityTaskResponse[]> {
|
||||
return readTasks().filter((task) => task.active)
|
||||
}
|
||||
|
||||
/** Returns scheduled tasks for a specific entity. */
|
||||
export async function getTasksByEntity(entityId: string): Promise<EntityTaskResponse[]> {
|
||||
return readTasks().filter((task) => task.entityId === entityId && task.active)
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export async function createTask(data: EntityTaskCreateDto): Promise<EntityTaskResponse> {
|
||||
const current = readTasks()
|
||||
const task: EntityTaskResponse = {
|
||||
...data,
|
||||
id: crypto.randomUUID(),
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
current.push(task)
|
||||
writeTasks(current)
|
||||
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
|
||||
}
|
||||
|
||||
/** Marks one scheduled task as inactive in local storage. */
|
||||
export async function inactivateTask(taskId: string): Promise<EntityTaskResponse | null> {
|
||||
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
|
||||
}
|
||||
|
||||
/** Marks one scheduled task as active in local storage. */
|
||||
export async function activateTask(taskId: string): Promise<EntityTaskResponse | null> {
|
||||
const current = readTasks()
|
||||
const existingTask = current.find((task) => task.id === taskId)
|
||||
|
||||
if (!existingTask) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedTask: EntityTaskResponse = {
|
||||
...existingTask,
|
||||
active: true,
|
||||
}
|
||||
|
||||
writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task)))
|
||||
return updatedTask
|
||||
}
|
||||
const STORAGE_KEY = 'condado:entity-tasks'
|
||||
|
||||
/** Deletes one scheduled task from local storage. */
|
||||
export async function deleteTask(taskId: string): Promise<void> {
|
||||
writeTasks(readTasks().filter((task) => task.id !== taskId))
|
||||
}
|
||||
export type EmailLookback = 'last_day' | 'last_week' | 'last_month'
|
||||
|
||||
export interface EntityTaskResponse {
|
||||
id: string
|
||||
entityId: string
|
||||
name: string
|
||||
prompt: string
|
||||
scheduleCron: string
|
||||
emailLookback: EmailLookback
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface EntityTaskCreateDto {
|
||||
entityId: string
|
||||
name: string
|
||||
prompt: string
|
||||
scheduleCron: string
|
||||
emailLookback: EmailLookback
|
||||
}
|
||||
|
||||
export type EntityTaskUpdateDto = EntityTaskCreateDto
|
||||
|
||||
function readTasks(): EntityTaskResponse[] {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
|
||||
try {
|
||||
return (JSON.parse(raw) as Array<Omit<EntityTaskResponse, 'active'> & { active?: boolean }>).map(
|
||||
(task) => ({
|
||||
...task,
|
||||
active: task.active ?? true,
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeTasks(tasks: EntityTaskResponse[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks))
|
||||
}
|
||||
|
||||
export function getEmailLookbackLabel(value: EmailLookback): string {
|
||||
if (value === 'last_day') return 'Last 24 hours'
|
||||
if (value === 'last_month') return 'Last month'
|
||||
return 'Last week'
|
||||
}
|
||||
|
||||
/** Simulates a task preview generated from the configured prompt. */
|
||||
export async function generateTaskPreview(data: EntityTaskCreateDto): Promise<string> {
|
||||
return [
|
||||
`SUBJECT: Internal Alignment Update - ${data.name}`,
|
||||
'BODY:',
|
||||
`Dear Team,`,
|
||||
'',
|
||||
`In strict accordance with our communication standards, this message was generated from the prompt: "${data.prompt}".`,
|
||||
`Context window considered: ${getEmailLookbackLabel(data.emailLookback)}.`,
|
||||
'Operational interpretation: please proceed casually, but with ceremonial seriousness.',
|
||||
'',
|
||||
'Regards,',
|
||||
'Automated Task Preview',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Returns all scheduled tasks currently configured in local storage. */
|
||||
export async function getAllTasks(): Promise<EntityTaskResponse[]> {
|
||||
return readTasks().filter((task) => task.active)
|
||||
}
|
||||
|
||||
/** Returns scheduled tasks for a specific entity. */
|
||||
export async function getTasksByEntity(entityId: string): Promise<EntityTaskResponse[]> {
|
||||
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. */
|
||||
export async function createTask(data: EntityTaskCreateDto): Promise<EntityTaskResponse> {
|
||||
const current = readTasks()
|
||||
const task: EntityTaskResponse = {
|
||||
...data,
|
||||
id: crypto.randomUUID(),
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
current.push(task)
|
||||
writeTasks(current)
|
||||
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
|
||||
}
|
||||
|
||||
/** Marks one scheduled task as inactive in local storage. */
|
||||
export async function inactivateTask(taskId: string): Promise<EntityTaskResponse | null> {
|
||||
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
|
||||
}
|
||||
|
||||
/** Marks one scheduled task as active in local storage. */
|
||||
export async function activateTask(taskId: string): Promise<EntityTaskResponse | null> {
|
||||
const current = readTasks()
|
||||
const existingTask = current.find((task) => task.id === taskId)
|
||||
|
||||
if (!existingTask) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedTask: EntityTaskResponse = {
|
||||
...existingTask,
|
||||
active: true,
|
||||
}
|
||||
|
||||
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<void> {
|
||||
writeTasks(readTasks().filter((task) => task.id !== taskId))
|
||||
}
|
||||
|
||||
@@ -61,16 +61,34 @@ export default function EntityDetailPage() {
|
||||
<h2 className="text-lg font-semibold text-slate-100">Scheduled Tasks</h2>
|
||||
<ul className="mt-3 divide-y divide-slate-800 overflow-hidden rounded-xl border border-slate-800 bg-slate-900/70">
|
||||
{tasks.map((task) => (
|
||||
<li key={task.id} className="space-y-1 px-4 py-3">
|
||||
<p className="font-medium text-slate-100">{task.name}</p>
|
||||
<p className="text-sm text-slate-300">Schedule: {task.scheduleCron}</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
<li
|
||||
key={task.id}
|
||||
className={`space-y-1 px-4 py-3 ${task.active ? '' : 'bg-slate-950/30 text-slate-500'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`font-medium ${task.active ? 'text-slate-100' : 'text-slate-400'}`}>
|
||||
{task.name}
|
||||
</p>
|
||||
{!task.active && (
|
||||
<span className="inline-flex rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-amber-200">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-sm ${task.active ? 'text-slate-300' : 'text-slate-500'}`}>
|
||||
Schedule: {task.scheduleCron}
|
||||
</p>
|
||||
<p className={`text-sm ${task.active ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
Email context: {getEmailLookbackLabel(task.emailLookback)}
|
||||
</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"
|
||||
className={`inline-flex rounded-md border px-3 py-1.5 text-sm ${
|
||||
task.active
|
||||
? 'border-slate-700 text-slate-200 hover:border-cyan-500 hover:text-cyan-300'
|
||||
: 'border-slate-800 text-slate-400 hover:border-amber-500/60 hover:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user