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 () => {
|
it('should_returnTasksForEntity_when_getTasksByEntityCalled', async () => {
|
||||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
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 () => {
|
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'
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletes one scheduled task from local storage. */
|
export type EmailLookback = 'last_day' | 'last_week' | 'last_month'
|
||||||
export async function deleteTask(taskId: string): Promise<void> {
|
|
||||||
writeTasks(readTasks().filter((task) => task.id !== taskId))
|
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>
|
<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">
|
<ul className="mt-3 divide-y divide-slate-800 overflow-hidden rounded-xl border border-slate-800 bg-slate-900/70">
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<li key={task.id} className="space-y-1 px-4 py-3">
|
<li
|
||||||
<p className="font-medium text-slate-100">{task.name}</p>
|
key={task.id}
|
||||||
<p className="text-sm text-slate-300">Schedule: {task.scheduleCron}</p>
|
className={`space-y-1 px-4 py-3 ${task.active ? '' : 'bg-slate-950/30 text-slate-500'}`}
|
||||||
<p className="text-sm text-slate-400">
|
>
|
||||||
|
<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)}
|
Email context: {getEmailLookbackLabel(task.emailLookback)}
|
||||||
</p>
|
</p>
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/entities/${entityId}/tasks/${task.id}`}
|
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
|
Details
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
Reference in New Issue
Block a user